chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful

Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 03:23:23 +03:00
parent 7025f4d075
commit 78dea8ebab
40 changed files with 1990 additions and 510 deletions
+75 -26
View File
@@ -6,66 +6,115 @@
## Purpose
Single static extension (`AddJwtAuth`) that registers JWT bearer authentication and the named authorization policy `FL` used by controllers.
Single static extension (`AddJwtAuth`) that registers JWT bearer authentication and the named authorization policy `FL` used by controllers. Token signatures are validated against **ECDSA P-256 public keys** retrieved from the central `admin` service's JWKS endpoint at startup and refreshed on the .NET `ConfigurationManager` default schedule.
## Public Interface
```csharp
public static class JwtExtensions {
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret);
// Env / config-key contract (string constants — referenced by tests + Program.cs).
public const string JwtIssuerEnvVar = "JWT_ISSUER";
public const string JwtIssuerConfigKey = "Jwt:Issuer";
public const string JwtAudienceEnvVar = "JWT_AUDIENCE";
public const string JwtAudienceConfigKey = "Jwt:Audience";
public const string JwtJwksUrlEnvVar = "JWT_JWKS_URL";
public const string JwtJwksUrlConfigKey = "Jwt:JwksUrl";
public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration);
}
```
`AddJwtAuth` takes `IConfiguration` — there is no string-secret parameter. All three required values are resolved internally via `ConfigurationResolver.ResolveRequiredOrThrow` (env var first, then config key, else throw at startup). See `modules/program.md` for the resolver contract.
## Internal Logic
1. `AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...)` configures token validation:
- `IssuerSigningKey = SymmetricSecurityKey(UTF-8(jwtSecret))` -> **HS256 / shared-secret** validation.
- `ValidateIssuer = false`, `ValidateAudience = false` -- `iss` / `aud` claims are NOT checked. Tokens with any issuer/audience are accepted as long as the signature and lifetime are valid. (CMMC L2 finding -- see `../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3 and the suite-level remediation tracked under AZ-487/AZ-494.)
- `ValidateIssuerSigningKey = true`, `ValidateLifetime = true`.
- `ClockSkew = 1 minute` (tighter than the .NET default of 5 minutes).
2. `AddAuthorizationBuilder()` registers one policy:
- `"FL"` -> requires the JWT to contain a `permissions` claim with value `"FL"`.
1. **Resolve three required values** via `ConfigurationResolver.ResolveRequiredOrThrow`:
- `JWT_ISSUER` / `Jwt:Issuer` — expected `iss` claim value.
- `JWT_AUDIENCE` / `Jwt:Audience` — expected `aud` claim value.
- `JWT_JWKS_URL` / `Jwt:JwksUrl` — HTTPS URL of `admin`'s JWKS document.
`RequireClaim("permissions", "FL")` matches on a claim named `"permissions"` whose value equals `"FL"`. With multi-permission tokens, the token typically has multiple `permissions` claims, one per permission.
If any is missing or whitespace-only, the call throws `InvalidOperationException` at startup. There is **no dev fallback** for any of these values.
2. **Build a `ConfigurationManager<JsonWebKeySet>`** wired with:
- The resolved `jwksUrl`.
- A custom `JwksRetriever : IConfigurationRetriever<JsonWebKeySet>` (private nested class) that delegates the HTTP fetch to the supplied `IDocumentRetriever` and constructs a `JsonWebKeySet` from the returned JSON body.
- An `HttpDocumentRetriever { RequireHttps = true }` — plain HTTP JWKS URLs are rejected.
The manager caches the JWKS in memory and refreshes on the .NET `ConfigurationManager` default schedule. This schedule matches admin's `Cache-Control: public, max-age=3600` on `/.well-known/jwks.json` (see `../components/05_identity/description.md` for the discovery rationale). The custom retriever exists because `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` targets the full OIDC discovery document, which `admin` does not expose; only the JWKS endpoint is published.
3. **Register `JwtBearer` authentication** with the following `TokenValidationParameters`:
| Parameter | Value | Notes |
|-----------|-------|-------|
| `ValidateIssuer` | `true` | `ValidIssuer = <resolved JWT_ISSUER>` |
| `ValidateAudience` | `true` | `ValidAudience = <resolved JWT_AUDIENCE>` |
| `ValidateLifetime` | `true` | |
| `ValidateIssuerSigningKey` | `true` | |
| `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` | Pinned — see Security §1 |
| `RequireSignedTokens` | `true` | |
| `RequireExpirationTime` | `true` | |
| `ClockSkew` | `TimeSpan.FromSeconds(30)` | Tighter than .NET default (5 minutes) |
| `IssuerSigningKeyResolver` | Delegate that fetches `JsonWebKeySet` via the cached `ConfigurationManager` and returns the subset whose `kid` matches the token header (or all keys when `kid` is empty) | Synchronous `GetAwaiter().GetResult()` over the async fetch — first call triggers the JWKS HTTP fetch and blocks until it completes; subsequent calls hit the cache |
4. **Register authorization policies** via `AddAuthorizationBuilder`:
- `"FL"` — requires a `permissions` claim with value `"FL"`.
- `"GPS"` — requires a `permissions` claim with value `"GPS"`. **Removed after Jira B7 lands** (the policy still exists today because `Controllers/FlightsController.cs` uses it for the GPS-Denied routes that B7 also removes).
`RequireClaim("permissions", <code>)` matches on a claim named `"permissions"` whose value equals the code. Multi-permission tokens typically have multiple `permissions` claims, one per permission.
## Suite-wide JWT pattern
This service consumes JWTs minted by the remote `admin` service against the central user PostgreSQL (per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/10_auth.md`). Every `.NET` service in the suite -- `admin`, `annotations`, `missions` (this one), `satellite-provider` -- shares one HMAC secret (`JWT_SECRET`) and validates tokens locally with no network round-trip. The user logs in once at the UI; the resulting bearer token is reusable across every service.
This service consumes JWTs minted by the remote `admin` service against the central user PostgreSQL (per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/10_auth.md`). Every `.NET` service in the suite `admin`, `annotations`, `missions` (this one), `satellite-provider` — uses the **same ECDSA public-key model**: `admin` signs with the private key; every consumer fetches the public JWKS from `admin` and validates locally. The user logs in once at the UI; the resulting bearer token is reusable across every service.
Unlike a pure "validate locally, never call back" model, this service **does** contact `admin` once at startup (and on JWKS refresh) to fetch the JWKS document. Once cached, request-path validation is purely cryptographic and does not call `admin`. The first request after a cold start blocks on the JWKS fetch (single-digit ms typical on the local LAN); subsequent requests use the cached keys.
## Dependencies
- `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`)
- `Microsoft.IdentityModel.Tokens` (transitive -- `SymmetricSecurityKey`, `TokenValidationParameters`)
- `System.Text` (for `Encoding.UTF8`)
- `Microsoft.IdentityModel.Protocols` (`ConfigurationManager<T>`, `HttpDocumentRetriever`, `IConfigurationRetriever<T>`, `IDocumentRetriever`)
- `Microsoft.IdentityModel.Tokens` (`JsonWebKeySet`, `TokenValidationParameters`, `SecurityAlgorithms`)
- `Azaion.Flights.Infrastructure.ConfigurationResolver` (internal — see `modules/program.md`)
No internal dependencies.
No internal dependencies on other domain modules.
## Consumers
- `Program.cs` -- `builder.Services.AddJwtAuth(jwtSecret)` is called once at startup.
- Controllers reference the policy indirectly via `[Authorize(Policy = "FL")]` (used on both `VehiclesController` and `MissionsController`).
- `Program.cs` `builder.Services.AddJwtAuth(builder.Configuration)` is called once at startup.
- Controllers reference the policies indirectly via `[Authorize(Policy = "FL")]` and (until B7) `[Authorize(Policy = "GPS")]`.
## Configuration
Reads no configuration directly -- `jwtSecret` is passed by the caller. `Program.cs` resolves it from `IConfiguration["JWT_SECRET"]` -> `Environment.GetEnvironmentVariable("JWT_SECRET")` -> fallback `"development-secret-key-min-32-chars!!"`.
Reads three values via `ConfigurationResolver.ResolveRequiredOrThrow`:
| Env var | Config key | Required? | Purpose |
|---------|------------|-----------|---------|
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** (throws at startup if missing) | Expected `iss` claim value |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** (throws at startup if missing) | Expected `aud` claim value |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** (throws at startup if missing) | HTTPS URL of admin's JWKS endpoint (e.g. `https://admin.azaion/.well-known/jwks.json`) |
Resolution order per value: `Environment.GetEnvironmentVariable(envVar)``IConfiguration[configKey]` → throw. No hardcoded fallback. No legacy `JWT_SECRET` is consulted.
## External Integrations
None at the network level -- token validation is purely cryptographic against the shared secret.
- **Outbound HTTPS to `admin`** for JWKS retrieval. Required at startup (the first protected request blocks on this fetch). `HttpDocumentRetriever.RequireHttps = true` rejects non-HTTPS URLs at configuration time. If `admin` is unreachable at the time of the first JWKS fetch, the first request fails with a 500 from the `IssuerSigningKeyResolver` delegate; the manager retries on the default refresh interval.
## Security
- **Algorithm**: HMAC-SHA256 via `SymmetricSecurityKey`. The token issuer (`admin`) must use the SAME secret to sign -- there is no public-key flow.
- **No issuer/audience validation** -- any service that knows the shared secret can mint tokens that this API will accept. This trust model assumes the secret is private to the suite; it is not safe for multi-tenant or third-party token issuance.
- **Clock skew tolerance**: 1 minute (tight, intentional).
- The fallback secret in `Program.cs` is hardcoded. It MUST be overridden in production.
1. **Algorithm pinning**: `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]`. Pinning prevents the classic "HS256 confusion" attack — without this, an attacker who learned the JWKS public key could forge a token with `alg: HS256` using the public key as the HMAC secret, and stock JWT bearer validation would accept it. The pin forces ECDSA-SHA256 regardless of the JWT header's `alg` claim.
2. **HTTPS-only JWKS**: `HttpDocumentRetriever { RequireHttps = true }`. A plain-HTTP JWKS URL is rejected at configuration time. MITM substitution of the public key requires breaking TLS to `admin`.
3. **Issuer + audience binding**: `ValidateIssuer = true` and `ValidateAudience = true` are enforced. Tokens minted by a different issuer or for a different audience are rejected even if the signature is valid. This was the AZ-487 / AZ-494 finding in the prior HS256 model; it is now structurally fixed in code.
4. **Fail-fast on missing config**: `ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` at startup if any of `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` is missing or whitespace-only. There is **no dev fallback**. A production deploy without these values cannot silently boot.
5. **Tight clock skew**: 30 seconds (`TimeSpan.FromSeconds(30)`) — tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting. Reduces the window during which a token rejected for clock drift is still cryptographically valid.
6. **JWKS rotation model**: `admin` rotates by publishing a new `kid` in the JWKS; tokens signed under the previous `kid` remain valid until they expire. Because the `IssuerSigningKeyResolver` returns all keys when the token header has no `kid` and the matching subset when it does, both old and new tokens validate during the overlap window. **No coordinated re-deploy is needed** when keys rotate — this is the major operational improvement over the legacy shared-secret model.
## Tests
None present.
None present today; will be filled by the autodev BUILD pipeline (Steps 57 in the existing-code flow). Test-spec scope is in `_docs/02_document/tests/security-tests.md` (NFT-SEC-*).
## Notes / Smells
1. **Single permission (`FL`) gates the whole API.** All routes carry `[Authorize(Policy = "FL")]`. There is no operator-vs-admin distinction at this layer; granular permissions are governed by the role->permission matrix in `../../suite/_docs/00_roles_permissions.md`.
2. **No authentication scheme name override** -- uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent.
3. **No claim type for "user id"** -- only the `permissions` claim is consumed; whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a user identifier currently have no per-call user binding (services don't take `HttpContext.User`). When `02_mission_planning` adds attribution to actions like waypoint-set / mission-rename, this becomes a blocker.
1. **Single permission (`FL`) gates the whole mission API.** All routes in `01_vehicle_catalog` and `02_mission_planning` carry `[Authorize(Policy = "FL")]`. There is no operator-vs-admin distinction at this layer; granular permissions are governed by the rolepermission matrix in `../../suite/_docs/00_roles_permissions.md`.
2. **Synchronous JWKS fetch on the first request after cold start**`IssuerSigningKeyResolver` calls `GetConfigurationAsync(...).GetAwaiter().GetResult()`. This blocks the worker thread until the JWKS document is fetched and parsed. On the local LAN this is single-digit ms; if `admin` is slow or unreachable, the first request takes the timeout hit. Subsequent requests use the cached keys without blocking.
3. **No authentication scheme name override** — uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent.
4. **No claim type for "user id" is consumed** — only the `permissions` claim is checked. Whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a per-user identifier currently have no per-call user binding (services don't take `HttpContext.User`). When `02_mission_planning` adds attribution to actions like waypoint-set / mission-rename, this becomes a blocker.
5. **`JwksRetriever` is a hand-rolled minimal implementation** — `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` is the stock retriever but it pulls the full OIDC discovery document; `admin` only exposes JWKS. The private nested class is ~5 lines and is the smallest correct adapter. If `admin` ever publishes a full OIDC discovery document, swapping to the stock retriever is a one-line change.
+3 -2
View File
@@ -46,11 +46,12 @@ public static class DatabaseMigrator {
- `Migrate(db)` calls `db.Execute(Sql)` where `Sql` is a single string literal containing:
- 4 `CREATE TABLE IF NOT EXISTS` statements: `vehicles`, `missions`, `waypoints`, `map_objects`.
- 3 `CREATE INDEX IF NOT EXISTS` statements on the foreign-key columns: `ix_missions_vehicle_id`, `ix_waypoints_mission_id`, `ix_map_objects_mission_id`.
- Foreign-key constraints declared inline via `REFERENCES`:
- **Foreign-key constraints declared inline via `REFERENCES`** (PostgreSQL `NO ACTION` is the default `ON DELETE` behavior — see `service_mission.md` and `service_waypoint.md` for the in-code cascade walks that compensate):
- `missions.vehicle_id REFERENCES vehicles(id)`
- `waypoints.mission_id REFERENCES missions(id)`
- `map_objects.mission_id REFERENCES missions(id)`
- Defaults: enums default to `0`, decimals to `0`, booleans to `FALSE`, timestamps to `NOW()`.
- **Column types**: timestamps use PostgreSQL `TIMESTAMP` (no timezone) — `missions.created_date`, `map_objects.first_seen_at`, `map_objects.last_seen_at`. This means `DateTime.Kind` round-trips as `Unspecified` from the database; the application is the source of truth for "this value was stored as UTC" (`MissionService.CreateMission` writes `DateTime.UtcNow`).
- **Defaults**: enums default to `0`, decimals (`NUMERIC`) to `0`, booleans to `FALSE`, timestamps to `NOW()`, and the `map_objects.label` text column defaults to empty string `''`. Nullable columns (`waypoints.lat`, `waypoints.lon`, `waypoints.mgrs`, `map_objects.lat`, `map_objects.lon`) have no `DEFAULT` clause.
- **Tables intentionally NOT in this migrator**: `media`, `annotations`, `detection`. These are exposed by `AppDataConnection` and consumed by services (delete cascades), but their schema is owned by other suite components (`annotations` migrates `media` + `annotations`; the detection pipeline owns `detection`). All edge-tier services share one local PostgreSQL on the device, so `missions` can read/delete from those tables without owning their DDL.
- **Tables removed from this migrator (per Jira B7 + B9)**: `orthophotos`, `gps_corrections`. These are now owned by the separate `gps-denied` service (per `../../suite/_docs/11_gps_denied.md`). Migration B9 includes a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` for fielded edge devices that previously ran the legacy schema.
+63 -35
View File
@@ -27,26 +27,37 @@ global using LinqToDB.Data;
```text
1. WebApplicationBuilder = WebApplication.CreateBuilder(args)
2. Resolve DATABASE_URL (Configuration -> Env -> fallback)
2. Resolve DATABASE_URL via ConfigurationResolver.ResolveRequiredOrThrow
(env var DATABASE_URL -> config key Database:Url -> THROW).
If it begins with "postgresql://" -> ConvertPostgresUrl() to Npgsql key=value form.
3. Resolve JWT_SECRET (Configuration -> Env -> fallback)
4. Register services (scoped where applicable):
3. Register services (scoped where applicable):
- AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString)
- MissionService, WaypointService, VehicleService <- scoped
- AddJwtAuth(jwtSecret) -> JWT bearer + "FL" policy
- AddCors with default policy = AllowAnyOrigin/Method/Header
- AddControllers, AddEndpointsApiExplorer, AddSwaggerGen
5. Build the WebApplication.
6. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db).
7. Configure pipeline (order matters):
a. UseMiddleware<ErrorHandlingMiddleware>
b. UseCors
c. UseAuthentication
d. UseAuthorization
e. UseSwagger, UseSwaggerUI
f. MapControllers
g. MapGet("/health", () => Results.Ok({status:"healthy"}))
8. app.Run()
- AddJwtAuth(builder.Configuration) -> resolves JWT_ISSUER + JWT_AUDIENCE + JWT_JWKS_URL
(each via ResolveRequiredOrThrow). Registers JWT bearer + "FL" + "GPS" policies.
(GPS policy is removed in Jira B7.)
4. Resolve CORS configuration:
- allowedOrigins = Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? []
- allowAnyOrigin = Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin")
- CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, EnvironmentName)
THROWS in Production when origins are empty AND AllowAnyOrigin is false (fail-fast guard).
5. Register CORS:
- If CorsConfigurationValidator.ShouldUsePermissivePolicy(...) -> AllowAnyOrigin/Method/Header
- Else -> WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod()
6. Register MVC infra: AddControllers, AddEndpointsApiExplorer, AddSwaggerGen.
7. Build the WebApplication.
8. If CorsConfigurationValidator.ShouldWarnAboutPermissiveDefault(...) -> log warning
(logs PermissiveDefaultWarning with the resolved EnvironmentName).
9. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db).
10. Configure pipeline (order matters):
a. UseMiddleware<ErrorHandlingMiddleware>
b. UseCors
c. UseAuthentication
d. UseAuthorization
e. UseSwagger, UseSwaggerUI (unconditional — see ADR-005)
f. MapControllers
g. MapGet("/health", () => Results.Ok({status:"healthy"}))
11. app.Run()
ConvertPostgresUrl(url):
parses postgresql://user[:pass]@host[:port]/db into
@@ -54,6 +65,14 @@ ConvertPostgresUrl(url):
(defaults port to 5432; absent password becomes empty)
```
The four required configuration values (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) all flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow(IConfiguration, envVar, configKey, humanLabel)`:
1. Read `Environment.GetEnvironmentVariable(envVar)`. If non-whitespace, return it.
2. Otherwise read `configuration[configKey]`. If non-whitespace, return it.
3. Otherwise throw `InvalidOperationException` with a human-readable message naming both the env var and the config key.
There is **no hardcoded fallback** for any of these values; an unset env var aborts startup. ADR-005 (in `architecture.md`) is obsolete for the secret-fallback aspect — only the Swagger-unconditional aspect of ADR-005 still applies today.
## Dependencies
- All internal namespaces: `Azaion.Missions.{Auth, Database, Middleware, Services}`.
@@ -66,26 +85,34 @@ ConvertPostgresUrl(url):
## Configuration
| Env / Config Key | Required? | Default |
|------------------|-----------|---------|
| `DATABASE_URL` | No (has dev fallback) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` |
| `JWT_SECRET` | No (has dev fallback) | `development-secret-key-min-32-chars!!` |
| `AZAION_REVISION` | Set by Dockerfile from `CI_COMMIT_SHA` | `unknown` (build arg default) |
| Env var | Config key | Required? | Default | Notes |
|---------|------------|-----------|---------|-------|
| `DATABASE_URL` | `Database:Url` | **Yes** | — (throws at startup if unset) | Either Npgsql key=value form OR a `postgresql://` URI (converted via `ConvertPostgresUrl`) |
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** | — (throws at startup if unset) | Expected `iss` claim value (per `modules/auth.md`) |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** | — (throws at startup if unset) | Expected `aud` claim value (per `modules/auth.md`) |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** | — (throws at startup if unset) | HTTPS URL of admin's JWKS endpoint (per `modules/auth.md`) |
| `CorsConfig:AllowedOrigins` | `CorsConfig:AllowedOrigins` | No (defaults to empty) | `[]` | String array. When non-empty, the CORS policy uses `WithOrigins(...)` |
| `CorsConfig:AllowAnyOrigin` | `CorsConfig:AllowAnyOrigin` | No (defaults to `false`) | `false` | When `true`, the CORS policy uses `AllowAnyOrigin/Method/Header` regardless of origins. When `false` AND origins are empty AND environment is `Production`, startup THROWS via `CorsConfigurationValidator.EnsureSafeForEnvironment` |
| `ASPNETCORE_ENVIRONMENT` | (n/a — framework) | No | `Production` | Read by the framework. The CORS validator's hard-fail behavior triggers only in `Production` |
| `AZAION_REVISION` | (n/a) | No | `unknown` (Dockerfile build-arg default) | Set by `Dockerfile` from `CI_COMMIT_SHA` |
There is **no `appsettings.json`** in this repo (per discovery) -- config comes from env / process variables only. Suite-wide env conventions live in `../../suite/_docs/00_top_level_architecture.md` (Edge compose excerpt).
There is **no `appsettings.json`** in this repo today (per discovery), so all values flow through env vars in practice. Both env-var and config-key resolution are wired so that an `appsettings.json` (or any other `IConfiguration` source) can be added later without code changes. Suite-wide env conventions live in `../../suite/_docs/00_top_level_architecture.md` (Edge compose excerpt).
The legacy `JWT_SECRET` env var is **no longer consulted**`modules/auth.md` documents the ECDSA + JWKS replacement.
## External Integrations
- PostgreSQL (read/write) via Npgsql.
- Identity provider: the suite's `admin` service mints JWTs against the central user PostgreSQL; `JWT_SECRET` is the shared HMAC secret. Local validation only -- no network round-trip per request.
- **PostgreSQL** (read/write) via Npgsql. Connection string resolved at startup; connection pool managed by Npgsql.
- **`admin` (JWKS)** — outbound HTTPS once at startup (and on JWKS refresh per the .NET `ConfigurationManager` default schedule). Subsequent request-path JWT validation uses the cached keys and does not call back. See `modules/auth.md` § External Integrations and § Security for details.
## Security
- **Hardcoded fallbacks** for both `DATABASE_URL` and `JWT_SECRET` are dev-only. Production deployments MUST override them; failure to do so silently runs with weak/known credentials.
- **CORS is permissive** in all environments (`AllowAnyOrigin/Method/Header`). Combined with JWT auth this is not catastrophic (browser will send the bearer token only if the front-end opts in), but exposes the API to opportunistic browser-based scraping.
- **Swagger is unconditionally enabled** -- both the JSON document and the UI are served regardless of environment. Anyone reaching the host can enumerate the API surface.
- **No HTTPS redirection** middleware (`UseHttpsRedirection`) -- TLS is assumed to terminate at an upstream reverse proxy.
- **`app.UseMiddleware<ErrorHandlingMiddleware>` runs before `UseAuthentication`/`UseAuthorization`** -- auth failures still emit the framework's stock 401/403 (which is fine), but any auth-stage exceptions ALSO run through the global handler (which converts `KeyNotFoundException` -> 404, etc.; auth pipeline doesn't typically throw those).
- **Fail-fast configuration**: `ConfigurationResolver.ResolveRequiredOrThrow` aborts startup if any of `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` is missing or whitespace-only. There are **no hardcoded dev fallbacks** for any of these values; a misconfigured production deploy cannot silently boot with a known-weak credential.
- **CORS is gated**: `CorsConfigurationValidator.EnsureSafeForEnvironment(...)` THROWS in `Production` when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin` is not `true`. In non-Production environments (e.g. local `dotnet run`, `Development`, `Staging`) an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. The "all-environments-permissive" model described in older docs no longer holds.
- **Swagger is unconditionally enabled** both the JSON document and the UI are served regardless of environment. Anyone reaching the host can enumerate the API surface. This is the **only remaining** aspect of ADR-005 that still applies after the fail-fast change; the "dev fallback secret" aspect of ADR-005 is now obsolete.
- **No HTTPS redirection** middleware (`UseHttpsRedirection`) TLS is assumed to terminate at an upstream reverse proxy. The container `EXPOSE 8080` is plain HTTP.
- **HTTPS-only JWKS**: `HttpDocumentRetriever { RequireHttps = true }` (set inside `AddJwtAuth`) rejects plain-HTTP JWKS URLs at configuration time. A misconfigured `JWT_JWKS_URL=http://...` aborts startup.
- **`app.UseMiddleware<ErrorHandlingMiddleware>` runs before `UseAuthentication`/`UseAuthorization`** — auth failures still emit the framework's stock 401/403 (which is fine), but any auth-stage exceptions ALSO run through the global handler (which converts `KeyNotFoundException` → 404, etc.; auth pipeline doesn't typically throw those).
## Tests
@@ -94,8 +121,9 @@ None present.
## Notes / Smells
1. **`DATABASE_URL` URL parsing**: `ConvertPostgresUrl` is a small ad-hoc parser. Fine for typical cases but does not URL-decode the user/password. A password containing `@`, `:`, `/`, `%` would break parsing or be interpreted wrong. Carry to verification log.
2. **No `IsDevelopment()` checks** anywhere in `Program.cs`. Dev/prod behaviors (Swagger, fallback secrets) are not gated.
3. **`AddSwaggerGen()` with no JWT bearer security definition** -- Swagger UI's "Authorize" button won't appear; users must supply tokens via `curl -H "Authorization: Bearer ..."`. Not a bug, but a usability issue.
4. **`DatabaseMigrator.Migrate` is fire-and-forget** -- if it throws (DB down at startup), the host process crashes. Acceptable for container orchestration that restarts on failure.
5. **`GlobalUsings.cs` imports `LinqToDB.Async`** but most async LINQ extensions used by the project (`AnyAsync`, `FirstOrDefaultAsync`, `ToListAsync`, etc.) actually live in the `LinqToDB` namespace already. Harmless redundancy.
6. **Service lifetime**: `AppDataConnection` is **scoped** (per-HTTP-request) -- correct, because `DataConnection` holds a backing Npgsql connection that should not be shared across requests. The three domain services share this scope, so all DB calls within one request go through the same physical connection (good for correctness, no implicit transactions though).
2. **CORS gating is `Production`-only at the hard-fail layer**. In `Staging` or any custom `ASPNETCORE_ENVIRONMENT` name that is not literal `Production` (case-insensitive), an empty allow-list with `AllowAnyOrigin=false` falls back to permissive instead of throwing. Operators deploying to a "Staging" tier that should be locked down need to set `CorsConfig:AllowedOrigins` explicitly — the validator will not enforce it for them.
3. **JWKS first-fetch is synchronous on the worker thread** (`GetAwaiter().GetResult()` inside the `IssuerSigningKeyResolver`). If `admin` is slow or unreachable when the first protected request arrives, that request blocks until the HTTP fetch returns. See `modules/auth.md` § Notes #2.
4. **`AddSwaggerGen()` with no JWT bearer security definition** — Swagger UI's "Authorize" button won't appear; users must supply tokens via `curl -H "Authorization: Bearer ..."`. Not a bug, but a usability issue.
5. **`DatabaseMigrator.Migrate` is fire-and-forget** — if it throws (DB down at startup), the host process crashes. Acceptable for container orchestration that restarts on failure.
6. **`GlobalUsings.cs` imports `LinqToDB.Async`** but most async LINQ extensions used by the project (`AnyAsync`, `FirstOrDefaultAsync`, `ToListAsync`, etc.) actually live in the `LinqToDB` namespace already. Harmless redundancy.
7. **Service lifetime**: `AppDataConnection` is **scoped** (per-HTTP-request) — correct, because `DataConnection` holds a backing Npgsql connection that should not be shared across requests. The three domain services share this scope, so all DB calls within one request go through the same physical connection (good for correctness, no implicit transactions though).