Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

338 řádky
14KB

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

Powered by TurnKey Linux.