| | | 1 | | using ArturRios.Data.PostgreSql; |
| | | 2 | | using ArturRios.Data.Relational.Core.DependencyInjection; |
| | | 3 | | using ArturRios.Heimdall.Command.Registration; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Data.Configuration; |
| | | 6 | | using ArturRios.Heimdall.Data.Registration; |
| | | 7 | | using ArturRios.Heimdall.Data.Seeding; |
| | | 8 | | using ArturRios.Heimdall.Query.Registration; |
| | | 9 | | using ArturRios.Heimdall.WebApi.Documentation; |
| | | 10 | | using ArturRios.Heimdall.WebApi.Metrics; |
| | | 11 | | using ArturRios.Heimdall.WebApi.Security; |
| | | 12 | | using ArturRios.Util.WebApi.Configuration; |
| | | 13 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 14 | | using Microsoft.AspNetCore.Diagnostics; |
| | | 15 | | using Microsoft.Net.Http.Headers; |
| | | 16 | | using Serilog; |
| | | 17 | | |
| | | 18 | | namespace 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> |
| | | 33 | | public 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> |
| | 973 | 43 | | 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> |
| | 973 | 52 | | private SensitiveDiagnostics _diagnostics = SensitiveDiagnostics.None; |
| | | 53 | | |
| | 973 | 54 | | 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). |
| | 973 | 58 | | LoggingConfiguration.Configure(); |
| | | 59 | | |
| | 973 | 60 | | Builder.Host.UseSerilog(); |
| | | 61 | | |
| | 973 | 62 | | Log.Information("Hello world!"); |
| | 973 | 63 | | Log.Information("Building web api on {EnvironmentEnvironmentName} environment", Builder.Environment.EnvironmentN |
| | 973 | 64 | | } |
| | | 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 | | { |
| | 973 | 98 | | options.Swagger.ConfigureGenerator = SwaggerConfiguration.Configure; |
| | 973 | 99 | | options.TraceActivity.LogClientIp = true; |
| | 973 | 100 | | options.TraceActivity.TagClientAddress = true; |
| | 973 | 101 | | } |
| | | 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 | | { |
| | 973 | 109 | | var app = Build(); |
| | | 110 | | |
| | 966 | 111 | | 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. |
| | 966 | 119 | | app.UseRateLimiter(); |
| | | 120 | | |
| | 966 | 121 | | Log.Information("App configured successfully"); |
| | | 122 | | |
| | 966 | 123 | | SeedDatabase(app); |
| | | 124 | | |
| | 966 | 125 | | Log.Information("Services started successfully"); |
| | 966 | 126 | | Log.Information("Ready to run!"); |
| | | 127 | | |
| | 966 | 128 | | return app; |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | protected override void ConfigureServices(WebApplicationBuilder builder) |
| | | 132 | | { |
| | 973 | 133 | | Log.Information("Configuration loaded successfully"); |
| | | 134 | | |
| | 973 | 135 | | Builder.Services.AddHeimdallControllers(); |
| | | 136 | | |
| | 973 | 137 | | Log.Information("Web api configured successfully"); |
| | | 138 | | |
| | 973 | 139 | | AddDependencies(); |
| | | 140 | | |
| | 973 | 141 | | Log.Information("Dependencies added successfully"); |
| | | 142 | | |
| | 973 | 143 | | AddMetrics(); |
| | | 144 | | |
| | 973 | 145 | | 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. |
| | 973 | 150 | | PasswordHashing.EnsureAllowed( |
| | 973 | 151 | | Builder.Environment.IsProduction() || Builder.Environment.IsStaging(), |
| | 973 | 152 | | PasswordHashing.Configuration); |
| | | 153 | | |
| | 973 | 154 | | builder.Services.AddTokenSecurity(); |
| | 966 | 155 | | ConfigureCors(); |
| | 966 | 156 | | builder.Services.AddTrustedForwardedHeaders(); |
| | | 157 | | |
| | 966 | 158 | | Log.Information("Security configured successfully"); |
| | 966 | 159 | | } |
| | | 160 | | |
| | | 161 | | private void AddDependencies() |
| | | 162 | | { |
| | 973 | 163 | | AddDatabase(); |
| | | 164 | | |
| | 973 | 165 | | Builder.Services.AddPersistenceServices(); |
| | 973 | 166 | | Builder.Services.AddRequestContext(); |
| | 973 | 167 | | Builder.Services.AddCommandHandlers(); |
| | | 168 | | |
| | 973 | 169 | | Builder.Services.AddDataRetention(); |
| | | 170 | | |
| | 973 | 171 | | Builder.Services.AddQueryHandlers(); |
| | 973 | 172 | | Builder.Services.AddHealthProbes(); |
| | | 173 | | |
| | 973 | 174 | | Builder.Services.AddAccountEmailFlows(); |
| | 973 | 175 | | Builder.Services.AddEmailSenders(Builder.Environment); |
| | 973 | 176 | | Builder.Services.AddGoogleSignIn(Builder.Environment); |
| | | 177 | | |
| | 973 | 178 | | Builder.Services.AddDataProtectionKeyRing(); |
| | 973 | 179 | | Builder.Services.AddTwoFactorServices(); |
| | 973 | 180 | | Builder.Services.AddTokenIssuing(); |
| | 973 | 181 | | Builder.Services.AddDatabaseSeeding(); |
| | 973 | 182 | | } |
| | | 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). |
| | 973 | 189 | | DecideSensitiveDiagnostics(); |
| | | 190 | | |
| | 973 | 191 | | 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. |
| | 973 | 196 | | if (AsksForDeveloperExceptionPage(Builder.Environment) && !_diagnostics.DeveloperExceptionPage) |
| | | 197 | | { |
| | 1 | 198 | | Builder.Services.AddSingleton<IDeveloperPageExceptionFilter, SuppressedDeveloperPageFilter>(); |
| | | 199 | | } |
| | | 200 | | |
| | 973 | 201 | | Builder.Services.AddSingleton(new DbContextDiagnosticsOptions |
| | 973 | 202 | | { |
| | 973 | 203 | | SensitiveDataLogging = _diagnostics.DataLogging, |
| | 973 | 204 | | DetailedErrors = _diagnostics.DataLogging |
| | 973 | 205 | | }); |
| | | 206 | | |
| | 973 | 207 | | Builder.Services.AddPostgreSqlProvider(); |
| | 973 | 208 | | Builder.Services.AddDataConfigFromEnvironment<AppDbContext>("HEIMDALL_DATA"); |
| | | 209 | | |
| | 973 | 210 | | DatabaseConnectionGuard.WarnIfDatabaseConnectionIsNotEncrypted(); |
| | 973 | 211 | | } |
| | | 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.<environment>, 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) => |
| | 1946 | 225 | | 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 | | { |
| | 973 | 234 | | _diagnostics = SensitiveDiagnosticsGuard.EvaluateFromEnvironment( |
| | 973 | 235 | | !Builder.Environment.IsProduction(), |
| | 973 | 236 | | AsksForDeveloperExceptionPage(Builder.Environment)); |
| | | 237 | | |
| | 973 | 238 | | switch (_diagnostics.Decision) |
| | | 239 | | { |
| | | 240 | | case SensitiveDiagnosticsDecision.Suppressed: |
| | 1 | 241 | | Log.Warning( |
| | 1 | 242 | | "This instance serves a public host ({Hosts}), so EF Core sensitive data logging, " + |
| | 1 | 243 | | "detailed errors and the developer exception page are off in the {Environment} " + |
| | 1 | 244 | | "environment. Set {Variable}=true to keep them on where the instance holds made-up " + |
| | 1 | 245 | | "people only", |
| | 1 | 246 | | string.Join(", ", _diagnostics.PublicHosts), Builder.Environment.EnvironmentName, |
| | 1 | 247 | | SensitiveDiagnosticsGuard.AllowVariable); |
| | 1 | 248 | | break; |
| | | 249 | | |
| | | 250 | | case SensitiveDiagnosticsDecision.Allowed: |
| | 1 | 251 | | Log.Warning( |
| | 1 | 252 | | "Sensitive diagnostics are on in the {Environment} environment and this instance serves " + |
| | 1 | 253 | | "a public host ({Hosts}); {Variable} is true, which states that it holds made-up people only", |
| | 1 | 254 | | Builder.Environment.EnvironmentName, string.Join(", ", _diagnostics.PublicHosts), |
| | 1 | 255 | | SensitiveDiagnosticsGuard.AllowVariable); |
| | | 256 | | break; |
| | | 257 | | } |
| | 972 | 258 | | } |
| | | 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 | | { |
| | 973 | 272 | | _metrics = MetricsOptions.FromEnvironment(); |
| | | 273 | | |
| | 973 | 274 | | if (_metrics.InvalidValue is not null) |
| | | 275 | | { |
| | 0 | 276 | | Log.Warning( |
| | 0 | 277 | | "Ignoring unusable {Variable} value {Value}; serving metrics on the default port {Port}", |
| | 0 | 278 | | MetricsOptions.PortVariable, _metrics.InvalidValue, _metrics.Port); |
| | | 279 | | } |
| | | 280 | | |
| | 973 | 281 | | if (!_metrics.Enabled) |
| | | 282 | | { |
| | 0 | 283 | | Log.Warning( |
| | 0 | 284 | | "Metrics are switched off by {Variable}; nothing will be collected or served for Prometheus", |
| | 0 | 285 | | MetricsOptions.PortVariable); |
| | | 286 | | |
| | 0 | 287 | | return; |
| | | 288 | | } |
| | | 289 | | |
| | 973 | 290 | | Builder.Services.AddHeimdallMetrics(_metrics); |
| | | 291 | | |
| | 973 | 292 | | Log.Information( |
| | 973 | 293 | | "Serving Prometheus metrics at {Path} on port {Port} only", |
| | 973 | 294 | | MetricsOptions.ScrapePath, _metrics.Port); |
| | 973 | 295 | | } |
| | | 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 | | { |
| | 966 | 328 | | var origins = (Environment.GetEnvironmentVariable(CorsAllowedOriginsEnvironmentVariable) ?? string.Empty) |
| | 966 | 329 | | .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | | 330 | | |
| | 966 | 331 | | if (origins.Length == 0) |
| | | 332 | | { |
| | 965 | 333 | | Log.Warning( |
| | 965 | 334 | | "No cross-origin front end is configured ({Variable}); every cross-origin request " + |
| | 965 | 335 | | "will be refused by the browser's same-origin policy", |
| | 965 | 336 | | CorsAllowedOriginsEnvironmentVariable); |
| | | 337 | | |
| | 965 | 338 | | return; |
| | | 339 | | } |
| | | 340 | | |
| | 1 | 341 | | 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. |
| | 3 | 348 | | Builder.Services.AddCors(cors => cors.AddPolicy(CorsPolicyName, policy => policy |
| | 3 | 349 | | .WithOrigins(origins) |
| | 3 | 350 | | .AllowAnyMethod() |
| | 3 | 351 | | .AllowAnyHeader() |
| | 3 | 352 | | .AllowCredentials() |
| | 3 | 353 | | .WithExposedHeaders(HeaderNames.RetryAfter))); |
| | | 354 | | |
| | 1 | 355 | | Options.CorsPolicy = CorsPolicyName; |
| | 1 | 356 | | } |
| | | 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 | | { |
| | 966 | 365 | | using var scope = app.Services.CreateScope(); |
| | | 366 | | |
| | 966 | 367 | | var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>(); |
| | | 368 | | |
| | 966 | 369 | | seeder.SeedAsync().GetAwaiter().GetResult(); |
| | 1932 | 370 | | } |
| | | 371 | | } |