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

290 строки
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.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. builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
  68. builder.Services.AddSingleton(_ =>
  69. LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow));
  70. builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
  71. new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
  72. builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp =>
  73. new LegacySchemaCompatibilityCheck(
  74. sp.GetRequiredService<LegacySchemaBaseline>(),
  75. sp.GetRequiredService<ILegacySchemaInspector>(),
  76. sp.GetRequiredService<TimeProvider>()));
  77. builder.Services.AddSingleton<ILegacySchemaCheckHistory, InMemoryLegacySchemaCheckHistory>();
  78. builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>();
  79. builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>();
  80. // Legacy identifier linking for extension records (Story 1.8).
  81. // ILegacyLinkValidator resolves references through the anti-corruption layer (AC #2, AC #3).
  82. // ILegacyLinkIntegrityCheck scans all registered extension record providers (AC #4, NFR13).
  83. // Additional ILegacyLinkedRecordProvider registrations are added by each extension record story.
  84. builder.Services.AddScoped<ILegacyLinkValidator, LegacyLinkValidator>();
  85. builder.Services.AddScoped<ILegacyLinkIntegrityCheck, LegacyLinkIntegrityService>();
  86. var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
  87. builder.Services.AddCors(options =>
  88. {
  89. options.AddPolicy("ConfiguredOrigins", policy =>
  90. {
  91. if (allowedOrigins.Length > 0)
  92. {
  93. policy.WithOrigins(allowedOrigins)
  94. .AllowAnyHeader()
  95. .AllowAnyMethod();
  96. }
  97. });
  98. });
  99. var keycloakOptions = builder.Configuration
  100. .GetSection(KeycloakOptions.SectionName)
  101. .Get<KeycloakOptions>() ?? new KeycloakOptions();
  102. if (!builder.Environment.IsDevelopment())
  103. {
  104. EnsureHttpsEndpoint(keycloakOptions.Authority, "Keycloak:Authority");
  105. EnsureHttpsEndpoint(keycloakOptions.TokenIssuer, "Keycloak:ValidIssuer/PublicAuthority");
  106. EnsureHttpsEndpoint(keycloakOptions.TokenEndpointAuthority, "Keycloak:PublicAuthority/Authority");
  107. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  108. {
  109. EnsureHttpsEndpoint(keycloakOptions.MetadataAddress, "Keycloak:MetadataAddress");
  110. }
  111. if (keycloakOptions.DisableHttpsMetadata)
  112. {
  113. throw new InvalidOperationException("Keycloak HTTPS metadata validation cannot be disabled outside Development.");
  114. }
  115. }
  116. builder.Services
  117. .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  118. .AddJwtBearer(options =>
  119. {
  120. options.RequireHttpsMetadata = !keycloakOptions.DisableHttpsMetadata;
  121. if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress))
  122. {
  123. options.MetadataAddress = keycloakOptions.MetadataAddress;
  124. }
  125. options.TokenValidationParameters = new TokenValidationParameters
  126. {
  127. ValidateIssuer = true,
  128. ValidIssuer = keycloakOptions.TokenIssuer,
  129. ValidateAudience = true,
  130. ValidAudiences = keycloakOptions.TokenAudiences,
  131. ValidateLifetime = true,
  132. NameClaimType = ClaimTypes.Name,
  133. RoleClaimType = ClaimTypes.Role,
  134. };
  135. if (!string.IsNullOrWhiteSpace(keycloakOptions.TestSigningKey))
  136. {
  137. var issuerSigningKey =
  138. new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keycloakOptions.TestSigningKey));
  139. options.TokenValidationParameters.ValidateIssuerSigningKey = true;
  140. options.TokenValidationParameters.IssuerSigningKey = issuerSigningKey;
  141. options.TokenValidationParameters.IssuerSigningKeys = [issuerSigningKey];
  142. options.Configuration = new OpenIdConnectConfiguration
  143. {
  144. Issuer = keycloakOptions.TokenIssuer,
  145. };
  146. options.Configuration.SigningKeys.Add(issuerSigningKey);
  147. }
  148. else
  149. {
  150. options.Authority = keycloakOptions.Authority;
  151. }
  152. options.Events = new JwtBearerEvents
  153. {
  154. OnTokenValidated = context =>
  155. {
  156. var auditStore = context.HttpContext.RequestServices
  157. .GetRequiredService<IAuthenticationAuditStore>();
  158. var subject = context.Principal?.Identity?.Name
  159. ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier)
  160. ?? "unknown";
  161. if (context.Principal?.Identity is ClaimsIdentity identity)
  162. {
  163. var roles = ApplicationRole.NormalizeMany(
  164. ApplicationRole.ExtractKeycloakRoles(identity.Claims, keycloakOptions.ClientId));
  165. foreach (var role in roles)
  166. {
  167. if (!identity.HasClaim(ClaimTypes.Role, role))
  168. {
  169. identity.AddClaim(new Claim(ClaimTypes.Role, role));
  170. }
  171. }
  172. }
  173. auditStore.RecordSuccess(subject, context.HttpContext.TraceIdentifier);
  174. return Task.CompletedTask;
  175. },
  176. OnAuthenticationFailed = context =>
  177. {
  178. var auditStore = context.HttpContext.RequestServices
  179. .GetRequiredService<IAuthenticationAuditStore>();
  180. var reason = context.Exception is null
  181. ? "invalid bearer token"
  182. : $"invalid bearer token: {context.Exception.GetType().Name}";
  183. auditStore.RecordFailure(reason, context.HttpContext.TraceIdentifier);
  184. return Task.CompletedTask;
  185. },
  186. OnChallenge = context =>
  187. {
  188. if (context.AuthenticateFailure is null &&
  189. context.Request.Headers.ContainsKey("Authorization"))
  190. {
  191. var auditStore = context.HttpContext.RequestServices
  192. .GetRequiredService<IAuthenticationAuditStore>();
  193. auditStore.RecordFailure("invalid authorization header", context.HttpContext.TraceIdentifier);
  194. }
  195. return Task.CompletedTask;
  196. },
  197. };
  198. });
  199. builder.Services.AddAuthorization(options =>
  200. {
  201. var recognizedPolicy = new AuthorizationPolicyBuilder()
  202. .RequireAuthenticatedUser()
  203. .RequireAssertion(context => ApplicationRole.NormalizeMany(
  204. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value)).Length > 0)
  205. .Build();
  206. options.DefaultPolicy = recognizedPolicy;
  207. options.AddPolicy(ApplicationPolicy.RecognizedApplicationRole, recognizedPolicy);
  208. options.AddPolicy(ApplicationPolicy.ClientServicesAccess, policy =>
  209. policy.RequireAuthenticatedUser()
  210. .RequireAssertion(context => ApplicationRole.HasAny(
  211. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  212. ApplicationRole.ClientServices)));
  213. options.AddPolicy(ApplicationPolicy.ProductionAccess, policy =>
  214. policy.RequireAuthenticatedUser()
  215. .RequireAssertion(context => ApplicationRole.HasAny(
  216. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  217. ApplicationRole.Production)));
  218. options.AddPolicy(ApplicationPolicy.AdminAccess, policy =>
  219. policy.RequireAuthenticatedUser()
  220. .RequireAssertion(context => ApplicationRole.HasAny(
  221. context.User.FindAll(ClaimTypes.Role).Select(claim => claim.Value),
  222. ApplicationRole.Admin)));
  223. });
  224. var app = builder.Build();
  225. // Release-gate CLI: when invoked with --check-legacy-schema we run the
  226. // compatibility check and exit instead of starting the web host. CI/CD blocks
  227. // releases on a non-zero exit code (Story 1.7 AC #4).
  228. if (LegacySchemaReleaseGate.ShouldRun(args))
  229. {
  230. var exitCode = await LegacySchemaReleaseGate.ExecuteAsync(
  231. app.Services.GetRequiredService<ILegacySchemaCompatibilityCheck>(),
  232. app.Services.GetRequiredService<ILegacySchemaCheckHistory>(),
  233. Console.Out);
  234. return exitCode;
  235. }
  236. // Configure the HTTP request pipeline.
  237. if (app.Environment.IsDevelopment())
  238. {
  239. app.MapOpenApi();
  240. }
  241. app.UseHttpsRedirection();
  242. app.UseCors("ConfiguredOrigins");
  243. app.UseAuthentication();
  244. app.UseAuthorization();
  245. app.MapControllers();
  246. app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
  247. app.Run();
  248. return 0;
  249. static void EnsureHttpsEndpoint(string endpoint, string settingName)
  250. {
  251. if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
  252. !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
  253. {
  254. throw new InvalidOperationException($"{settingName} must be an HTTPS URL outside Development.");
  255. }
  256. }
  257. public partial class Program;

Powered by TurnKey Linux.