25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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

Powered by TurnKey Linux.