< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Startup
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Startup.cs
Line coverage
94%
Covered lines: 111
Uncovered lines: 7
Coverable lines: 118
Total lines: 371
Line coverage: 94%
Branch coverage
77%
Covered branches: 17
Total branches: 22
Branch coverage: 77.2%
Method coverage
100%
Covered methods: 11
Fully covered methods: 10
Total methods: 11
Method coverage: 100%
Full method coverage: 90.9%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ConfigureStandardSequence(...)100%11100%
CreateApplication()100%11100%
ConfigureServices(...)50%22100%
AddDependencies()100%11100%
AddDatabase()75%44100%
AsksForDeveloperExceptionPage(...)100%22100%
DecideSensitiveDiagnostics()100%44100%
AddMetrics()50%6453.33%
ConfigureCors()83.33%66100%
SeedDatabase(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Startup.cs

#LineLine coverage
 1using ArturRios.Data.PostgreSql;
 2using ArturRios.Data.Relational.Core.DependencyInjection;
 3using ArturRios.Heimdall.Command.Registration;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Data.Configuration;
 6using ArturRios.Heimdall.Data.Registration;
 7using ArturRios.Heimdall.Data.Seeding;
 8using ArturRios.Heimdall.Query.Registration;
 9using ArturRios.Heimdall.WebApi.Documentation;
 10using ArturRios.Heimdall.WebApi.Metrics;
 11using ArturRios.Heimdall.WebApi.Security;
 12using ArturRios.Util.WebApi.Configuration;
 13using ArturRios.Util.WebApi.Security.Extensions;
 14using Microsoft.AspNetCore.Diagnostics;
 15using Microsoft.Net.Http.Headers;
 16using Serilog;
 17
 18namespace ArturRios.Heimdall.WebApi;
 19
 20/// <summary>
 21///     Builds the API on Util.WebApi's standard sequence: configuration, controllers, the
 22///     invalid-model-state envelope and Swagger, then <see cref="ConfigureServices" />, then the
 23///     standard pipeline — forwarded headers, tracing, <c>ExceptionMiddleware</c>, Swagger, CORS and
 24///     <c>AuthenticationMiddleware</c>, in that order — and the controllers.
 25/// </summary>
 26/// <remarks>
 27///     What the standard pipeline has no place for is added around it rather than inside it. The
 28///     metrics branch, forwarded headers, the developer exception page and HTTPS redirection go ahead
 29///     of it, through <see cref="EdgePipeline" />; rate limiting goes after it, in
 30///     <see cref="CreateApplication" />.
 31///     Call <see cref="CreateApplication" />, not <c>Build</c>, to get a complete API.
 32/// </remarks>
 33public class Startup : WebApiStartup
 34{
 35    private const string CorsAllowedOriginsEnvironmentVariable = "HEIMDALL_CORS_ALLOWED_ORIGINS";
 36    private const string CorsPolicyName = "HeimdallFrontEnds";
 37
 38    /// <summary>
 39    ///     Read in <see cref="AddMetrics" />, after <c>LoadConfiguration</c> has put the .env file's
 40    ///     values in the environment, and used again by <see cref="EdgePipeline" /> to mount the scrape
 41    ///     endpoint — one reading for both, so the exporter and its endpoint cannot disagree.
 42    /// </summary>
 97343    private MetricsOptions _metrics = MetricsOptions.Disabled;
 44
 45    /// <summary>
 46    ///     Decided once in <see cref="AddDependencies" /> (NFR-28) and read by every place that turns a
 47    ///     sensitive diagnostic on — EF Core's options, <see cref="EdgePipeline" />'s developer exception
 48    ///     page and, for the page the host adds by itself in Development,
 49    ///     <see cref="SuppressedDeveloperPageFilter" /> — so they can never disagree. Everything off until
 50    ///     it is decided.
 51    /// </summary>
 97352    private SensitiveDiagnostics _diagnostics = SensitiveDiagnostics.None;
 53
 97354    public Startup(string[] args) : base(args, ConfigureStandardSequence)
 55    {
 56        // Before anything else, so every later step has somewhere to log — which is why
 57        // LoggingConfiguration reads its settings straight from the environment (see its remarks).
 97358        LoggingConfiguration.Configure();
 59
 97360        Builder.Host.UseSerilog();
 61
 97362        Log.Information("Hello world!");
 97363        Log.Information("Building web api on {EnvironmentEnvironmentName} environment", Builder.Environment.EnvironmentN
 97364    }
 65
 66    /// <summary>
 67    ///     Tunes the parts of the standard sequence Heimdall does not take as they come.
 68    /// </summary>
 69    /// <remarks>
 70    ///     <para>
 71    ///         Swagger is described by <see cref="SwaggerConfiguration" /> alone, the same method
 72    ///         tools/ArturRios.Heimdall.OpenApiGen applies to produce docs/openapi/heimdall.json, so the
 73    ///         published page and the running API's are one document. Util.WebApi's own JWT scheme is
 74    ///         left off for that reason: <see cref="SwaggerConfiguration" /> defines the "Bearer" scheme
 75    ///         and the document-wide requirement itself, and the library's would collide with it — its
 76    ///         <c>AddSecurityDefinition</c> throws on a duplicate key, and it runs after this callback.
 77    ///     </para>
 78    ///     <para>
 79    ///         This does not expose the document in production: Util.WebApi registers Swagger only in
 80    ///         the environments it allows, and in Production it registers nothing at all.
 81    ///     </para>
 82    ///     <para>
 83    ///         The client IP address goes in every request's log entry. That is the library's default,
 84    ///         set here all the same because it is a decision about personal data, not a technicality:
 85    ///         the Privacy Notice (1.2) and the Data Retention Schedule declare it, and turning it off
 86    ///         would make them say more than the API does.
 87    ///     </para>
 88    ///     <para>
 89    ///         The same address is tagged on the request's activity as <c>client.address</c>, also the
 90    ///         library's default and also set here, for the same reason. Nothing exports traces, so
 91    ///         today the tag lives and dies with the activity in memory; adding a trace exporter would
 92    ///         carry the address out of the process, and that is a change to the Privacy Notice and the
 93    ///         Data Retention Schedule before it is a change here.
 94    ///     </para>
 95    /// </remarks>
 96    private static void ConfigureStandardSequence(WebApiStartupOptions options)
 97    {
 97398        options.Swagger.ConfigureGenerator = SwaggerConfiguration.Configure;
 97399        options.TraceActivity.LogClientIp = true;
 973100        options.TraceActivity.TagClientAddress = true;
 973101    }
 102
 103    /// <summary>
 104    ///     Builds the API and completes it: <c>Build</c>'s standard sequence, then rate limiting, then
 105    ///     the database seed — so nothing is served before the reference data exists.
 106    /// </summary>
 107    public WebApplication CreateApplication()
 108    {
 973109        var app = Build();
 110
 966111        Log.Information("App built successfully");
 112
 113        // After the standard pipeline, because it has no slot for it. Rate limiting still sees the
 114        // endpoint — WebApplication routes ahead of every middleware it is given — and still runs
 115        // before MVC: the endpoint itself is always the last step, however late this is added. Most
 116        // rate-limited endpoints are anonymous, so AuthenticationMiddleware passes them through to
 117        // this point rather than spending anything on them first; the few authenticated ones that
 118        // check a password or a second factor pay only for reading the bearer token.
 966119        app.UseRateLimiter();
 120
 966121        Log.Information("App configured successfully");
 122
 966123        SeedDatabase(app);
 124
 966125        Log.Information("Services started successfully");
 966126        Log.Information("Ready to run!");
 127
 966128        return app;
 129    }
 130
 131    protected override void ConfigureServices(WebApplicationBuilder builder)
 132    {
 973133        Log.Information("Configuration loaded successfully");
 134
 973135        Builder.Services.AddHeimdallControllers();
 136
 973137        Log.Information("Web api configured successfully");
 138
 973139        AddDependencies();
 140
 973141        Log.Information("Dependencies added successfully");
 142
 973143        AddMetrics();
 144
 973145        builder.Services.AddSingleton<IStartupFilter>(new EdgePipeline(_metrics, _diagnostics));
 146
 147        // The insecure test cost is for the test suite: deployed, in Production or in homologation's
 148        // Staging, it would leave every password hash trivial to brute-force, so start-up refuses it
 149        // rather than warning.
 973150        PasswordHashing.EnsureAllowed(
 973151            Builder.Environment.IsProduction() || Builder.Environment.IsStaging(),
 973152            PasswordHashing.Configuration);
 153
 973154        builder.Services.AddTokenSecurity();
 966155        ConfigureCors();
 966156        builder.Services.AddTrustedForwardedHeaders();
 157
 966158        Log.Information("Security configured successfully");
 966159    }
 160
 161    private void AddDependencies()
 162    {
 973163        AddDatabase();
 164
 973165        Builder.Services.AddPersistenceServices();
 973166        Builder.Services.AddRequestContext();
 973167        Builder.Services.AddCommandHandlers();
 168
 973169        Builder.Services.AddDataRetention();
 170
 973171        Builder.Services.AddQueryHandlers();
 973172        Builder.Services.AddHealthProbes();
 173
 973174        Builder.Services.AddAccountEmailFlows();
 973175        Builder.Services.AddEmailSenders(Builder.Environment);
 973176        Builder.Services.AddGoogleSignIn(Builder.Environment);
 177
 973178        Builder.Services.AddDataProtectionKeyRing();
 973179        Builder.Services.AddTwoFactorServices();
 973180        Builder.Services.AddTokenIssuing();
 973181        Builder.Services.AddDatabaseSeeding();
 973182    }
 183
 184    private void AddDatabase()
 185    {
 186        // EF diagnostics expose parameter and column values — password hashes, salts, e-mails — so
 187        // they stay off in production, and off wherever the instance serves a public host unless the
 188        // operator keeps them on (NFR-28).
 973189        DecideSensitiveDiagnostics();
 190
 973191        Builder.Services.AddSingleton(_diagnostics);
 192
 193        // The developer exception page the host adds in Development answers to the same decision:
 194        // where the environment asks for the page but it is off, a failure that reaches it gets the
 195        // API's own 500 envelope. Elsewhere there is no page, so there is nothing to filter.
 973196        if (AsksForDeveloperExceptionPage(Builder.Environment) && !_diagnostics.DeveloperExceptionPage)
 197        {
 1198            Builder.Services.AddSingleton<IDeveloperPageExceptionFilter, SuppressedDeveloperPageFilter>();
 199        }
 200
 973201        Builder.Services.AddSingleton(new DbContextDiagnosticsOptions
 973202        {
 973203            SensitiveDataLogging = _diagnostics.DataLogging,
 973204            DetailedErrors = _diagnostics.DataLogging
 973205        });
 206
 973207        Builder.Services.AddPostgreSqlProvider();
 973208        Builder.Services.AddDataConfigFromEnvironment<AppDbContext>("HEIMDALL_DATA");
 209
 973210        DatabaseConnectionGuard.WarnIfDatabaseConnectionIsNotEncrypted();
 973211    }
 212
 213    /// <summary>
 214    ///     Whether the environment asks for the developer exception page: Development and Local. What
 215    ///     is served is <see cref="_diagnostics" />, which may turn it off.
 216    /// </summary>
 217    /// <remarks>
 218    ///     Local is included alongside Development because it is what a developer machine now runs:
 219    ///     the configuration loader resolves Environments/.env.&lt;environment&gt;, so the launch
 220    ///     profiles name Local to reach .env.local rather than relying on the loader's fallback.
 221    ///     Testing <c>IsDevelopment()</c> alone would have silently cost the developer exception page
 222    ///     in the one environment that exists to have it.
 223    /// </remarks>
 224    private static bool AsksForDeveloperExceptionPage(IWebHostEnvironment environment) =>
 1946225        environment.IsDevelopment() || environment.IsEnvironment("Local");
 226
 227    /// <summary>
 228    ///     NFR-28: settles <see cref="_diagnostics" />. On an instance that serves a public host the
 229    ///     sensitive diagnostics are turned off, unless <c>HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS</c> is
 230    ///     <c>true</c> (see <see cref="SensitiveDiagnosticsGuard" />). Either way it warns, once.
 231    /// </summary>
 232    private void DecideSensitiveDiagnostics()
 233    {
 973234        _diagnostics = SensitiveDiagnosticsGuard.EvaluateFromEnvironment(
 973235            !Builder.Environment.IsProduction(),
 973236            AsksForDeveloperExceptionPage(Builder.Environment));
 237
 973238        switch (_diagnostics.Decision)
 239        {
 240            case SensitiveDiagnosticsDecision.Suppressed:
 1241                Log.Warning(
 1242                    "This instance serves a public host ({Hosts}), so EF Core sensitive data logging, " +
 1243                    "detailed errors and the developer exception page are off in the {Environment} " +
 1244                    "environment. Set {Variable}=true to keep them on where the instance holds made-up " +
 1245                    "people only",
 1246                    string.Join(", ", _diagnostics.PublicHosts), Builder.Environment.EnvironmentName,
 1247                    SensitiveDiagnosticsGuard.AllowVariable);
 1248                break;
 249
 250            case SensitiveDiagnosticsDecision.Allowed:
 1251                Log.Warning(
 1252                    "Sensitive diagnostics are on in the {Environment} environment and this instance serves " +
 1253                    "a public host ({Hosts}); {Variable} is true, which states that it holds made-up people only",
 1254                    Builder.Environment.EnvironmentName, string.Join(", ", _diagnostics.PublicHosts),
 1255                    SensitiveDiagnosticsGuard.AllowVariable);
 256                break;
 257        }
 972258    }
 259
 260    /// <summary>
 261    ///     Registers the OpenTelemetry metrics pipeline and its Prometheus exporter, on the port
 262    ///     <c>HEIMDALL_METRICS_PORT</c> names (9464 by default, <c>0</c> to switch it off).
 263    /// </summary>
 264    /// <remarks>
 265    ///     The scrape endpoint is recognised by the port the connection arrived on, never by the
 266    ///     <c>Host</c> header — Traefik forwards the client's own, so it is attacker-controlled. See
 267    ///     <see cref="MetricsOptions" /> for why the port is the one thing a caller cannot choose, and
 268    ///     the Dockerfile for how the container is made to listen on it without publishing it.
 269    /// </remarks>
 270    private void AddMetrics()
 271    {
 973272        _metrics = MetricsOptions.FromEnvironment();
 273
 973274        if (_metrics.InvalidValue is not null)
 275        {
 0276            Log.Warning(
 0277                "Ignoring unusable {Variable} value {Value}; serving metrics on the default port {Port}",
 0278                MetricsOptions.PortVariable, _metrics.InvalidValue, _metrics.Port);
 279        }
 280
 973281        if (!_metrics.Enabled)
 282        {
 0283            Log.Warning(
 0284                "Metrics are switched off by {Variable}; nothing will be collected or served for Prometheus",
 0285                MetricsOptions.PortVariable);
 286
 0287            return;
 288        }
 289
 973290        Builder.Services.AddHeimdallMetrics(_metrics);
 291
 973292        Log.Information(
 973293            "Serving Prometheus metrics at {Path} on port {Port} only",
 973294            MetricsOptions.ScrapePath, _metrics.Port);
 973295    }
 296
 297    /// <summary>
 298    ///     Restricts cross-origin access to the front ends named by
 299    ///     <c>HEIMDALL_CORS_ALLOWED_ORIGINS</c> (comma-separated, scheme and host as the browser sends
 300    ///     them, e.g. <c>https://app.example.com</c>). With the variable unset, no cross-origin
 301    ///     request is allowed.
 302    /// </summary>
 303    /// <remarks>
 304    ///     <para>
 305    ///         This used to be <c>AllowAnyOrigin</c> in every environment. The same-origin policy is
 306    ///         the control that stops a page on an unrelated origin from reading an authenticated
 307    ///         response, and switching it off system-wide is a poor trade for an identity API: any
 308    ///         site the caller visits could then read <c>/api/persons/{id}</c> with a token it
 309    ///         scraped, and drive the anonymous endpoints from every visitor's browser at once.
 310    ///     </para>
 311    ///     <para>
 312    ///         The policy is registered here and applied by the standard pipeline, which names it in
 313    ///         <c>UseCors</c> ahead of <c>AuthenticationMiddleware</c>, so a preflight request is
 314    ///         answered without a token. With no origins configured no policy is named, and the
 315    ///         pipeline adds no CORS middleware at all.
 316    ///     </para>
 317    ///     <para>
 318    ///         Refusing by default rather than falling back to the wildcard is deliberate, and it is
 319    ///         the <c>UnconfiguredGoogleIdTokenVerifier</c> judgement rather than the e-mail sender's:
 320    ///         a missing CORS entry costs a browser-based front end its access until an operator adds
 321    ///         one, which is visible and quickly fixed, while defaulting to "any origin" would leave a
 322    ///         deployment wide open with nothing to indicate it. Server-to-server callers are
 323    ///         unaffected — CORS is a browser rule and non-browser clients never send an Origin.
 324    ///     </para>
 325    /// </remarks>
 326    private void ConfigureCors()
 327    {
 966328        var origins = (Environment.GetEnvironmentVariable(CorsAllowedOriginsEnvironmentVariable) ?? string.Empty)
 966329            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 330
 966331        if (origins.Length == 0)
 332        {
 965333            Log.Warning(
 965334                "No cross-origin front end is configured ({Variable}); every cross-origin request " +
 965335                "will be refused by the browser's same-origin policy",
 965336                CorsAllowedOriginsEnvironmentVariable);
 337
 965338            return;
 339        }
 340
 1341        Log.Information("Allowing cross-origin requests from {Origins}", origins);
 342
 343        // Credentials are allowed because the front end sends the bearer token UC-11 issued. That is
 344        // also why the origin list has to be explicit: AllowAnyOrigin and AllowCredentials are
 345        // mutually exclusive by specification, precisely to stop this combination from existing.
 346        // Retry-After is exposed because it is not a safelisted response header: without it a front
 347        // end on another origin sees a 429 but cannot read how long to wait.
 3348        Builder.Services.AddCors(cors => cors.AddPolicy(CorsPolicyName, policy => policy
 3349            .WithOrigins(origins)
 3350            .AllowAnyMethod()
 3351            .AllowAnyHeader()
 3352            .AllowCredentials()
 3353            .WithExposedHeaders(HeaderNames.RetryAfter)));
 354
 1355        Options.CorsPolicy = CorsPolicyName;
 1356    }
 357
 358    /// <summary>
 359    ///     Runs the database seeder before the host starts serving, so the reference data the
 360    ///     application depends on is guaranteed to exist. Migrations are not applied here — the
 361    ///     seeder throws if any are pending.
 362    /// </summary>
 363    private static void SeedDatabase(WebApplication app)
 364    {
 966365        using var scope = app.Services.CreateScope();
 366
 966367        var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
 368
 966369        seeder.SeedAsync().GetAwaiter().GetResult();
 1932370    }
 371}