|
- 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<KeycloakOptions>(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 <ContentRoot>/audit-logs.
- var auditLogDirectory = builder.Configuration["Audit:LogDirectory"]
- ?? Path.Combine(builder.Environment.ContentRootPath, "audit-logs");
- builder.Services.AddSingleton<IAuditService>(sp =>
- new AppendOnlyFileAuditService(
- auditLogDirectory,
- sp.GetRequiredService<ILogger<AppendOnlyFileAuditService>>()));
-
- // IAuthenticationAuditStore delegates durable writes to IAuditService and
- // maintains an in-process queue for fast test / recent-event queries.
- builder.Services.AddSingleton<IAuthenticationAuditStore, InMemoryAuthenticationAuditStore>();
-
- // 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<ILegacyDataAccess>(_ =>
- #pragma warning disable CA1416
- new OleDbLegacyDataAccess(legacyConnectionString));
- #pragma warning restore CA1416
- }
- else if (builder.Environment.IsDevelopment())
- {
- var jsonSeedPath = Path.GetFullPath(
- Path.Combine(builder.Environment.ContentRootPath, "..", "development-data", "jurisdictions.json"));
- builder.Services.AddSingleton<ILegacyDataAccess>(
- _ => InMemoryLegacyDataAccess.FromJsonSeedFile(jsonSeedPath));
- }
- 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>(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<ILegacySchemaInspector>(_ =>
- #pragma warning disable CA1416
- new OleDbLegacySchemaInspector(legacyConnectionString));
- #pragma warning restore CA1416
- }
- else
- {
- builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
- new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
- }
- builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp =>
- new LegacySchemaCompatibilityCheck(
- sp.GetRequiredService<LegacySchemaBaseline>(),
- sp.GetRequiredService<ILegacySchemaInspector>(),
- sp.GetRequiredService<TimeProvider>()));
- builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ =>
- 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<ISeedDataStore>(_ =>
- new FileSeedDataStore(Path.GetFullPath(seedDataPath)));
- builder.Services.AddSingleton<ISeedService, SeedService>();
- builder.Services.AddHostedService<SeedHostedService>();
-
- builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>();
- builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>();
-
- // 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<LegacyLinkIntegrityOptions>(
- builder.Configuration.GetSection("LegacyLinkIntegrity"));
- builder.Services.AddSingleton<ILegacyLinkValidator, LegacyLinkValidator>();
- builder.Services.AddScoped<ILegacyLinkIntegrityCheck, LegacyLinkIntegrityService>();
- builder.Services.AddSingleton<InMemoryExtensionRecordStore>();
- builder.Services.AddSingleton<IExtensionRecordStore>(sp =>
- sp.GetRequiredService<InMemoryExtensionRecordStore>());
- builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
- sp.GetRequiredService<InMemoryExtensionRecordStore>());
- builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>();
-
- // Municipality account profiles (Story 1.10).
- // InMemoryMunicipalityProfileRepository also implements ILegacyLinkedRecordProvider,
- // so profiles participate in the nightly link integrity check automatically.
- builder.Services.AddSingleton<InMemoryMunicipalityProfileRepository>();
- builder.Services.AddSingleton<IMunicipalityProfileRepository>(sp =>
- sp.GetRequiredService<InMemoryMunicipalityProfileRepository>());
- builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
- sp.GetRequiredService<InMemoryMunicipalityProfileRepository>());
-
- var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
- 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<KeycloakOptions>() ?? 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<IAuthenticationAuditStore>();
- 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<IAuthenticationAuditStore>();
-
- 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<IAuthenticationAuditStore>();
-
- 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<ILegacySchemaCompatibilityCheck>(),
- app.Services.GetRequiredService<ILegacySchemaCheckHistory>(),
- 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;
|