您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

358 行
15KB

  1. using System.Security.Claims;
  2. using System.Text;
  3. using Campaign_Tracker.Server.Audit;
  4. using Campaign_Tracker.Server.Authentication;
  5. using Campaign_Tracker.Server.Authorization;
  6. using Campaign_Tracker.Server.Configuration;
  7. using Campaign_Tracker.Server.ElectionCycles;
  8. using Campaign_Tracker.Server.ExtensionData;
  9. using Campaign_Tracker.Server.LegacyData;
  10. using Campaign_Tracker.Server.Municipalities;
  11. using Campaign_Tracker.Server.LegacyData.Schema;
  12. using Campaign_Tracker.Server.Seed;
  13. using Microsoft.AspNetCore.Authentication.JwtBearer;
  14. using Microsoft.AspNetCore.Authorization;
  15. using Microsoft.AspNetCore.Authorization.Policy;
  16. using Microsoft.IdentityModel.Protocols.OpenIdConnect;
  17. using Microsoft.IdentityModel.Tokens;
  18. var builder = WebApplication.CreateBuilder(args);
  19. DotEnvConfiguration.Load(builder.Configuration, builder.Environment.ContentRootPath);
  20. // Add services to the container.
  21. builder.Services.AddControllers();
  22. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
  23. builder.Services.AddOpenApi();
  24. builder.Services.Configure<KeycloakOptions>(builder.Configuration.GetSection(KeycloakOptions.SectionName));
  25. // Shared audit logging infrastructure (Story 1.5).
  26. // AppendOnlyFileAuditService writes JSON Lines to daily rotating files in {logDirectory}.
  27. // The directory is configurable via Audit:LogDirectory; defaults to <ContentRoot>/audit-logs.
  28. var auditLogDirectory = builder.Configuration["Audit:LogDirectory"]
  29. ?? Path.Combine(builder.Environment.ContentRootPath, "audit-logs");
  30. builder.Services.AddSingleton<IAuditService>(sp =>
  31. new AppendOnlyFileAuditService(
  32. auditLogDirectory,
  33. sp.GetRequiredService<ILogger<AppendOnlyFileAuditService>>()));
  34. // IAuthenticationAuditStore delegates durable writes to IAuditService and
  35. // maintains an in-process queue for fast test / recent-event queries.
  36. builder.Services.AddSingleton<IAuthenticationAuditStore, InMemoryAuthenticationAuditStore>();
  37. // Legacy anti-corruption data access layer (Story 1.6).
  38. // A real OleDb-backed provider is used whenever LegacyDatabase:ConnectionString is configured.
  39. // Development can run with deterministic in-memory records; non-development must not silently
  40. // fall back to sample data.
  41. var legacyConnectionString = builder.Configuration["LegacyDatabase:ConnectionString"];
  42. if (!string.IsNullOrWhiteSpace(legacyConnectionString))
  43. {
  44. if (!OperatingSystem.IsWindows())
  45. {
  46. throw new PlatformNotSupportedException(
  47. "OleDb legacy Access data access is supported only on Windows.");
  48. }
  49. builder.Services.AddSingleton<ILegacyDataAccess>(_ =>
  50. #pragma warning disable CA1416
  51. new OleDbLegacyDataAccess(legacyConnectionString));
  52. #pragma warning restore CA1416
  53. }
  54. else if (builder.Environment.IsDevelopment())
  55. {
  56. var jsonSeedPath = Path.GetFullPath(
  57. Path.Combine(builder.Environment.ContentRootPath, "..", "development-data", "jurisdictions.json"));
  58. builder.Services.AddSingleton<ILegacyDataAccess>(
  59. _ => InMemoryLegacyDataAccess.FromJsonSeedFile(jsonSeedPath));
  60. }
  61. else
  62. {
  63. throw new InvalidOperationException(
  64. "LegacyDatabase:ConnectionString is required outside Development.");
  65. }
  66. // Legacy schema compatibility validation gate (Story 1.7).
  67. // The baseline is captured at startup from the approved Access schema dump
  68. // shipped in source control. The inspector is in-memory in dev (mirrors the
  69. // baseline → no drift) and is replaced with an OleDb-backed implementation
  70. // when running against a live Access database.
  71. var schemaBaselinePath = builder.Configuration["LegacySchema:BaselineFile"]
  72. ?? Path.Combine(builder.Environment.ContentRootPath, "..", "Initial Documents", "Access_Schema.txt");
  73. var schemaHistoryPath = builder.Configuration["LegacySchema:HistoryFile"]
  74. ?? Path.Combine(builder.Environment.ContentRootPath, "legacy-schema-history.jsonl");
  75. builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
  76. builder.Services.AddSingleton(_ =>
  77. LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow));
  78. if (!string.IsNullOrWhiteSpace(legacyConnectionString))
  79. {
  80. if (!OperatingSystem.IsWindows())
  81. {
  82. throw new PlatformNotSupportedException(
  83. "OleDb legacy schema inspection is supported only on Windows.");
  84. }
  85. builder.Services.AddSingleton<ILegacySchemaInspector>(_ =>
  86. #pragma warning disable CA1416
  87. new OleDbLegacySchemaInspector(legacyConnectionString));
  88. #pragma warning restore CA1416
  89. }
  90. else
  91. {
  92. builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
  93. new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
  94. }
  95. builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp =>
  96. new LegacySchemaCompatibilityCheck(
  97. sp.GetRequiredService<LegacySchemaBaseline>(),
  98. sp.GetRequiredService<ILegacySchemaInspector>(),
  99. sp.GetRequiredService<TimeProvider>()));
  100. builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ =>
  101. new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath)));
  102. // System reference data and rule defaults (Story 1.9).
  103. // Seed keys are stable idempotency boundaries; reruns insert missing defaults
  104. // without overwriting admin-managed changes to existing values.
  105. var seedDataPath = builder.Configuration["Seed:DataFile"]
  106. ?? Path.Combine(builder.Environment.ContentRootPath, "seed-data.json");
  107. builder.Services.AddSingleton<ISeedDataStore>(_ =>
  108. new FileSeedDataStore(Path.GetFullPath(seedDataPath)));
  109. builder.Services.AddSingleton<ISeedService, SeedService>();
  110. builder.Services.AddHostedService<SeedHostedService>();
  111. builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>();
  112. builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>();
  113. // Legacy identifier linking for extension records (Story 1.8).
  114. // ILegacyLinkValidator resolves references through the anti-corruption layer (AC #2, AC #3).
  115. // ILegacyLinkIntegrityCheck scans all registered extension record providers (AC #4, NFR13).
  116. // Additional ILegacyLinkedRecordProvider registrations are added by each extension record story.
  117. builder.Services.Configure<LegacyLinkIntegrityOptions>(
  118. builder.Configuration.GetSection("LegacyLinkIntegrity"));
  119. builder.Services.AddSingleton<ILegacyLinkValidator, LegacyLinkValidator>();
  120. builder.Services.AddScoped<ILegacyLinkIntegrityCheck, LegacyLinkIntegrityService>();
  121. builder.Services.AddSingleton<InMemoryExtensionRecordStore>();
  122. builder.Services.AddSingleton<IExtensionRecordStore>(sp =>
  123. sp.GetRequiredService<InMemoryExtensionRecordStore>());
  124. builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
  125. sp.GetRequiredService<InMemoryExtensionRecordStore>());
  126. builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>();
  127. // Municipality account profiles (Story 1.10).
  128. // InMemoryMunicipalityProfileRepository also implements ILegacyLinkedRecordProvider,
  129. // so profiles participate in the nightly link integrity check automatically.
  130. builder.Services.AddSingleton<InMemoryMunicipalityProfileRepository>();
  131. builder.Services.AddSingleton<IMunicipalityProfileRepository>(sp =>
  132. sp.GetRequiredService<InMemoryMunicipalityProfileRepository>());
  133. builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
  134. sp.GetRequiredService<InMemoryMunicipalityProfileRepository>());
  135. // Municipality operational addresses (Story 1.11).
  136. builder.Services.AddSingleton<IMunicipalityAddressRepository, InMemoryMunicipalityAddressRepository>();
  137. // Municipality service contacts (Story 1.12).
  138. builder.Services.AddSingleton<IMunicipalityContactRepository, InMemoryMunicipalityContactRepository>();
  139. // Municipality prior-cycle service defaults read model (Story 1.13).
  140. builder.Services.AddSingleton<
  141. IMunicipalityPriorCycleDefaultsRepository,
  142. InMemoryMunicipalityPriorCycleDefaultsRepository>();
  143. // Election cycle kanban read model (Story 2.1).
  144. // Active cycle jobs are extension-layer records; legacy Access remains read-only.
  145. builder.Services.AddSingleton<IElectionCycleJobRepository, InMemoryElectionCycleJobRepository>();
  146. builder.Services.AddSingleton<IElectionCycleKanbanReadModel, ElectionCycleKanbanReadModel>();
  147. var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
  148. builder.Services.AddCors(options =>
  149. {
  150. options.AddPolicy("ConfiguredOrigins", policy =>
  151. {
  152. if (allowedOrigins.Length > 0)
  153. {
  154. policy.WithOrigins(allowedOrigins)
  155. .AllowAnyHeader()
  156. .AllowAnyMethod();
  157. }
  158. });
  159. });
  160. var keycloakOptions = builder.Configuration
  161. .GetSection(KeycloakOptions.SectionName)
  162. .Get<KeycloakOptions>() ?? new KeycloakOptions();
  163. if (!builder.Environment.IsDevelopment())
  164. {
  165. EnsureHttpsEndpoint(keycloakOptions.Authority, "Keycloak:Authority");
  166. EnsureHttpsEndpoint(keycloakOptions.TokenIssuer, "Keycloak:ValidIssuer/PublicAuthority");
  167. EnsureHttpsEndpoint(keycloakOptions.TokenEndpointAuthority, "Keycloak:PublicAuthority/Authority");
  168. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  169. {
  170. EnsureHttpsEndpoint(keycloakOptions.MetadataAddress, "Keycloak:MetadataAddress");
  171. }
  172. if (keycloakOptions.DisableHttpsMetadata)
  173. {
  174. throw new InvalidOperationException("Keycloak HTTPS metadata validation cannot be disabled outside Development.");
  175. }
  176. }
  177. builder.Services
  178. .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  179. .AddJwtBearer(options =>
  180. {
  181. options.RequireHttpsMetadata = !keycloakOptions.DisableHttpsMetadata;
  182. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  183. {
  184. options.MetadataAddress = keycloakOptions.MetadataAddress;
  185. }
  186. options.TokenValidationParameters = new TokenValidationParameters
  187. {
  188. ValidateIssuer = true,
  189. ValidIssuer = keycloakOptions.TokenIssuer,
  190. ValidateAudience = true,
  191. ValidAudiences = keycloakOptions.TokenAudiences,
  192. ValidateLifetime = true,
  193. NameClaimType = ClaimTypes.Name,
  194. RoleClaimType = ClaimTypes.Role,
  195. };
  196. if (!string.IsNullOrWhiteSpace(keycloakOptions.TestSigningKey))
  197. {
  198. var issuerSigningKey =
  199. new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keycloakOptions.TestSigningKey));
  200. options.TokenValidationParameters.ValidateIssuerSigningKey = true;
  201. options.TokenValidationParameters.IssuerSigningKey = issuerSigningKey;
  202. options.TokenValidationParameters.IssuerSigningKeys = [issuerSigningKey];
  203. options.Configuration = new OpenIdConnectConfiguration
  204. {
  205. Issuer = keycloakOptions.TokenIssuer,
  206. };
  207. options.Configuration.SigningKeys.Add(issuerSigningKey);
  208. }
  209. else
  210. {
  211. options.Authority = keycloakOptions.Authority;
  212. }
  213. options.Events = new JwtBearerEvents
  214. {
  215. OnTokenValidated = context =>
  216. {
  217. var auditStore = context.HttpContext.RequestServices
  218. .GetRequiredService<IAuthenticationAuditStore>();
  219. var subject = context.Principal?.Identity?.Name
  220. ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier)
  221. ?? "unknown";
  222. if (context.Principal?.Identity is ClaimsIdentity identity)
  223. {
  224. var roles = ApplicationRole.NormalizeMany(
  225. ApplicationRole.ExtractKeycloakRoles(identity.Claims, keycloakOptions.ClientId));
  226. foreach (var role in roles)
  227. {
  228. if (!identity.HasClaim(ClaimTypes.Role, role))
  229. {
  230. identity.AddClaim(new Claim(ClaimTypes.Role, role));
  231. }
  232. }
  233. }
  234. auditStore.RecordSuccess(subject, context.HttpContext.TraceIdentifier);
  235. return Task.CompletedTask;
  236. },
  237. OnAuthenticationFailed = context =>
  238. {
  239. var auditStore = context.HttpContext.RequestServices
  240. .GetRequiredService<IAuthenticationAuditStore>();
  241. var reason = context.Exception is null
  242. ? "invalid bearer token"
  243. : $"invalid bearer token: {context.Exception.GetType().Name}";
  244. auditStore.RecordFailure(reason, context.HttpContext.TraceIdentifier);
  245. return Task.CompletedTask;
  246. },
  247. OnChallenge = context =>
  248. {
  249. if (context.AuthenticateFailure is null &&
  250. context.Request.Headers.ContainsKey("Authorization"))
  251. {
  252. var auditStore = context.HttpContext.RequestServices
  253. .GetRequiredService<IAuthenticationAuditStore>();
  254. auditStore.RecordFailure("invalid authorization header", context.HttpContext.TraceIdentifier);
  255. }
  256. return Task.CompletedTask;
  257. },
  258. };
  259. });
  260. builder.Services.AddAuthorization(options =>
  261. {
  262. var recognizedPolicy = new AuthorizationPolicyBuilder()
  263. .RequireAuthenticatedUser()
  264. .RequireAssertion(context => ApplicationRole.NormalizeMany(
  265. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value)).Length > 0)
  266. .Build();
  267. options.DefaultPolicy = recognizedPolicy;
  268. options.AddPolicy(ApplicationPolicy.RecognizedApplicationRole, recognizedPolicy);
  269. options.AddPolicy(ApplicationPolicy.ClientServicesAccess, policy =>
  270. policy.RequireAuthenticatedUser()
  271. .RequireAssertion(context => ApplicationRole.HasAny(
  272. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  273. ApplicationRole.ClientServices)));
  274. options.AddPolicy(ApplicationPolicy.ProductionAccess, policy =>
  275. policy.RequireAuthenticatedUser()
  276. .RequireAssertion(context => ApplicationRole.HasAny(
  277. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  278. ApplicationRole.Production)));
  279. options.AddPolicy(ApplicationPolicy.AdminAccess, policy =>
  280. policy.RequireAuthenticatedUser()
  281. .RequireAssertion(context => ApplicationRole.HasAny(
  282. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  283. ApplicationRole.Admin)));
  284. });
  285. var app = builder.Build();
  286. // Release-gate CLI: when invoked with --check-legacy-schema we run the
  287. // compatibility check and exit instead of starting the web host. CI/CD blocks
  288. // releases on a non-zero exit code (Story 1.7 AC #4).
  289. if (LegacySchemaReleaseGate.ShouldRun(args))
  290. {
  291. var exitCode = await LegacySchemaReleaseGate.ExecuteAsync(
  292. app.Services.GetRequiredService<ILegacySchemaCompatibilityCheck>(),
  293. app.Services.GetRequiredService<ILegacySchemaCheckHistory>(),
  294. Console.Out);
  295. return exitCode;
  296. }
  297. // Configure the HTTP request pipeline.
  298. if (app.Environment.IsDevelopment())
  299. {
  300. app.MapOpenApi();
  301. }
  302. app.UseHttpsRedirection();
  303. app.UseCors("ConfiguredOrigins");
  304. app.UseAuthentication();
  305. app.UseAuthorization();
  306. app.MapControllers();
  307. app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
  308. app.Run();
  309. return 0;
  310. static void EnsureHttpsEndpoint(string endpoint, string settingName)
  311. {
  312. if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
  313. !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
  314. {
  315. throw new InvalidOperationException($"{settingName} must be an HTTPS URL outside Development.");
  316. }
  317. }
  318. public partial class Program;

Powered by TurnKey Linux.