using System.Security.Claims; using System.Text; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authentication; using Campaign_Tracker.Server.Authorization; using Campaign_Tracker.Server.Configuration; using Campaign_Tracker.Server.ExtensionData; using Campaign_Tracker.Server.LegacyData; using Campaign_Tracker.Server.Municipalities; using Campaign_Tracker.Server.LegacyData.Schema; using Campaign_Tracker.Server.Seed; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); DotEnvConfiguration.Load(builder.Configuration, builder.Environment.ContentRootPath); // Add services to the container. builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); builder.Services.Configure(builder.Configuration.GetSection(KeycloakOptions.SectionName)); // Shared audit logging infrastructure (Story 1.5). // AppendOnlyFileAuditService writes JSON Lines to daily rotating files in {logDirectory}. // The directory is configurable via Audit:LogDirectory; defaults to /audit-logs. var auditLogDirectory = builder.Configuration["Audit:LogDirectory"] ?? Path.Combine(builder.Environment.ContentRootPath, "audit-logs"); builder.Services.AddSingleton(sp => new AppendOnlyFileAuditService( auditLogDirectory, sp.GetRequiredService>())); // IAuthenticationAuditStore delegates durable writes to IAuditService and // maintains an in-process queue for fast test / recent-event queries. builder.Services.AddSingleton(); // Legacy anti-corruption data access layer (Story 1.6). // A real OleDb-backed provider is used whenever LegacyDatabase:ConnectionString is configured. // Development can run with deterministic in-memory records; non-development must not silently // fall back to sample data. var legacyConnectionString = builder.Configuration["LegacyDatabase:ConnectionString"]; if (!string.IsNullOrWhiteSpace(legacyConnectionString)) { if (!OperatingSystem.IsWindows()) { throw new PlatformNotSupportedException( "OleDb legacy Access data access is supported only on Windows."); } builder.Services.AddSingleton(_ => #pragma warning disable CA1416 new OleDbLegacyDataAccess(legacyConnectionString)); #pragma warning restore CA1416 } else if (builder.Environment.IsDevelopment()) { builder.Services.AddSingleton(); } else { throw new InvalidOperationException( "LegacyDatabase:ConnectionString is required outside Development."); } // Legacy schema compatibility validation gate (Story 1.7). // The baseline is captured at startup from the approved Access schema dump // shipped in source control. The inspector is in-memory in dev (mirrors the // baseline → no drift) and is replaced with an OleDb-backed implementation // when running against a live Access database. var schemaBaselinePath = builder.Configuration["LegacySchema:BaselineFile"] ?? Path.Combine(builder.Environment.ContentRootPath, "..", "Initial Documents", "Access_Schema.txt"); var schemaHistoryPath = builder.Configuration["LegacySchema:HistoryFile"] ?? Path.Combine(builder.Environment.ContentRootPath, "legacy-schema-history.jsonl"); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(_ => LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow)); if (!string.IsNullOrWhiteSpace(legacyConnectionString)) { if (!OperatingSystem.IsWindows()) { throw new PlatformNotSupportedException( "OleDb legacy schema inspection is supported only on Windows."); } builder.Services.AddSingleton(_ => #pragma warning disable CA1416 new OleDbLegacySchemaInspector(legacyConnectionString)); #pragma warning restore CA1416 } else { builder.Services.AddSingleton(sp => new InMemoryLegacySchemaInspector(sp.GetRequiredService().Tables)); } builder.Services.AddSingleton(sp => new LegacySchemaCompatibilityCheck( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); builder.Services.AddSingleton(_ => new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath))); // System reference data and rule defaults (Story 1.9). // Seed keys are stable idempotency boundaries; reruns insert missing defaults // without overwriting admin-managed changes to existing values. var seedDataPath = builder.Configuration["Seed:DataFile"] ?? Path.Combine(builder.Environment.ContentRootPath, "seed-data.json"); builder.Services.AddSingleton(_ => new FileSeedDataStore(Path.GetFullPath(seedDataPath))); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); // Legacy identifier linking for extension records (Story 1.8). // ILegacyLinkValidator resolves references through the anti-corruption layer (AC #2, AC #3). // ILegacyLinkIntegrityCheck scans all registered extension record providers (AC #4, NFR13). // Additional ILegacyLinkedRecordProvider registrations are added by each extension record story. builder.Services.Configure( builder.Configuration.GetSection("LegacyLinkIntegrity")); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); // Municipality account profiles (Story 1.10). // InMemoryMunicipalityProfileRepository also implements ILegacyLinkedRecordProvider, // so profiles participate in the nightly link integrity check automatically. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? []; builder.Services.AddCors(options => { options.AddPolicy("ConfiguredOrigins", policy => { if (allowedOrigins.Length > 0) { policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod(); } }); }); var keycloakOptions = builder.Configuration .GetSection(KeycloakOptions.SectionName) .Get() ?? new KeycloakOptions(); if (!builder.Environment.IsDevelopment()) { EnsureHttpsEndpoint(keycloakOptions.Authority, "Keycloak:Authority"); EnsureHttpsEndpoint(keycloakOptions.TokenIssuer, "Keycloak:ValidIssuer/PublicAuthority"); EnsureHttpsEndpoint(keycloakOptions.TokenEndpointAuthority, "Keycloak:PublicAuthority/Authority"); if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress)) { EnsureHttpsEndpoint(keycloakOptions.MetadataAddress, "Keycloak:MetadataAddress"); } if (keycloakOptions.DisableHttpsMetadata) { throw new InvalidOperationException("Keycloak HTTPS metadata validation cannot be disabled outside Development."); } } builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.RequireHttpsMetadata = !keycloakOptions.DisableHttpsMetadata; if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress)) { options.MetadataAddress = keycloakOptions.MetadataAddress; } options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = keycloakOptions.TokenIssuer, ValidateAudience = true, ValidAudiences = keycloakOptions.TokenAudiences, ValidateLifetime = true, NameClaimType = ClaimTypes.Name, RoleClaimType = ClaimTypes.Role, }; if (!string.IsNullOrWhiteSpace(keycloakOptions.TestSigningKey)) { var issuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keycloakOptions.TestSigningKey)); options.TokenValidationParameters.ValidateIssuerSigningKey = true; options.TokenValidationParameters.IssuerSigningKey = issuerSigningKey; options.TokenValidationParameters.IssuerSigningKeys = [issuerSigningKey]; options.Configuration = new OpenIdConnectConfiguration { Issuer = keycloakOptions.TokenIssuer, }; options.Configuration.SigningKeys.Add(issuerSigningKey); } else { options.Authority = keycloakOptions.Authority; } options.Events = new JwtBearerEvents { OnTokenValidated = context => { var auditStore = context.HttpContext.RequestServices .GetRequiredService(); var subject = context.Principal?.Identity?.Name ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; if (context.Principal?.Identity is ClaimsIdentity identity) { var roles = ApplicationRole.NormalizeMany( ApplicationRole.ExtractKeycloakRoles(identity.Claims, keycloakOptions.ClientId)); foreach (var role in roles) { if (!identity.HasClaim(ClaimTypes.Role, role)) { identity.AddClaim(new Claim(ClaimTypes.Role, role)); } } } auditStore.RecordSuccess(subject, context.HttpContext.TraceIdentifier); return Task.CompletedTask; }, OnAuthenticationFailed = context => { var auditStore = context.HttpContext.RequestServices .GetRequiredService(); var reason = context.Exception is null ? "invalid bearer token" : $"invalid bearer token: {context.Exception.GetType().Name}"; auditStore.RecordFailure(reason, context.HttpContext.TraceIdentifier); return Task.CompletedTask; }, OnChallenge = context => { if (context.AuthenticateFailure is null && context.Request.Headers.ContainsKey("Authorization")) { var auditStore = context.HttpContext.RequestServices .GetRequiredService(); auditStore.RecordFailure("invalid authorization header", context.HttpContext.TraceIdentifier); } return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(options => { var recognizedPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .RequireAssertion(context => ApplicationRole.NormalizeMany( context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value)).Length > 0) .Build(); options.DefaultPolicy = recognizedPolicy; options.AddPolicy(ApplicationPolicy.RecognizedApplicationRole, recognizedPolicy); options.AddPolicy(ApplicationPolicy.ClientServicesAccess, policy => policy.RequireAuthenticatedUser() .RequireAssertion(context => ApplicationRole.HasAny( context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value), ApplicationRole.ClientServices))); options.AddPolicy(ApplicationPolicy.ProductionAccess, policy => policy.RequireAuthenticatedUser() .RequireAssertion(context => ApplicationRole.HasAny( context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value), ApplicationRole.Production))); options.AddPolicy(ApplicationPolicy.AdminAccess, policy => policy.RequireAuthenticatedUser() .RequireAssertion(context => ApplicationRole.HasAny( context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value), ApplicationRole.Admin))); }); var app = builder.Build(); // Release-gate CLI: when invoked with --check-legacy-schema we run the // compatibility check and exit instead of starting the web host. CI/CD blocks // releases on a non-zero exit code (Story 1.7 AC #4). if (LegacySchemaReleaseGate.ShouldRun(args)) { var exitCode = await LegacySchemaReleaseGate.ExecuteAsync( app.Services.GetRequiredService(), app.Services.GetRequiredService(), Console.Out); return exitCode; } // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseHttpsRedirection(); app.UseCors("ConfiguredOrigins"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.Run(); return 0; static void EnsureHttpsEndpoint(string endpoint, string settingName) { if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"{settingName} must be an HTTPS URL outside Development."); } } public partial class Program;