books/backend/Books.Api/Startup.cs
Nicolaj Hartmann 8e05171b66 Full product audit: fix security, compliance, UX, and wire broken features
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>
2026-02-05 21:35:26 +01:00

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;
});
}
}