Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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

Powered by TurnKey Linux.