< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Startup
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Startup.cs
Line coverage
89%
Covered lines: 592
Uncovered lines: 66
Coverable lines: 658
Total lines: 1509
Line coverage: 89.9%
Branch coverage
66%
Covered branches: 64
Total branches: 96
Branch coverage: 66.6%
Method coverage
100%
Covered methods: 28
Fully covered methods: 21
Total methods: 28
Method coverage: 100%
Full method coverage: 75%

Metrics

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Startup.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Net.Sockets;
 4using ArturRios.Data.PostgreSql;
 5using ArturRios.Data.Relational.Core.DependencyInjection;
 6using ArturRios.Heimdall.Command.Auditing;
 7using ArturRios.Heimdall.Command.Handlers;
 8using ArturRios.Heimdall.Command.Input;
 9using ArturRios.Heimdall.Command.Input.Validation;
 10using ArturRios.Heimdall.Command.Output;
 11using ArturRios.Heimdall.Command.Services;
 12using ArturRios.Heimdall.Domain.Enums;
 13using ArturRios.Heimdall.Domain.Persistence;
 14using ArturRios.Heimdall.Data.Configuration;
 15using ArturRios.Heimdall.Data.Persistence;
 16using ArturRios.Heimdall.Data.Seeding;
 17using ArturRios.Heimdall.Query.Handlers;
 18using ArturRios.Heimdall.Query.HealthChecks;
 19using ArturRios.Heimdall.Query.Input;
 20using ArturRios.Heimdall.Query.Input.Validation;
 21using ArturRios.Heimdall.Query.Output;
 22using ArturRios.Heimdall.Shared.Messages;
 23using ArturRios.Heimdall.Shared.Retention;
 24using ArturRios.Heimdall.Shared.Security;
 25using ArturRios.Heimdall.Shared.Services;
 26using ArturRios.Heimdall.WebApi.Binding;
 27using ArturRios.Heimdall.WebApi.Documentation;
 28using ArturRios.Heimdall.WebApi.Email;
 29using ArturRios.Heimdall.WebApi.Metrics;
 30using ArturRios.Heimdall.WebApi.Monitoring;
 31using ArturRios.Heimdall.WebApi.Retention;
 32using ArturRios.Heimdall.WebApi.Security;
 33using ArturRios.Jwt;
 34using ArturRios.Output;
 35using ArturRios.Messaging.Email;
 36using ArturRios.Mediator.Command;
 37using ArturRios.Mediator.Query;
 38using ArturRios.Mediator.Query.Interfaces;
 39using ArturRios.Util.WebApi.Configuration;
 40using Microsoft.AspNetCore.DataProtection;
 41using Microsoft.AspNetCore.Diagnostics;
 42using FluentValidation;
 43using Microsoft.AspNetCore.HttpOverrides;
 44using ArturRios.Util.WebApi.Security.Authentication;
 45using ArturRios.Util.WebApi.Security.Enums;
 46using ArturRios.Util.WebApi.Security.Extensions;
 47using ArturRios.Util.WebApi.Security.Interfaces;
 48using Microsoft.AspNetCore.RateLimiting;
 49using Microsoft.AspNetCore.Rewrite;
 50using Microsoft.Net.Http.Headers;
 51using Serilog;
 52using Serilog.Events;
 53using Serilog.Formatting.Json;
 54using System.Threading.RateLimiting;
 55
 56namespace ArturRios.Heimdall.WebApi;
 57
 58/// <summary>
 59///     Builds the API on Util.WebApi's standard sequence: configuration, controllers, the
 60///     invalid-model-state envelope and Swagger, then <see cref="ConfigureServices" />, then the
 61///     standard pipeline — forwarded headers, tracing, <c>ExceptionMiddleware</c>, Swagger, CORS and
 62///     <c>AuthenticationMiddleware</c>, in that order — and the controllers.
 63/// </summary>
 64/// <remarks>
 65///     What the standard pipeline has no place for is added around it rather than inside it. The
 66///     metrics branch, forwarded headers, the developer exception page and HTTPS redirection go ahead
 67///     of it, through <see cref="EdgePipeline" />; rate limiting goes after it, in
 68///     <see cref="CreateApplication" />.
 69///     Call <see cref="CreateApplication" />, not <c>Build</c>, to get a complete API.
 70/// </remarks>
 71public class Startup : WebApiStartup
 72{
 73    /// <summary>
 74    ///     Set to <c>true</c> to refuse start-up when the database connection does not require TLS,
 75    ///     rather than warning. Opt-in: see <see cref="WarnIfDatabaseConnectionIsNotEncrypted" />.
 76    /// </summary>
 77    private const string RequireDatabaseTlsVariable = "HEIMDALL_DATA_REQUIRE_TLS";
 78
 79    private const string LogDirectoryEnvironmentVariable = "HEIMDALL_LOG_DIRECTORY";
 80    private const string DefaultLogDirectory = "logs";
 81
 82    private const string TokenAudienceEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_AUDIENCE";
 83    private const string TokenExpirationEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS";
 84    private const string TokenIssuerEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_ISSUER";
 85    private const string TokenSecretEnvironmentVariable = TokenSigningSecrets.SecretVariable;
 86    private const string PreviousTokenSecretEnvironmentVariable = TokenSigningSecrets.PreviousSecretVariable;
 87    private const double DefaultTokenExpirationInSeconds = 3600;
 88
 89    private const string CorsAllowedOriginsEnvironmentVariable = "HEIMDALL_CORS_ALLOWED_ORIGINS";
 90    private const string CorsPolicyName = "HeimdallFrontEnds";
 91
 92    /// <summary>
 93    ///     Rate-limiting policy name applied via <c>[EnableRateLimiting(AuthEndpointRateLimitPolicy)]</c>
 94    ///     to <c>AuthController</c>'s anonymous, credential-checking endpoints. Public so the
 95    ///     controller and this configuration stay in sync without duplicating the literal.
 96    /// </summary>
 97    public const string AuthEndpointRateLimitPolicy = "AuthAnonymous";
 98
 99    /// <summary>
 100    ///     Rate-limiting policy name for the endpoints that create a person with a password — each of
 101    ///     which derives an Argon2id hash — partitioned by the acting person rather than by address
 102    ///     (see <see cref="AddAccountCreationRateLimiting" />).
 103    /// </summary>
 104    public const string AccountCreationRateLimitPolicy = "AccountCreation";
 105
 106    /// <summary>Requests per minute one actor may make to the account-creation endpoints.</summary>
 107    public const int AccountCreationPermitLimit = 30;
 108
 109    /// <summary>
 110    ///     Length, in seconds, of the <c>AuthAnonymous</c> fixed window. One value for the limiter's
 111    ///     <c>Window</c> and for the <c>Retry-After</c> a rejection falls back to, so the two cannot
 112    ///     drift apart.
 113    /// </summary>
 114    private const int AuthEndpointRateLimitWindowSeconds = 60;
 115
 116    /// <summary>
 117    ///     The IPv6 prefix one rate-limit partition covers (see <see cref="RateLimitPartitionKey" />).
 118    ///     A whole number of bytes.
 119    /// </summary>
 120    private const int RateLimitIPv6PrefixLength = 56;
 121
 122    /// <summary>
 123    ///     Read in <see cref="AddMetrics" />, after <c>LoadConfiguration</c> has put the .env file's
 124    ///     values in the environment, and used again by <see cref="EdgePipeline" /> to mount the scrape
 125    ///     endpoint — one reading for both, so the exporter and its endpoint cannot disagree.
 126    /// </summary>
 899127    private MetricsOptions _metrics = MetricsOptions.Disabled;
 128
 129    /// <summary>
 130    ///     Decided once in <see cref="AddDependencies" /> (NFR-28) and read by every place that turns a
 131    ///     sensitive diagnostic on — EF Core's options, <see cref="EdgePipeline" />'s developer exception
 132    ///     page and, for the page the host adds by itself in Development,
 133    ///     <see cref="SuppressedDeveloperPageFilter" /> — so they can never disagree. Everything off until
 134    ///     it is decided.
 135    /// </summary>
 899136    private SensitiveDiagnostics _diagnostics = SensitiveDiagnostics.None;
 137
 899138    public Startup(string[] args) : base(args, ConfigureStandardSequence)
 139    {
 140        // Before anything else, so every later step has somewhere to log — which is why
 141        // ConfigureLogging reads its settings straight from the environment (see its remarks).
 899142        ConfigureLogging();
 143
 899144        Builder.Host.UseSerilog();
 145
 899146        Log.Information("Hello world!");
 899147        Log.Information("Building web api on {EnvironmentEnvironmentName} environment", Builder.Environment.EnvironmentN
 899148    }
 149
 150    /// <summary>
 151    ///     Tunes the parts of the standard sequence Heimdall does not take as they come.
 152    /// </summary>
 153    /// <remarks>
 154    ///     <para>
 155    ///         Swagger is described by <see cref="SwaggerConfiguration" /> alone, the same method
 156    ///         tools/ArturRios.Heimdall.OpenApiGen applies to produce docs/openapi/heimdall.json, so the
 157    ///         published page and the running API's are one document. Util.WebApi's own JWT scheme is
 158    ///         left off for that reason: <see cref="SwaggerConfiguration" /> defines the "Bearer" scheme
 159    ///         and the document-wide requirement itself, and the library's would collide with it — its
 160    ///         <c>AddSecurityDefinition</c> throws on a duplicate key, and it runs after this callback.
 161    ///     </para>
 162    ///     <para>
 163    ///         This does not expose the document in production: Util.WebApi registers Swagger only in
 164    ///         the environments it allows, and in Production it registers nothing at all.
 165    ///     </para>
 166    ///     <para>
 167    ///         The client IP address goes in every request's log entry. That is the library's default,
 168    ///         set here all the same because it is a decision about personal data, not a technicality:
 169    ///         the Privacy Notice (1.2) and the Data Retention Schedule declare it, and turning it off
 170    ///         would make them say more than the API does.
 171    ///     </para>
 172    ///     <para>
 173    ///         The same address is tagged on the request's activity as <c>client.address</c>, also the
 174    ///         library's default and also set here, for the same reason. Nothing exports traces, so
 175    ///         today the tag lives and dies with the activity in memory; adding a trace exporter would
 176    ///         carry the address out of the process, and that is a change to the Privacy Notice and the
 177    ///         Data Retention Schedule before it is a change here.
 178    ///     </para>
 179    /// </remarks>
 180    private static void ConfigureStandardSequence(WebApiStartupOptions options)
 181    {
 899182        options.Swagger.ConfigureGenerator = SwaggerConfiguration.Configure;
 899183        options.TraceActivity.LogClientIp = true;
 899184        options.TraceActivity.TagClientAddress = true;
 899185    }
 186
 187    /// <summary>
 188    ///     Builds the API and completes it: <c>Build</c>'s standard sequence, then rate limiting, then
 189    ///     the database seed — so nothing is served before the reference data exists.
 190    /// </summary>
 191    public WebApplication CreateApplication()
 192    {
 899193        var app = Build();
 194
 899195        Log.Information("App built successfully");
 196
 197        // After the standard pipeline, because it has no slot for it. Rate limiting still sees the
 198        // endpoint — WebApplication routes ahead of every middleware it is given — and still runs
 199        // before MVC: the endpoint itself is always the last step, however late this is added. Most
 200        // rate-limited endpoints are anonymous, so AuthenticationMiddleware passes them through to
 201        // this point rather than spending anything on them first; the few authenticated ones that
 202        // check a password or a second factor pay only for reading the bearer token.
 899203        app.UseRateLimiter();
 204
 899205        Log.Information("App configured successfully");
 206
 899207        SeedDatabase(app);
 208
 899209        Log.Information("Services started successfully");
 899210        Log.Information("Ready to run!");
 211
 899212        return app;
 213    }
 214
 215    protected override void ConfigureServices(WebApplicationBuilder builder)
 216    {
 899217        Log.Information("Configuration loaded successfully");
 218
 899219        ConfigureWebApi();
 220
 899221        Log.Information("Web api configured successfully");
 222
 899223        AddDependencies();
 224
 899225        Log.Information("Dependencies added successfully");
 226
 899227        AddMetrics();
 228
 899229        builder.Services.AddSingleton<IStartupFilter>(new EdgePipeline(_metrics, _diagnostics));
 230
 899231        ConfigureSecurity();
 899232        ConfigureCors();
 899233        ConfigureForwardedHeaders();
 234
 899235        Log.Information("Security configured successfully");
 899236    }
 237
 238    private void AddDependencies()
 239    {
 240        // EF diagnostics expose parameter and column values — password hashes, salts, e-mails — so
 241        // they stay off in production, and off wherever the instance serves a public host unless the
 242        // operator keeps them on (NFR-28).
 899243        DecideSensitiveDiagnostics();
 244
 899245        Builder.Services.AddSingleton(_diagnostics);
 246
 247        // The developer exception page the host adds in Development answers to the same decision:
 248        // where the environment asks for the page but it is off, a failure that reaches it gets the
 249        // API's own 500 envelope. Elsewhere there is no page, so there is nothing to filter.
 899250        if (AsksForDeveloperExceptionPage(Builder.Environment) && !_diagnostics.DeveloperExceptionPage)
 251        {
 1252            Builder.Services.AddSingleton<IDeveloperPageExceptionFilter, SuppressedDeveloperPageFilter>();
 253        }
 254
 899255        Builder.Services.AddSingleton(new DbContextDiagnosticsOptions
 899256        {
 899257            SensitiveDataLogging = _diagnostics.DataLogging,
 899258            DetailedErrors = _diagnostics.DataLogging
 899259        });
 260
 899261        Builder.Services.AddPostgreSqlProvider();
 899262        Builder.Services.AddDataConfigFromEnvironment<AppDbContext>("HEIMDALL_DATA");
 263
 899264        WarnIfDatabaseConnectionIsNotEncrypted();
 265
 266        // Spending single-use credentials and charging bounded budgets as one conditional UPDATE
 267        // each, so concurrent requests cannot both spend one code or lose each other's counts.
 899268        Builder.Services.AddScoped<IAtomicWrites, AtomicWrites>();
 269        // UC-53's two-live-credentials limit, held under a row lock so concurrent issues cannot pass it.
 899270        Builder.Services.AddScoped<IApplicationCredentialSlots, ApplicationCredentialSlots>();
 271        // NFR-21 (#195): the tombstones a hard delete writes first, idempotently, as one upsert.
 899272        Builder.Services.AddScoped<IErasedSubjects, ErasedSubjects>();
 273        // FR-SR-14: the per-address budget of registration-attempt notices, charged as one upsert.
 899274        Builder.Services.AddScoped<IAttemptNoticeBudgets, AttemptNoticeBudgets>();
 275
 899276        Builder.Services.AddScoped<CommandMediator>();
 899277        Builder.Services.AddHttpContextAccessor();
 899278        Builder.Services.AddScoped<IActorAccessor, HttpContextActorAccessor>();
 899279        Builder.Services.AddScoped<IRetryAfterAdvisor, HttpResponseRetryAfterAdvisor>();
 899280        Builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
 899281        Builder.Services.AddScoped<IValidator<CreateScopeCommand>, CreateScopeCommandValidator>();
 899282        Builder.Services.AddAuditedCommandHandler<CreateScopeCommand, CreateScopeCommandOutput, CreateScopeCommandHandle
 899283        Builder.Services.AddScoped<IValidator<UpdateScopeCommand>, UpdateScopeCommandValidator>();
 899284        Builder.Services.AddAuditedCommandHandler<UpdateScopeCommand, UpdateScopeCommandOutput, UpdateScopeCommandHandle
 899285        Builder.Services.AddAuditedCommandHandler<DeleteScopeCommand, DeleteScopeCommandOutput, DeleteScopeCommandHandle
 899286        Builder.Services.AddAuditedCommandHandler<HardDeleteScopeCommand, HardDeleteScopeCommandOutput, HardDeleteScopeC
 287        // UC-24 does have a validator despite carrying a single field: Enabled is nullable so an
 288        // omitted value is refused (NFR-10) rather than binding to false and disabling the setting.
 899289        Builder.Services.AddScoped<IValidator<SetGoogleSignInCommand>, SetGoogleSignInCommandValidator>();
 899290        Builder.Services.AddAuditedCommandHandler<SetGoogleSignInCommand, SetGoogleSignInCommandOutput, SetGoogleSignInC
 899291        Builder.Services.AddScoped<IValidator<SetSelfRegistrationCommand>, SetSelfRegistrationCommandValidator>();
 899292        Builder.Services.AddAuditedCommandHandler<SetSelfRegistrationCommand, SetSelfRegistrationCommandOutput, SetSelfR
 899293        Builder.Services.AddScoped<IValidator<SetScopeEmailLinksCommand>, SetScopeEmailLinksCommandValidator>();
 899294        Builder.Services.AddAuditedCommandHandler<SetScopeEmailLinksCommand, SetScopeEmailLinksCommandOutput, SetScopeEm
 295        // UC-50 (FR-SR-12): no validator — both identifiers are route values already constrained to
 296        // GUIDs. Audited like every write, with the registration as target.
 899297        Builder.Services.AddAuditedCommandHandler<DiscardPendingRegistrationCommand, DiscardPendingRegistrationCommandOu
 899298        Builder.Services.AddScoped<IValidator<CreateAdminCommand>, CreateAdminCommandValidator>();
 899299        Builder.Services.AddAuditedCommandHandler<CreateAdminCommand, CreatePersonCommandOutput, CreateAdminCommandHandl
 899300        Builder.Services.AddScoped<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
 899301        Builder.Services.AddAuditedCommandHandler<CreateUserCommand, CreatePersonCommandOutput, CreateUserCommandHandler
 899302        Builder.Services.AddScoped<IValidator<CreateScopeOwnerCommand>, CreateScopeOwnerCommandValidator>();
 899303        Builder.Services.AddAuditedCommandHandler<CreateScopeOwnerCommand, CreatePersonCommandOutput, CreateScopeOwnerCo
 304        // No validator: UC-21's request carries no body — both identifiers are route values already
 305        // constrained to GUIDs — so there is nothing left for NFR-10 to validate.
 899306        Builder.Services.AddAuditedCommandHandler<AddScopeOwnerCommand, AddScopeOwnerCommandOutput, AddScopeOwnerCommand
 307        // Likewise no validator for UC-22: both identifiers are route values.
 899308        Builder.Services.AddAuditedCommandHandler<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput, RemoveScopeOwn
 309        // Likewise no validator for UC-23: both identifiers are route values.
 899310        Builder.Services.AddAuditedCommandHandler<PromoteScopeUserCommand, PromoteScopeUserCommandOutput, PromoteScopeUs
 899311        Builder.Services.AddScoped<IValidator<UpdatePersonCommand>, UpdatePersonCommandValidator>();
 899312        Builder.Services.AddAuditedCommandHandler<UpdatePersonCommand, UpdatePersonCommandOutput, UpdatePersonCommandHan
 899313        Builder.Services.AddAuditedCommandHandler<DeletePersonCommand, DeletePersonCommandOutput, DeletePersonCommandHan
 899314        Builder.Services.AddAuditedCommandHandler<HardDeletePersonCommand, HardDeletePersonCommandOutput, HardDeletePers
 899315        Builder.Services.AddScoped<IValidator<LoginCommand>, LoginCommandValidator>();
 899316        Builder.Services.AddAuditedCommandHandler<LoginCommand, LoginCommandOutput, LoginCommandHandler>();
 899317        Builder.Services.AddScoped<IValidator<PasswordRecoveryCommand>, PasswordRecoveryCommandValidator>();
 899318        Builder.Services.AddAuditedCommandHandler<PasswordRecoveryCommand, PasswordRecoveryCommandOutput, PasswordRecove
 899319        Builder.Services.AddScoped<IValidator<RegisterCommand>, RegisterCommandValidator>();
 899320        Builder.Services.AddAuditedCommandHandler<RegisterCommand, RegisterCommandOutput, RegisterCommandHandler>();
 899321        Builder.Services.AddScoped<IValidator<ResetPasswordCommand>, ResetPasswordCommandValidator>();
 899322        Builder.Services.AddAuditedCommandHandler<ResetPasswordCommand, ResetPasswordCommandOutput, ResetPasswordCommand
 899323        Builder.Services.AddScoped<IValidator<VerifyEmailCommand>, VerifyEmailCommandValidator>();
 899324        Builder.Services.AddAuditedCommandHandler<VerifyEmailCommand, VerifyEmailCommandOutput, VerifyEmailCommandHandle
 899325        Builder.Services.AddScoped<IValidator<RequestErasureCommand>, RequestErasureCommandValidator>();
 899326        Builder.Services
 899327            .AddAuditedCommandHandler<RequestErasureCommand, RequestErasureCommandOutput,
 899328                RequestErasureCommandHandler>();
 899329        Builder.Services
 899330            .AddAuditedCommandHandler<ExportMyDataCommand, DataExportCommandOutput,
 899331                ExportMyDataCommandHandler>();
 899332        Builder.Services
 899333            .AddAuditedCommandHandler<RestrictProcessingCommand, RestrictProcessingCommandOutput,
 899334                RestrictProcessingCommandHandler>();
 899335        Builder.Services
 899336            .AddAuditedCommandHandler<LiftProcessingRestrictionCommand,
 899337                LiftProcessingRestrictionCommandOutput, LiftProcessingRestrictionCommandHandler>();
 899338        Builder.Services
 899339            .AddAuditedCommandHandler<ReapplyErasuresCommand, ReapplyErasuresCommandOutput,
 899340                ReapplyErasuresCommandHandler>();
 341        // No validator: UC-15's request carries no caller-supplied input at all — the person comes
 342        // from the bearer token — so there is nothing for NFR-10 to validate.
 899343        Builder.Services.AddAuditedCommandHandler<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput, 
 344        // Likewise no validator for UC-25, for a different reason: the use case defines no 400 flow
 345        // and needs none, since an absent ID token fails verification (AF-25a, 401) and an empty
 346        // scope identifier matches no scope (AF-25b, 403).
 899347        Builder.Services.AddAuditedCommandHandler<GoogleSignInCommand, GoogleSignInCommandOutput, GoogleSignInCommandHan
 348        // No validator for UC-26 either, and for UC-15's reason: the sign-out request carries no
 349        // caller-supplied input — the Google User comes from the bearer token.
 899350        Builder.Services.AddAuditedCommandHandler<GoogleSignOutCommand, GoogleSignOutCommandOutput, GoogleSignOutCommand
 351        // UC-28 needs no validator either: both fields are typed route parameters, so there is no
 352        // caller-supplied input NFR-10 could reject that the route would not have refused first.
 899353        Builder.Services.AddAuditedCommandHandler<DeleteGoogleUserCommand, DeleteGoogleUserCommandOutput, DeleteGoogleUs
 899354        Builder.Services.AddAuditedCommandHandler<HardDeleteGoogleUserCommand, HardDeleteGoogleUserCommandOutput, HardDe
 899355        Builder.Services.AddScoped<IValidator<CreateApplicationCommand>, CreateApplicationCommandValidator>();
 899356        Builder.Services.AddAuditedCommandHandler<CreateApplicationCommand, CreateApplicationCommandOutput, CreateApplic
 899357        Builder.Services.AddScoped<IValidator<UpdateApplicationCommand>, UpdateApplicationCommandValidator>();
 899358        Builder.Services.AddAuditedCommandHandler<UpdateApplicationCommand, UpdateApplicationCommandOutput, UpdateApplic
 899359        Builder.Services.AddAuditedCommandHandler<DeleteApplicationCommand, DeleteApplicationCommandOutput, DeleteApplic
 899360        Builder.Services.AddAuditedCommandHandler<HardDeleteApplicationCommand, HardDeleteApplicationCommandOutput, Hard
 899361        Builder.Services.AddScoped<IValidator<IssueApplicationCredentialCommand>, IssueApplicationCredentialCommandValid
 899362        Builder.Services.AddAuditedCommandHandler<IssueApplicationCredentialCommand, IssueApplicationCredentialCommandOu
 899363        Builder.Services.AddAuditedCommandHandler<RevokeApplicationCredentialCommand, RevokeApplicationCredentialCommand
 899364        Builder.Services.AddScoped<IValidator<IssueApplicationTokenCommand>, IssueApplicationTokenCommandValidator>();
 899365        Builder.Services.AddAuditedCommandHandler<IssueApplicationTokenCommand, IssueApplicationTokenCommandOutput, Issu
 899366        Builder.Services.AddScoped<IValidator<CreateScopePermissionCommand>, CreateScopePermissionCommandValidator>();
 899367        Builder.Services.AddAuditedCommandHandler<CreateScopePermissionCommand, CreateScopePermissionCommandOutput, Crea
 899368        Builder.Services.AddScoped<IValidator<UpdateScopePermissionCommand>, UpdateScopePermissionCommandValidator>();
 899369        Builder.Services.AddAuditedCommandHandler<UpdateScopePermissionCommand, UpdateScopePermissionCommandOutput, Upda
 899370        Builder.Services.AddAuditedCommandHandler<DeleteScopePermissionCommand, DeleteScopePermissionCommandOutput, Dele
 899371        Builder.Services.AddAuditedCommandHandler<HardDeleteScopePermissionCommand, HardDeleteScopePermissionCommandOutp
 372
 899373        AddDataRetention();
 374
 899375        Builder.Services.AddScoped<QueryMediator>();
 899376        Builder.Services
 899377            .AddScoped<IQueryHandlerAsync<GetScopeByIdQuery, ScopeOutput>, GetScopeByIdQueryHandler>();
 899378        Builder.Services
 899379            .AddScoped<IPaginatedQueryHandlerAsync<ListScopesQuery, ScopeOutput>, ListScopesQueryHandler>();
 899380        Builder.Services
 899381            .AddScoped<IQueryHandlerAsync<GetPersonByIdQuery, PersonOutput>, GetPersonByIdQueryHandler>();
 899382        Builder.Services
 899383            .AddScoped<IPaginatedQueryHandlerAsync<ListScopePersonsQuery, PersonOutput>, ListScopePersonsQueryHandler>()
 899384        Builder.Services
 899385            .AddScoped<IPaginatedQueryHandlerAsync<ListScopeOwnersQuery, PersonOutput>, ListScopeOwnersQueryHandler>();
 899386        Builder.Services
 899387            .AddScoped<IPaginatedQueryHandlerAsync<ListScopeAdminsQuery, PersonSummaryOutput>,
 899388                ListScopeAdminsQueryHandler>();
 899389        Builder.Services
 899390            .AddScoped<IPaginatedQueryHandlerAsync<ListPendingRegistrationsQuery, PendingRegistrationOutput>,
 899391                ListPendingRegistrationsQueryHandler>();
 899392        Builder.Services
 899393            .AddScoped<IPaginatedQueryHandlerAsync<ListSystemAdminsQuery, PersonSummaryOutput>,
 899394                ListSystemAdminsQueryHandler>();
 899395        Builder.Services
 899396            .AddScoped<IQueryHandlerAsync<GetApplicationByIdQuery, ApplicationOutput>,
 899397                GetApplicationByIdQueryHandler>();
 899398        Builder.Services
 899399            .AddScoped<IQueryHandlerAsync<CheckApplicationTokenQuery, ApplicationTokenCheckOutput>,
 899400                CheckApplicationTokenQueryHandler>();
 899401        Builder.Services
 899402            .AddScoped<IPaginatedQueryHandlerAsync<ListScopeApplicationsQuery, ApplicationOutput>,
 899403                ListScopeApplicationsQueryHandler>();
 899404        Builder.Services
 899405            .AddScoped<IQueryHandlerAsync<ListApplicationCredentialsQuery, ApplicationCredentialListOutput>,
 899406                ListApplicationCredentialsQueryHandler>();
 899407        Builder.Services
 899408            .AddScoped<IQueryHandlerAsync<GetScopePermissionByIdQuery, ScopePermissionOutput>,
 899409                GetScopePermissionByIdQueryHandler>();
 899410        Builder.Services
 899411            .AddScoped<IPaginatedQueryHandlerAsync<ListScopePermissionsQuery, ScopePermissionOutput>,
 899412                ListScopePermissionsQueryHandler>();
 899413        Builder.Services
 899414            .AddScoped<IQueryHandlerAsync<GetGoogleUserByIdQuery, GoogleUserOutput>,
 899415                GetGoogleUserByIdQueryHandler>();
 899416        Builder.Services
 899417            .AddScoped<IPaginatedQueryHandlerAsync<ListScopeGoogleUsersQuery, GoogleUserOutput>,
 899418                ListScopeGoogleUsersQueryHandler>();
 899419        Builder.Services
 899420            .AddScoped<IPaginatedQueryHandlerAsync<ListErasureRequestsQuery, ErasureRequestOutput>,
 899421                ListErasureRequestsQueryHandler>();
 899422        Builder.Services
 899423            .AddScoped<IQueryHandlerAsync<DetectSecuritySignalsQuery, SecuritySignalsOutput>,
 899424                DetectSecuritySignalsQueryHandler>();
 899425        Builder.Services
 899426            .AddScoped<IValidator<ListErasureRequestsQuery>, ListErasureRequestsQueryValidator>();
 427
 428        // UC-48 (FR-AT-01/02): the audit trail, System Admins only.
 899429        Builder.Services
 899430            .AddScoped<IPaginatedQueryHandlerAsync<ListAuditLogQuery, AuditLogEntryOutput>,
 899431                ListAuditLogQueryHandler>();
 899432        Builder.Services.AddScoped<IValidator<ListAuditLogQuery>, ListAuditLogQueryValidator>();
 899433        Builder.Services
 899434            .AddScoped<IQueryHandlerAsync<GetTwoFactorStatusQuery, TwoFactorStatusOutput>,
 899435                GetTwoFactorStatusQueryHandler>();
 436
 437        // NFR-10: pagination/filter validation for every paginated list query.
 899438        Builder.Services.AddScoped<IValidator<ListScopesQuery>, ListScopesQueryValidator>();
 899439        Builder.Services.AddScoped<IValidator<ListScopePersonsQuery>, ListScopePersonsQueryValidator>();
 899440        Builder.Services.AddScoped<IValidator<ListScopeOwnersQuery>, ListScopeOwnersQueryValidator>();
 899441        Builder.Services.AddScoped<IValidator<ListScopeAdminsQuery>, ListScopeAdminsQueryValidator>();
 899442        Builder.Services
 899443            .AddScoped<IValidator<ListPendingRegistrationsQuery>, ListPendingRegistrationsQueryValidator>();
 899444        Builder.Services.AddScoped<IValidator<ListSystemAdminsQuery>, ListSystemAdminsQueryValidator>();
 899445        Builder.Services
 899446            .AddScoped<IValidator<ListScopeApplicationsQuery>, ListScopeApplicationsQueryValidator>();
 899447        Builder.Services
 899448            .AddScoped<IValidator<ListScopePermissionsQuery>, ListScopePermissionsQueryValidator>();
 899449        Builder.Services
 899450            .AddScoped<IValidator<ListScopeGoogleUsersQuery>, ListScopeGoogleUsersQueryValidator>();
 451
 452        // Health checks (UC-30). Each IServiceHealthCheck is one verified dependency; the detailed
 453        // handler resolves them all as IEnumerable, so new checks are added by registering another.
 899454        Builder.Services.AddScoped<IServiceHealthCheck, DatabaseHealthCheck>();
 899455        Builder.Services
 899456            .AddScoped<IQueryHandlerAsync<DetailedHealthQuery, HealthCheckOutput>, GetDetailedHealthQueryHandler>();
 457
 899458        Builder.Services.AddSingleton(EmailVerificationOptions.FromEnvironment());
 899459        Builder.Services.AddScoped<IEmailVerificationService, EmailVerificationService>();
 899460        Builder.Services.AddSingleton(PasswordResetOptions.FromEnvironment());
 899461        Builder.Services.AddScoped<IPasswordResetService, PasswordResetService>();
 462        // UC-51/UC-52 (FR-EV-07, FR-PR-06): the per-person cooldown on administrator-initiated email,
 463        // read once at start-up like the token lifetimes it is compared against.
 899464        var adminEmail = AdminEmailOptions.FromEnvironment();
 899465        WarnOnFallback(AdminEmailOptions.CooldownVariable, adminEmail.RejectedCooldown,
 899466            AdminEmailOptions.DefaultCooldownSeconds);
 899467        Builder.Services.AddSingleton(adminEmail);
 468        // UC-51: no validator — the only input is the route's person id, already constrained to a GUID.
 899469        Builder.Services.AddAuditedCommandHandler<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommand
 470        // UC-52: no validator either — the route's person id is the whole of the input.
 899471        Builder.Services.AddAuditedCommandHandler<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput, 
 472        // UC-12 runs in two halves (AF-12a): the handler queues, the dispatcher does the work after
 473        // the caller has been answered, so no address takes longer to answer than another. See
 474        // PasswordRecoveryQueue for why this is an in-memory queue rather than a floor or an outbox.
 899475        Builder.Services.AddSingleton<PasswordRecoveryQueue>();
 899476        Builder.Services.AddSingleton<IPasswordRecoveryQueue>(
 915477            provider => provider.GetRequiredService<PasswordRecoveryQueue>());
 899478        Builder.Services.AddScoped<PasswordRecoveryProcessor>();
 899479        Builder.Services.AddHostedService<PasswordRecoveryDispatcher>();
 480        // UC-47 runs in the same two halves as UC-12 (AF-47b): the handler queues, the dispatcher
 481        // stores the pending registration or tells the holder of the attempt after the caller has been
 482        // answered. Following the mailed link (UC-14) then reaches the confirmer, which creates the User.
 899483        Builder.Services.AddSingleton<RegistrationQueue>();
 899484        Builder.Services.AddSingleton<IRegistrationQueue>(
 910485            provider => provider.GetRequiredService<RegistrationQueue>());
 899486        Builder.Services.AddScoped<RegistrationProcessor>();
 899487        Builder.Services.AddScoped<IPendingRegistrationConfirmer, PendingRegistrationConfirmer>();
 899488        Builder.Services.AddHostedService<RegistrationDispatcher>();
 899489        AddEmailSenders();
 899490        AddGoogleSignIn();
 491
 492        // UC-36 (FR-2F-02): the TOTP secret is encrypted at rest with ASP.NET Core's Data
 493        // Protection API before it is persisted.
 494        //
 495        // The key ring goes in the database, and the application name is fixed, because the default
 496        // is neither durable nor shared: keys land in a directory on the local filesystem that the
 497        // image does not persist and a second instance does not see. Either way the effect is the
 498        // same and it is silent — every stored TOTP secret becomes undecryptable, ITotpCodeVerifier
 499        // catches the CryptographicException and reports it as a wrong code, and every caller whose
 500        // second factor is an authenticator app is locked out of it with no indication why. That
 501        // survives a redeploy today only because a single container keeps its filesystem between
 502        // restarts; it would not survive being recreated, and it never survives a second replica
 503        // (NFR-06).
 899504        Builder.Services.AddDataProtection()
 899505            .PersistKeysToDbContext<AppDbContext>()
 899506            .SetApplicationName("Heimdall");
 899507        Builder.Services.AddScoped<ITotpSecretProtector, TotpSecretProtector>();
 508        // Shared by UC-37's confirmation and ITwoFactorFactorVerifier (UC-38/39/40): the TOTP secret,
 509        // the clock-drift window, and the single-use rule that keeps an app code from being replayed
 510        // live in one place rather than a copy per call site.
 899511        Builder.Services.AddScoped<ITotpCodeVerifier, TotpCodeVerifier>();
 899512        Builder.Services.AddScoped<IValidator<EnableTwoFactorAuthCommand>, EnableTwoFactorAuthCommandValidator>();
 899513        Builder.Services.AddAuditedCommandHandler<EnableTwoFactorAuthCommand, EnableTwoFactorAuthCommandOutput, EnableTw
 514
 515        // UC-37 (FR-2F-04/05): no validator — which code(s) are required depends on the pending row's
 516        // AppEnabled/EmailEnabled, a database read the handler alone can make.
 899517        Builder.Services.AddAuditedCommandHandler<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput, Confir
 518
 519        // UC-38 (FR-2F-06…FR-2F-09): no validator either — same reason as UC-37, plus the challenge
 520        // token itself is validated inside the handler, the same "opaque token as a body value"
 521        // shape UC-13's ResetPasswordCommand uses.
 899522        Builder.Services.AddAuditedCommandHandler<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput, VerifyTw
 523
 524        // UC-46 (FR-2F-16): no validator — the challenge token is the whole of the input and is
 525        // validated inside the handler, the same shape UC-38 uses.
 899526        Builder.Services.AddAuditedCommandHandler<ResendTwoFactorChallengeCodeCommand, ResendTwoFactorChallengeCodeComma
 527
 528        // Shared by UC-11's AF-11g, UC-36's Email method and UC-46's reissue: retire-then-issue is
 529        // one step in one place, so the three cannot drift and leave two live codes for one
 530        // configuration — the retirement is what makes a code single-use in practice.
 899531        Builder.Services.AddScoped<ITwoFactorEmailCodeIssuer, TwoFactorEmailCodeIssuer>();
 532
 533        // Shared by VerifyTwoFactorAuthCommandHandler (UC-38), DisableTwoFactorAuthCommandHandler
 534        // (UC-39), and RegenerateRecoveryCodesCommandHandler (UC-40) — the "code against TOTP, or
 535        // against the current email code, or against an unused recovery code" comparison lives in
 536        // exactly one place.
 899537        Builder.Services.AddScoped<ITwoFactorFactorVerifier, TwoFactorFactorVerifier>();
 538
 539        // UC-39 (FR-2F-11): no validator — which shape of second factor (code vs. recoveryCode) is
 540        // valid depends on a database read (the pending row's AppEnabled/EmailEnabled), the same
 541        // reason UC-37/UC-38 have none.
 899542        Builder.Services.AddAuditedCommandHandler<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput, Disabl
 543
 544        // UC-40 (FR-2F-12): no validator either, same reason as UC-39 — reuses ITwoFactorFactorVerifier
 545        // (registered above) rather than reimplementing the second-factor check.
 899546        Builder.Services.AddAuditedCommandHandler<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput, 
 547
 899548        Builder.Services.AddScoped<IScopeOwnershipChecker, ScopeOwnershipChecker>();
 549
 550        // UC-11 issues tokens through the same claims mapper the middleware validates them with,
 551        // registered by AddTokenAuthentication in ConfigureSecurity.
 899552        Builder.Services.AddScoped<IAuthTokenIssuer, JwtAuthTokenIssuer>();
 553        // Shared by LoginCommandHandler (UC-11) and VerifyTwoFactorAuthCommandHandler (UC-38) so the
 554        // scope-eligibility rules and the final token-issuing call live in one place.
 899555        Builder.Services.AddScoped<PersonAuthTokenService>();
 556        // UC-38's challenge token (FR-2F-07…FR-2F-10, NFR-17): one class both issues and validates
 557        // it, registered once and exposed through both of its interfaces.
 899558        Builder.Services.AddScoped<JwtTwoFactorChallengeTokenIssuer>();
 899559        Builder.Services.AddScoped<ITwoFactorChallengeTokenIssuer>(
 1004560            provider => provider.GetRequiredService<JwtTwoFactorChallengeTokenIssuer>());
 899561        Builder.Services.AddScoped<ITwoFactorChallengeTokenValidator>(
 989562            provider => provider.GetRequiredService<JwtTwoFactorChallengeTokenIssuer>());
 899563        Builder.Services.AddSingleton(MasterUserOptions.FromEnvironment());
 899564        Builder.Services.AddScoped<DatabaseSeeder>();
 899565    }
 566
 567    /// <summary>
 568    ///     Whether the environment asks for the developer exception page: Development and Local. What
 569    ///     is served is <see cref="_diagnostics" />, which may turn it off.
 570    /// </summary>
 571    /// <remarks>
 572    ///     Local is included alongside Development because it is what a developer machine now runs:
 573    ///     the configuration loader resolves Environments/.env.&lt;environment&gt;, so the launch
 574    ///     profiles name Local to reach .env.local rather than relying on the loader's fallback.
 575    ///     Testing <c>IsDevelopment()</c> alone would have silently cost the developer exception page
 576    ///     in the one environment that exists to have it.
 577    /// </remarks>
 578    private static bool AsksForDeveloperExceptionPage(IWebHostEnvironment environment) =>
 1798579        environment.IsDevelopment() || environment.IsEnvironment("Local");
 580
 581    /// <summary>
 582    ///     NFR-28: settles <see cref="_diagnostics" />. On an instance that serves a public host the
 583    ///     sensitive diagnostics are turned off, unless <c>HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS</c> is
 584    ///     <c>true</c> (see <see cref="SensitiveDiagnosticsGuard" />). Either way it warns, once.
 585    /// </summary>
 586    private void DecideSensitiveDiagnostics()
 587    {
 899588        _diagnostics = SensitiveDiagnosticsGuard.EvaluateFromEnvironment(
 899589            !Builder.Environment.IsProduction(),
 899590            AsksForDeveloperExceptionPage(Builder.Environment));
 591
 899592        switch (_diagnostics.Decision)
 593        {
 594            case SensitiveDiagnosticsDecision.Suppressed:
 1595                Log.Warning(
 1596                    "This instance serves a public host ({Hosts}), so EF Core sensitive data logging, " +
 1597                    "detailed errors and the developer exception page are off in the {Environment} " +
 1598                    "environment. Set {Variable}=true to keep them on where the instance holds made-up " +
 1599                    "people only",
 1600                    string.Join(", ", _diagnostics.PublicHosts), Builder.Environment.EnvironmentName,
 1601                    SensitiveDiagnosticsGuard.AllowVariable);
 1602                break;
 603
 604            case SensitiveDiagnosticsDecision.Allowed:
 1605                Log.Warning(
 1606                    "Sensitive diagnostics are on in the {Environment} environment and this instance serves " +
 1607                    "a public host ({Hosts}); {Variable} is true, which states that it holds made-up people only",
 1608                    Builder.Environment.EnvironmentName, string.Join(", ", _diagnostics.PublicHosts),
 1609                    SensitiveDiagnosticsGuard.AllowVariable);
 610                break;
 611        }
 898612    }
 613
 614    /// <summary>
 615    ///     The middlewares that must run ahead of the standard pipeline. A startup filter is how they
 616    ///     get there: ASP.NET Core wraps the application's whole pipeline in it, so what it adds runs
 617    ///     before anything <c>Build</c> adds.
 618    /// </summary>
 899619    private sealed class EdgePipeline(MetricsOptions metrics, SensitiveDiagnostics diagnostics) : IStartupFilter
 620    {
 899621        public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
 899622        {
 899623            // First, ahead of everything else — the standard pipeline's ExceptionMiddleware and
 899624            // AuthenticationMiddleware included. The scrape endpoint is a terminal branch taken only on
 899625            // the metrics port, so Prometheus is never asked for a token, never rate limited, never
 899626            // subject to CORS, and the endpoint never reaches the OpenAPI document. Every request on
 899627            // the public port falls straight through, /metrics included.
 899628            app.UseHeimdallMetrics(metrics);
 899629
 899630            // The shared decision, never the environment directly: on a public host without the
 899631            // override it is off in Development too (NFR-28). This call is what Local needs; in
 899632            // Development the host adds its own page further in, which SuppressedDeveloperPageFilter
 899633            // holds to the same decision.
 899634            if (diagnostics.DeveloperExceptionPage)
 899635            {
 898636                app.UseDeveloperExceptionPage();
 899637            }
 899638
 899639            // Ahead of HTTPS redirection, which decides on the scheme: behind Traefik every request
 899640            // arrives as http, and only X-Forwarded-Proto says the caller used https. The standard
 899641            // pipeline runs the same middleware again first thing; by then the trusted hop has been
 899642            // consumed and the connection's address is no longer a proxy's, so it changes nothing.
 899643            app.UseForwardedHeaders();
 899644
 899645            app.UseHttpsRedirection();
 899646
 899647            // The health check moved under api/ so the web UI can reach it through its own host; the
 899648            // container probe and yggdrasil's status catalog still call the root address. Rewritten
 899649            // here, ahead of routing and authentication, so the alias runs the same endpoint with the
 899650            // same authorization, and stays out of the OpenAPI document.
 899651            app.UseRewriter(new RewriteOptions()
 899652                .AddRewrite("(?i)^healthcheck(/detailed)?/?$", "api/healthcheck$1", skipRemainingRules: true));
 899653
 899654            next(app);
 1798655        };
 656    }
 657
 658    /// <summary>
 659    ///     Registers the OpenTelemetry metrics pipeline and its Prometheus exporter, on the port
 660    ///     <c>HEIMDALL_METRICS_PORT</c> names (9464 by default, <c>0</c> to switch it off).
 661    /// </summary>
 662    /// <remarks>
 663    ///     The scrape endpoint is recognised by the port the connection arrived on, never by the
 664    ///     <c>Host</c> header — Traefik forwards the client's own, so it is attacker-controlled. See
 665    ///     <see cref="MetricsOptions" /> for why the port is the one thing a caller cannot choose, and
 666    ///     the Dockerfile for how the container is made to listen on it without publishing it.
 667    /// </remarks>
 668    private void AddMetrics()
 669    {
 899670        _metrics = MetricsOptions.FromEnvironment();
 671
 899672        if (_metrics.InvalidValue is not null)
 673        {
 0674            Log.Warning(
 0675                "Ignoring unusable {Variable} value {Value}; serving metrics on the default port {Port}",
 0676                MetricsOptions.PortVariable, _metrics.InvalidValue, _metrics.Port);
 677        }
 678
 899679        if (!_metrics.Enabled)
 680        {
 0681            Log.Warning(
 0682                "Metrics are switched off by {Variable}; nothing will be collected or served for Prometheus",
 0683                MetricsOptions.PortVariable);
 684
 0685            return;
 686        }
 687
 899688        Builder.Services.AddHeimdallMetrics(_metrics);
 689
 899690        Log.Information(
 899691            "Serving Prometheus metrics at {Path} on port {Port} only",
 899692            MetricsOptions.ScrapePath, _metrics.Port);
 899693    }
 694
 695    /// <summary>
 696    ///     Restricts cross-origin access to the front ends named by
 697    ///     <c>HEIMDALL_CORS_ALLOWED_ORIGINS</c> (comma-separated, scheme and host as the browser sends
 698    ///     them, e.g. <c>https://app.example.com</c>). With the variable unset, no cross-origin
 699    ///     request is allowed.
 700    /// </summary>
 701    /// <remarks>
 702    ///     <para>
 703    ///         This used to be <c>AllowAnyOrigin</c> in every environment. The same-origin policy is
 704    ///         the control that stops a page on an unrelated origin from reading an authenticated
 705    ///         response, and switching it off system-wide is a poor trade for an identity API: any
 706    ///         site the caller visits could then read <c>/api/persons/{id}</c> with a token it
 707    ///         scraped, and drive the anonymous endpoints from every visitor's browser at once.
 708    ///     </para>
 709    ///     <para>
 710    ///         The policy is registered here and applied by the standard pipeline, which names it in
 711    ///         <c>UseCors</c> ahead of <c>AuthenticationMiddleware</c>, so a preflight request is
 712    ///         answered without a token. With no origins configured no policy is named, and the
 713    ///         pipeline adds no CORS middleware at all.
 714    ///     </para>
 715    ///     <para>
 716    ///         Refusing by default rather than falling back to the wildcard is deliberate, and it is
 717    ///         the <c>UnconfiguredGoogleIdTokenVerifier</c> judgement rather than the e-mail sender's:
 718    ///         a missing CORS entry costs a browser-based front end its access until an operator adds
 719    ///         one, which is visible and quickly fixed, while defaulting to "any origin" would leave a
 720    ///         deployment wide open with nothing to indicate it. Server-to-server callers are
 721    ///         unaffected — CORS is a browser rule and non-browser clients never send an Origin.
 722    ///     </para>
 723    /// </remarks>
 724    private void ConfigureCors()
 725    {
 899726        var origins = (Environment.GetEnvironmentVariable(CorsAllowedOriginsEnvironmentVariable) ?? string.Empty)
 899727            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 728
 899729        if (origins.Length == 0)
 730        {
 898731            Log.Warning(
 898732                "No cross-origin front end is configured ({Variable}); every cross-origin request " +
 898733                "will be refused by the browser's same-origin policy",
 898734                CorsAllowedOriginsEnvironmentVariable);
 735
 898736            return;
 737        }
 738
 1739        Log.Information("Allowing cross-origin requests from {Origins}", origins);
 740
 741        // Credentials are allowed because the front end sends the bearer token UC-11 issued. That is
 742        // also why the origin list has to be explicit: AllowAnyOrigin and AllowCredentials are
 743        // mutually exclusive by specification, precisely to stop this combination from existing.
 744        // Retry-After is exposed because it is not a safelisted response header: without it a front
 745        // end on another origin sees a 429 but cannot read how long to wait.
 3746        Builder.Services.AddCors(cors => cors.AddPolicy(CorsPolicyName, policy => policy
 3747            .WithOrigins(origins)
 3748            .AllowAnyMethod()
 3749            .AllowAnyHeader()
 3750            .AllowCredentials()
 3751            .WithExposedHeaders(HeaderNames.RetryAfter)));
 752
 1753        Options.CorsPolicy = CorsPolicyName;
 1754    }
 755
 756    /// <summary>
 757    ///     Puts <see cref="PersonTokenValidator" /> in the place of the <see cref="JwtTokenValidator" />
 758    ///     that <c>AddTokenAuthentication</c> registers as an <see cref="ITokenValidator" />, keeping the
 759    ///     library's validator as the inner one it wraps.
 760    /// </summary>
 761    /// <remarks>
 762    ///     TB-5: the library's validator checks the signature and lifetime but not the issuer or the
 763    ///     audience, and the signing key is not Heimdall's alone. PersonTokenValidator wraps it and
 764    ///     takes its place, so a token reaches the middleware's user only if Heimdall issued it, for
 765    ///     one of the person audiences. When the library registers its validator in some other way,
 766    ///     start-up stops and says why, rather than running with the library's validator alone.
 767    /// </remarks>
 768    /// <exception cref="InvalidOperationException">
 769    ///     The services hold no <see cref="ITokenValidator" /> registered as <see cref="JwtTokenValidator" />.
 770    /// </exception>
 771    public static void ReplaceLibraryTokenValidator(IServiceCollection services)
 772    {
 901773        var libraryValidator = services.SingleOrDefault(descriptor =>
 456701774            descriptor.ServiceType == typeof(ITokenValidator) &&
 456701775            descriptor.ImplementationType == typeof(JwtTokenValidator));
 776
 901777        if (libraryValidator is null)
 778        {
 1779            throw new InvalidOperationException(
 1780                "ArturRios.Util.WebApi no longer registers JwtTokenValidator as an ITokenValidator; " +
 1781                "PersonTokenValidator cannot replace it. Refusing to start.");
 782        }
 783
 900784        services.Remove(libraryValidator);
 900785        services.AddSingleton<JwtTokenValidator>();
 900786        services.AddSingleton<ITokenValidator, PersonTokenValidator>();
 900787    }
 788
 789    private void ConfigureSecurity()
 790    {
 791        // Authentication is Util.WebApi's alone: ASP.NET Core's authentication and authorization
 792        // middlewares are not registered, because nothing here reads HttpContext.User or carries
 793        // ASP.NET Core's [Authorize] — the controllers use the library's attributes, which read the
 794        // user AuthenticationMiddleware attaches.
 795        //
 796        // AuthenticationMiddleware resolves AuthenticationOptions and the token validators from the
 797        // container, and JwtTokenValidator additionally needs JwtConfiguration, JwtHandler, and the
 798        // claims mapper. IdentityUserMapper replaces the library default so tokens carry PublicIds
 799        // and the scope claims of FR-AU-04. Defaults are kept otherwise: app JWT only, read from the
 800        // Authorization header, user rebuilt from claims — so no IAuthenticationProvider is required
 801        // and no database read happens per request.
 899802        var jwtConfiguration = BuildJwtConfiguration();
 899803        Builder.Services.AddSingleton(jwtConfiguration);
 899804        Builder.Services.AddSingleton<JwtHandler>();
 805
 806        // TH-06/TH-35: the challenge and subject-rights-only tokens carry audiences derived from the
 807        // person audience, so a consumer pinning it refuses them. Logged, like the application
 808        // audience below, so a consumer can be checked against them.
 899809        var personTokenAudiences = PersonTokenAudiences.From(jwtConfiguration);
 899810        Builder.Services.AddSingleton(personTokenAudiences);
 899811        Log.Information(
 899812            "Person tokens: audience {Audience}; challenge tokens {ChallengeAudience}; " +
 899813            "subject-rights-only tokens {SubjectRightsAudience}",
 899814            personTokenAudiences.Person, personTokenAudiences.TwoFactorChallenge,
 899815            personTokenAudiences.SubjectRights);
 816
 817        // UC-56/UC-57: application tokens share the signing keys but not the audience. Read here,
 818        // with the rest of the token configuration, so a missing or equal audience stops start-up
 819        // before anything is served (TH-35).
 899820        var applicationTokens = ApplicationTokenOptions.FromEnvironment();
 899821        Builder.Services.AddSingleton(applicationTokens);
 899822        WarnOnFallback(ApplicationTokenOptions.ExpirationVariable, applicationTokens.RejectedExpiration,
 899823            ApplicationTokenOptions.DefaultLifetime.TotalSeconds);
 899824        Builder.Services.AddScoped<JwtApplicationTokenService>();
 899825        Builder.Services.AddScoped<IApplicationTokenIssuer>(provider =>
 921826            provider.GetRequiredService<JwtApplicationTokenService>());
 899827        Builder.Services.AddScoped<IApplicationTokenValidator>(provider =>
 911828            provider.GetRequiredService<JwtApplicationTokenService>());
 829
 830        // The audience is not a secret; logging it makes a mismatch with a consumer diagnosable.
 899831        Log.Information(
 899832            "Application tokens: audience {Audience}, lifetime {Seconds}s",
 899833            applicationTokens.Audience, applicationTokens.Lifetime.TotalSeconds);
 834
 899835        Builder.Services.AddTokenAuthentication<IdentityUserMapper>(options =>
 899836        {
 899837            options.Source = TokenSource.Header;
 899838            options.EnableJwt = true;
 899839            options.EnableGoogle = false;
 899840            options.JwtMode = JwtValidationMode.ClaimsOnly;
 1798841        });
 842
 899843        ReplaceLibraryTokenValidator(Builder.Services);
 844
 899845        AddAuthEndpointRateLimiting();
 846
 847        // TH-03: bound how many Argon2id derivations run at once. Read here, with the rest of the
 848        // security configuration, because it belongs to the same defence: the rate limiter caps what
 849        // one address can ask for, and this caps what the process will attempt regardless of how
 850        // many addresses ask.
 899851        PasswordHashGate.ConfigureSharedFromEnvironment();
 899852    }
 853
 854    /// <summary>
 855    ///     Believes <c>X-Forwarded-For</c> and <c>X-Forwarded-Proto</c> from the proxies
 856    ///     <c>HEIMDALL_TRUSTED_PROXIES</c> names, and from no one else — see
 857    ///     <see cref="TrustedProxyOptions" />. Applied by <c>UseForwardedHeaders</c>, which runs
 858    ///     ahead of everything that reads the client address: the rate limiter, and the request log.
 859    /// </summary>
 860    private void ConfigureForwardedHeaders()
 861    {
 899862        var proxies = TrustedProxyOptions.FromEnvironment();
 863
 899864        if (proxies.InvalidEntries.Count > 0)
 865        {
 0866            Log.Warning(
 0867                "Ignoring unusable {Variable} entries {Entries}; they are neither an address nor a CIDR network",
 0868                TrustedProxyOptions.ProxiesVariable, proxies.InvalidEntries);
 869        }
 870
 899871        if (!proxies.Configured)
 872        {
 899873            Log.Warning(
 899874                "No trusted proxy is configured ({Variable}); behind a reverse proxy every caller will " +
 899875                "be rate limited and logged under the proxy's address",
 899876                TrustedProxyOptions.ProxiesVariable);
 877
 899878            return;
 879        }
 880
 0881        Log.Information(
 0882            "Trusting forwarded headers from {Proxies} and {Networks}",
 0883            proxies.Proxies.Select(proxy => proxy.ToString()),
 0884            proxies.Networks.Select(network => network.ToString()));
 885
 0886        Builder.Services.Configure<ForwardedHeadersOptions>(proxies.Apply);
 0887    }
 888
 889    /// <summary>
 890    ///     Throttles the credential-checking endpoints (login, password recovery/reset, email
 891    ///     verification, Google sign-in, 2FA challenge verification and resend) per calling IP
 892    ///     address. None of these require a bearer token, so nothing else stops a caller from firing
 893    ///     an unbounded burst of requests at them — each login attempt alone costs a full Argon2id
 894    ///     verification (600 MB / 16 threads by this codebase's hashing library default), and a 2FA
 895    ///     email code has only 1,000,000 possible values, so an unthrottled brute force or memory/CPU
 896    ///     exhaustion attempt is realistic without this. The authenticated endpoints that check a
 897    ///     password or a second factor (2FA confirm, disable and recovery-code regeneration, and the
 898    ///     erasure request) carry it too: a bearer token is not the password, and without a limit
 899    ///     whoever holds one could test passwords or six-digit codes against them as fast as they
 900    ///     could send requests, outside the login lockout. The verification-link resend carries it
 901    ///     as well, since each request there mails the account's address. Policy name matches the
 902    ///     <see cref="Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute" /> applied to
 903    ///     each endpoint in <c>AuthController</c>.
 904    /// </summary>
 905    /// <remarks>
 906    ///     Partitioned by <see cref="HttpContext.Connection" />'s remote IP (an IPv6 caller by its /56 —
 907    ///     see <see cref="RateLimitPartitionKey" />), which is the caller's own
 908    ///     only when <see cref="TrustedProxyOptions" /> names the proxy in front of the API; otherwise
 909    ///     every caller shares the proxy's partition. A client system that is itself one caller to
 910    ///     Heimdall for many end users may instead name each user's address, with a key from
 911    ///     <see cref="TrustedCallerOptions" /> (see <see cref="AuthRateLimitPartition" />). Either way
 912    ///     this is a per-instance, defense-in-depth throttle, not a substitute for a WAF or an API
 913    ///     gateway's own rate limiting in front of a real deployment.
 914    /// </remarks>
 915    /// <summary>
 916    ///     The partition a caller's requests are counted in: the whole address for IPv4, and the
 917    ///     <c>/56</c> it belongs to for IPv6.
 918    /// </summary>
 919    /// <remarks>
 920    ///     <para>
 921    ///         A /64 is the least one IPv6 subscriber is handed, and every address inside it is theirs
 922    ///         to use — 2<sup>64</sup> of them. Counted per address, a single caller could give every
 923    ///         request a fresh partition and never meet the limit at all. Counted per /64 it is no
 924    ///         better for the many subscribers routinely handed a /56: 256 partitions each, enough on
 925    ///         their own to keep the password-hash gate full. A /56 is one caller here.
 926    ///     </para>
 927    ///     <para>
 928    ///         Coarser is not safer: a /48 is a whole site, often many unrelated people behind one
 929    ///         provider, and they would share one budget of ten a minute. A flood from many unrelated
 930    ///         networks is not bounded here at all — that is for the edge (Threat Model TH-03). An
 931    ///         IPv4-mapped IPv6 address is one IPv4 caller, and is counted as that address.
 932    ///     </para>
 933    /// </remarks>
 934    public static string RateLimitPartitionKey(IPAddress? address)
 935    {
 787936        if (address is null)
 937        {
 624938            return "unknown";
 939        }
 940
 163941        if (address.IsIPv4MappedToIPv6)
 942        {
 2943            address = address.MapToIPv4();
 944        }
 945
 163946        if (address.AddressFamily != AddressFamily.InterNetworkV6)
 947        {
 139948            return address.ToString();
 949        }
 950
 24951        var bytes = address.GetAddressBytes();
 24952        Array.Clear(bytes, RateLimitIPv6PrefixLength / 8, 16 - RateLimitIPv6PrefixLength / 8);
 953
 24954        return $"{new IPAddress(bytes)}/{RateLimitIPv6PrefixLength}";
 955    }
 956
 957    /// <summary>
 958    ///     Warns that a numeric setting was present but unusable and its default applied instead. Absent
 959    ///     is not a fallback, so <paramref name="rejectedValue" /> is <c>null</c> for it and nothing is
 960    ///     logged. None of these values is a secret, so the rejected text is logged as written.
 961    /// </summary>
 962    private static void WarnOnFallback(string variable, string? rejectedValue, double defaultSeconds)
 963    {
 2697964        if (rejectedValue is null)
 965        {
 2697966            return;
 967        }
 968
 0969        Log.Warning(
 0970            "Ignoring unusable {Variable}={Value}; using the default of {Default} seconds",
 0971            variable, rejectedValue, defaultSeconds);
 0972    }
 973
 974    /// <summary>
 975    ///     Whether a request spends the account-creation address bound: only an administrator's
 976    ///     person access token — the full sign-in audience, neither a two-factor challenge nor a
 977    ///     subject-rights token (<see cref="IdentityUserMapper" /> has already held each token's marks
 978    ///     to its audience) — and only the roles the three endpoints admit. Anything else is turned
 979    ///     away by the endpoint's own checks and must not use up a bound administrators share.
 980    /// </summary>
 981    private static bool CountsAgainstCreationAddress(IdentityUser? actor) =>
 184982        actor is { MfaPending: false, SubjectRightsOnly: false } &&
 184983        actor.RoleId is (int)Roles.SystemAdmin or (int)Roles.ScopeAdmin;
 984
 985    /// <summary>
 986    ///     Throttles the three endpoints that create a person with a password (<c>POST /api/persons</c>,
 987    ///     <c>POST /api/scopes/{id}/persons</c>, <c>POST /api/scopes/{id}/owners</c>) twice over:
 988    ///     <see cref="AccountCreationPermitLimit" /> a minute per acting person, and as many per address.
 989    ///     Each derives an Argon2id hash through <c>PasswordHashGate</c>, and each needs only an
 990    ///     administrator's token, so without a bound one caller could keep the gate full and starve every
 991    ///     sign-in (Threat Model TH-03).
 992    /// </summary>
 993    /// <remarks>
 994    ///     <para>
 995    ///         The actor bound is the endpoint policy. The address bound — the partition the
 996    ///         credential endpoints use (<see cref="AuthRateLimitPartition" />), in a namespace of its
 997    ///         own — is the global limiter, which ASP.NET Core applies before the endpoint policy, and
 998    ///         which answers "no limit" for every endpoint without that policy. Both apply to every
 999    ///         administrator's request, so neither can be stepped around by changing only the other
 1000    ///         (Threat Model TH-03).
 1001    ///     </para>
 1002    ///     <para>
 1003    ///         Thirty a minute is more than anyone creates by hand. The limiter runs after
 1004    ///         authentication, so the actor is known. The global address bucket is spent only by an
 1005    ///         administrator's person access token (<see cref="CountsAgainstCreationAddress" />): a
 1006    ///         request without a token, or with a User's, a challenge or a subject-rights token, is
 1007    ///         refused by the endpoint anyway and must not use up a bound administrators share.
 1008    ///     </para>
 1009    /// </remarks>
 1010    private static void AddAccountCreationRateLimiting(RateLimiterOptions options, TrustedCallerOptions callers)
 1011    {
 571012        FixedWindowRateLimiterOptions Window(string _) => new()
 571013        {
 571014            PermitLimit = AccountCreationPermitLimit,
 571015            Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds),
 571016            QueueLimit = 0
 571017        };
 1018
 8991019        options.AddPolicy(AccountCreationRateLimitPolicy, httpContext =>
 8991020        {
 1821021            var actor = httpContext.GetUser<IdentityUser>();
 8991022
 1821023            return RateLimitPartition.GetFixedWindowLimiter(
 1821024                partitionKey: actor is null
 1821025                    ? AuthRateLimitPartition.KeyFor(httpContext, callers)
 1821026                    : $"actor:{actor.Id}",
 1821027                factory: Window);
 8991028        });
 1029
 8991030        options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
 23441031            httpContext.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName ==
 23441032            AccountCreationRateLimitPolicy && CountsAgainstCreationAddress(httpContext.GetUser<IdentityUser>())
 23441033                ? RateLimitPartition.GetFixedWindowLimiter(
 23441034                    "creation:" + AuthRateLimitPartition.KeyFor(httpContext, callers), Window)
 23441035                : RateLimitPartition.GetNoLimiter(string.Empty));
 8991036    }
 1037
 1038    private void AddAuthEndpointRateLimiting()
 1039    {
 1040        // Throws on a key too short to be a secret, so a misconfigured deployment stops here.
 8991041        var callers = TrustedCallerOptions.FromEnvironment();
 1042
 8991043        Builder.Services.AddSingleton(callers);
 1044
 1045        // How many, never which: the keys are secrets and the headers carrying them are not logged.
 8991046        Log.Information(
 8991047            "{Count} trusted caller key(s) configured ({Variable})",
 8991048            callers.Count, TrustedCallerOptions.KeysVariable);
 1049
 8991050        Builder.Services.AddRateLimiter(options =>
 8991051        {
 8991052            options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
 8991053
 8991054            // TH-32: a turned-away caller is told when to come back and gets the API's usual error
 8991055            // envelope, not an empty 429. The lease's own hint is used when the limiter gives one;
 8991056            // a fixed window gives none, so the window's length is the honest upper bound.
 8991057            options.OnRejected = async (context, cancellationToken) =>
 8991058            {
 161059                var retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
 161060                    ? (int)Math.Ceiling(retryAfter.TotalSeconds)
 161061                    : AuthEndpointRateLimitWindowSeconds;
 8991062
 161063                context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
 161064                context.HttpContext.Response.Headers.RetryAfter =
 161065                    Math.Max(1, retryAfterSeconds).ToString(CultureInfo.InvariantCulture);
 8991066
 161067                await context.HttpContext.Response.WriteAsJsonAsync(
 161068                    DataOutput<object?>.New.WithError(RateLimitMessages.TooManyRequests),
 161069                    cancellationToken);
 9151070            };
 8991071
 8991072            options.AddPolicy(AuthEndpointRateLimitPolicy, httpContext =>
 15591073                RateLimitPartition.GetFixedWindowLimiter(
 15591074                    partitionKey: AuthRateLimitPartition.KeyFor(httpContext, callers),
 18851075                    factory: _ => new FixedWindowRateLimiterOptions
 18851076                    {
 18851077                        PermitLimit = 10,
 18851078                        Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds),
 18851079                        QueueLimit = 0
 18851080                    }));
 8991081
 8991082            AddAccountCreationRateLimiting(options, callers);
 17981083        });
 8991084    }
 1085
 1086    /// <summary>
 1087    ///     Chooses how the two transactional emails — UC-06's verification token and UC-12's
 1088    ///     password reset token — are delivered. With Mailgun credentials present, both go out for
 1089    ///     real through <c>ArturRios.Messaging</c>; without them, both are logged.
 1090    /// </summary>
 1091    /// <remarks>
 1092    ///     The fallback is deliberate rather than a failure: a developer running the API locally,
 1093    ///     and the functional suite, both need person creation and password recovery to work without
 1094    ///     credentials and without reaching the network. Failing startup instead would make Mailgun
 1095    ///     a prerequisite for running the tests. In Production, though, the fallback logs a
 1096    ///     verification token, a password reset token, or a 2FA code in plaintext on every send — an
 1097    ///     account-takeover primitive for anyone who can read the logs — so an unconfigured Production
 1098    ///     deployment fails startup outright instead of silently degrading into that.
 1099    /// </remarks>
 1100    private void AddEmailSenders()
 1101    {
 8991102        var options = EmailDeliveryOptions.FromEnvironment();
 1103
 8991104        Builder.Services.AddSingleton(options);
 1105
 8991106        if (!options.MailgunConfigured)
 1107        {
 8991108            if (Builder.Environment.IsProduction())
 1109            {
 01110                throw new InvalidOperationException(
 01111                    $"Mailgun is not configured ({MailgunEmailService.ApiKeyVariable} / " +
 01112                    $"{MailgunEmailService.DomainVariable}) in Production. Refusing to start: the " +
 01113                    "fallback sender logs verification tokens, password reset tokens, and 2FA codes " +
 01114                    "in plaintext, which must never happen outside local development and tests.");
 1115            }
 1116
 8991117            Log.Warning(
 8991118                "Mailgun is not configured ({ApiKeyVariable} / {DomainVariable}); verification and " +
 8991119                "password reset tokens will be logged instead of emailed",
 8991120                MailgunEmailService.ApiKeyVariable, MailgunEmailService.DomainVariable);
 1121
 8991122            Builder.Services.AddScoped<IEmailVerificationSender, LoggingEmailVerificationSender>();
 8991123            Builder.Services.AddScoped<IPasswordResetSender, LoggingPasswordResetSender>();
 8991124            Builder.Services.AddScoped<ITwoFactorEmailSender, LoggingTwoFactorEmailSender>();
 8991125            Builder.Services.AddScoped<IRestrictionLiftNotifier, LoggingRestrictionLiftNotifier>();
 8991126            Builder.Services.AddScoped<IRegistrationAttemptSender, LoggingRegistrationAttemptSender>();
 8991127            Builder.Services.AddScoped<IRegistrationLinkSender, LoggingRegistrationLinkSender>();
 8991128            Builder.Services.AddScoped<IEmailChangeNoticeSender, LoggingEmailChangeNoticeSender>();
 1129
 8991130            return;
 1131        }
 1132
 1133        // A typed client, so the Mailgun service reuses pooled connections instead of creating an
 1134        // HttpClient per send.
 01135        Builder.Services.AddHttpClient<IEmailService, MailgunEmailService>();
 01136        Builder.Services.AddScoped<IEmailVerificationSender, MailgunEmailVerificationSender>();
 01137        Builder.Services.AddScoped<IPasswordResetSender, MailgunPasswordResetSender>();
 01138        Builder.Services.AddScoped<ITwoFactorEmailSender, MailgunTwoFactorEmailSender>();
 01139        Builder.Services.AddScoped<IRestrictionLiftNotifier, MailgunRestrictionLiftNotifier>();
 01140        Builder.Services.AddScoped<IRegistrationAttemptSender, MailgunRegistrationAttemptSender>();
 01141        Builder.Services.AddScoped<IRegistrationLinkSender, MailgunRegistrationLinkSender>();
 01142        Builder.Services.AddScoped<IEmailChangeNoticeSender, MailgunEmailChangeNoticeSender>();
 01143    }
 1144
 1145    /// <summary>
 1146    ///     Chooses how UC-25 verifies a Google ID token (FR-GO-11, NFR-13). With Google client IDs
 1147    ///     configured, tokens are validated against Google; without them, every token is refused, so
 1148    ///     an unconfigured deployment answers 401 rather than trusting a token no one checked.
 1149    /// </summary>
 1150    /// <remarks>
 1151    ///     The third branch exists for the functional suite, which cannot override a DI registration
 1152    ///     (<c>WebApiTest&lt;T&gt;</c> exposes neither its factory nor a settable gateway) and must
 1153    ///     still reach the flows behind verification. It is guarded twice — never in Production, and
 1154    ///     never without an explicitly set signing secret — and is checked before the real verifier so
 1155    ///     a test environment cannot accidentally run both. See <see cref="LocalGoogleIdTokenVerifier" />.
 1156    /// </remarks>
 1157    private void AddGoogleSignIn()
 1158    {
 8991159        var options = GoogleSignInOptions.FromEnvironment();
 1160
 8991161        Builder.Services.AddSingleton(options);
 1162
 8991163        if (!Builder.Environment.IsProduction() && options.TestSigningConfigured)
 1164        {
 8991165            Log.Warning(
 8991166                "Google ID tokens will be verified against a local signing secret ({Variable}), not " +
 8991167                "against Google. This is for automated tests only",
 8991168                GoogleSignInOptions.TestSigningSecretVariable);
 1169
 8991170            Builder.Services.AddScoped<IGoogleIdTokenVerifier, LocalGoogleIdTokenVerifier>();
 1171
 8991172            return;
 1173        }
 1174
 01175        if (!options.GoogleConfigured)
 1176        {
 01177            Log.Warning(
 01178                "No Google client is configured ({Variable}); Google sign-in (UC-25) will refuse " +
 01179                "every token",
 01180                GoogleSignInOptions.ClientIdsVariable);
 1181
 01182            Builder.Services.AddScoped<IGoogleIdTokenVerifier, UnconfiguredGoogleIdTokenVerifier>();
 1183
 01184            return;
 1185        }
 1186
 01187        Builder.Services.AddScoped<IGoogleIdTokenVerifier, GoogleIdTokenVerifier>();
 01188    }
 1189
 1190    /// <summary>
 1191    ///     Reads the token settings from the environment. The signing secret is required, and it and
 1192    ///     any previous secret must be at least 32 bytes of ASCII (<see cref="TokenSigningSecrets" />):
 1193    ///     an unusable one fails start-up rather than signing with a weak key, or failing every
 1194    ///     request inside the token validator.
 1195    /// </summary>
 1196    /// <remarks>
 1197    ///     <para>
 1198    ///         <see cref="PreviousTokenSecretEnvironmentVariable" /> is how a signing secret is
 1199    ///         replaced without signing everybody out (Threat Model TH-22). Both secrets are handed
 1200    ///         to <c>JwtConfiguration.Keys</c>, so a token signed with either is accepted, while new
 1201    ///         tokens are signed with the current one. Rotating is then: set the previous variable to
 1202    ///         the secret in use, set the current variable to a new one, restart; and an hour later —
 1203    ///         one token lifetime — clear the previous variable and restart again. An empty previous
 1204    ///         variable is never passed to the library as a key.
 1205    ///     </para>
 1206    ///     <para>
 1207    ///         The keys carry ids because <c>JwtKey</c> requires them, but no <c>kid</c> is written to
 1208    ///         the tokens: <c>SigningKeyId</c> is deliberately left unset, so a token names no key and
 1209    ///         validation tries both. That is the point of not using the <c>kid</c> feature here. An
 1210    ///         identifier stamped on the token has to stay attached to the same key forever, and these
 1211    ///         ids are positional — today's "current" is tomorrow's "previous" — so stamping them
 1212    ///         would make every token issued before a rotation name the wrong key afterwards, and be
 1213    ///         refused. Deriving stable ids from the secrets instead would mean publishing something
 1214    ///         computed from a signing key on every token, which is a poor trade for saving one HMAC
 1215    ///         against two keys.
 1216    ///     </para>
 1217    /// </remarks>
 1218    private static JwtConfiguration BuildJwtConfiguration()
 1219    {
 8991220        var secret = Environment.GetEnvironmentVariable(TokenSecretEnvironmentVariable);
 1221
 1222        // The current secret is always accepted; the previous one only while it is configured, and
 1223        // only when it is genuinely a different key.
 8991224        var keys = TokenSigningSecrets.Parse(
 8991225            secret, Environment.GetEnvironmentVariable(PreviousTokenSecretEnvironmentVariable));
 1226
 8991227        var lifetime = TokenLifetimeSetting.Parse(
 8991228            Environment.GetEnvironmentVariable(TokenExpirationEnvironmentVariable),
 8991229            DefaultTokenExpirationInSeconds);
 1230
 8991231        WarnOnFallback(TokenExpirationEnvironmentVariable, lifetime.RejectedValue, DefaultTokenExpirationInSeconds);
 1232
 8991233        return new JwtConfiguration(
 8991234            lifetime.Seconds,
 8991235            Environment.GetEnvironmentVariable(TokenIssuerEnvironmentVariable) ?? string.Empty,
 8991236            Environment.GetEnvironmentVariable(TokenAudienceEnvironmentVariable) ?? string.Empty,
 8991237            secret!,
 8991238            [])
 8991239        {
 8991240            Keys = keys
 8991241        };
 1242    }
 1243
 1244    /// <summary>
 1245    ///     Runs the database seeder before the host starts serving, so the reference data the
 1246    ///     application depends on is guaranteed to exist. Migrations are not applied here — the
 1247    ///     seeder throws if any are pending.
 1248    /// </summary>
 1249    private static void SeedDatabase(WebApplication app)
 1250    {
 8991251        using var scope = app.Services.CreateScope();
 1252
 8991253        var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
 1254
 8991255        seeder.SeedAsync().GetAwaiter().GetResult();
 17981256    }
 1257
 1258    private void ConfigureWebApi()
 1259    {
 1260        // The standard sequence has already called AddControllers(); calling it again adds this
 1261        // configuration to the same MVC registration.
 1262        //
 1263        // MfaPendingGuardFilter (FR-2F-10, NFR-17) runs as a global MVC authorization filter, on
 1264        // every controller action — added here rather than per-action, since a UC-38 challenge token
 1265        // must be rejected everywhere except POST /api/auth/2fa/verify, and that endpoint needs no
 1266        // opt-out: it never reads the challenge token as a bearer credential to begin with.
 8991267        Builder.Services.AddControllers(options =>
 8991268        {
 8991269            options.Filters.Add<MfaPendingGuardFilter>();
 8991270
 8991271            // NFR-24: a subject-rights-only token, issued to a restricted identity at sign-in, is
 8991272            // confined to the data-subject-rights actions — on its claims alone, so before the
 8991273            // liveness check's database read, like the guard above.
 8991274            options.Filters.Add<SubjectRightsOnlyGuardFilter>();
 8991275
 8991276            // Runs after the guard above, which is the cheap check: a challenge token misused as a
 8991277            // bearer credential is rejected on its claims alone, without spending the database read
 8991278            // this one makes. Global for the same reason — a token naming a deleted identity must be
 8991279            // refused everywhere, and leaving that to each handler is what let a logically deleted
 8991280            // System Admin keep acting until their token expired.
 8991281            options.Filters.Add<ActorLivenessFilter>();
 8991282
 8991283            // Turns a saturated password-hash gate into 503 rather than 500 (TH-03). An exception
 8991284            // filter because the condition is cross-cutting: every endpoint that derives a password
 8991285            // can meet it, and each has its own message map.
 8991286            options.Filters.Add<PasswordHashSaturationFilter>();
 8991287
 8991288            // NFR-10 (#186): a request MVC could not bind answers 400 instead of running the action with
 8991289            // a null or default argument. An action filter, so after the authorization filters above —
 8991290            // an unauthenticated caller still gets 401 — and after model binding has filled ModelState.
 8991291            options.Filters.Add<MalformedRequestFilter>();
 8991292
 8991293            // Also applied by ArturRios.Heimdall.OpenApiGen/Program.cs, which builds its own
 8991294            // AddControllers() rather than running this Startup, so that call site cannot catch a
 8991295            // removal here. Without it, [FromQuery] list-query properties marked server-populated
 8991296            // would become bindable from the query string again on the running API — the controllers
 8991297            // still overwrite them before use, so the query-string forgery tests would keep passing,
 8991298            // and the published document (generated from the other call site) would look unchanged.
 8991299            // The guarantee that these properties are non-bindable, not merely undocumented, would
 8991300            // silently revert with no test catching it.
 8991301            ModelBindingConfiguration.Configure(options);
 17981302        });
 8991303        Builder.Services.AddEndpointsApiExplorer();
 8991304    }
 1305
 1306    /// <summary>
 1307    ///     Registers the retention schedule (NFR-19) and the pass that enforces it.
 1308    /// </summary>
 1309    /// <remarks>
 1310    ///     The options are a singleton because they are a deployment-wide policy read once at
 1311    ///     start-up, and the purge is registered as a hosted service unless it has been switched
 1312    ///     off — which is a deployment decision an operator has to take deliberately, since keeping
 1313    ///     personal data past its retention period is the thing NFR-19 forbids. The handler is
 1314    ///     registered audited like every other command: the entry recording that the schedule was
 1315    ///     enforced is as much the point as the deletion is.
 1316    /// </remarks>
 1317    private void AddDataRetention()
 1318    {
 8991319        var retention = DataRetentionOptions.FromEnvironment();
 1320
 8991321        if (retention.InvalidVariables.Count > 0)
 1322        {
 01323            Log.Warning(
 01324                "Ignoring unusable retention settings and applying the documented defaults: {Variables}",
 01325                retention.InvalidVariables);
 1326        }
 1327
 8991328        Builder.Services.AddSingleton(retention);
 8991329        Builder.Services
 8991330            .AddAuditedCommandHandler<PurgeExpiredTokensCommand, PurgeExpiredTokensCommandOutput,
 8991331                PurgeExpiredTokensCommandHandler>();
 8991332        Builder.Services
 8991333            .AddAuditedCommandHandler<AnonymiseExpiredDeletionsCommand, AnonymiseExpiredDeletionsCommandOutput,
 8991334                AnonymiseExpiredDeletionsCommandHandler>();
 8991335        Builder.Services
 8991336            .AddAuditedCommandHandler<PseudonymiseAuditActorsCommand, PseudonymiseAuditActorsCommandOutput,
 8991337                PseudonymiseAuditActorsCommandHandler>();
 1338
 8991339        if (retention.PurgeEnabled)
 1340        {
 8991341            Builder.Services.AddHostedService<TokenRetentionService>();
 1342        }
 1343        else
 1344        {
 01345            Log.Warning(
 01346                "The token retention purge is switched off by {Variable}; expired tokens will not be removed",
 01347                DataRetentionOptions.PurgeEnabledVariable);
 1348        }
 1349
 8991350        if (retention.AnonymisationEnabled)
 1351        {
 8991352            Builder.Services.AddHostedService<IdentityAnonymisationService>();
 1353        }
 1354        else
 1355        {
 01356            Log.Warning(
 01357                "Identity anonymisation is switched off by {Variable}; logically deleted identities " +
 01358                "will be kept past their retention window",
 01359                DataRetentionOptions.AnonymisationEnabledVariable);
 1360        }
 1361
 8991362        if (retention.SecurityMonitoringEnabled)
 1363        {
 8991364            Builder.Services.AddHostedService<SecurityMonitoringService>();
 1365        }
 1366        else
 1367        {
 01368            Log.Warning(
 01369                "Security signal detection is switched off by {Variable}; the audit trail and lockout " +
 01370                "counters will keep recording what happens and nobody will be told about it",
 01371                DataRetentionOptions.SecurityMonitoringEnabledVariable);
 1372        }
 1373
 8991374        if (retention.AuditPseudonymisationEnabled)
 1375        {
 8991376            Builder.Services.AddHostedService<AuditRetentionService>();
 1377        }
 1378        else
 1379        {
 01380            Log.Warning(
 01381                "Audit attribution pseudonymisation is switched off by {Variable}; the trail will " +
 01382                "keep naming people past its attribution period, including erased ones",
 01383                DataRetentionOptions.AuditPseudonymisationEnabledVariable);
 1384        }
 01385    }
 1386
 1387    /// <summary>
 1388    ///     Warns when the database connection string does not require TLS (NFR-25, GDPR Art. 32,
 1389    ///     LGPD Art. 46).
 1390    /// </summary>
 1391    /// <remarks>
 1392    ///     <para>
 1393    ///         Everything this database holds is personal data, and Npgsql's default
 1394    ///         <c>SSL Mode</c> is <c>Prefer</c> — which silently falls back to an unencrypted
 1395    ///         connection when the server does not offer TLS. A deployment can therefore be sending
 1396    ///         credentials and addresses in clear text across the network while looking correctly
 1397    ///         configured, which is exactly the failure worth a start-up warning.
 1398    ///     </para>
 1399    ///     <para>
 1400    ///         It warns by default rather than refusing to start, which is a deliberate departure
 1401    ///         from how this codebase treats other security configuration — TH-23's email control
 1402    ///         fails start-up in Production. The difference is that failing by default would take
 1403    ///         down a working deployment on upgrade over a setting the operator may not control
 1404    ///         directly.
 1405    ///     </para>
 1406    ///     <para>
 1407    ///         Setting <c>HEIMDALL_DATA_REQUIRE_TLS=true</c> turns the warning into a refusal. It is
 1408    ///         opt-in rather than the default for that reason, and it is the right setting for any
 1409    ///         deployment that has confirmed TLS is in place: from that point, a connection string
 1410    ///         that does not ask for encryption is a misconfiguration rather than a known state, and
 1411    ///         starting anyway would be starting in the one condition the check exists to prevent.
 1412    ///     </para>
 1413    /// </remarks>
 1414    private static void WarnIfDatabaseConnectionIsNotEncrypted()
 1415    {
 8991416        var connectionString = Environment.GetEnvironmentVariable("HEIMDALL_DATA_CONNECTIONSTRING");
 1417
 8991418        if (string.IsNullOrWhiteSpace(connectionString))
 1419        {
 01420            return;
 1421        }
 1422
 8991423        var required = string.Equals(
 8991424            Environment.GetEnvironmentVariable(RequireDatabaseTlsVariable),
 8991425            "true",
 8991426            StringComparison.OrdinalIgnoreCase);
 1427
 8991428        var requiresTls = connectionString
 8991429            .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 44951430            .Where(part => part.Contains('='))
 44951431            .Select(part => part.Split('=', 2))
 44951432            .Where(pair => pair[0].Replace(" ", string.Empty)
 44951433                .Equals("SSLMode", StringComparison.OrdinalIgnoreCase))
 8991434            .Any(pair => pair[1].Trim() is "Require" or "VerifyCA" or "VerifyFull");
 1435
 8991436        if (requiresTls)
 1437        {
 01438            return;
 1439        }
 1440
 1441        const string explanation =
 1442            "The database connection does not require TLS. Npgsql defaults to SSL Mode=Prefer, " +
 1443            "which falls back to an unencrypted connection — every credential and address this " +
 1444            "API stores would cross the network in clear text. Set SSL Mode=Require or stronger";
 1445
 8991446        if (required)
 1447        {
 1448            // Opted in, so the operator has said TLS is in place: a connection string that does not
 1449            // ask for it is then a misconfiguration to fail on rather than warn about.
 01450            throw new InvalidOperationException(
 01451                $"{explanation}. {RequireDatabaseTlsVariable} is set, so this is refused rather than warned about.");
 1452        }
 1453
 8991454        Log.Warning("{Explanation}", explanation);
 8991455    }
 1456
 1457    /// <summary>
 1458    ///     Configures the console and file sinks, and bounds how long the files live (NFR-22).
 1459    /// </summary>
 1460    /// <remarks>
 1461    ///     <para>
 1462    ///         <b>The year/month directory layout is gone, deliberately.</b> The sink used to be
 1463    ///         wrapped in <c>WriteTo.Map</c> keyed on <c>yyyy/MM</c>, which created a <em>new sink
 1464    ///         per month</em>. A retention limit bounds the files within one sink, so it would have
 1465    ///         bounded each month's directory and never removed a month — the files from the first
 1466    ///         month a deployment ran were still on disk years later. A flat rolling sink with
 1467    ///         <c>retainedFileTimeLimit</c> is the shape where the limit actually applies.
 1468    ///     </para>
 1469    ///     <para>
 1470    ///         Read straight from the environment rather than through the configured options,
 1471    ///         because this runs before <c>LoadConfiguration</c> — the same reason the log directory
 1472    ///         is read this way. An unusable value falls back to the documented default, silently
 1473    ///         here because there is no logger yet to warn with; <c>AddDataRetention</c> reads the
 1474    ///         same variable later and does warn.
 1475    ///     </para>
 1476    ///     <para>
 1477    ///         <b>Every request's client IP address is logged</b>, by Util.WebApi's
 1478    ///         <c>TraceActivityMiddleware</c>, as the address the request came from — the real caller's
 1479    ///         once <see cref="TrustedProxyOptions" /> names the proxy in front of the API. It is
 1480    ///         security telemetry, kept for the same period as the rest of the log, and the Data
 1481    ///         Retention Schedule and the Privacy Notice say so.
 1482    ///     </para>
 1483    ///     <para>
 1484    ///         <b><c>MailgunEmailService</c> is silenced.</b> The messaging library logs every send
 1485    ///         with the recipient's address in the clear — "Sending e-mail to …" and "accepted the
 1486    ///         message for …" at Information, the same on a rejection at Error — which is exactly what
 1487    ///         NFR-22 forbids, and this codebase's own redaction cannot reach a library's templates.
 1488    ///         Nothing is lost: <c>MailgunSender</c> already logs every outcome, refusal and exception
 1489    ///         included, under a <c>LogSafeEmail</c> reference.
 1490    ///     </para>
 1491    /// </remarks>
 1492    private static void ConfigureLogging()
 1493    {
 8991494        var logDirectory = Environment.GetEnvironmentVariable(LogDirectoryEnvironmentVariable)
 8991495                           ?? DefaultLogDirectory;
 1496
 8991497        var retention = DataRetentionOptions.FromEnvironment().LogRetention;
 1498
 8991499        Log.Logger = new LoggerConfiguration()
 8991500            .MinimumLevel.Override(typeof(MailgunEmailService).FullName!, LogEventLevel.Fatal)
 8991501            .WriteTo.Console(new JsonFormatter())
 8991502            .WriteTo.File(
 8991503                new JsonFormatter(),
 8991504                Path.Combine(logDirectory, "log-.json"),
 8991505                rollingInterval: RollingInterval.Day,
 8991506                retainedFileTimeLimit: retention)
 8991507            .CreateLogger();
 8991508    }
 1509}