You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

282 line
12KB

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

Powered by TurnKey Linux.