Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

344 lignes
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. var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
  137. builder.Services.AddCors(options =>
  138. {
  139. options.AddPolicy("ConfiguredOrigins", policy =>
  140. {
  141. if (allowedOrigins.Length > 0)
  142. {
  143. policy.WithOrigins(allowedOrigins)
  144. .AllowAnyHeader()
  145. .AllowAnyMethod();
  146. }
  147. });
  148. });
  149. var keycloakOptions = builder.Configuration
  150. .GetSection(KeycloakOptions.SectionName)
  151. .Get<KeycloakOptions>() ?? new KeycloakOptions();
  152. if (!builder.Environment.IsDevelopment())
  153. {
  154. EnsureHttpsEndpoint(keycloakOptions.Authority, "Keycloak:Authority");
  155. EnsureHttpsEndpoint(keycloakOptions.TokenIssuer, "Keycloak:ValidIssuer/PublicAuthority");
  156. EnsureHttpsEndpoint(keycloakOptions.TokenEndpointAuthority, "Keycloak:PublicAuthority/Authority");
  157. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  158. {
  159. EnsureHttpsEndpoint(keycloakOptions.MetadataAddress, "Keycloak:MetadataAddress");
  160. }
  161. if (keycloakOptions.DisableHttpsMetadata)
  162. {
  163. throw new InvalidOperationException("Keycloak HTTPS metadata validation cannot be disabled outside Development.");
  164. }
  165. }
  166. builder.Services
  167. .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  168. .AddJwtBearer(options =>
  169. {
  170. options.RequireHttpsMetadata = !keycloakOptions.DisableHttpsMetadata;
  171. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  172. {
  173. options.MetadataAddress = keycloakOptions.MetadataAddress;
  174. }
  175. options.TokenValidationParameters = new TokenValidationParameters
  176. {
  177. ValidateIssuer = true,
  178. ValidIssuer = keycloakOptions.TokenIssuer,
  179. ValidateAudience = true,
  180. ValidAudiences = keycloakOptions.TokenAudiences,
  181. ValidateLifetime = true,
  182. NameClaimType = ClaimTypes.Name,
  183. RoleClaimType = ClaimTypes.Role,
  184. };
  185. if (!string.IsNullOrWhiteSpace(keycloakOptions.TestSigningKey))
  186. {
  187. var issuerSigningKey =
  188. new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keycloakOptions.TestSigningKey));
  189. options.TokenValidationParameters.ValidateIssuerSigningKey = true;
  190. options.TokenValidationParameters.IssuerSigningKey = issuerSigningKey;
  191. options.TokenValidationParameters.IssuerSigningKeys = [issuerSigningKey];
  192. options.Configuration = new OpenIdConnectConfiguration
  193. {
  194. Issuer = keycloakOptions.TokenIssuer,
  195. };
  196. options.Configuration.SigningKeys.Add(issuerSigningKey);
  197. }
  198. else
  199. {
  200. options.Authority = keycloakOptions.Authority;
  201. }
  202. options.Events = new JwtBearerEvents
  203. {
  204. OnTokenValidated = context =>
  205. {
  206. var auditStore = context.HttpContext.RequestServices
  207. .GetRequiredService<IAuthenticationAuditStore>();
  208. var subject = context.Principal?.Identity?.Name
  209. ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier)
  210. ?? "unknown";
  211. if (context.Principal?.Identity is ClaimsIdentity identity)
  212. {
  213. var roles = ApplicationRole.NormalizeMany(
  214. ApplicationRole.ExtractKeycloakRoles(identity.Claims, keycloakOptions.ClientId));
  215. foreach (var role in roles)
  216. {
  217. if (!identity.HasClaim(ClaimTypes.Role, role))
  218. {
  219. identity.AddClaim(new Claim(ClaimTypes.Role, role));
  220. }
  221. }
  222. }
  223. auditStore.RecordSuccess(subject, context.HttpContext.TraceIdentifier);
  224. return Task.CompletedTask;
  225. },
  226. OnAuthenticationFailed = context =>
  227. {
  228. var auditStore = context.HttpContext.RequestServices
  229. .GetRequiredService<IAuthenticationAuditStore>();
  230. var reason = context.Exception is null
  231. ? "invalid bearer token"
  232. : $"invalid bearer token: {context.Exception.GetType().Name}";
  233. auditStore.RecordFailure(reason, context.HttpContext.TraceIdentifier);
  234. return Task.CompletedTask;
  235. },
  236. OnChallenge = context =>
  237. {
  238. if (context.AuthenticateFailure is null &&
  239. context.Request.Headers.ContainsKey("Authorization"))
  240. {
  241. var auditStore = context.HttpContext.RequestServices
  242. .GetRequiredService<IAuthenticationAuditStore>();
  243. auditStore.RecordFailure("invalid authorization header", context.HttpContext.TraceIdentifier);
  244. }
  245. return Task.CompletedTask;
  246. },
  247. };
  248. });
  249. builder.Services.AddAuthorization(options =>
  250. {
  251. var recognizedPolicy = new AuthorizationPolicyBuilder()
  252. .RequireAuthenticatedUser()
  253. .RequireAssertion(context => ApplicationRole.NormalizeMany(
  254. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value)).Length > 0)
  255. .Build();
  256. options.DefaultPolicy = recognizedPolicy;
  257. options.AddPolicy(ApplicationPolicy.RecognizedApplicationRole, recognizedPolicy);
  258. options.AddPolicy(ApplicationPolicy.ClientServicesAccess, policy =>
  259. policy.RequireAuthenticatedUser()
  260. .RequireAssertion(context => ApplicationRole.HasAny(
  261. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  262. ApplicationRole.ClientServices)));
  263. options.AddPolicy(ApplicationPolicy.ProductionAccess, policy =>
  264. policy.RequireAuthenticatedUser()
  265. .RequireAssertion(context => ApplicationRole.HasAny(
  266. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  267. ApplicationRole.Production)));
  268. options.AddPolicy(ApplicationPolicy.AdminAccess, policy =>
  269. policy.RequireAuthenticatedUser()
  270. .RequireAssertion(context => ApplicationRole.HasAny(
  271. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  272. ApplicationRole.Admin)));
  273. });
  274. var app = builder.Build();
  275. // Release-gate CLI: when invoked with --check-legacy-schema we run the
  276. // compatibility check and exit instead of starting the web host. CI/CD blocks
  277. // releases on a non-zero exit code (Story 1.7 AC #4).
  278. if (LegacySchemaReleaseGate.ShouldRun(args))
  279. {
  280. var exitCode = await LegacySchemaReleaseGate.ExecuteAsync(
  281. app.Services.GetRequiredService<ILegacySchemaCompatibilityCheck>(),
  282. app.Services.GetRequiredService<ILegacySchemaCheckHistory>(),
  283. Console.Out);
  284. return exitCode;
  285. }
  286. // Configure the HTTP request pipeline.
  287. if (app.Environment.IsDevelopment())
  288. {
  289. app.MapOpenApi();
  290. }
  291. app.UseHttpsRedirection();
  292. app.UseCors("ConfiguredOrigins");
  293. app.UseAuthentication();
  294. app.UseAuthorization();
  295. app.MapControllers();
  296. app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
  297. app.Run();
  298. return 0;
  299. static void EnsureHttpsEndpoint(string endpoint, string settingName)
  300. {
  301. if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
  302. !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
  303. {
  304. throw new InvalidOperationException($"{settingName} must be an HTTPS URL outside Development.");
  305. }
  306. }
  307. public partial class Program;

Powered by TurnKey Linux.