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

317 строки
13KB

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

Powered by TurnKey Linux.