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
+31 -23
View File
@@ -30,7 +30,7 @@
These behaviors wrap every flow at the pipeline level. They are described once here rather than repeated in each flow:
1. **JWT bearer validation (F5)**. ASP.NET Core's `JwtBearerHandler` runs on every request marked `[Authorize]`. Validation is local (HMAC HS256, shared secret with `admin`) — no network call to the issuer. Failures surface as `401 Unauthorized` (no token / invalid signature / expired) or `403 Forbidden` (token valid but missing the `"FL"` permission claim). See `diagrams/flows/flow_jwt_validation.md` for the sequence.
1. **JWT bearer validation (F5)**. ASP.NET Core's `JwtBearerHandler` runs on every request marked `[Authorize]`. Validation is local ECDSA-SHA256 against `admin`'s JWKS, which this service fetches once at startup (lazy, on the first protected request) and caches via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>`; subsequent request-path validation does not call `admin`. The handler enforces `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew), and pins `alg` to `EcdsaSha256` (defends against HS256-confusion). Failures surface as `401 Unauthorized` (no token / signature / claims / lifetime invalid) or `403 Forbidden` (token valid but missing the `"FL"` permission claim). See `diagrams/flows/flow_jwt_validation.md` for the sequence.
2. **Permission gate**. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy requirement is satisfied by a `permissions` claim equal to `"FL"`. The policy NAME is referenced as a raw string in feature controllers — a typo would silently turn into a permanent 403 (see `module-layout.md` § Verification Needed #4).
3. **Global exception → JSON middleware**. `ErrorHandlingMiddleware` (`06_http_conventions`) is registered FIRST in the pipeline. It maps `KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`; everything else → 500 with the stack trace logged. Wire shape: entity / DTO bodies are PascalCase (suite-spec divergence — see `architecture.md` ADR-002); the global error envelope is camelCase already (accidental match — anonymous object literal `new { statusCode, message }` uses lowercase property names) but still missing the spec's `errors` field.
4. **No correlation ID, no request-level audit trail**. Logs are timestamp-only; supporting a production incident requires grep-by-timestamp.
@@ -167,19 +167,20 @@ See `diagrams/flows/flow_waypoint_lifecycle.md`.
See `diagrams/flows/flow_jwt_validation.md`.
**Description**: The cross-cutting auth flow that runs on every `[Authorize]` request. Validation is **local** — this service never calls the `admin` service that issued the token.
**Description**: The cross-cutting auth flow that runs on every `[Authorize]` request. Signature validation is local against admin's cached JWKS public keys; the only call to `admin` is the JWKS fetch (once at startup, plus refreshes on the default schedule). Request-path validation does NOT call `admin`.
**Preconditions**: `JWT_SECRET` is set (or the dev fallback applies — see `architecture.md` ADR-005); the JWT bearer middleware was registered by `AddJwtAuth` in `07_host`.
**Preconditions**: `JWT_ISSUER`, `JWT_AUDIENCE`, and `JWT_JWKS_URL` all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow` (any missing value aborts startup); the JWT bearer middleware was registered by `AddJwtAuth(builder.Configuration)` in `07_host`.
**Key sequence steps**:
1. Request arrives at the ASP.NET Core pipeline with `Authorization: Bearer <jwt>`.
2. `JwtBearerHandler`:
- Parse the token.
- Verify HMAC-SHA256 signature with `SymmetricSecurityKey(UTF-8(JWT_SECRET))`.
- Verify `lifetime` (`ClockSkew = 1 minute` tighter than .NET's 5-minute default).
- **Skip** `iss` / `aud` validation (`ValidateIssuer = false`, `ValidateAudience = false` — known CMMC L2 finding, suite-tracked under AZ-487 / AZ-494, see `05_identity` § Implementation Details).
3. If signature or lifetime fails: `401 Unauthorized` (without ever invoking the controller).
- Parse the token header.
- Reject unless `alg ∈ ValidAlgorithms` (pinned to `EcdsaSha256` — defends against HS256-confusion).
- Resolve signing key for the token's `kid` via the cached `ConfigurationManager<JsonWebKeySet>`. On a cold cache, this triggers a one-time HTTPS GET of `JWT_JWKS_URL` from `admin`.
- Verify ECDSA-SHA256 signature against the matching public key.
- Verify `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew).
3. If algorithm, signature, claims, or lifetime fails: `401 Unauthorized` (without ever invoking the controller).
4. If valid: parse claims into `ClaimsPrincipal`; attach to the request.
5. Authorization policy `"FL"` evaluator checks for a `permissions` claim with value `"FL"`.
6. If absent: `403 Forbidden`.
@@ -190,11 +191,15 @@ See `diagrams/flows/flow_jwt_validation.md`.
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `Authorization` header | Pipeline | `JwtBearerHandler` | `401` |
| Invalid signature | Pipeline | HMAC verify fails | `401` |
| Expired token | Pipeline | `ValidateLifetime` (with 1min skew) | `401`; client re-authenticates with `admin` |
| Token signed with old `JWT_SECRET` (rotation) | Pipeline | HMAC verify fails | `401`; coordinated re-deploy across all backends sharing the secret + UI re-login |
| Forged `alg: HS256` token | Pipeline | `ValidAlgorithms` pin | `401`. Pin defense — see `05_identity` Caveats #6 |
| Invalid signature | Pipeline | ECDSA verify fails | `401` |
| `iss``JWT_ISSUER` | Pipeline | `ValidateIssuer = true` | `401` |
| `aud``JWT_AUDIENCE` | Pipeline | `ValidateAudience = true` | `401` |
| Expired token | Pipeline | `ValidateLifetime` (with 30s skew) | `401`; client re-authenticates with `admin` |
| `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key | `401`. Manager refreshes on default schedule; new `kid` becomes available there |
| `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` | First request fails 500. Subsequent requests retry on next refresh. **Operationally**: keep `admin` reachable from edge |
| `permissions` claim missing or not `"FL"` | Policy evaluator | Claim lookup | `403` |
| `JWT_SECRET` is the well-known dev fallback in production | n/a (silent) | None at runtime | **Security risk** — any party with the fallback can mint accepted tokens. ADR-005 carry-forward; suite-level remediation pending |
| JWKS rotation on `admin` | `ConfigurationManager` refresh | next scheduled refresh tick | **No coordinated redeploy needed** — new keys are picked up on refresh; old tokens with the old `kid` remain valid until expiry |
---
@@ -204,32 +209,35 @@ See `diagrams/flows/flow_startup_migration.md`.
**Description**: One-time-per-process bootstrap. `Program.cs` builds the DI graph, runs `DatabaseMigrator.Migrate(db)` once, then starts serving HTTP. The migrator is idempotent (`CREATE ... IF NOT EXISTS`). After B9, the migrator additionally runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` once for fielded edge devices that previously ran the legacy schema.
**Preconditions**: `DATABASE_URL` resolves (env or hardcoded dev fallback); `postgres-local` is reachable; the `azaion` database exists.
**Preconditions**: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` all resolve via `ConfigurationResolver.ResolveRequiredOrThrow` (any missing value aborts startup — no hardcoded fallbacks); `postgres-local` is reachable; the `azaion` database exists. `admin` does NOT need to be reachable at this point — the JWKS fetch is lazy on the first protected request.
**Key sequence steps**:
1. Container starts → entrypoint `dotnet Azaion.Missions.dll`.
2. `Program.cs` reads `DATABASE_URL``ConvertPostgresUrl` → Npgsql connection string.
3. Reads `JWT_SECRET``AddJwtAuth(jwt)` (DI registration; no network).
4. Registers controllers, middleware, scoped `AppDataConnection`, scoped service classes.
5. Builds the host. Opens a single startup scope and calls `DatabaseMigrator.Migrate(db)`:
2. `Program.cs` resolves `DATABASE_URL` via `ConfigurationResolver.ResolveRequiredOrThrow``ConvertPostgresUrl` → Npgsql connection string.
3. Calls `AddJwtAuth(builder.Configuration)`, which resolves `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` (each via `ResolveRequiredOrThrow`), wires the `ConfigurationManager<JsonWebKeySet>`, and registers JWT bearer + `"FL"` (+ legacy `"GPS"` until B7) policies. No network call yet.
4. Reads `CorsConfig:AllowedOrigins` + `CorsConfig:AllowAnyOrigin`; runs `CorsConfigurationValidator.EnsureSafeForEnvironment` (throws in `Production` with implicit-permissive config); registers the CORS policy (permissive OR `WithOrigins`).
5. Registers controllers, middleware, scoped `AppDataConnection`, scoped service classes.
6. Builds the host. If implicit-permissive CORS applies (non-Production, empty origins, `AllowAnyOrigin=false`), logs `PermissiveDefaultWarning` at startup. Opens a single startup scope and calls `DatabaseMigrator.Migrate(db)`:
- `CREATE TABLE IF NOT EXISTS vehicles (...)`.
- `CREATE TABLE IF NOT EXISTS missions (...)`.
- `CREATE TABLE IF NOT EXISTS waypoints (...)`.
- `CREATE TABLE IF NOT EXISTS map_objects (...)`.
- `CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id ...` and similar.
- **B9 one-shot**: `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;`.
6. Registers `ErrorHandlingMiddleware` FIRST in the pipeline; mounts auth, controllers, `MapGet("/health")`, Swagger UI.
7. `app.Run()` — ready to serve HTTP on port 8080.
7. Registers `ErrorHandlingMiddleware` FIRST in the pipeline; mounts CORS, auth, controllers, `MapGet("/health")`, Swagger UI.
8. `app.Run()` — ready to serve HTTP on port 8080.
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `postgres-local` unreachable | Step 5 | Npgsql connection failure | Process exits non-zero. Watchtower restarts the container; `flight-gate` prevents restart mid-mission |
| `azaion` database does not exist | Step 5 | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database (provisioning concern, not this service) |
| `DROP TABLE IF EXISTS orthophotos` fails because the table is being read by `gps-denied` | Step 5 (B9 one-shot) | Lock timeout | Process exits. Restart loop until `gps-denied` releases the lock — should be moments. **Out-of-band ordering**: deploy `gps-denied` first so it has its own copy before `missions` drops the legacy tables |
| Migrator partial failure mid-statement | Step 5 | Npgsql exception | Process exits. Each statement is individually idempotent (`IF NOT EXISTS`) so the next startup retries safely |
| Missing `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Step 2 or 3 | `ResolveRequiredOrThrow` throws `InvalidOperationException` | Process exits non-zero with a message naming the env var and config key. Watchtower restarts, but the new container hits the same failure until the value is provided |
| CORS misconfigured in `Production` (empty origins + `AllowAnyOrigin != true`) | Step 4 | `EnsureSafeForEnvironment` throws | Process exits with `MissingOriginsMessage`. **Fix**: set `CorsConfig:AllowedOrigins` or explicit `AllowAnyOrigin=true` |
| `postgres-local` unreachable | Step 6 | Npgsql connection failure | Process exits non-zero. Watchtower restarts the container; `flight-gate` prevents restart mid-mission |
| `azaion` database does not exist | Step 6 | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database (provisioning concern, not this service) |
| `DROP TABLE IF EXISTS orthophotos` fails because the table is being read by `gps-denied` | Step 6 (B9 one-shot) | Lock timeout | Process exits. Restart loop until `gps-denied` releases the lock — should be moments. **Out-of-band ordering**: deploy `gps-denied` first so it has its own copy before `missions` drops the legacy tables |
| Migrator partial failure mid-statement | Step 6 | Npgsql exception | Process exits. Each statement is individually idempotent (`IF NOT EXISTS`) so the next startup retries safely |
---