Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
181 lines
6.9 KiB
C#
181 lines
6.9 KiB
C#
using Books.Api.Authentication;
|
|
using Books.Api.Authorization;
|
|
using Books.Api.EventFlow.Extensions;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using Books.Api.EventFlow.Infrastructure;
|
|
using Books.Api.GraphQL;
|
|
using Books.Api.Infrastructure;
|
|
using Books.Api.Logging;
|
|
using EventFlow;
|
|
using EventFlow.Configuration;
|
|
using EventFlow.Extensions;
|
|
using EventFlow.Hangfire.Extensions;
|
|
using EventFlow.PostgreSql.Connections;
|
|
using EventFlow.PostgreSql.Extensions;
|
|
using EventFlow.ReadStores;
|
|
using EventFlow.Subscribers;
|
|
using GraphQL;
|
|
using Hangfire;
|
|
using Hangfire.PostgreSql;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
using Npgsql;
|
|
|
|
namespace Books.Api;
|
|
|
|
public static class Startup
|
|
{
|
|
public static void ConfigureServices(IServiceCollection services, IConfiguration config, IHostEnvironment? environment = null)
|
|
{
|
|
var connectionString = config.GetConnectionString("Default")
|
|
?? throw new InvalidOperationException("Connection string 'Default' not found");
|
|
|
|
// Run database migrations (skipped in Test environment where migrations run separately with test connection string)
|
|
var isTestEnvironment = environment?.EnvironmentName == "Test";
|
|
if (!isTestEnvironment)
|
|
{
|
|
DatabaseMigrator.Migrate(connectionString);
|
|
}
|
|
|
|
// PostgreSQL data source
|
|
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
|
services.AddSingleton(dataSource);
|
|
|
|
// Hangfire
|
|
services.AddHangfire(c => c
|
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
|
.UseSimpleAssemblyNameTypeSerializer()
|
|
.UseRecommendedSerializerSettings()
|
|
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));
|
|
services.AddHangfireServer();
|
|
|
|
// Scheduler abstraction over Hangfire
|
|
services.AddSingleton<IScheduler, HangfireScheduler>();
|
|
|
|
// EventFlow
|
|
services.AddEventFlow(o => o
|
|
.UsePostgreSqlEventStore()
|
|
.ConfigurePostgreSql(PostgreSqlConfiguration.New.SetConnectionString(connectionString))
|
|
.AddDefaults(typeof(Startup).Assembly)
|
|
.AddReadModels()
|
|
.Configure(c => c.IsAsynchronousSubscribersEnabled = true)
|
|
.UseHangfireJobScheduler());
|
|
|
|
// Resilience strategies
|
|
services.AddSingleton<IDispatchToReadStoresResilienceStrategy, ReadStoresResilienceStrategy>();
|
|
services.AddSingleton<IDispatchToSubscriberResilienceStrategy, DispatchToSubscriberResilienceStrategy>();
|
|
|
|
// Read model repositories
|
|
services.AddRepositories();
|
|
|
|
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
|
|
services.AddHttpContextAccessor();
|
|
|
|
// User company access repository and access service
|
|
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
|
|
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
|
|
|
|
// Logging decorators
|
|
services.DecorateAsyncEventHandlersWithLogging();
|
|
|
|
// GraphQL
|
|
services.AddGraphQL(builder => builder
|
|
.AddSchema<BooksSchema>()
|
|
.AddSystemTextJson()
|
|
.AddDataLoader()
|
|
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
|
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
|
|
|
|
// Memory cache for API key caching
|
|
services.AddMemoryCache();
|
|
|
|
// Controllers (for AuthController)
|
|
services.AddControllers();
|
|
|
|
// CORS
|
|
SetupCors(services, config);
|
|
|
|
// Authentication & Authorization
|
|
SetupAuthentication(services, config);
|
|
}
|
|
|
|
private static void SetupCors(IServiceCollection services, IConfiguration config)
|
|
{
|
|
var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
|
?? ["http://localhost:3000"];
|
|
|
|
services.AddCors(options =>
|
|
{
|
|
options.AddDefaultPolicy(builder =>
|
|
{
|
|
builder.WithOrigins(allowedOrigins)
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.AllowCredentials();
|
|
});
|
|
});
|
|
}
|
|
|
|
private static void SetupAuthentication(IServiceCollection services, IConfiguration config)
|
|
{
|
|
services.AddAuthorization();
|
|
|
|
var keycloakClientSecret = config["Keycloak:ClientSecret"];
|
|
|
|
// If Keycloak not configured, use cookie-only auth (for development)
|
|
if (string.IsNullOrEmpty(keycloakClientSecret))
|
|
{
|
|
Console.WriteLine("[WARNING] Keycloak:ClientSecret not configured - OIDC authentication disabled");
|
|
services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(options =>
|
|
{
|
|
options.Cookie.Name = ".Books.Auth";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
|
options.SlidingExpiration = true;
|
|
})
|
|
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
|
ApiKeyDefaults.AuthenticationScheme, _ => { });
|
|
return;
|
|
}
|
|
|
|
// Full authentication with OIDC
|
|
services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(options =>
|
|
{
|
|
options.Cookie.Name = ".Books.Auth";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
options.Cookie.SameSite = SameSiteMode.None;
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
|
options.SlidingExpiration = true;
|
|
})
|
|
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
|
|
ApiKeyDefaults.AuthenticationScheme, _ => { })
|
|
.AddOpenIdConnect(options =>
|
|
{
|
|
options.GetClaimsFromUserInfoEndpoint = true;
|
|
options.MetadataAddress = config["Keycloak:MetadataAddress"]
|
|
?? "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration";
|
|
options.ClientId = config["Keycloak:ClientId"] ?? "books";
|
|
options.SaveTokens = true;
|
|
options.ClientSecret = keycloakClientSecret;
|
|
options.ResponseType = "code";
|
|
options.Scope.Add("openid");
|
|
options.Scope.Add("email");
|
|
options.Scope.Add("profile");
|
|
options.CallbackPath = "/callback";
|
|
options.UsePkce = true;
|
|
});
|
|
}
|
|
}
|