Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

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

Powered by TurnKey Linux.