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
+30 -8
View File
@@ -87,11 +87,11 @@ All symbol-level claims reconcile. ✓
| F2 Mission create/read/update | Existence check on `vehicle_id` returns `ArgumentException → 400` (spec wants 404) | `FlightService.CreateFlight / UpdateFlight``aircraftExists` check throws `ArgumentException("Aircraft {id} not found")` → middleware → 400 | ✓ matches; spec divergence carry-forward |
| F3 Mission cascade-delete | Order: `map_objects → waypoints/media/annotations/detection → waypoints → missions`. NOT transaction-wrapped. Post-B7: no orthophoto/gps_correction branches | `FlightService.DeleteFlight` order today: `map_objects → gps_corrections → orthophotos → waypoints/media/annotations/detection → waypoints → flights`. NOT transaction-wrapped | ✓ B7 removes the two extra branches |
| F4 Waypoint create/read/update/delete | Delete walks `media/annotations/detection`, post-B7 no `gps_corrections` branch; `UpdateWaypoint` is full overwrite | `WaypointService.DeleteWaypoint` walks `media/annotations/detection` AND `gps_corrections` today; `UpdateWaypoint` is full overwrite | ✓ B7 removes `gps_corrections` branch |
| F5 JWT validation | HS256, shared secret, `ValidateIssuer/ValidateAudience = false`, `ClockSkew = 1 minute`, single `"FL"` policy post-B7 | `JwtExtensions` matches exactly; today has BOTH `"FL"` and `"GPS"` policies | ✓ B7 drops `"GPS"` |
| F6 Startup + migration | `Program.cs` builds host → resolves `DATABASE_URL` (with `ConvertPostgresUrl`) → `JWT_SECRET` → registers scoped services → migrates → starts. Pipeline: `ErrorHandlingMiddleware FIRST → Cors → Authentication → Authorization → Swagger → MapControllers → MapGet("/health") → Run` | `Program.cs` matches exactly; service registration is `FlightService, WaypointService, AircraftService` today instead of `Mission/Waypoint/VehicleService` | ✓ B5+B6 rename + DI re-registration |
| F5 JWT validation | **REISSUED 2026-05-14** — ECDSA-SHA256 against admin's JWKS (cached via `ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever{RequireHttps=true}` + private `JwksRetriever`); `ValidateIssuer = true` against `JWT_ISSUER`; `ValidateAudience = true` against `JWT_AUDIENCE`; `ClockSkew = 30 seconds`; `ValidAlgorithms = [EcdsaSha256]`; `RequireSignedTokens = true`; `RequireExpirationTime = true`. Single `"FL"` policy post-B7 | `Auth/JwtExtensions.cs` matches the reissued claim exactly; today has BOTH `"FL"` and `"GPS"` policies | ✓ B7 drops `"GPS"`. **The previous verdict ("matches exactly" against the HS256 / shared-secret doc) was wrong** — the underlying docs were stale; corrected via the 2026-05-14 re-verification pass and rewritten in `modules/auth.md`, `components/05_identity/description.md`, `diagrams/flows/flow_jwt_validation.md`, `architecture.md` § 7 + Tech Stack, `system-flows.md` Cross-cutting #1 + F5, and `00_problem/*` (see § 4.3 below) |
| F6 Startup + migration | **REISSUED 2026-05-14**`Program.cs` builds host → `ConfigurationResolver.ResolveRequiredOrThrow` resolves `DATABASE_URL` (with `ConvertPostgresUrl`) → resolves `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL` (all required, no fallback) → registers scoped services + JWT bearer + JWKS `ConfigurationManager` → reads `CorsConfig:AllowedOrigins` + `CorsConfig:AllowAnyOrigin``CorsConfigurationValidator.EnsureSafeForEnvironment` (throws in Production with implicit-permissive config) → registers CORS policy (permissive OR `WithOrigins`) → migrates → starts. Pipeline: `ErrorHandlingMiddleware FIRST → Cors → Authentication → Authorization → Swagger → MapControllers → MapGet("/health") → Run`. May emit `PermissiveDefaultWarning` startup log when implicit-permissive CORS applies | `Program.cs` matches the reissued claim exactly; service registration is `FlightService, WaypointService, AircraftService` today instead of `Mission/Waypoint/VehicleService` | ✓ B5+B6 rename + DI re-registration. **The previous verdict ("matches exactly" against docs claiming hardcoded `JWT_SECRET` fallback + unconditional permissive CORS) was wrong** — corrected via the 2026-05-14 re-verification pass and rewritten in `modules/program.md`, `components/07_host/description.md`, `diagrams/flows/flow_startup_migration.md`, `architecture.md` § 3 deployment table + ADR-005, and `system-flows.md` F6 |
| F7 Health probe | `MapGet("/health", () => Results.Ok(new { status = "healthy" }))`, anonymous | identical | ✓ no rename gap |
All flow claims reconcile. ✓
All flow claims reconcile after the 2026-05-14 reissue. ✓
## 4. Drift NOT covered by the rename mapping
@@ -117,7 +117,29 @@ These are real findings. **Items in § 4.1 were corrected inline as part of this
|---|-------|---------|------------------|
| F1 | Cascade-delete error scenario in `diagrams/flows/flow_mission_cascade_delete.md` § Error Scenarios | Text references "step 7" (a successful `DELETE FROM missions`) but the cascade order list above it numbers steps 15 and the data-flow table numbers them 18. Three different numberings in one file | Pre-existing inconsistency; minor; correcting it would also need a numbering decision the user might prefer to make once globally |
| F2 | `module-layout.md` § Per-Component Mapping — `05_identity` Public API | Lists only the `"FL"` policy as the public API surface. Code today also exposes `"GPS"`. Forward-looking is correct (B7 will drop `"GPS"`); the `05_identity/description.md` already mentions the dual-policy state in its forward-looking note. Decision: leave `module-layout.md` forward-looking-only (consistent with the rest of the file), OR add a one-line "today also exposes `\"GPS\"` — see B7" caveat | Editorial choice for the user — both readings are defensible |
| F3 | The pre-existing carry-forward divergences in `00_discovery.md` § Spec ↔ Code Divergences (Geopoint shape, error envelope `errors` field, Swagger / CORS unconditional, etc.) | All real, all already documented with their resolution path (this Epic vs out-of-Epic). No new finding here | These are the *intentional* carry-forward items. They are the agenda for future Epics; not in scope for verification |
| F3 | The pre-existing carry-forward divergences in `00_discovery.md` § Spec ↔ Code Divergences (Geopoint shape, error envelope `errors` field, Swagger unconditional, etc.) **note: "CORS unconditional" was REMOVED from this list on 2026-05-14**. CORS is gated by `Infrastructure/CorsConfigurationValidator.cs`; it throws in Production with implicit-permissive config and falls back to permissive (with `PermissiveDefaultWarning`) only in non-Production. See § 4.3 below | All remaining items are real, already documented with their resolution path | These are the *intentional* carry-forward items |
### 4.3 Re-verification pass on 2026-05-14 (targeted)
While preparing autodev Step 4 (Code Testability Revision), a targeted code-level cross-check of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/{ConfigurationResolver, CorsConfigurationValidator}.cs`, `Database/DatabaseMigrator.cs`, and `Services/*.cs` against the corresponding `_docs/` artifacts surfaced that the original § 3 verdicts for F5 (JWT) and F6 (Startup) had been performed **doc-vs-doc** rather than against actual source. The actual code state is materially different from what the docs described. The findings were captured in `_docs/02_document/05_drift_findings_2026-05-14.md`; the doc revisions applied in this pass:
| Doc | Sections rewritten |
|-----|--------------------|
| `modules/auth.md` | Full rewrite — ECDSA + JWKS + `ConfigurationManager` + iss/aud + 30s skew + alg pin; no fallback |
| `modules/program.md` | Internal Logic block; Configuration table (6 keys); Security; External Integrations; Notes |
| `modules/database.md` | Internal Logic — explicit `TIMESTAMP` (not `TIMESTAMPTZ`), explicit `REFERENCES`, explicit `DEFAULT` clauses |
| `components/05_identity/description.md` | Full rewrite — same scope as `modules/auth.md` |
| `components/07_host/description.md` | Header source-of-truth note; Implementation Details (Configuration table + CORS gating); Caveats |
| `diagrams/flows/flow_jwt_validation.md` | Full rewrite — new sequence with JWKS resolver + algorithm-pin step + iss/aud branches |
| `diagrams/flows/flow_startup_migration.md` | Preconditions + sequence + flowchart + data-flow + error-scenarios — 4 required env vars + CORS gate |
| `architecture.md` | Architecture Vision; § 5 External Integrations; § 7 Security Architecture; § 3 Environment-specific config table; Tech Stack JWT row; ADR-005 (scope reduced) |
| `data_model.md` | § 5 ERD — column-type annotations; § 6 Owned-table invariants — explicit FK / TIMESTAMP notes |
| `system-flows.md` | Cross-cutting #1 (JWT); F5 sequence + error table; F6 sequence + error table |
| `04_verification_log.md` (this file) | § 3 rows F5 + F6 reissued; § 4.2 row F3 corrected; this § 4.3 block added |
| `00_problem/*` (Phase 2 — next session) | AC-5 group, AC-6.1/6.2, AC-9.1, AC-1.5/1.6/2.3, E1, E3, E4, E9 — see `05_drift_findings_2026-05-14.md` Phase 2 |
| `_docs/02_document/tests/*` (Phase 2 — next session) | environment.md (JWKS mock), test-data.md, blackbox-tests.md (case-insensitive + ordering), security-tests.md (full NFT-SEC revision), resilience-tests.md (NFT-RES-05 + NFT-RES-07), traceability-matrix.md — see drift findings Phase 2 |
**Root cause** (recorded in `_autodev_state.md` for the retrospective): the prior verification step did doc-vs-doc consistency checks for these areas instead of opening the actual `.cs` files. The docs were internally consistent describing a stale HS256 / shared-secret / permissive-CORS / dev-fallback world that no longer exists in code. Subsequent verification passes (Step 4 prep, this reissue) must open source files for any flow whose verdict is "matches exactly" and explicitly note which files were read.
## 5. Stale-folder check (resolved)
@@ -142,9 +164,9 @@ The git status snapshot at session start showed 11 untracked component folders u
## 7. Summary
- The forward-looking documentation is **internally consistent** with respect to the rename + GPS-Denied removal it describes (B5B12).
- It is **consistent with the actual pre-rename code** when read through the rename mapping documented in § 0 — every counted symbol, signature, route, and flow reconciles.
- One **systematic doc-internal inconsistency** was found and fixed: the global error envelope's wire-shape case-style was misstated as PascalCase across 8 files when the middleware actually emits camelCase. The unrelated divergences (missing `errors` field, dead `ErrorResponse` DTO) remain as carry-forward concerns and are now stated correctly.
- It is **consistent with the actual pre-rename code** when read through the rename mapping documented in § 0 — every counted symbol, signature, route, and flow reconciles, after the 2026-05-14 reissue corrected the F5 (JWT) and F6 (Startup) flow descriptions to match actual code.
- One **systematic doc-internal inconsistency** was found and fixed in the initial pass: the global error envelope's wire-shape case-style was misstated as PascalCase across 8 files when the middleware actually emits camelCase.
- One **doc-vs-code drift** was found and fixed in the 2026-05-14 reissue: the JWT model (ECDSA + JWKS + iss/aud + 30s skew + alg pin, fail-fast on missing env), the configuration model (`ResolveRequiredOrThrow` — no hardcoded fallbacks), the CORS model (gated; Production hard-fail), and the DB schema details (TIMESTAMP, REFERENCES, DEFAULTs). The downstream test-spec re-issue is queued for the next autodev session (Phase 2 in `05_drift_findings_2026-05-14.md`).
- No hallucinated entities or methods. No missing module or component coverage.
- Two minor editorial concerns (F1, F2) are flagged but not auto-fixed — confirm with user.
**Outcome**: docs are accurate as the spec for B5B12. Ready to proceed to Step 4.5 (Glossary & Architecture Vision).
**Outcome**: docs are now accurate as the spec for B5B12 AND faithful to the actual current behavior of the JWT / config / CORS / DB-schema surfaces. The next autodev pass continues from Phase 2 (test-spec scoped re-issue), then Phase 3 (resume Step 4 — Code Testability Revision).
@@ -0,0 +1,152 @@
# Drift Findings — Targeted Verification Re-run, 2026-05-14
**Status**: discovery complete; **doc revisions and test-spec re-issues PENDING** (next session).
**Scope**: targeted re-verification of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/*.cs`, `Services/*.cs`, `Database/DatabaseMigrator.cs`, `Middleware/ErrorHandlingMiddleware.cs`, `Controllers/FlightsController.cs`, `Controllers/AircraftsController.cs` against the corresponding `_docs/` artifacts.
**Trigger**: while preparing autodev Step 4 (Code Testability Revision), ran a code-level cross-check that contradicts the `_docs/02_document/04_verification_log.md` § 3 "All flow claims reconcile" verdict for F5 (JWT) + F6 (Startup) + AC-9 (Authz).
**Root cause** (likely): the prior verification did doc-vs-doc consistency checks for these areas instead of opening the actual `.cs` files. The docs are internally consistent describing a HS256+shared-secret+permissive-CORS+dev-fallback world that no longer exists in the code.
**Decision (user, this turn)**: Option A — re-run /document Step 4 targeted at the drifted areas, then re-issue test-spec for the affected ACs.
---
## Drift NOT covered by the B-ticket rename mapping
These are real findings. Each item is actual today-code state, NOT a "post-rename target". The `_docs/` and the test specs in `_docs/02_document/tests/` need updates to match.
### D-JWT — Auth/JwtExtensions.cs (MAJOR)
| # | Aspect | Doc claim (AC-5.*, modules/auth.md, components/05_identity, architecture.md § 7) | Code today (`Auth/JwtExtensions.cs`) |
|---|--------|----------------------------------------------------------------------------------|--------------------------------------|
| J1 | Algorithm | HS256 (`SymmetricSecurityKey(UTF-8(JWT_SECRET))`) | **ECDSA-SHA256** (`ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]`) with JWKS keys |
| J2 | Key material | Shared HMAC secret in `JWT_SECRET` env var | **JWKS retrieved from `admin`** via `ConfigurationManager<JsonWebKeySet>` + `JwksRetriever` + `HttpDocumentRetriever { RequireHttps = true }` |
| J3 | Env var contract | `JWT_SECRET` (single var) | **Three vars**: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` (NO `JWT_SECRET`) |
| J4 | `ValidateIssuer` | `false` | **`true`** + `ValidIssuer = <JWT_ISSUER>` |
| J5 | `ValidateAudience` | `false` | **`true`** + `ValidAudience = <JWT_AUDIENCE>` |
| J6 | `ClockSkew` | `1 minute` | **`30 seconds`** |
| J7 | Pinned algorithms | not mentioned | **`ValidAlgorithms = [EcdsaSha256]`** (forces algo to prevent HS256-confusion attack) |
| J8 | `RequireSignedTokens` / `RequireExpirationTime` | not explicitly mentioned | both `true` |
| J9 | Coupling to `admin` | "Local validation; this service never calls back to `admin`" | **Calls `admin` for JWKS** at startup + on `ConfigurationManager` refresh schedule |
| J10 | Rotation model | "Token signed with old `JWT_SECRET` → 401 across the entire device until coordinated re-deploy" | **JWKS rotation on `admin`** + auto-refresh; no coordinated re-deploy needed |
| J11 | Dev fallback | `JWT_SECRET=development-secret-key-min-32-chars!!` if env unset (ADR-005 carry-forward) | **No fallback**; `ConfigurationResolver.ResolveRequiredOrThrow` throws at startup if any of `JWT_ISSUER`/`JWT_AUDIENCE`/`JWT_JWKS_URL` is unset |
| J12 | Authz policies | Single `"FL"` policy; `"GPS"` is the post-B7 target (existing-code has both today) | **Today has both `"FL"` AND `"GPS"`** — matches what the verification log already says, kept here for completeness |
**ACs / NFTs to revise**: AC-5.1, AC-5.2, AC-5.3, AC-5.4, AC-5.5, AC-5.6, AC-5.7, AC-5.9; NFT-SEC-0109; NFT-RES-07; FT-N-08; results_report.md AC-5 entire group; environment.md JWT mock spec; test-data.md JWT mint section.
### D-CONFIG — Program.cs + Infrastructure/ConfigurationResolver.cs (MAJOR)
| # | Aspect | Doc claim | Code today |
|---|--------|-----------|------------|
| C1 | Required env vars | Two: `DATABASE_URL`, `JWT_SECRET` (E1) | **Four**: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` |
| C2 | Configuration source order | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | **Env var first** (`Environment.GetEnvironmentVariable`), then `IConfiguration` key (e.g. `Database:Url`, `Jwt:Issuer`), then **throw** (no fallback) |
| C3 | Dev fallbacks for `JWT_SECRET` / `DATABASE_URL` | "ungated by `IsDevelopment()`; production deploy without env vars silently boots with the dev secret" (ADR-005 carry-forward) | **No fallbacks at all**; production deploy without env vars now throws `InvalidOperationException` at startup. ADR-005 is OBSOLETE for this aspect |
| C4 | `Database:Url` config-key alternative | not mentioned in docs (env-only) | **Code reads `Database:Url`** as fallback to `DATABASE_URL` env var |
| C5 | `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl` config-key alternatives | not mentioned | **Code reads each** as fallback to its env var |
**ACs / NFTs to revise**: AC-6.1, AC-6.2, E1, E3, E4 (no shared secret anymore); NFT-RES-05 (still tests DB-down crash, but the failure mode is more direct now); environment.md "Test Execution" env var list; test-data.md env vars; results_report.md AC-6 group + E3 lock test.
### D-CORS — Infrastructure/CorsConfigurationValidator.cs (MAJOR)
| # | Aspect | Doc claim (E9) | Code today |
|---|--------|----------------|------------|
| O1 | Permissive policy scope | "`AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in **all** environments (assumed safe behind suite reverse proxy)" | **Conditionally** permissive: `EnsureSafeForEnvironment` THROWS in `Production` if `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. Permissive only when explicit opt-in OR non-Production |
| O2 | Config keys | not mentioned | New keys: **`CorsConfig:AllowedOrigins`** (string array) and **`CorsConfig:AllowAnyOrigin`** (bool) |
| O3 | Warning behavior | not mentioned | Logs `PermissiveDefaultWarning` at startup when implicit-permissive applies (origins empty + AllowAnyOrigin=false + non-Production) |
**ACs / NFTs to revise**: E9 restriction; results_report.md E9 lock test (currently doesn't exist; was deferred to follow-up); ADR-002 in architecture.md only if it discusses CORS.
### D-DBSCHEMA — Database/DatabaseMigrator.cs (SMALL)
| # | Aspect | Doc claim (data_parameters.md § 3) | Code today |
|---|--------|------------------------------------|------------|
| S1 | `created_date` / `first_seen_at` / `last_seen_at` column type | `TIMESTAMPTZ` | **`TIMESTAMP`** (no timezone) — affects how DateTime kinds round-trip |
| S2 | Foreign-key declarations | "logical FK ... no DB-level FK constraint declared in migrator" (data_parameters.md § 3.2 + § 3.3 note) | **`REFERENCES <parent>(id)` declared on every FK** in the migrator (`flights.aircraft_id`, `waypoints.flight_id`, `orthophotos.flight_id`, `gps_corrections.flight_id` + `gps_corrections.waypoint_id`, `map_objects.flight_id`) |
| S3 | Default values | not detailed | **Migrator sets `DEFAULT 0` / `DEFAULT FALSE` / `DEFAULT NOW()` / `DEFAULT ''`** on most non-nullable columns |
**ACs / NFTs to revise**: data_parameters.md § 3 schema tables (TIMESTAMPTZ → TIMESTAMP, add REFERENCES notes, add DEFAULT notes); AC-2.8 (TOCTOU on FK) — actually now PARTLY mitigated by DB-level FK (insert would fail at DB layer with PG error 23503, not just app-layer); modules/database.md § Internal Logic.
### D-FILTER — Services/AircraftService.cs + FlightService.cs (SMALL)
| # | Aspect | Doc claim (AC-1.6) | Code today |
|---|--------|---------------------|------------|
| F1 | Vehicle (today: Aircraft) `name` filter case sensitivity | "**case-sensitive contains** on `Name`" (AC-1.6, data_parameters.md § 2.1) | **`a.Name.ToLower().Contains(query.Name.ToLower())`** — **case-INSENSITIVE** contains |
| F2 | Mission (today: Flight) `name` filter case sensitivity | not specified in AC-2.3 | **case-INSENSITIVE** contains (same `ToLower().Contains(ToLower())` pattern) |
| F3 | Mission list ordering | AC-2.3 doesn't specify | **`OrderByDescending(f => f.CreatedDate)`** — newest first |
| F4 | Vehicle list ordering | AC-1.5 doesn't specify | **`OrderBy(a => a.Name)`** — alphabetical ASC |
**ACs / NFTs to revise**: AC-1.6 (case-INSENSITIVE); FT-N-01 (current test asserts "case mismatch returns 0 rows" which is WRONG against today's code — case is ignored, so a `name=br` query against `BR-01` actually returns 1 row, not 0); add ordering specs to AC-1.5 and AC-2.3; FT-P-04 + FT-P-08 should assert ordering.
### D-WP-NEST-CHECK — Services/WaypointService.cs (TINY)
| # | Aspect | Doc claim (AC-4.2) | Code today |
|---|--------|---------------------|------------|
| W1 | Parent-mission existence check | "Parent mission missing → 404" | Code's `CreateWaypoint` checks `db.Flights.AnyAsync(f => f.Id == flightId)` and throws `KeyNotFoundException` ✓; `UpdateWaypoint` and `DeleteWaypoint` use a composite WHERE `w.FlightId == flightId && w.Id == waypointId` and throw `KeyNotFoundException` if no match — meaning the test for "parent missing" returns 404 BUT the doc-implied "parent missing first, then waypoint missing" two-step check is collapsed into one. |
**ACs / NFTs to revise**: minor — clarify in AC-4.2 that the check is "matching `(flightId, waypointId)` returns no row → 404", which collapses two error cases into one.
### D-VERIFICATION-LOG — `_docs/02_document/04_verification_log.md` (META)
The verification log itself is wrong:
- § 3 row F5 (JWT validation): says "JwtExtensions matches exactly" — **wrong**, see D-JWT above.
- § 3 row F6 (Startup + migration): says "matches exactly" but the docs claim hardcoded fallbacks while code has `ResolveRequiredOrThrow`**wrong**, see D-CONFIG.
- § 4.1 D6 (modules/middleware.md correction): correctly identifies the camelCase envelope, ✓.
- § 4.2 F3 (carry-forward Swagger / CORS unconditional): "CORS unconditional" is wrong — code is gated. Swagger is still unconditional ✓.
**Action**: re-issue § 3 rows F5, F6 with the new evidence; demote § 4.2 F3 (CORS unconditional) into the corrected list.
---
## Recommended re-verification + revision plan (next session)
### Phase 1 — `/document` re-run in `task` mode, scope = drifted files
Inputs: this drift findings report.
Skills: `.cursor/skills/document/SKILL.md` in **Task mode**.
Files to update (estimate 1012 doc files):
| File | Sections to revise |
|------|---------------------|
| `_docs/02_document/architecture.md` | § 7 Cross-cutting (auth subsection: ECDSA+JWKS+iss/aud), § 7 (CORS subsection: gated), ADR-005 (mark obsolete or rewrite "no dev fallback" + "Swagger still ungated"), ADR-002 (no change — wire shape unaffected) |
| `_docs/02_document/components/05_identity/description.md` | full rewrite of "Mechanism" + "Caveats" (ECDSA, JWKS, iss/aud, calls admin) |
| `_docs/02_document/components/07_host/description.md` | Program.cs section (ConfigurationResolver, CorsConfigurationValidator); ADR-005 cross-ref |
| `_docs/02_document/modules/auth.md` | full rewrite |
| `_docs/02_document/modules/program.md` | rewrite startup section: env var contract, no fallback, CORS gating |
| `_docs/02_document/modules/database.md` | TIMESTAMP (not TIMESTAMPTZ), REFERENCES declared, DEFAULT clauses |
| `_docs/02_document/data_model.md` | § 11 schema table column types + FK note |
| `_docs/02_document/04_verification_log.md` | re-issue § 3 F5+F6 rows; correct § 4.2 F3 |
| `_docs/02_document/state.json` | append `decomposition_revised` entry recording the verification re-run; update `last_updated` |
| `_docs/00_problem/problem.md` | review for any auth-shape claims |
| `_docs/00_problem/acceptance_criteria.md` | AC-1.6 (case), AC-1.5 + AC-2.3 (ordering), AC-5.15.7, AC-5.9, AC-9.1 (today both `FL`+`GPS`), AC-6.1, AC-6.2 |
| `_docs/00_problem/restrictions.md` | E1 (4 env vars), E3 (no fallback today), E4 (no shared secret), E9 (gated CORS), S6 (Swagger still ungated ✓ — no change) |
| `_docs/00_problem/security_approach.md` | JWT validation, CORS gating, no dev secret |
| `_docs/00_problem/input_data/data_parameters.md` | § 1 env vars (4 vars now), § 3 schema (TIMESTAMP, REFERENCES, DEFAULT) |
| `_docs/01_solution/solution.md` | per-component table for 05 + 07 |
### Phase 2 — `/test-spec` re-issue in scoped mode
Inputs: revised docs from Phase 1.
Skill: `.cursor/skills/test-spec/SKILL.md` in **cycle-update mode** (NOT full re-run; scoped to AC-5, AC-6.1/6.2, AC-9.1, AC-1.5, AC-1.6, AC-2.3, E1, E3, E4, E9 + the 4 NFT families that those ACs feed).
Files to revise:
| File | Scope |
|------|-------|
| `_docs/00_problem/input_data/expected_results/results_report.md` | re-issue AC-1 row 1.6, AC-5 entire group, AC-6 rows 6.16.2, AC-9 row 9.1, AC-1 ordering rows, AC-2 ordering rows; add E3+E9 lock rows |
| `_docs/02_document/tests/environment.md` | replace "in-process JWT mint with HS256 shared secret" with **"in-process ECDSA keypair + ephemeral JWKS HTTP service mock"** (e.g. WireMock.NET serves `/.well-known/jwks.json`); add `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL` env vars; remove `JWT_SECRET` |
| `_docs/02_document/tests/test-data.md` | rewrite "External Dependency Mocks" — `admin` JWKS mock; rewrite Data Validation Rules JWT rows |
| `_docs/02_document/tests/blackbox-tests.md` | revise FT-N-01 (case-insensitive); add ordering assertions to FT-P-04 + FT-P-08 |
| `_docs/02_document/tests/security-tests.md` | full revision of NFT-SEC-01 through NFT-SEC-09 (ECDSA, iss/aud, JWKS rotation, missing JWT_ISSUER startup throw) |
| `_docs/02_document/tests/resilience-tests.md` | revise NFT-RES-05 (`ResolveRequiredOrThrow` failure modes — add scenarios for missing each of the 4 env vars); revise NFT-RES-07 (JWKS rotation, not shared-secret rotation) |
| `_docs/02_document/tests/traceability-matrix.md` | re-trace AC-5, AC-6, AC-9, AC-1.5, AC-1.6, AC-2.3, E-rows |
| `docker-compose.test.yml` | replace `JWT_SECRET` with `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL`; add a `jwks-mock` service (e.g. WireMock or a small Kestrel test server) |
### Phase 3 — Resume autodev Step 4 (Code Testability Revision)
After Phase 1+2: re-enter `existing-code` Step 4 with the revised docs + test specs. The original Step 4 analysis result ("code is largely testable as-is") still holds — the JWT/CORS/Config drift didn't introduce hardcoded paths or singletons, it just made the code MORE testable than the docs described.
Expected outcome: Step 4 → "all scenarios testable as-is" → Step 5 (Decompose Tests, **session boundary**).
---
## Cross-cutting acknowledgements
- The B-ticket plan (B5B12) is unaffected. None of the drift overlaps with the rename/GPS-Denied work — the JWT/CORS/Config evolution happened independently.
- The `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` leftover stays as-is.
- The suite docs (`../suite/_docs/00_roles_permissions.md`, `../suite/_docs/05_identity*`, etc.) likely have correlated drift on the JWT model. Out of scope for this repo's `/autodev`; flag at suite-level next time `/autodev` runs in the suite workspace.
+26 -22
View File
@@ -6,7 +6,7 @@
> **Status**: confirmed-by-user (autodev `/document` Step 4.5, 2026-05-14). Source-of-truth for "what this service is and why" — downstream skills (`/refactor`, `/decompose`, `/new-task`, `/code-review`) consume this section before reading the lower-level technical sections below.
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of each Azaion deployment — vehicle inventory, mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. **Exactly one instance runs per device** (Jetson Orin / OrangePI / operator-PC) alongside sibling edge services (`annotations`, detection, `autopilot`, `gps-denied`, `ui`), all sharing **ONE local PostgreSQL with per-service table ownership enforced by convention**. JWTs are minted remotely by the central `admin` service and validated locally with a shared HMAC secret; this service never calls back. The dominant pattern is **thin controller → service → linq2db active-record over a per-request scoped `DataConnection`**, with **no repository abstraction** and **no in-process message queue / event bus**.
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of each Azaion deployment — vehicle inventory, mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. **Exactly one instance runs per device** (Jetson Orin / OrangePI / operator-PC) alongside sibling edge services (`annotations`, detection, `autopilot`, `gps-denied`, `ui`), all sharing **ONE local PostgreSQL with per-service table ownership enforced by convention**. JWTs are minted remotely by the central `admin` service using ECDSA-SHA256 and validated locally against `admin`'s JWKS, which this service fetches once at startup and caches; request-path validation is local and does not call `admin`. The dominant pattern is **thin controller → service → linq2db active-record over a per-request scoped `DataConnection`**, with **no repository abstraction** and **no in-process message queue / event bus**.
### Components & responsibilities (6 logical components, 1 csproj)
@@ -15,7 +15,7 @@
| 01 | `01_vehicle_catalog` | Vehicle CRUD + "is_default" exclusivity (stricter than spec — B12 decision pending) |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + the cross-service cascade-delete walk (canonical owner of the full mission ownership graph) |
| 04 | `04_persistence` | `AppDataConnection` (LinqToDB) + `DatabaseMigrator` (`CREATE TABLE IF NOT EXISTS` for the 4 owned tables post-B7 + B9) |
| 05 | `05_identity` | `JwtExtensions`; shared-secret HS256 validation; one `"FL"` policy (post-B7) |
| 05 | `05_identity` | `JwtExtensions`; ECDSA-SHA256 validation against admin's JWKS (cached locally); one `"FL"` policy (post-B7) |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` + `PaginatedResponse<T>` + the unused `ErrorResponse` DTO |
| 07 | `07_host` | `Program.cs` composition root; runs migrator at startup; serves on port 8080 |
@@ -25,7 +25,7 @@
- **F2 Mission create/read/update** — UI → mission service, with vehicle existence check.
- **F3 Mission delete + CASCADE** *(critical)* — walks across `annotations` + detection schemas; **not transaction-wrapped today** (ADR-006).
- **F4 Waypoint CRUD** — delete is a scoped F3 cascade.
- **F5 JWT bearer validation** — every protected request; local HS256, no `iss`/`aud` (CMMC L2 finding, suite-tracked under AZ-487 / AZ-494).
- **F5 JWT bearer validation** — every protected request; local ECDSA-SHA256 against admin's JWKS (cached); `iss` and `aud` both validated; `alg` pinned to `EcdsaSha256` (defends against HS256-confusion). The CMMC L2 finding tracked under AZ-487 / AZ-494 is now structurally addressed in this service's code; the suite-level docs still describe the legacy HS256 model and have a sync task pending.
- **F6 Startup + schema migration** — `Program → DatabaseMigrator.Migrate → app.Run`.
- **F7 Health probe** — anonymous `GET /health`; process-liveness only.
@@ -33,7 +33,7 @@
- **One PostgreSQL per device; per-service table ownership enforced by convention.** *[inferred-from: `../../suite/_docs/00_top_level_architecture.md` § Database Topology, `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`]*
- **Manual cascade-delete in code, NOT `ON DELETE CASCADE` in schema.** *[inferred-from: `Database/DatabaseMigrator.cs`, `FlightService.DeleteFlight` (today's `MissionService.DeleteMission`)]*
- **JWT validated locally with no callback to `admin`** (HS256 shared-secret). *[inferred-from: `Auth/JwtExtensions.cs`]*
- **JWT validated locally against admin's public JWKS** (ECDSA-SHA256). The JWKS is fetched once at startup (via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>`) and refreshed on the default schedule; per-request validation is local. *[inferred-from: `Auth/JwtExtensions.cs`]*
- **Forward-only-additive schema bootstrap** (`CREATE TABLE IF NOT EXISTS`); B9's `DROP TABLE IF EXISTS` is the one explicit destructive step. *[inferred-from: `Database/DatabaseMigrator.cs`]*
- **Layer-organized layout** (`Controllers/`, `Services/`, `DTOs/`, `Enums/`), NOT feature-folders; one project / one root namespace; layering rules in `module-layout.md` enforced by convention not by the compiler. *[inferred-from: repository tree + `Azaion.Flights.csproj` (today's `Azaion.Missions.csproj`)]*
- **`gps-denied` is decoupled by design** — no runtime call in either direction; rows reference `mission_id` / `waypoint_id` as plain GUIDs in `gps-denied`'s own tables. *[inferred-from: ADR-007 + AZ-546 acceptance criteria]*
@@ -45,7 +45,7 @@ These divergences from spec or known foot-guns are tracked in `00_discovery.md`
- PascalCase entity-body wire shape vs spec's camelCase (the *error envelope* is already camelCase by accidental match — see ADR-002).
- Cascade-delete is not transaction-wrapped (ADR-006); one-line fix to land opportunistically with B6.
- Swagger UI + dev-fallback secrets (`JWT_SECRET`, `DATABASE_URL`) NOT gated on `IsDevelopment()` (ADR-005).
- Swagger UI NOT gated on `IsDevelopment()` (ADR-005, scope reduced — the "dev fallback secrets" aspect is now obsolete; see ADR-005 below for details).
- `"FL"` policy code retains the legacy "Flight" wording even after the service rename — fleet-wide auth change, not in this Epic.
- `Geopoint` stored as 3 flat columns (`lat`, `lon`, `mgrs`) instead of spec's single auto-converting `string GPS`.
- F2 returns `400` instead of spec's `404` on a missing `VehicleId` (`ArgumentException` mapping).
@@ -64,7 +64,7 @@ These divergences from spec or known foot-guns are tracked in `00_discovery.md`
| System | Integration Type | Direction | Purpose |
|--------|------------------|-----------|---------|
| `admin` (.NET, central) | JWT (HMAC shared secret) | Inbound (validation only) | Issues bearer tokens that this service validates locally; no network call back |
| `admin` (.NET, central) | JWKS over HTTPS (outbound, startup + refresh) + JWT validation (inbound) | Outbound at startup; inbound on every request | Issues ECDSA-signed bearer tokens; this service fetches admin's public JWKS once at startup, caches it, and validates tokens locally thereafter. No per-request callback. JWKS rotation does not require a coordinated redeploy |
| Operator UI (React, edge) | REST (JSON over HTTP) | Inbound | All vehicle / mission / waypoint CRUD |
| `autopilot` (edge) | Shared DB (PostgreSQL on the same device) | Bidirectional | `autopilot` writes `map_objects` (this service owns the schema and cascade-deletes them); `autopilot` reads `missions` + `waypoints` to drive the vehicle |
| `annotations` (edge) | Shared DB | Outbound delete | `missions` cascade-deletes from `media` + `annotations` on mission/waypoint delete; `annotations` owns the schema |
@@ -81,7 +81,7 @@ These divergences from spec or known foot-guns are tracked in `00_discovery.md`
| Data access | linq2db | 6.2.0 | Suite-wide ORM choice; explicit SQL escape hatch + attribute mapping; works well with the manual cascade pattern |
| Database driver | Npgsql | 10.0.2 | PostgreSQL native protocol driver |
| Schema bootstrap | linq2db raw `Execute` (`CREATE TABLE IF NOT EXISTS`) | — | Forward-only-additive; one `DROP TABLE IF EXISTS orthophotos / gps_corrections` block in B9 |
| Auth | `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer with HS256 (shared secret with `admin`); local validation, no callback to issuer |
| Auth | `Microsoft.AspNetCore.Authentication.JwtBearer` + `Microsoft.IdentityModel.Protocols` | 10.0.5 | JWT bearer with ECDSA-SHA256 against admin's JWKS (cached via `ConfigurationManager<JsonWebKeySet>`); `iss`/`aud` validated; algorithm pinned |
| API docs | `Swashbuckle.AspNetCore` | 10.1.5 | Swagger UI + JSON spec (mounted unconditionally — see ADR-005) |
| HTTP error envelope | Custom `ErrorHandlingMiddleware` | — | Maps `KeyNotFoundException`/`ArgumentException`/`InvalidOperationException` → 404/400/409 (see ADR-002 and component `06_http_conventions` Caveats for divergences from suite spec) |
| Container | `mcr.microsoft.com/dotnet/aspnet:10.0` (multi-arch SDK build) | 10.0 | Matches edge target architectures (ARM64 dominant; AMD64 used for operator-PC) |
@@ -119,11 +119,15 @@ These divergences from spec or known foot-guns are tracked in `00_discovery.md`
| Config | Development | Edge production |
|--------|-------------|-----------------|
| `DATABASE_URL` | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` (hardcoded fallback) | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (compose env) |
| `JWT_SECRET` | `development-secret-key-min-32-chars!!` (hardcoded fallback) | Provisioned secret shared across `admin` + every backend service on the device |
| Logging | Console / Debug (ASP.NET Core defaults) | Console only (no Serilog / structured logging configured today) |
| `DATABASE_URL` | Operator-supplied env var or `Database:Url` config key (e.g. `Host=localhost;Database=azaion;Username=postgres;Password=changeme`). **No hardcoded fallback**`ConfigurationResolver.ResolveRequiredOrThrow` aborts startup if unset | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (compose env) |
| `JWT_ISSUER` | Operator-supplied (e.g. `https://admin.azaion.dev/`). **Required at startup** | Set by Edge compose to the central admin issuer |
| `JWT_AUDIENCE` | Operator-supplied (e.g. `missions`). **Required at startup** | Set by Edge compose to this service's audience identifier |
| `JWT_JWKS_URL` | Operator-supplied HTTPS URL (e.g. `https://admin.azaion.dev/.well-known/jwks.json`). **Required at startup** + must be HTTPS (`HttpDocumentRetriever.RequireHttps = true`) | Set by Edge compose to admin's JWKS endpoint |
| `CorsConfig:AllowedOrigins` | Optional; defaults to `[]` (implicit-permissive policy + startup warning) | Required when `CorsConfig:AllowAnyOrigin != true` — startup THROWS in `Production` with empty origins |
| `CorsConfig:AllowAnyOrigin` | Optional; defaults to `false` | Optional; explicit opt-in if reverse-proxy already enforces origin checks |
| Logging | Console / Debug (ASP.NET Core defaults) + `PermissiveDefaultWarning` when implicit-permissive CORS applies | Console only (no Serilog / structured logging configured today) |
| Swagger | enabled | enabled (NOT gated on `IsDevelopment()` — see ADR-005) |
| CORS | `AllowAnyOrigin/Method/Header` | `AllowAnyOrigin/Method/Header` (no environment override; assumed safe behind suite reverse proxy) |
| CORS | Permissive fallback (with `PermissiveDefaultWarning` startup log) | Explicit allow-list via `CorsConfig:AllowedOrigins`, or explicit `AllowAnyOrigin=true` if reverse-proxy gates origins; implicit-permissive aborts startup |
| Migrator | runs at process start | runs at process start (idempotent `IF NOT EXISTS` + the one B9 `DROP TABLE IF EXISTS` block for legacy GPS-Denied tables on previously-deployed devices) |
For containerization details, CI pipeline structure, and observability, see `_docs/02_document/deployment/`.
@@ -210,21 +214,21 @@ This service is a single .NET process. Components communicate via direct C# call
## 7. Security Architecture
**Authentication**: JWT bearer (HS256). Tokens are minted by the central `admin` service and validated locally by `05_identity` using a shared HMAC secret (`JWT_SECRET`). This service NEVER calls back to `admin`; rotation of the secret requires coordinated redeploy across every backend service that shares the secret. Per the CMMC L2 scorecard (`../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3), `iss` / `aud` validation is currently disabled; this is a known finding tracked at the suite level under AZ-487 / AZ-494 (out of this Epic's scope).
**Authentication**: JWT bearer with **ECDSA-SHA256** signature validation. Tokens are minted by the central `admin` service (which holds the ECDSA private key) and validated locally by `05_identity` against admin's public JWKS document. The JWKS is fetched once at startup via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` against `JWT_JWKS_URL` (HTTPS only — `HttpDocumentRetriever.RequireHttps = true`) and refreshed on the manager's default schedule. After the initial fetch, request-path validation is local; no per-request callback to `admin`. Validation enforces `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew), and pins `alg` to `EcdsaSha256` to defend against the HS256-confusion attack. **JWKS rotation does NOT require a coordinated redeploy** — consumers pick up new keys on the next refresh tick, and old tokens signed with the previous `kid` remain valid until their natural expiry. The CMMC L2 finding (`../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3) about missing `iss`/`aud` validation is structurally fixed in this service's code; the suite-level docs still describe the legacy HS256 model and have a sync task pending (drift recorded in `_docs/02_document/05_drift_findings_2026-05-14.md`).
**Authorization**: Single named policy `"FL"`, gated by a `permissions` claim value. Every controller route in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The role → permission matrix lives in `../../suite/_docs/00_roles_permissions.md`. Note: the policy code `"FL"` carries the legacy "Flight" name even after the service rename to `missions`; renaming the permission code is a fleet-wide auth change (would invalidate every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../suite/_docs/00_roles_permissions.md`.
**Data protection**:
- **At rest**: PostgreSQL on-disk encryption is the device-level concern (suite-level, not this service). This service does not encrypt data at the column level.
- **In transit**: TLS termination is the reverse proxy's responsibility. This service does NOT enforce HTTPS redirection. The container `EXPOSE 8080` is plain HTTP; the upstream reverse proxy adds TLS.
- **Secrets management**: `DATABASE_URL` and `JWT_SECRET` are env vars. The `Program.cs` hardcoded fallbacks (`development-secret-key-min-32-chars!!`, `Password=changeme`) are dev-only and MUST be overridden in production. There is **no runtime gate** that blocks startup with the dev fallback in production — see ADR-005.
- **In transit**: TLS termination is the reverse proxy's responsibility. This service does NOT enforce HTTPS redirection. The container `EXPOSE 8080` is plain HTTP; the upstream reverse proxy adds TLS. The JWKS fetch is independently constrained to HTTPS by `HttpDocumentRetriever { RequireHttps = true }`.
- **Secrets management**: Four required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) plus optional CORS keys flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow`. **There are no hardcoded fallbacks**; a missing required value aborts startup with `InvalidOperationException` before the host is built. A production deploy that forgets `JWT_JWKS_URL` cannot silently accept tokens — it fails fast. The legacy `JWT_SECRET` env var is no longer consulted.
**Audit logging**: None at the application level. The only structured log emitted by app code is `06_http_conventions`' middleware `LogError(ex, "Unhandled exception")` for unhandled 500s. There is no per-request audit trail, no correlation ID, and no per-user attribution (the JWT's user-id claim is not consumed — see `05_identity` Caveats #2).
**Audit logging**: None at the application level. The only structured log emitted by app code is `06_http_conventions`' middleware `LogError(ex, "Unhandled exception")` for unhandled 500s, plus `Program.cs`' `PermissiveDefaultWarning` when implicit-permissive CORS applies. There is no per-request audit trail, no correlation ID, and no per-user attribution (the JWT's user-id claim is not consumed — see `05_identity` Caveats #2).
**Input validation**: None. No `[Required]` attributes, no range checks. Empty `Name`, negative `BatteryCapacity`, invalid enum int values are accepted on input. Carry-forward improvement; not in this Epic's scope.
**CORS**: `AllowAnyOrigin/Method/Header` in all environments. Spec does not mandate a CORS policy — likely safe behind the suite's edge reverse proxy (per `../../suite/_docs/00_top_level_architecture.md`) but worth confirming on first production deployment.
**CORS**: Gated by `Infrastructure/CorsConfigurationValidator.cs`. In `Production` (case-insensitive match on `ASPNETCORE_ENVIRONMENT`) an empty `CorsConfig:AllowedOrigins` with `CorsConfig:AllowAnyOrigin != true` aborts startup. In non-Production environments, an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. Explicit `AllowAnyOrigin=true` always applies permissive without warning. The previous "permissive in all environments" model no longer holds.
## 8. Key Architectural Decisions
@@ -297,21 +301,21 @@ This service is a single .NET process. Components communicate via direct C# call
- No version table; the migrator is idempotent and runs every startup.
- Acceptable today; will become a real problem if the schema starts evolving frequently.
### ADR-005: Swagger + dev fallbacks not gated on `IsDevelopment()`
### ADR-005: Swagger NOT gated on `IsDevelopment()` (scope reduced — dev-fallback secrets obsoleted)
**Context**: ASP.NET Core's idiomatic pattern gates Swagger UI and dev-only convenience features on `app.Environment.IsDevelopment()`. Today, this service mounts Swagger unconditionally and uses hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL` if env vars are unset.
**Context**: ASP.NET Core's idiomatic pattern gates Swagger UI and dev-only convenience features on `app.Environment.IsDevelopment()`. The original form of this ADR also covered hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL`; that aspect is now obsolete after the introduction of `Infrastructure/ConfigurationResolver.cs` (fail-fast `ResolveRequiredOrThrow`). The only remaining gap is Swagger.
**Decision (current, carry-forward)**: Leave both unconditional today. Swagger UI is useful on edge devices for one-off operator debugging through the local network. The hardcoded dev fallbacks are a known foot-gun (a misconfigured production deploy will silently use the well-known secret) but they are intentional during the rename phase to keep `dotnet run` working zero-config.
**Decision (current, carry-forward)**: Leave Swagger UI mounted unconditionally. Swagger UI is useful on edge devices for one-off operator debugging through the local network. There is no hardcoded dev fallback for any secret today.
**Alternatives considered**:
1. **Gate both on `IsDevelopment()`** — preferred long-term; out of this Epic.
2. **Fail-fast at startup if `JWT_SECRET` is unset** — preferred long-term; out of this Epic.
1. **Gate Swagger on `IsDevelopment()` (or on `ASPNETCORE_ENVIRONMENT != "Production"`)** — preferred long-term; out of this Epic.
2. **Add a Swagger security scheme so the UI knows how to attach `Authorization: Bearer ...`** — usability improvement; out of this Epic.
**Consequences**:
- Swagger UI is exposed on every deployment. The reverse proxy may or may not whitelist it; verify on first production rollout.
- A production deployment without `JWT_SECRET` set will silently boot with the well-known dev secret. This is a security finding tracked at the suite level (see CMMC L2 row 3).
- The "production silently boots with the dev secret" risk no longer exists: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`, and `DATABASE_URL` are all required, and `ResolveRequiredOrThrow` aborts startup with `InvalidOperationException` if any is missing. The CMMC L2 row-3 finding (HS256 + missing `iss`/`aud`) is also structurally addressed by the ECDSA + JWKS + iss/aud-validation model see Section 7 above.
### ADR-006: Cascade-delete is NOT transaction-wrapped (carry-forward)
@@ -2,96 +2,133 @@
**Spec source**: `../../../suite/_docs/10_auth.md` (suite-wide JWT model), `../../../suite/_docs/00_roles_permissions.md` (the `FL` permission code).
**Implementation status**: ✅ implemented. Single policy `FL` is declared and consumed by every controller.
**Implementation status**: ✅ implemented. Single policy `FL` is declared and consumed by every controller in the post-rename target scope.
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's `JwtExtensions.cs` also declares a `"GPS"` policy reserved for the (now-removed-from-this-repo) GPS-Denied endpoints. After Jira AZ-EPIC child B7 lands, only `"FL"` remains.
**Files**: `Auth/JwtExtensions.cs`
**Files**: `Auth/JwtExtensions.cs`, `Infrastructure/ConfigurationResolver.cs` (consumed for fail-fast value resolution)
## 1. High-Level Overview
**Purpose**: Validate JWT bearer tokens issued by the remote `admin` service and expose the named authorization policy (`FL`) used by controllers in the feature components. This service does not issue tokens -- it consumes them.
**Purpose**: Validate JWT bearer tokens issued by the remote `admin` service and expose the named authorization policy (`FL`) used by controllers in the feature components. This service does not issue tokens it consumes them.
**Architectural pattern**: ASP.NET Core extension method (`AddJwtAuth`) configuring `IServiceCollection` at DI time.
**Architectural pattern**: ASP.NET Core extension method (`AddJwtAuth`) configuring `IServiceCollection` at DI time. JWT signature validation is **asymmetric (ECDSA-SHA256)** against public keys retrieved from `admin`'s JWKS endpoint and cached locally; `admin` is **not** contacted on the request path after the first JWKS fetch.
**Upstream dependencies**: None internally.
**Upstream dependencies**: `Infrastructure/ConfigurationResolver.cs` (shared with `07_host`) for fail-fast value resolution.
**Downstream consumers**: `07_host` (calls `AddJwtAuth(jwtSecret)` once); `01_vehicle_catalog`, `02_mission_planning` (controllers carry `[Authorize(Policy = "FL")]`).
**Downstream consumers**: `07_host` (calls `AddJwtAuth(builder.Configuration)` once); `01_vehicle_catalog`, `02_mission_planning` (controllers carry `[Authorize(Policy = "FL")]`).
## 2. Internal Interface
```csharp
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret);
public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration);
```
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and one named authorization policy in DI:
`AddJwtAuth` reads three required values via `ConfigurationResolver.ResolveRequiredOrThrow`:
| Policy | Requirement |
|--------|-------------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` |
| Env var | Config key | Purpose |
|---------|------------|---------|
| `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 (e.g. `https://admin.azaion/.well-known/jwks.json`) |
## 3. Suite-wide JWT pattern
Each value is resolved env-var-first, then config-key, then throws `InvalidOperationException` at startup. There is **no dev fallback**. The legacy `JWT_SECRET` env var is no longer consulted.
This is the canonical "every backend service" identity model in the Azaion suite. Per `../../../suite/_docs/00_top_level_architecture.md` and `../../../suite/_docs/10_auth.md`:
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and **two** named authorization policies in DI (one is removed after B7 lands):
| Policy | Requirement | Notes |
|--------|-------------|-------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` | Permanent |
| `"GPS"` | JWT contains a `permissions` claim with value `"GPS"` | Removed in Jira B7 (legacy GPS-Denied routes are moving out of this repo) |
## 3. JWT model (this service) vs. suite-wide pattern
**This service's implementation** is described in code below. The suite-wide pattern lives in `../../../suite/_docs/00_top_level_architecture.md` and `../../../suite/_docs/10_auth.md` — those documents currently describe the legacy HS256 / shared-secret model and have **not yet been updated** to reflect the ECDSA-on-JWKS evolution captured here. The drift between this service and the suite docs is flagged in `_docs/02_document/05_drift_findings_2026-05-14.md` and will be picked up at the suite level on the next suite `/autodev` invocation. The remaining .NET consumers (`annotations`, `satellite-provider`) may or may not have made the same transition; their docs are the source of truth for their own implementation.
What is verified against `Auth/JwtExtensions.cs` today:
```
┌─────────────────────┐ ┌──────────────────────┐
│ Operator UI │ POST /login │ admin (.NET, remote) │
│ (React, edge) │ ──────────────► │ central user DB │
│ │ ◄────────────── │ mints HS256 JWT
│ │ Bearer JWT │ (claim: permissions)
└──────────┬──────────┘ └─────────────────────┘
│ Bearer JWT (the SAME token reused for every service)
├──────────────────► annotations (.NET, edge) -- ANN claim
├──────────────────► missions (.NET, edge) -- FL claim ◄── this service
├──────────────────► satellite-provider (.NET, remote) -- ADM claim
└──────────────────► (any future .NET service)
│ │ ◄────────────── │ ECDSA-signs JWT,
│ │ Bearer JWT │ exposes JWKS
└──────────┬──────────┘ └─────────────────────┘
│ Bearer JWT
│ /.well-known/jwks.json
│ │ (HTTPS, fetched once at startup,
│ │ cached by ConfigurationManager,
│ │ refreshed on default schedule)
└────────────► missions ◄───────────┘
(this service)
validates: ECDSA-SHA256 signature,
iss = JWT_ISSUER,
aud = JWT_AUDIENCE,
exp (with 30s clock skew),
alg pinned to EcdsaSha256
```
Every service (admin, annotations, missions, 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 neither issues tokens nor talks to the central user DB** -- it only validates.
`admin` holds the **private** ECDSA key and signs tokens. This service fetches the **public** JWKS document from `admin` once at startup (on the first protected request after process start) and caches it. Request-path validation is purely cryptographic against the cached keys; `admin` is not contacted per request. The user logs in once at the UI; the resulting bearer token is reusable across every backend service for its lifetime.
The `permissions` claim drives per-service `[Authorize(Policy = "...")]` checks. The role -> permission matrix lives in `../../../suite/_docs/00_roles_permissions.md`. All routes here require `FL`.
The `permissions` claim drives per-service `[Authorize(Policy = "...")]` checks. The role permission matrix lives in `../../../suite/_docs/00_roles_permissions.md`. All routes in `01_vehicle_catalog` and `02_mission_planning` require `FL`.
## 4. External API
None directly. Auth contract is observable only via `401 Unauthorized` / `403 Forbidden` on protected routes.
None directly. Auth contract is observable only via `401 Unauthorized` / `403 Forbidden` on protected routes, plus the HTTPS JWKS fetch to `admin` at startup (out-of-band).
## 5. Data Access Patterns
None.
None against the local PostgreSQL. One outbound HTTPS GET to the configured `JWT_JWKS_URL` at process start, cached by `ConfigurationManager<JsonWebKeySet>` and refreshed on its default schedule (matches admin's `Cache-Control: public, max-age=3600` on the JWKS endpoint).
## 6. Implementation Details
**Algorithm**: HMAC-SHA256 signature validation via `SymmetricSecurityKey(UTF-8(jwtSecret))`. Matches the suite-wide shared-secret model.
**Mechanism**: ECDSA-SHA256 signature validation against public keys retrieved from `admin`'s JWKS endpoint. The keys are wrapped in a `ConfigurationManager<JsonWebKeySet>` configured with:
**Token validation flags**:
- `ValidateIssuerSigningKey = true`
- `ValidateLifetime = true` (with `ClockSkew = 1 minute` -- tighter than .NET's 5-minute default)
- `ValidateIssuer = false`, `ValidateAudience = false` -- `iss` / `aud` NOT enforced (consistent with shared-secret intra-suite model). Per the CMMC L2 scorecard (`../../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3), this is a known finding tracked at the suite level under AZ-487/AZ-494; the remediation will copy the `satellite-provider` pattern across `annotations` and `missions`.
- `jwksUrl` — resolved at startup from `JWT_JWKS_URL` / `Jwt:JwksUrl` (fail-fast if missing).
- A custom `JwksRetriever : IConfigurationRetriever<JsonWebKeySet>` (private nested class in `JwtExtensions.cs`) that wraps an `IDocumentRetriever` and parses the response as a `JsonWebKeySet`. The stock `OpenIdConnectConfigurationRetriever` targets the full OIDC discovery document, which `admin` does not publish — only the JWKS endpoint is exposed — so the minimal retriever is used.
- `HttpDocumentRetriever { RequireHttps = true }` — non-HTTPS JWKS URLs are rejected at configuration time.
**Token validation parameters** (`TokenValidationParameters`):
| Parameter | Value |
|-----------|-------|
| `ValidateIssuer` | `true` |
| `ValidIssuer` | `<resolved JWT_ISSUER>` |
| `ValidateAudience` | `true` |
| `ValidAudience` | `<resolved JWT_AUDIENCE>` |
| `ValidateLifetime` | `true` |
| `ValidateIssuerSigningKey` | `true` |
| `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` |
| `RequireSignedTokens` | `true` |
| `RequireExpirationTime` | `true` |
| `ClockSkew` | `TimeSpan.FromSeconds(30)` |
| `IssuerSigningKeyResolver` | Delegate that fetches the cached `JsonWebKeySet` and returns the matching `kid`'s keys (or all keys if `kid` is empty) |
**Key Dependencies**:
| Library | Version | Purpose |
|---------|---------|---------|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer middleware + handler |
| `Microsoft.IdentityModel.Tokens` | (transitive) | `SymmetricSecurityKey`, `TokenValidationParameters` |
| `Microsoft.IdentityModel.Protocols` | (transitive) | `ConfigurationManager<T>`, `HttpDocumentRetriever`, `IConfigurationRetriever<T>`, `IDocumentRetriever` |
| `Microsoft.IdentityModel.Tokens` | (transitive) | `JsonWebKeySet`, `TokenValidationParameters`, `SecurityAlgorithms` |
## 7. Extensions and Helpers
None.
- `JwksRetriever` — private nested class in `JwtExtensions.cs`. Minimal `IConfigurationRetriever<JsonWebKeySet>` implementation; ~5 lines. Exists because Microsoft does not ship a JWKS-only retriever.
## 8. Caveats & Edge Cases
1. **Shared-secret trust model** -- any service that knows `JWT_SECRET` can mint tokens this API will accept. Not safe for multi-tenant or third-party token issuance. Consistent with the rest of the suite; tightening this is suite-wide work, not a per-service decision.
2. **No claim type for "user id" is consumed** -- only the `permissions` claim is checked. Services don't know who is calling them; per-user audit trails / business rules cannot be enforced at the service layer today. When a future feature needs an "applied by" attribution this gap will need to close.
3. **No offline-grace-window logic in this service** -- `../../../suite/_docs/10_auth.md` describes an offline JWT cache; that lives in the UI / `admin` consumption pattern, not here.
4. **Hardcoded fallback secret** in `Program.cs` (`"development-secret-key-min-32-chars!!"`) is dev-only. Production deployments MUST set `JWT_SECRET`.
5. **`FL` permission code carries the legacy "Flight" name even after the service rename to `missions`.** The plan documents this explicitly: changing the permission code is a fleet-wide auth change (would break every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../../suite/_docs/00_roles_permissions.md`.
1. **`admin` reachability at startup** — the first protected request blocks on the JWKS fetch. If `admin` is unreachable when that fetch happens, the request fails with a 500 (the `IssuerSigningKeyResolver` delegate throws while resolving signing keys). On the local LAN this is single-digit ms typical. Once cached, subsequent requests do not call `admin`.
2. **No claim type for "user id" is consumed** only the `permissions` claim is checked. Services don't know who is calling them; per-user audit trails / business rules cannot be enforced at the service layer today. When a future feature needs an "applied by" attribution this gap will need to close.
3. **No offline-grace-window logic in this service** `../../../suite/_docs/10_auth.md` describes an offline JWT cache; that lives in the UI / `admin` consumption pattern, not here.
4. **Fail-fast on missing configuration**: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all required at startup. A production deploy without any of them throws `InvalidOperationException` from `ConfigurationResolver.ResolveRequiredOrThrow` before the host is built. There is **no hardcoded fallback** (ADR-005's "dev-fallback secret" branch is obsolete for JWT).
5. **JWKS rotation does NOT require a coordinated redeploy** — when `admin` rotates keys, the next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed by the previous key remain valid until expiry as long as the old `kid` is still published. This is the major operational improvement over the legacy HS256 shared-secret model.
6. **Algorithm pin (`ValidAlgorithms = [EcdsaSha256]`)** prevents the classic "HS256 confusion" attack — without the pin, an attacker who learned the JWKS public key could forge `alg: HS256` tokens using the public key as the HMAC secret. The pin forces ECDSA regardless of the token header's `alg` claim.
7. **`FL` permission code carries the legacy "Flight" name even after the service rename to `missions`.** The plan documents this explicitly: changing the permission code is a fleet-wide auth change (would break every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../../suite/_docs/00_roles_permissions.md`.
## 9. Dependency Graph
**Must be implemented after**: nothing.
**Must be implemented after**: `Infrastructure/ConfigurationResolver.cs` (the fail-fast resolver — shared with `07_host`).
**Can be implemented in parallel with**: `04_persistence`, `06_http_conventions`.
@@ -99,4 +136,4 @@ None.
## 10. Logging Strategy
ASP.NET Core's JwtBearer handler logs token validation outcomes at default levels (Information / Debug). Not customized.
ASP.NET Core's JwtBearer handler logs token validation outcomes at default levels (Information / Debug). Not customized. The custom `JwksRetriever` does not emit logs of its own; the `ConfigurationManager<JsonWebKeySet>` may log refresh failures at Warning per its built-in instrumentation.
@@ -1,12 +1,12 @@
# 07 — Host (Composition Root)
**Spec source**: `../../../suite/_docs/00_top_level_architecture.md` § Edge compose excerpt -- confirms the env vars (`DATABASE_URL`, `JWT_SECRET`), port (`5002:8080`), and DB target (`postgres-local`).
**Spec source**: `../../../suite/_docs/00_top_level_architecture.md` § Edge compose excerpt confirms the port (`5002:8080`) and DB target (`postgres-local`). **The env-var contract in suite docs still references the legacy `JWT_SECRET`** and predates this service's transition to JWKS-based JWT validation; the four-variable env contract documented below is the verified current state in code, and the suite docs are flagged for sync in `_docs/02_document/05_drift_findings_2026-05-14.md`.
**Implementation status**: ✅ implemented.
> **NOTE (forward-looking)**: post-rename. Today's source has `Azaion.Flights` namespace + `dotnet Azaion.Flights.dll` entrypoint + container image `azaion/flights:*-arm`. Renames + DLL/image/compose changes tracked under Jira AZ-EPIC children B5 (namespace), B10 (Dockerfile + Woodpecker + suite compose).
**Files**: `Program.cs`, `GlobalUsings.cs`
**Files**: `Program.cs`, `GlobalUsings.cs`, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs`
## 1. High-Level Overview
@@ -50,9 +50,26 @@ None. The host has no exported types -- its surface is the running HTTP server.
**Error Handling**: Delegated to `06_http_conventions`' middleware, placed FIRST in the pipeline so it wraps everything else.
**Configuration**: Reads `DATABASE_URL` and `JWT_SECRET` from `IConfiguration` -> `Environment.GetEnvironmentVariable` -> hardcoded dev fallback. Both fallbacks are dev-only and MUST be overridden in production.
**Configuration**: All required values flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow`. Resolution order per value: `Environment.GetEnvironmentVariable(envVar)``IConfiguration[configKey]` → throws `InvalidOperationException` at startup with a message naming both the env var and the config key. There are **no hardcoded dev fallbacks**; a misconfigured production deploy cannot silently boot.
**`ConvertPostgresUrl` helper**: ad-hoc parser converting `postgresql://user[:pass]@host[:port]/db` to Npgsql key=value form. Does not URL-decode user/password -- caveat for credentials with `@`, `:`, `/`, `%`.
| Env var | Config key | Required? | Purpose |
|---------|------------|-----------|---------|
| `DATABASE_URL` | `Database:Url` | **Yes** | Either Npgsql key=value form OR a `postgresql://` URI (converted via `ConvertPostgresUrl`) |
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** | Expected `iss` claim value (see `05_identity`) |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** | Expected `aud` claim value (see `05_identity`) |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** | HTTPS URL of admin's JWKS endpoint (see `05_identity`) |
| `CorsConfig:AllowedOrigins` | (same) | No (defaults to `[]`) | String array of allowed origins for the CORS policy |
| `CorsConfig:AllowAnyOrigin` | (same) | No (defaults to `false`) | When `true`, applies `AllowAnyOrigin/Method/Header` regardless of origins |
The legacy `JWT_SECRET` env var is **no longer consulted**; `05_identity` documents the JWKS-based replacement.
**CORS gating** (`Infrastructure/CorsConfigurationValidator.cs`):
- `EnsureSafeForEnvironment(origins, allowAnyOrigin, environmentName)` THROWS `InvalidOperationException` in `Production` (case-insensitive match on the `ASPNETCORE_ENVIRONMENT` value) when origins are empty AND `allowAnyOrigin` is `false`. The host refuses to start with an implicit permissive policy in Production.
- `ShouldUsePermissivePolicy(origins, allowAnyOrigin)` returns `true` when `allowAnyOrigin == true` OR origins is empty — used by the CORS policy builder. In non-Production environments with empty origins this falls back to permissive.
- `ShouldWarnAboutPermissiveDefault(origins, allowAnyOrigin)` is `true` when origins are empty AND `allowAnyOrigin` is `false` (implicit permissive). When true, the host logs `PermissiveDefaultWarning` at startup with the current environment name.
**`ConvertPostgresUrl` helper**: ad-hoc parser converting `postgresql://user[:pass]@host[:port]/db` to Npgsql key=value form. Does not URL-decode user/password — caveat for credentials with `@`, `:`, `/`, `%`.
## 6. Extensions and Helpers
@@ -60,10 +77,11 @@ None. The host has no exported types -- its surface is the running HTTP server.
## 7. Caveats & Edge Cases
- **No environment guards**: Swagger and the dev fallbacks for secrets are NOT gated on `IsDevelopment()`. If `JWT_SECRET` is unset in production, the service silently runs with the well-known development secret.
- **CORS open by default**: `AllowAnyOrigin/Method/Header` applied unconditionally. Spec doesn't mandate a CORS policy -- likely safe behind suite's reverse proxy on edge, but worth confirming.
- **Swagger is unconditional**: Swagger UI + JSON spec are mounted regardless of environment (no `IsDevelopment()` guard). This is the **only remaining** aspect of ADR-005 that still applies — the legacy "dev-fallback secret" aspect of ADR-005 is now obsolete (`ConfigurationResolver.ResolveRequiredOrThrow` throws on any missing value at startup).
- **CORS hard-fail is `Production`-only**. In `Staging` or any custom environment name that is not literal `Production` (case-insensitive), an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (with a startup warning) 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.
- **JWKS startup dependency**: the first protected request after process start triggers a synchronous HTTPS fetch to `JWT_JWKS_URL`. If `admin` is unreachable at that moment, the request fails 500 from `05_identity`'s `IssuerSigningKeyResolver`. Once cached, request-path validation does not call `admin`.
- **Migrator failure crashes the process** at startup. Container orchestrator (Watchtower-restarted Docker) is expected to bring it back; `flight-gate` (per `../../../suite/_docs/00_top_level_architecture.md`) ensures this doesn't happen mid-mission.
- **No HTTPS redirection** middleware; assumes a TLS-terminating reverse proxy upstream (Caddy fronting Gitea is documented but in-deployment TLS termination is environment-specific).
- **No HTTPS redirection** middleware; assumes a TLS-terminating reverse proxy upstream (Caddy fronting Gitea is documented but in-deployment TLS termination is environment-specific). Note: `JWT_JWKS_URL` is independently constrained to HTTPS by `HttpDocumentRetriever { RequireHttps = true }` inside `05_identity`.
- **Port 8080** matches the Dockerfile `EXPOSE 8080` and edge compose `5002:8080` mapping per `../../../suite/_docs/00_top_level_architecture.md` excerpt.
- **No GPS-Denied service registration** here. Earlier drafts of this doc reserved a slot for a GPS-Denied feature component; per Jira AZ-EPIC child B7, GPS-Denied lives in a separate (out-of-this-repo) service, so this host registers only `VehicleService`, `MissionService`, `WaypointService`.
+20 -18
View File
@@ -97,36 +97,36 @@ erDiagram
}
MISSION {
uuid id PK
timestamp created_date
timestamp created_date "PG TIMESTAMP (no TZ), DEFAULT NOW()"
text name
uuid vehicle_id FK
uuid vehicle_id FK "REFERENCES vehicles(id), NO ACTION on delete"
}
WAYPOINT {
uuid id PK
uuid mission_id FK
uuid mission_id FK "REFERENCES missions(id), NO ACTION on delete"
decimal lat "nullable"
decimal lon "nullable"
text mgrs "nullable"
int waypoint_source "WaypointSource enum"
int waypoint_objective "WaypointObjective enum"
int order_num
decimal height
int waypoint_source "WaypointSource enum, DEFAULT 0"
int waypoint_objective "WaypointObjective enum, DEFAULT 0"
int order_num "DEFAULT 0"
decimal height "DEFAULT 0"
}
MAP_OBJECT {
uuid id PK
uuid mission_id FK
uuid mission_id FK "REFERENCES missions(id), NO ACTION on delete"
text h3_index "Uber H3 hex grid"
text mgrs
decimal lat "nullable"
decimal lon "nullable"
int class_num
text label
decimal size_width_m
decimal size_length_m
decimal confidence
int object_status "ObjectStatus enum"
timestamp first_seen_at
timestamp last_seen_at
int class_num "DEFAULT 0"
text label "DEFAULT ''"
decimal size_width_m "DEFAULT 0"
decimal size_length_m "DEFAULT 0"
decimal confidence "DEFAULT 0"
int object_status "ObjectStatus enum, DEFAULT 0"
timestamp first_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
timestamp last_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
}
MEDIA {
text id PK "XxHash64-based; computed by annotations service"
@@ -148,10 +148,12 @@ The diagram above is a scoped restatement of `../../suite/_docs/00_database_sche
### Owned-table invariants
- **`mission.vehicle_id` MUST reference an existing `vehicle.id`** — enforced by FK + by `MissionService` existence check at create / update. The two together close the TOCTOU gap (FK rejects insert if the vehicle was deleted between check and insert; UX surfaces as a `500` instead of a `400` in that race window — see `02_mission_planning` Caveats #4).
- **`waypoint.mission_id` MUST reference an existing `mission.id`** — enforced by FK + by `WaypointService` existence check at create.
- **`mission.vehicle_id` MUST reference an existing `vehicle.id`** — enforced by FK (`REFERENCES vehicles(id)` declared in the migrator) + by `MissionService` existence check at create / update. The two together close the TOCTOU gap (FK rejects insert with PostgreSQL error `23503` if the vehicle was deleted between check and insert; UX surfaces as a `500` instead of a `400` in that race window — see `02_mission_planning` Caveats #4 and the AC-2.8 entry in `00_problem/acceptance_criteria.md`).
- **`waypoint.mission_id` MUST reference an existing `mission.id`** — enforced by FK + by `WaypointService` existence check at create. The composite WHERE on update/delete (`w.MissionId == missionId && w.Id == waypointId`) collapses "parent missing" and "child missing" into a single 404 — see `service_waypoint.md` Caveats #2.
- **`map_object.mission_id` MUST reference an existing `mission.id`** — enforced by FK only. `autopilot` is the writer; `missions` is the cascade-deleter.
- **At most one `vehicle.is_default = TRUE`** is the spec invariant. Code enforces "exactly one default" by clearing the flag on every other row before setting it on the target — **stricter than spec, race-prone without a transaction.** Tracked under Jira AZ-551 (B12) for resolution.
- **All FK columns have `REFERENCES` declared in the migrator** (no `ON DELETE` clause; PostgreSQL defaults to `NO ACTION`). The in-code cascade walks in `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` delete child rows before parent rows — see `architecture.md` ADR-003 for why the cascade lives in code instead of `ON DELETE CASCADE`.
- **All timestamp columns use PostgreSQL `TIMESTAMP`** (no timezone): `missions.created_date`, `map_objects.first_seen_at`, `map_objects.last_seen_at`. `DateTime.Kind` round-trips as `Unspecified` from the database; the application writes `DateTime.UtcNow` and treats values as UTC by convention.
### Cross-service-table invariants (cascade only)
@@ -1,15 +1,16 @@
# Flow F5 — JWT bearer validation
> Cross-cutting flow that runs on every `[Authorize]` request. Local validation only — this service never calls back to the issuing `admin` service.
> Cross-cutting flow that runs on every `[Authorize]` request. **ECDSA-SHA256 asymmetric validation against public keys cached from `admin`'s JWKS endpoint.** `admin` is contacted once at startup (and on JWKS refresh) for the JWKS document; subsequent request-path validation is local and does not call `admin`.
## Description
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers against the shared HMAC secret (`JWT_SECRET`). On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime failure → `401`. On valid token but missing `"FL"` permission claim → `403`. The `iss` / `aud` claims are intentionally NOT validated today (CMMC L2 finding tracked at suite level under AZ-487 / AZ-494 — see `05_identity` § Implementation Details).
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers using public ECDSA keys cached locally from `admin`'s JWKS endpoint. On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime / `iss` / `aud` / `alg` failure → `401`. On valid token but missing required permission claim → `403`. Both `iss` and `aud` are validated against the resolved `JWT_ISSUER` / `JWT_AUDIENCE` values; the signing algorithm is pinned to `EcdsaSha256` (see `05_identity` § Implementation Details for the rationale).
## Preconditions
- `JWT_SECRET` is resolved at startup (env or hardcoded dev fallback per `architecture.md` ADR-005).
- `AddJwtAuth(jwtSecret)` was called during `Program.cs` startup (F6).
- `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built.
- `AddJwtAuth(builder.Configuration)` was called during `Program.cs` startup (F6); this also wired the `ConfigurationManager<JsonWebKeySet>` against the resolved JWKS URL.
- For the **first** protected request after process start, the cached JWKS is empty; the `IssuerSigningKeyResolver` synchronously fetches it from `admin`. After that fetch, subsequent requests use the cached keys until the manager's next refresh tick.
## Sequence Diagram
@@ -19,6 +20,9 @@ sequenceDiagram
participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler
participant Resolver as IssuerSigningKeyResolver
participant Mgr as ConfigurationManager<JsonWebKeySet>
participant Admin as admin (JWKS endpoint)
participant Policy as Auth policy "FL"
participant Ctrl as Feature Controller
participant Errs as 06_http_conventions
@@ -26,17 +30,36 @@ sequenceDiagram
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this)
Handler->>Handler: parse token; verify HMAC-SHA256 signature using SymmetricSecurityKey(UTF-8(JWT_SECRET))
alt Signature invalid OR token expired (ClockSkew = 1 minute)
Handler-->>Client: 401 Unauthorized
else Valid token
Handler->>Handler: build ClaimsPrincipal; skip iss/aud validation
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
Handler->>Handler: parse token header — check alg ∈ ValidAlgorithms (EcdsaSha256)
alt alg not in pin list
Handler-->>Client: 401 Unauthorized (algorithm rejected)
else alg OK
Handler->>Resolver: resolve signing key for kid
Resolver->>Mgr: GetConfigurationAsync(...).GetAwaiter().GetResult()
alt JWKS not cached yet
Mgr->>Admin: GET /.well-known/jwks.json (HTTPS, RequireHttps=true)
Admin-->>Mgr: JsonWebKeySet
Mgr->>Mgr: cache JWKS, schedule next refresh
else JWKS cached
Mgr-->>Resolver: cached JsonWebKeySet
end
Resolver-->>Handler: signing keys matching kid (or all keys if kid empty)
Handler->>Handler: verify ECDSA-SHA256 signature
alt Signature invalid
Handler-->>Client: 401 Unauthorized
else Signature valid
Handler->>Handler: validate iss == JWT_ISSUER, aud == JWT_AUDIENCE, exp (ClockSkew = 30s)
alt iss/aud mismatch OR token expired
Handler-->>Client: 401 Unauthorized
else Claims OK
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
end
end
end
end
```
@@ -49,11 +72,20 @@ flowchart TD
AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized])
Header -->|yes| Sig{HMAC-SHA256 signature valid?}
Header -->|yes| AlgPin{alg in ValidAlgorithms — EcdsaSha256?}
AlgPin -->|no| UnauthAlg([401 Unauthorized — algorithm rejected])
AlgPin -->|yes| Cache{JWKS cached?}
Cache -->|no| Fetch[HTTPS GET JWT_JWKS_URL → cache]
Cache -->|yes| Sig{ECDSA-SHA256 signature valid for kid?}
Fetch --> Sig
Sig -->|no| Unauth2([401 Unauthorized])
Sig -->|yes| Life{Lifetime valid? ClockSkew=1min}
Sig -->|yes| Iss{iss == JWT_ISSUER?}
Iss -->|no| UnauthIss([401 Unauthorized — issuer mismatch])
Iss -->|yes| Aud{aud == JWT_AUDIENCE?}
Aud -->|no| UnauthAud([401 Unauthorized — audience mismatch])
Aud -->|yes| Life{Lifetime valid? ClockSkew=30s}
Life -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal — skip iss/aud]
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal]
BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward
@@ -64,10 +96,13 @@ flowchart TD
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header |
| 2 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 3 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` | .NET principal object |
| 4 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 5 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
| 2 | `JwtBearerHandler` | `ConfigurationManager` | request for cached `JsonWebKeySet` (or refresh) | in-process |
| 3 | `HttpDocumentRetriever` (on cold cache) | `admin` | `GET /.well-known/jwks.json` over HTTPS | HTTP |
| 4 | `admin` | `HttpDocumentRetriever` | JWKS JSON document | application/json |
| 5 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 6 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` (with `iss`, `aud`, `permissions`, …) | .NET principal object |
| 7 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 8 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
## Error Scenarios
@@ -75,19 +110,25 @@ flowchart TD
|-------|-------|-----------|----------|
| Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` |
| Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` |
| Signature mismatch (wrong / rotated `JWT_SECRET`) | `JwtBearerHandler` | HMAC verify fails | `401`. Suite-wide secret rotation is coordinated re-deploy of every backend that shares the secret + UI re-login |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 1 min`) | `401`. Tighter than .NET's 5-min default — caller may experience earlier expiration than expected |
| Token header `alg` not in `[EcdsaSha256]` (e.g. forged `alg: HS256`) | `JwtBearerHandler` | Algorithm pin check | `401`. Pin defends against the HS256-confusion attack — see `05_identity` Caveats #6 |
| Signature mismatch (wrong key, key not yet published, key rotated out) | `JwtBearerHandler` | ECDSA verify fails | `401`. Recovery: ensure `admin` published the corresponding `kid` in JWKS; on rotation the cache picks up the new keys at the next refresh tick |
| Signing `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key in current cache | `401`. The manager refreshes on its default schedule; a new `kid` becomes available there |
| `iss` claim ≠ `JWT_ISSUER` | `JwtBearerHandler` | `ValidateIssuer = true` | `401`. Tokens issued by a different `iss` (e.g. another suite environment) are rejected |
| `aud` claim ≠ `JWT_AUDIENCE` | `JwtBearerHandler` | `ValidateAudience = true` | `401`. Tokens minted for a different audience (e.g. `admin` itself, or another backend) are rejected |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 30s`) | `401`. Tight 30-second skew — caller may experience earlier expiration than under the .NET default of 5 minutes (or the prior 1-minute setting) |
| `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` |
| Token signed with the well-known dev fallback secret | (silent acceptance) | None | **Security risk in production**. ADR-005 carry-forward; suite-tracked under CMMC L2 row 3 |
| Token from a third-party that knows `JWT_SECRET` | (silent acceptance) | None | **Trust model is shared-secret intra-suite**. Any third-party with the secret can mint accepted tokens. Out of this Epic's scope; suite-wide concern |
| `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` propagated through `IssuerSigningKeyResolver` | First protected request fails 500 (handler exception → `06_http_conventions` global handler). Subsequent requests retry on the next refresh tick. **Operationally**: ensure `admin` is reachable from every edge device that authenticates against it |
| `JWT_JWKS_URL` is plain HTTP | Startup (`HttpDocumentRetriever { RequireHttps = true }`) | URL scheme check at retrieve time | Service fails to validate any request; symptom is `InvalidOperationException` on JWKS fetch. **Fix**: set `JWT_JWKS_URL` to an `https://` URL |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Validation latency | sub-millisecond typical | Pure HMAC + claim lookup; no I/O, no network call |
| Throughput | bounded by request throughput | No back-pressure; no token cache (no DB / network round-trip to cache) |
| Validation latency (warm cache) | sub-millisecond typical | Pure ECDSA verify + claim lookup; no I/O |
| Validation latency (cold cache, first request) | one-time JWKS fetch cost (single-digit ms on local LAN) | Synchronous `GetAwaiter().GetResult()` blocks the worker thread until the fetch returns |
| Throughput | bounded by request throughput | No back-pressure; the cached JWKS handles all subsequent requests until refresh |
| JWKS refresh frequency | `ConfigurationManager` default (5 minutes minimum) | Matches admin's `Cache-Control: public, max-age=3600` so a forced refresh always sees fresh content |
## Notes on `iss` / `aud` validation (suite-tracked)
## Notes on key rotation
`ValidateIssuer = false`, `ValidateAudience = false` — consistent with the shared-secret intra-suite model. The CMMC L2 scorecard (`../../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3) flags this as a finding. The remediation will copy the `satellite-provider` pattern across `annotations` and `missions` (suite work, AZ-487 / AZ-494). It is **NOT** in this Epic's scope and will not change as part of the rename refactor.
Unlike the legacy shared-secret model, JWKS rotation does **NOT** require a coordinated redeploy of every consumer. When `admin` rotates keys it publishes the new key alongside the old `kid` (or with a new `kid`). The next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed under the previous `kid` remain valid until expiry as long as the old `kid` is still published. This is a major operational improvement over the previous "rotate `JWT_SECRET`, re-deploy every backend, force every user to re-login" sequence.
@@ -8,9 +8,10 @@
## Preconditions
- `DATABASE_URL` resolves (env or hardcoded dev fallback).
- `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` all resolve via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built — there are no hardcoded fallbacks.
- `postgres-local` is reachable.
- The `azaion` database itself exists in PostgreSQL (created at provisioning time, NOT by this migrator).
- `admin` does NOT need to be reachable at process start. The JWKS fetch is lazy — it happens on the first protected request, not during the startup sequence diagrammed below.
## Sequence Diagram
@@ -26,9 +27,12 @@ sequenceDiagram
participant DB as postgres-local
Docker->>Host: ENTRYPOINT dotnet Azaion.Missions.dll
Host->>Cfg: read DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: read JWT_SECRET (env or hardcoded fallback)
Host->>Identity: AddJwtAuth(jwtSecret) — DI registration only, no network
Host->>Cfg: ResolveRequiredOrThrow DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: ResolveRequiredOrThrow JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URL — throw on any miss
Host->>Identity: AddJwtAuth(builder.Configuration) — DI registration + ConfigurationManager<JsonWebKeySet> wiring (no network yet)
Host->>Cfg: read CorsConfig:AllowedOrigins, CorsConfig:AllowAnyOrigin
Host->>Cfg: CorsConfigurationValidator.EnsureSafeForEnvironment — throws in Production when origins=[] AND allowAnyOrigin=false
Host->>DI: register CORS policy (permissive OR WithOrigins(...))
Host->>DI: register controllers + middleware + scoped AppDataConnection + scoped services
Host->>DI: build Host
Host->>Migrator: scope.Resolve<AppDataConnection>(); Migrate(db)
@@ -41,8 +45,9 @@ sequenceDiagram
Migrator->>DB: DROP TABLE IF EXISTS gps_corrections
note right of Migrator: B9 one-shot. Idempotent on devices that already cleaned up.
Migrator-->>Host: void
Host->>Host: emit PermissiveDefaultWarning if implicit-permissive CORS applies (non-Production with empty origins)
Host->>Host: register ErrorHandlingMiddleware FIRST in pipeline
Host->>Host: UseAuthentication / UseAuthorization
Host->>Host: UseCors / UseAuthentication / UseAuthorization
Host->>Host: MapControllers + MapGet("/health") + UseSwagger
Host->>Docker: app.Run() — listening on 0.0.0.0:8080
```
@@ -51,15 +56,24 @@ sequenceDiagram
```mermaid
flowchart TD
Start([Container start]) --> ReadCfg[Read DATABASE_URL + JWT_SECRET]
ReadCfg --> RegDI[DI registrations: controllers, middleware, scoped DB + services]
Start([Container start]) --> ResolveDB[ResolveRequiredOrThrow DATABASE_URL]
ResolveDB --> ResolveJwt["ResolveRequiredOrThrow JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URL"]
ResolveJwt --> CorsCfg[Read CorsConfig:AllowedOrigins + CorsConfig:AllowAnyOrigin]
CorsCfg --> CorsGate{Production AND origins=[] AND allowAnyOrigin=false?}
CorsGate -->|yes| FailFast([InvalidOperationException — Watchtower restarts])
CorsGate -->|no| RegDI[DI registrations: JWT bearer + JWKS manager, CORS, controllers, scoped DB + services]
RegDI --> Build[Build Host]
Build --> OpenScope[Open startup scope]
Build --> CorsWarn{Implicit-permissive CORS in this env?}
CorsWarn -->|yes| LogWarn[Log PermissiveDefaultWarning]
CorsWarn -->|no| OpenScope[Open startup scope]
LogWarn --> OpenScope
OpenScope --> Migrate[Run DatabaseMigrator.Migrate]
Migrate --> Create["CREATE TABLE IF NOT EXISTS x4 + indexes"]
Create --> Drop["DROP TABLE IF EXISTS orthophotos, gps_corrections (B9)"]
Drop --> Pipeline[Wire pipeline: error MW first, auth, controllers, /health, Swagger]
Pipeline --> Run([app.Run on :8080])
ResolveDB -. on missing value .-> FailFast
ResolveJwt -. on missing value .-> FailFast
Migrate -. on failure .-> Crash([Process exits non-zero — Watchtower restarts])
```
@@ -67,15 +81,18 @@ flowchart TD
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Environment / appsettings | `Program.cs` | `DATABASE_URL`, `JWT_SECRET` | string |
| 2 | `Program.cs` | DI container | service registrations | C# code |
| 3 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL |
| 4 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener |
| 1 | Environment / IConfiguration | `Program.cs` (via `ConfigurationResolver`) | `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` | string (required) |
| 2 | Environment / IConfiguration | `Program.cs` | `CorsConfig:AllowedOrigins` (string[]), `CorsConfig:AllowAnyOrigin` (bool) | optional |
| 3 | `Program.cs` | DI container | service registrations + JWT bearer + JWKS `ConfigurationManager` | C# code |
| 4 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL |
| 5 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | `ConfigurationResolver.ResolveRequiredOrThrow` | env var + config key both empty/whitespace | Process exits non-zero with `InvalidOperationException` whose message names the missing env var and config key. Watchtower restarts but the new container hits the same failure. **Fix**: provide the value via env or `appsettings.json` |
| CORS misconfigured in Production (`CorsConfig:AllowedOrigins=[]` AND `CorsConfig:AllowAnyOrigin!=true`) | `CorsConfigurationValidator.EnsureSafeForEnvironment` at startup | hard-fail guard | Process exits with `InvalidOperationException("CORS is misconfigured: ...")`. **Fix**: set `CorsConfig:AllowedOrigins` to the production UI origins, or set `CorsConfig:AllowAnyOrigin=true` to opt in explicitly |
| `postgres-local` unreachable | Migrate step | Npgsql `IOException` / `SocketException` | Process exits non-zero. Watchtower restarts; `flight-gate` prevents restart mid-mission. **Fix**: ensure `postgres-local` healthcheck passes before `missions` starts (compose `depends_on` with `condition: service_healthy`) |
| `azaion` database missing | Migrate step | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database — provisioning concern, not this service. Documented in `../../suite/_docs/00_top_level_architecture.md` |
| `DROP TABLE IF EXISTS orthophotos` fails because table is locked by `gps-denied` | B9 one-shot | Lock timeout or `55006` | Process exits, Watchtower restarts in a few seconds. **Out-of-band ordering**: deploy `gps-denied` FIRST so it has its own copy of the schema before `missions` drops the legacy tables. Documented in B9 ticket |
+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).
+74
View File
@@ -0,0 +1,74 @@
# Ripple Log — Cycle 1 (2026-05-14 re-verification)
> **Source trigger**: `_docs/02_document/05_drift_findings_2026-05-14.md` — targeted re-verification of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs`, `Database/DatabaseMigrator.cs`, `Services/AircraftService.cs`, `Services/FlightService.cs`, `Services/WaypointService.cs`.
> **Mode**: `document` skill in **Task mode** (re-run on previously "complete" docs). The drift was discovered AFTER the initial pass declared `current_step: complete`; this cycle is a targeted refresh.
## Files in the changed-source set (cycle trigger)
These code files are the **observed-current-state** that the docs were re-aligned against. None of them were modified during this documentation cycle — code stays as-is; only the docs change.
| Source file | Why it triggered ripple |
|-------------|--------------------------|
| `Auth/JwtExtensions.cs` | ECDSA-SHA256 + JWKS + iss/aud (was HS256 + shared-secret in docs) |
| `Program.cs` | Calls `ResolveRequiredOrThrow` + `CorsConfigurationValidator.EnsureSafeForEnvironment` (was hardcoded dev fallbacks in docs) |
| `Infrastructure/ConfigurationResolver.cs` | New file, no module doc previously existed |
| `Infrastructure/CorsConfigurationValidator.cs` | New file, no module doc previously existed |
| `Database/DatabaseMigrator.cs` | `TIMESTAMP` (not `TIMESTAMPTZ`); explicit `REFERENCES` on every FK; `DEFAULT` on every non-nullable non-key column |
| `Services/AircraftService.cs` | Case-INSENSITIVE name filter + `OrderBy(Name)` (docs said case-sensitive + no ordering) |
| `Services/FlightService.cs` | Case-INSENSITIVE name filter + `OrderByDescending(CreatedDate)` (docs didn't specify) |
| `Services/WaypointService.cs` | Composite `(missionId, waypointId)` predicate collapses two error cases into one 404 |
## Doc updates in this cycle
Direct updates driven by the drift findings:
| Doc | Reason |
|-----|--------|
| `_docs/02_document/modules/auth.md` | Full rewrite — ECDSA-JWKS model, iss/aud, alg pin, no shared secret |
| `_docs/02_document/modules/program.md` | Startup section rewrite — 4 required vars, fail-fast, CORS gate |
| `_docs/02_document/modules/database.md` | TIMESTAMP type, REFERENCES on FKs, DEFAULT clauses |
| `_docs/02_document/components/05_identity/description.md` | Mechanism + Caveats rewrite (matches `modules/auth.md`) |
| `_docs/02_document/components/07_host/description.md` | Configuration + CORS gating sections (matches `modules/program.md`) |
| `_docs/02_document/diagrams/flows/flow_jwt_validation.md` | Sequence + flowchart + data flow + error scenarios — full rewrite for JWKS |
| `_docs/02_document/diagrams/flows/flow_startup_migration.md` | Config resolution + CORS validation; no `JWT_SECRET` fallback |
| `_docs/02_document/architecture.md` | § Vision, § Components, § Major flows, § Principles, § Tech Stack (Auth row), § External Integrations (admin row), § Deployment env table, § Security, ADR-005 |
| `_docs/02_document/data_model.md` | ERD + Owned-table invariants — explicit TIMESTAMP, DEFAULT, REFERENCES |
| `_docs/02_document/system-flows.md` | Cross-cutting JWT + F5 + F6 detailed flows + error scenarios |
| `_docs/02_document/04_verification_log.md` | Re-issued § 3 F5 + F6 rows; demoted § 4.2 F3 CORS-unconditional; added § 4.3 |
| `_docs/00_problem/problem.md` | "What is", "Problem", "Users", "How it works", "Cross-cutting contracts" sections |
| `_docs/00_problem/restrictions.md` | E1, E3, E4, E9 — 4 env vars, no fallback, gated CORS |
| `_docs/00_problem/acceptance_criteria.md` | AC-1.5, AC-1.6, AC-2.3, AC-2.8, AC-4.2, AC-5 entire group (rewrite), AC-6.1, AC-6.2, AC-6.4, AC-6.5, AC-6.11, AC-6.12, AC-9.1 |
| `_docs/00_problem/security_approach.md` | § 1 (full rewrite), § 2 (FL claim semantics), § 3 (secrets), § 5 (CORS), § 6 (footguns), § 7 (audit) untouched, § 8 (threat model), § 9 (refs) |
| `_docs/00_problem/input_data/data_parameters.md` | § 1 env vars (4 required), § 2.1 / § 2.2 query case sensitivity, § 3 schema (TIMESTAMP, REFERENCES, DEFAULT) |
| `_docs/01_solution/solution.md` | Topology paragraph, component table rows 05 + 07, § 2.2 ADR-005 row, § 3.3 JWT scenario, § 5.1 + § 5.2 references |
## Import-graph ripple (computed, not provided by trigger)
Two new C# files were introduced under `Infrastructure/`:
- `Infrastructure/ConfigurationResolver.cs` (`Azaion.Flights.Infrastructure.ConfigurationResolver`)
- `Infrastructure/CorsConfigurationValidator.cs` (`Azaion.Flights.Infrastructure.CorsConfigurationValidator`)
Reverse-dependency scan (`rg "ConfigurationResolver|CorsConfigurationValidator"` in C# sources) finds **only `Program.cs` consumes them today**. No additional components are reached transitively. Both files belong to component `07_host` (composition root); they did NOT warrant a new component — the host doc was extended to cover them.
The JWT changes in `Auth/JwtExtensions.cs` (`Azaion.Flights.Auth.JwtExtensions`) are consumed only by `Program.cs`. The downstream `ClaimsPrincipal` is consumed by every `[Authorize(Policy="FL")]` controller, but the **wire-shape contract** of those controllers is unchanged — the policy still requires `permissions=FL`, the policy name is still `"FL"`. No component doc refresh needed beyond `05_identity` + `07_host`.
The DB schema changes (`TIMESTAMP`, `REFERENCES`, `DEFAULT`) ripple to:
- `_docs/02_document/data_model.md` (already in the direct list) — ERD + invariants.
- `_docs/00_problem/input_data/data_parameters.md` (already in the direct list) — § 3 schema tables.
- `_docs/00_problem/acceptance_criteria.md` AC-2.8 (already in the direct list) — TOCTOU mitigation via FK error 23503.
No further out-of-list ripple discovered.
## Verdict
All ripple-traced docs are included in the direct update list above; the import-graph scan surfaced no new candidates not already covered. The remaining suite-level docs (`../suite/_docs/05_identity*.md`, `../suite/_docs/00_roles_permissions.md`) likely carry correlated drift on the JWT model but are **out of scope** for this repo's `/autodev` cycle and are flagged in `04_verification_log.md` § 4.3 for the next suite-level autodev run.
## State at end of cycle
- All Phase 1 (doc revisions) tasks from `05_drift_findings_2026-05-14.md` are complete.
- Phase 2 (test-spec re-issue) is queued — next sub-skill invocation: `test-spec` in cycle-update mode.
- Phase 3 (resume Step 4) is the autodev step transition after Phase 2 lands.
- `_docs/02_document/state.json` is updated to record the re-verification entry.
- `_docs/_autodev_state.md` advances `sub_step` from `targeted-reverification-needed``complete`, then Step 1 → Step 2 (Plan) per the existing-code flow auto-chain.
+49 -1
View File
@@ -43,6 +43,46 @@
"renames_doc_only_until_jira_lands": true,
"jira_epic": "AZ-EPIC (rename flights -> missions; multi-vehicle; drop GPS-Denied)",
"jira_children_in_plan": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9", "B10", "B11", "B12"]
},
{
"trigger": "targeted re-verification of JWT/Config/CORS/DB-schema/filter drift against actual .cs source (2026-05-14, autodev cycle 1)",
"drift_findings_doc": "_docs/02_document/05_drift_findings_2026-05-14.md",
"ripple_log": "_docs/02_document/ripple_log_cycle1.md",
"areas_revised": [
"JWT validation model (HS256 shared-secret -> ECDSA-SHA256 + JWKS + iss/aud + alg pin)",
"Configuration resolution (hardcoded fallbacks -> ResolveRequiredOrThrow, 4 required vars)",
"CORS gating (always-permissive -> production-gated via CorsConfigurationValidator)",
"DB schema (TIMESTAMP not TIMESTAMPTZ; explicit REFERENCES on every FK; DEFAULT clauses)",
"Filter case sensitivity (case-sensitive in docs -> case-INSENSITIVE in code) + result ordering (unspecified -> documented)",
"Waypoint nested existence check (two-step in docs -> single composite predicate in code)"
],
"docs_touched": [
"modules/auth.md", "modules/program.md", "modules/database.md",
"components/05_identity/description.md", "components/07_host/description.md",
"diagrams/flows/flow_jwt_validation.md", "diagrams/flows/flow_startup_migration.md",
"architecture.md", "data_model.md", "system-flows.md", "04_verification_log.md",
"../00_problem/problem.md", "../00_problem/restrictions.md", "../00_problem/acceptance_criteria.md",
"../00_problem/security_approach.md", "../00_problem/input_data/data_parameters.md",
"../01_solution/solution.md"
],
"phase_2_complete": {
"completed_at": "2026-05-14T20:55:00Z",
"test_spec_files_touched": [
"../../docker-compose.test.yml",
"tests/environment.md",
"tests/test-data.md",
"tests/security-tests.md",
"tests/resilience-tests.md",
"tests/blackbox-tests.md",
"../00_problem/input_data/expected_results/results_report.md",
"tests/traceability-matrix.md"
],
"new_test_ids_added": ["NFT-SEC-04b", "NFT-SEC-10", "NFT-SEC-11", "NFT-SEC-12", "NFT-SEC-13"],
"rewritten_tests": ["NFT-SEC-02", "NFT-SEC-03", "NFT-SEC-04", "NFT-SEC-06", "NFT-RES-05", "NFT-RES-07", "FT-P-04", "FT-P-05", "FT-P-08", "FT-N-01"],
"coverage_after": "97% in-scope (was 93%)",
"uncovered_items_resolved": ["E3", "E9", "AC-6.2"]
},
"phase_3_pending": "resume autodev Step 4 (Code Testability Revision)"
}
],
"step_4_5_glossary_vision": "confirmed",
@@ -63,5 +103,13 @@
"verification_log": "_docs/02_document/04_verification_log.md",
"verification_corrections_inline": 9,
"verification_drift_flagged": 2,
"last_updated": "2026-05-14T09:32:00Z"
"verification_re_run_2026_05_14": {
"doc": "_docs/02_document/04_verification_log.md (§ 4.3)",
"drift_findings": "_docs/02_document/05_drift_findings_2026-05-14.md",
"ripple_log": "_docs/02_document/ripple_log_cycle1.md",
"phase_1_doc_revisions": "complete",
"phase_2_test_spec_re_issue": "complete",
"phase_3_resume_step_4": "pending"
},
"last_updated": "2026-05-14T20:51:00Z"
}
+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 |
---
+19 -15
View File
@@ -75,14 +75,14 @@
---
### FT-P-04: Vehicle list returns plain JSON array (no pagination)
### FT-P-04: Vehicle list returns plain JSON array (no pagination), ordered by Name ASC
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions`.
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions` — and that results are ordered alphabetically by `Name` ASC (per AircraftService.GetVehicles `OrderBy(a => a.Name)`).
**Traces to**: AC-1.5
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default`
- `seed_3_vehicles_2_default` containing `BR-01`, `BR-02`, `MQ-9` (any insert order)
**Input data**: `GET /vehicles`
@@ -90,16 +90,16 @@
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01 |
| 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) |
**Expected outcome**: results_report.md AC-1 row 1.5.
**Max execution time**: 2s.
---
### FT-P-05: Vehicle filter by name + isDefault
### FT-P-05: Vehicle filter by name + isDefault (case-INSENSITIVE name)
**Summary**: Verifies query-string filter (case-sensitive substring on `name`, exact on `isDefault`).
**Summary**: Verifies query-string filter **case-INSENSITIVE** substring on `name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`), exact on `isDefault`.
**Traces to**: AC-1.6
**Category**: Vehicle CRUD
@@ -113,6 +113,7 @@
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` |
| 2 | `GET /vehicles?name=br&isDefault=true` (lowercase) | `200`; `body.length == 1`; `body[0].Name == "BR-01"` (case-INSENSITIVE match) |
**Expected outcome**: results_report.md AC-1 row 1.6.
**Max execution time**: 2s.
@@ -165,14 +166,14 @@
---
### FT-P-08: Mission list paginated default page
### FT-P-08: Mission list paginated default page, ordered by CreatedDate DESC
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20.
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20, ordered by `CreatedDate` DESC (newest first per `FlightService.GetMissions` `OrderByDescending(f => f.CreatedDate)`).
**Traces to**: AC-2.3, AC-8.7
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions`
- `seed_25_missions` with deterministic `CreatedDate` values spanning January-February 2026
**Input data**: `GET /missions`
@@ -180,7 +181,8 @@
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20` |
| 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC ordering) |
| 2 | `GET /missions?name=re` (lowercase) against missions containing `"Recon-*"` names | `200`; `body.TotalCount > 0` — case-INSENSITIVE name filter matches Mission Name `"Recon-*"` |
**Expected outcome**: results_report.md AC-2 row 2.3.
**Max execution time**: 2s.
@@ -421,24 +423,26 @@
## Negative Scenarios
### FT-N-01: Vehicle name filter is case-sensitive
### FT-N-01: Vehicle name filter returns empty when no row matches case-insensitively
**Summary**: Verifies `name=br` does NOT match `BR-01` (case sensitivity).
**Summary**: Verifies that `?name=` returns an empty body when no row's `Name` contains the substring (case-insensitive). This is the "no-match" half of AC-1.6 — distinct from FT-P-05 which asserts that lowercase input DOES match `BR-01`.
**Traces to**: AC-1.6
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- `seed_3_vehicles_2_default` (only contains `BR-*` names — no `br-*`)
- `seed_3_vehicles_2_default` (`BR-01`, `BR-02`, `MQ-9`)
**Input data**: `GET /vehicles?name=br`
**Input data**: `GET /vehicles?name=ZZ` (substring `ZZ` is absent from every name)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.length == 0` |
| 1 | `GET /vehicles?name=ZZ` | `200`; `body.length == 0` |
| 2 | `GET /vehicles?name=zz` (lowercase) | `200`; `body.length == 0` (still no match) |
**Expected outcome**: results_report.md AC-1 row 1.7.
**Note (drift, 2026-05-14)**: this test was previously titled "Vehicle name filter is case-sensitive" and asserted `?name=br → length 0`. That assertion was WRONG against the actual code (`a.Name.ToLower().Contains(query.Name.ToLower())` — case-insensitive). The test is rewritten to assert the genuine no-match case.
**Max execution time**: 2s.
---
+90 -28
View File
@@ -20,19 +20,19 @@ The side-channel DB access is allowed because the AC catalogue (AC-1.2, AC-1.4,
|---------|--------------|---------|-------|
| `missions` | build context `./` (`Dockerfile`); image tag `azaion/missions:test` | System under test | `5002:8080` |
| `postgres-test` | `postgres:16-alpine` | Owned PostgreSQL for test isolation. Started fresh per test class via Testcontainers OR via `docker compose down -v && docker compose up -d` between scenarios that mutate startup-sensitive state (AC-6.5 legacy drop, AC-6.6 idempotency) | `5433:5432` |
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv` | — |
| `jwks-mock` | build context `tests/Azaion.Missions.JwksMock/`; image tag `azaion/jwks-mock:test` | **In-process stand-in for the `admin` service's JWKS endpoint.** Holds a fixed ECDSA P-256 keypair (in-memory), serves the public key as JWKS at `https://jwks-mock:8443/.well-known/jwks.json` (HTTPS-only, self-signed CA mounted into `missions` and `e2e-consumer`), and signs tokens for the consumer via `POST /sign`. Supports `POST /rotate-key` for NFT-RES-07 (JWKS rotation). The mock's `Cache-Control: max-age` is set to 60s in tests (vs admin's 3600s) so rotation completes within the 15-min CI gate. | — (internal only) |
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv`. Fetches signed test tokens from `jwks-mock` instead of minting locally — the private key never leaves `jwks-mock`, eliminating the class of bugs where the consumer signs with a key that doesn't match the published JWKS. | — |
| `pg-side` (optional) | reused `postgres-test` connection on a side port | Side-channel DB connection for fixture seeding + post-call assertions | shares `postgres-test` |
No external mock services are required:
- `admin` (JWT issuer): the test runner mints HS256 tokens itself using a known `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`.
- `annotations`, `detection`, `autopilot`: their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are not running.
- `flight-gate`, Watchtower, suite reverse proxy: not required for service-level e2e.
The only external services not running are the sibling backend services (`annotations`, `detection`, `autopilot`, `flight-gate`, Watchtower, suite reverse proxy) — their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are out of scope for service-level e2e.
The `jwks-mock` service replaces the pre-2026-05-14 "consumer mints HS256 tokens with a shared secret" pattern. The current code path (per `Auth/JwtExtensions.cs`) is ECDSA-SHA256 + JWKS — there is no shared secret to mint with anymore. See `test-data.md` § External Dependency Mocks for the mock's contract.
### Networks
| Network | Services | Purpose |
|---------|----------|---------|
| `e2e-net` | `missions`, `postgres-test`, `e2e-consumer` | Isolated bridge network; no host network access |
| `e2e-net` | `missions`, `postgres-test`, `jwks-mock`, `e2e-consumer` | Isolated bridge network; no host network access |
### Volumes
@@ -43,8 +43,9 @@ No external mock services are required:
### docker-compose structure
The canonical compose file is `docker-compose.test.yml` at the repo root. Below is the abbreviated structural outline — see the actual file for the full healthchecks, depends_on, and volume mounts.
```yaml
# Outline only — not runnable code (the actual scripts/run-tests.sh wires this up)
services:
postgres-test:
image: postgres:16-alpine
@@ -52,39 +53,51 @@ services:
POSTGRES_DB: azaion
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
interval: 1s
timeout: 1s
retries: 30
# healthcheck: pg_isready
jwks-mock:
build: { context: tests/Azaion.Missions.JwksMock }
environment:
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
OLD_KEY_GRACE_SECONDS: 5
# healthcheck: GET /.well-known/jwks.json over HTTPS (--no-check-certificate)
missions:
build:
context: .
build: { context: . }
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
ASPNETCORE_ENVIRONMENT: Test # NOT Production -- CORS falls back to permissive with a warning log line
volumes:
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
depends_on:
postgres-test:
condition: service_healthy
postgres-test: { condition: service_healthy }
jwks-mock: { condition: service_healthy }
e2e-consumer:
build:
context: tests/Azaion.Missions.E2E.Tests
build: { context: tests/Azaion.Missions.E2E.Tests }
environment:
MISSIONS_BASE_URL: http://missions:8080
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
depends_on:
missions:
condition: service_started
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
volumes:
- ./e2e-results:/app/results
- ./test-results:/app/results
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
depends_on:
missions: { condition: service_healthy }
jwks-mock: { condition: service_healthy }
```
**Production-gate (E9) variant**: for the CORS production-gate lock test (E9), the test runner spawns `missions` with `ASPNETCORE_ENVIRONMENT=Production` and an empty `CorsConfig:AllowedOrigins` and asserts startup THROWS `InvalidOperationException`. This variant runs OUTSIDE the main compose stack via `docker run` to avoid disturbing the rest of the suite.
## Consumer Application
**Tech stack**: xUnit 2.x + `Microsoft.AspNetCore.Mvc.Testing` (HttpClient via `IClassFixture`) OR plain `HttpClient` against the dockerized service. Bogus 35.x for synthetic data. JWT minting via `System.IdentityModel.Tokens.Jwt`. PostgreSQL side-channel via Npgsql (NOT linq2db — keep the consumer free of system-under-test runtime libs).
**Tech stack**: xUnit 2.x + plain `HttpClient` against the dockerized `missions` service. Bogus 35.x for synthetic data. JWT acquisition via HTTPS `POST jwks-mock:8443/sign` (no in-process JWT library on the consumer side — the consumer treats tokens as opaque bearer strings). PostgreSQL side-channel via Npgsql (NOT linq2db — keep the consumer free of system-under-test runtime libs).
**Entry point**: `dotnet test tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj --logger "trx;LogFileName=results.trx"` followed by a small post-processor that converts trx → `report.csv`.
@@ -92,18 +105,21 @@ services:
| Interface | Protocol | Endpoint | Authentication |
|-----------|----------|----------|----------------|
| Vehicle API | HTTP/1.1 JSON | `http://missions:8080/vehicles[?name=&isDefault=]` and `/vehicles/{id}[/setDefault]` | `Authorization: Bearer <HS256, permissions=FL>` |
| Vehicle API | HTTP/1.1 JSON | `http://missions:8080/vehicles[?name=&isDefault=]` and `/vehicles/{id}[/setDefault]` | `Authorization: Bearer <ECDSA-SHA256, iss=$JWT_ISSUER, aud=$JWT_AUDIENCE, permissions=FL>` |
| Mission API | HTTP/1.1 JSON | `http://missions:8080/missions[?name=&fromDate=&toDate=&page=&pageSize=]` | same |
| Waypoint API | HTTP/1.1 JSON | `http://missions:8080/missions/{id}/waypoints[/{wpId}]` | same |
| Health | HTTP/1.1 JSON | `http://missions:8080/health` | anonymous |
| DB side-channel (assertions only) | TCP/Postgres wire | `postgres-test:5432` | `postgres:postgres-test` |
| JWKS mock sign endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/sign` body `{ "iss":..., "aud":..., "exp":..., "permissions":..., ... }` returns signed JWT | none (test-network internal only) |
| JWKS mock JWKS endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/.well-known/jwks.json` | none (consumed by `missions` itself, not by the test consumer) |
| JWKS mock rotate-key endpoint | HTTPS/1.1 JSON | `POST https://jwks-mock:8443/rotate-key` body `{}` returns `{ "newKid": "..." }` and starts the `OldKeyGraceSeconds` window | none |
### What the consumer does NOT have access to
- No `using Azaion.Missions.*;` — the consumer is a separate csproj with no project reference to the system under test.
- No `AppDataConnection` instantiation; the side-channel uses raw Npgsql `NpgsqlCommand` only.
- No file-system overlap; `e2e-consumer` is a separate container.
- No environment variable shared in process; the system-under-test's `JWT_SECRET` is supplied through compose env, the consumer mints with the same value via its own env.
- No JWT signing key in the consumer process the consumer requests signed tokens from `jwks-mock` via HTTPS `POST /sign`. The ECDSA private key never leaves the mock container. This guarantees the test setup cannot drift away from "consumer-signed token matches missions-cached JWKS public key".
## CI/CD Integration
@@ -125,4 +141,50 @@ Categories: `BLACKBOX`, `PERF`, `RES`, `SEC`, `RES_LIM`. `Traces` is a comma-sep
## Hardware Assessment
To be filled by `phases/hardware-assessment.md` between Phase 3 and Phase 4. Today's expected outcome: no GPU, no specialised hardware, no model inference — this is a CRUD service. Test execution requires only a Postgres-capable container and the .NET 10 SDK image. AMD64 + ARM64 both supported (matches H2). Resource ceiling: 2 GB RAM total for `missions + postgres-test + e2e-consumer` is sufficient.
> Filled by autodev `/test-spec` Hardware Assessment phase (2026-05-14).
### Decision: Docker execution
The project is **NOT hardware-dependent**. Test execution is fully containerised; no local-mode runner is needed.
### Hardware dependencies found: NONE
Documentation scan (`restrictions.md`, `solution.md`, `architecture.md`, `components/*/description.md`):
- H1H6 cover edge-device deployment shape (Jetson Orin / OrangePI / operator-PC, multi-arch ARM64+AMD64, vertical scale only) but none of those concerns require hardware code paths inside the test runner — the suite-level CI matrix builds for both arches separately (per O4 + H2).
- No GPU / model inference / sensor / camera / GPIO / V4L2 mention in any docs.
- `solution.md` § 2.1 explicitly classifies every component as standard ASP.NET Core + linq2db over Postgres — no hardware adapter.
Code scan (`*.csproj`, `Dockerfile`, all `.cs` source):
- `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) packages: `linq2db 6.2.0`, `Npgsql 10.0.2`, `Microsoft.AspNetCore.Authentication.JwtBearer 10.0.5`, `Swashbuckle.AspNetCore 10.1.5`. None is hardware-specific.
- `Dockerfile`: stock `mcr.microsoft.com/dotnet/sdk:10.0` build stage + `mcr.microsoft.com/dotnet/aspnet:10.0` runtime stage. Multi-arch via `--platform=$BUILDPLATFORM` + `dotnet publish --os linux --arch $arch`. No `runtime: nvidia`, no GPU device mounts.
- No `RuntimeInformation.IsOSPlatform`, no `coreml` / `cuda` / `gpio` / `v4l2` / `opencl` / `vulkan` / `tpu` / `fpga` references in any production source file (verified via grep — matches were only in skill templates and docs, not in `Controllers/`, `Services/`, `Database/`, `Auth/`, `Middleware/`, `Program.cs`).
Multi-arch matters for the **production deploy** (per H2), but it does NOT affect the test runner: tests exercise the API black-box, identical on both architectures. The suite CI matrix (`.woodpecker/build-arm.yml` + the future `.woodpecker/build-amd.yml`) tests each arch in its own container.
### Execution instructions — Docker mode (the only mode)
**Prerequisites** on the test host:
- Docker Engine ≥ 24.0 with Docker Compose v2 plugin.
- 2.5 GB free RAM (1 GB for `missions`, 256 MB for `postgres-test`, 256 MB for `jwks-mock`, 512 MB for `e2e-consumer`, 512 MB headroom).
- 2 CPU cores recommended for the test wall-clock to fit under the 15-minute CI gate.
- Free TCP ports `5002` (host → `missions:8080`) and `5433` (host → `postgres-test:5432`) — used only when running outside compose. The `jwks-mock` HTTPS port (`8443` inside the container) is NOT mapped to the host; only `missions` and `e2e-consumer` reach it via the internal `e2e-net` network.
**Run command** (from repo root):
```bash
./scripts/run-tests.sh
```
The runner script (Phase 4) wires up `docker compose up`, waits for `missions` health, executes `dotnet test` inside `e2e-consumer`, collects `report.csv`, and tears down the compose stack. See `scripts/run-tests.sh` for the canonical command sequence.
**Performance tests**:
```bash
./scripts/run-performance-tests.sh
```
Same compose stack, but with the perf seed (1000 missions for NFT-PERF-04, 100 minimal missions for NFT-PERF-01) and the `[Trait("Category","Perf")]` filter. See `scripts/run-performance-tests.sh` (Phase 4).
### Resource ceiling
Total RAM: ≤ 2.5 GB for `missions + postgres-test + jwks-mock + e2e-consumer` together. Test wall-clock budget: ≤ 15 minutes (CI gate). Storage: ephemeral (the `pg-test-data` tmpfs is recreated per scenario class via `docker compose down -v` for the bootstrap-sensitive scenarios — NFT-RES-03, NFT-RES-04, NFT-RES-05, NFT-RES-06, NFT-RES-07).
+32 -26
View File
@@ -110,27 +110,29 @@
---
### NFT-RES-05: DB unreachable at startup — process exits non-zero
### NFT-RES-05: Required configuration missing → fail-fast at startup
**Summary**: Verifies AC-6.7 — DB unreachability causes process exit, NOT silent retry-forever.
**Traces to**: AC-6.7
**Summary**: Verifies AC-6.1 / AC-6.2 / E3 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` when any of the four required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) is missing or whitespace-only. Also verifies AC-6.7 — DB unreachability (after config resolution succeeds) still causes process exit. The legacy "silent dev fallback boot" failure mode is structurally eliminated.
**Traces to**: AC-6.1, AC-6.2, AC-6.7, E3, E4
**Preconditions**:
- `missions` NOT running
- `missions` NOT running.
- This scenario uses `docker run` outside the main compose to isolate env-var manipulation.
**Fault injection**: stop `postgres-test` (`docker compose stop postgres-test`) then start `missions`.
**Steps**:
**Steps** (each row is a separate `docker run` invocation; each times out at 30s):
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `docker compose stop postgres-test` | |
| 2 | `docker compose up -d missions` | |
| 3 | Poll `docker inspect --format '{{.State.ExitCode}}' missions` every 1s for ≤ 30s | At some point within 30s, the container has exited with non-zero exit code |
| 4 | `docker logs missions` | Contains an Npgsql connection error message (e.g., `Connection refused`) |
| 1 | `docker run --rm azaion/missions:test` with ALL four required env vars unset | container exits non-zero within 5s; logs contain `InvalidOperationException`; logs mention at least one of the four required keys |
| 2 | `docker run` with `DATABASE_URL` unset; the three JWT vars set correctly | same shape; logs mention `DATABASE_URL` or `Database:Url` |
| 3 | `docker run` with `JWT_ISSUER=""` (whitespace-only); other three set | same shape; logs mention `JWT_ISSUER` or `Jwt:Issuer` |
| 4 | `docker run` with `JWT_AUDIENCE` unset; others set | same shape; logs mention `JWT_AUDIENCE` or `Jwt:Audience` |
| 5 | `docker run` with `JWT_JWKS_URL` unset; others set | same shape; logs mention `JWT_JWKS_URL` or `Jwt:JwksUrl` |
| 6 | `docker compose stop postgres-test`, then start `missions` with all four env vars set correctly — config resolution succeeds, then DB-connect fails | container exits non-zero within 30s; logs contain a recognisable Npgsql connection error (e.g., `Connection refused`) — NOT an `InvalidOperationException` from the resolver (this differentiates "config missing" from "config valid but DB down") |
**Pass criteria**: container exits with non-zero code within 30s; logs contain a recognisable Npgsql error.
**Max execution time**: 60s.
**Pass criteria**: rows 15 → fail-fast at config resolution; row 6 → fail at DB-connect AFTER config resolution succeeded.
**Note**: this test now exercises BOTH the fail-fast resolver (rows 15) AND the DB-unreachable case (row 6). Pre-revision, only row 6 was tested under the assumption of hardcoded dev fallbacks.
**Max execution time**: 180s (6 docker-run cycles).
---
@@ -158,30 +160,34 @@
---
### NFT-RES-07: JWT_SECRET rotation invalidates existing tokens
### NFT-RES-07: JWKS key rotation — no missions restart required
**Summary**: Verifies AC-5.7 — restarting the service with a different `JWT_SECRET` causes previously-valid tokens to fail validation.
**Summary**: Verifies AC-5.7 — rotating the signing key on `admin` (via `jwks-mock POST /rotate-key`) propagates to `missions` on the JWKS cache refresh tick **without restarting `missions`**. This is the primary operational win over the legacy shared-HMAC model, which required coordinated re-deploy across every backend on the device.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` running with `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`
- Token `T1` minted with the same secret, valid for 1h
- `missions` running with warm JWKS cache (any previous protected request succeeded).
- `jwks-mock` running with `Cache-Control: max-age=60` and `OldKeyGraceSeconds=5`.
- Token `T1` requested via `POST /sign` with the CURRENT `kid` (`kid_v1`), valid for 1h.
**Fault injection**: restart `missions` with `JWT_SECRET=rotated-secret-32-chars-min!!!!!`.
**Fault injection**: `POST https://jwks-mock:8443/rotate-key {}` — generates `kid_v2`, retains `kid_v1` in the JWKS for `OldKeyGraceSeconds`, then evicts `kid_v1`.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` |
| 2 | `docker compose stop missions` | |
| 3 | `docker compose run -e JWT_SECRET=rotated-secret-32-chars-min!!!!! -d missions` | |
| 4 | Wait for `GET /health` 200 | |
| 5 | `GET /vehicles` with `Authorization: Bearer T1` (same token as step 1) | `401` |
| 6 | Mint token `T2` with the new secret, `GET /vehicles` with `T2` | `200` |
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` (cached JWKS knows `kid_v1`) |
| 2 | `POST https://jwks-mock:8443/rotate-key {}` → returns `kid_v2` | jwks-mock now publishes BOTH `kid_v1` and `kid_v2` in its JWKS for `OldKeyGraceSeconds=5` |
| 3 | Immediately request token `T2` signed with `kid_v2` via `POST /sign {}` | mock returns JWT with header `kid: kid_v2` |
| 4 | Immediately `GET /vehicles` with `Authorization: Bearer T2` (BEFORE `missions` JWKS cache refresh) | `401` (cache still only has `kid_v1`) |
| 5 | Wait up to 90s for `missions`'s `ConfigurationManager<JsonWebKeySet>` to refresh against the new JWKS (the mock's `max-age=60` triggers a refresh on the next request after that interval) | — |
| 6 | `GET /vehicles` with `Authorization: Bearer T2` again | `200` (cache now contains `kid_v2`) |
| 7 | `GET /vehicles` with `Authorization: Bearer T1` (still has unexpired lifetime, signed with `kid_v1`) | `200` IF the JWKS refresh happened BEFORE the mock's `OldKeyGraceSeconds=5` window closed (the JWKS still had `kid_v1`); `401` AFTER the grace window when `missions` refreshes and `kid_v1` is no longer in the JWKS. Test asserts the eventual `401` |
| 8 | Verify `missions` was NEVER restarted during this scenario (`docker inspect --format '{{.State.StartedAt}}' missions` is unchanged from before step 1) | startup timestamp unchanged |
**Pass criteria**: `T1` works pre-rotation, fails post-rotation; `T2` works post-rotation.
**Max execution time**: 90s.
**Pass criteria**: rotation propagates without restart; `T2` (new kid) eventually accepted; `T1` (old kid) eventually rejected; `missions` startup timestamp unchanged.
**Note**: this test replaces the pre-revision "shared-secret rotation requires coordinated redeploy" scenario. The pre-revision test asserted that ALL services on the device had to restart together; the post-revision test asserts the opposite — they do NOT have to restart.
**Max execution time**: 180s (longest wait is the JWKS refresh tick).
---
+129 -22
View File
@@ -1,8 +1,8 @@
# Security Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14), revised in cycle-update mode (drift findings Phase 2) for the ECDSA+JWKS JWT model.
> **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9.
> **Out-of-scope (suite-tracked)**: the `iss` / `aud` validation gap (AC-5.3, CMMC L2 row 3, AZ-487 / AZ-494) is documented but NOT enforced today. Tests assert today's behaviour (AC-5.3 returns 200) — when the suite-wide remediation lands, update NFT-SEC-04.
> **Auth model**: ECDSA-SHA256 with JWKS retrieved from `admin` (mocked via `jwks-mock` in tests), iss + aud + alg-pin validated, 30s clock skew. The CMMC L2 row 3 (`iss`/`aud`) finding is now structurally fixed in code; the corresponding NFT-SEC scenarios now assert REJECTION, not acceptance.
---
@@ -26,51 +26,70 @@
### NFT-SEC-02: Invalid signature → 401
**Summary**: Verifies AC-5.5 — token signed with a different secret is rejected.
**Summary**: Verifies AC-5.5 — token whose ECDSA signature doesn't verify against any cached JWKS public key is rejected.
**Traces to**: AC-5.5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_bad` with `WRONG_SECRET=other-secret-32-chars-min!!!!!!`, otherwise valid (`exp = now + 1h`, `permissions=FL`) | |
| 1 | Acquire valid signed token `T_good` from `jwks-mock POST /sign`, then flip a single byte in the token's signature segment to produce `T_bad` | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
| 3 | Acquire token from a SEPARATE ECDSA keypair not present in the mock's published JWKS (via an out-of-band test helper) and call `GET /vehicles` | `401` |
**Pass criteria**: `401`.
**Pass criteria**: both bad-signature cases return `401`.
---
### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200
**Summary**: Verifies AC-5.6 + AC-5.2 (1-min skew tighter than .NET's 5-min default).
**Summary**: Verifies AC-5.6 + AC-5.2 (**30s** clock skew, tighter than .NET's 5-min default and the legacy 1-min setting).
**Traces to**: AC-5.2, AC-5.6
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_exp` with `exp = now - 120s` (outside 60s skew); `permissions=FL` | |
| 1 | Request token via `POST /sign { "exp_offset_seconds": -60 }` (exp = now - 60s; outside the 30s skew) | mock returns signed JWT |
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
| 3 | Mint token `T_skew` with `exp = now - 30s` (inside 60s skew); `permissions=FL` | |
| 3 | Request token via `POST /sign { "exp_offset_seconds": -15 }` (exp = now - 15s; inside the 30s skew) | mock returns signed JWT |
| 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
**Pass criteria**: `T_exp` rejected; `T_skew` accepted.
---
### NFT-SEC-04: Missing `iss` and `aud` claims accepted (today's behavior, AC-5.3)
### NFT-SEC-04: Token with `iss` ≠ `JWT_ISSUER` → 401
**Summary**: Verifies the `ValidateIssuer = false` and `ValidateAudience = false` configuration. This test will FAIL once the suite-wide remediation (AZ-487 / AZ-494) lands — that's good news; update the test then.
**Traces to**: AC-5.3
**Summary**: Verifies AC-5.11 — `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`. This is the structural fix for CMMC L2 row 3 row (issuer validation half).
**Traces to**: AC-5.3, AC-5.11
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with NO `iss` and NO `aud` claim, valid signature + lifetime, `permissions=FL` | |
| 2 | `GET /vehicles` with that token | `200` |
| 1 | Request token via `POST /sign { "iss": "https://attacker.example.com" }` (every other claim valid) | mock returns signed JWT — the signature is correct, only `iss` is wrong |
| 2 | `GET /vehicles` with that token | `401` |
| 3 | Request token via `POST /sign { }` (iss defaults to the mock's `JWT_ISSUER` env, which matches `missions`'s configured `ValidIssuer`) | mock returns signed JWT |
| 4 | `GET /vehicles` with that token | `200` |
**Pass criteria**: `200` today; will become `401` post-remediation.
**Pass criteria**: wrong-`iss` rejected with 401; matching-`iss` accepted.
---
### NFT-SEC-04b: Token with `aud` ≠ `JWT_AUDIENCE` → 401
**Summary**: Verifies AC-5.12 — `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. This is the structural fix for CMMC L2 row 3 (audience validation half).
**Traces to**: AC-5.3, AC-5.12
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Request token via `POST /sign { "aud": "wrong-audience" }` (every other claim valid) | mock returns signed JWT |
| 2 | `GET /vehicles` with that token | `401` |
**Pass criteria**: wrong-`aud` rejected with 401.
---
@@ -90,23 +109,25 @@
---
### NFT-SEC-06: Wrong `permissions` claim value → 403
### NFT-SEC-06: Wrong `permissions` claim value → 403; multi-permission token accepted
**Summary**: Verifies AC-9.2 — the policy is exact-string match, hardcoded.
**Traces to**: AC-9.2
**Summary**: Verifies AC-9.1 + AC-9.2 — the policy is `RequireClaim("permissions", "FL")` (contains-match, not exact body-match). Wrong values → 403; a multi-permission token where one value equals `"FL"` → 200.
**Traces to**: AC-9.1, AC-9.2
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with `permissions="ADMIN"`, valid otherwise | |
| 1 | Acquire token with `permissions="ADMIN"`, valid otherwise | mock returns signed JWT |
| 2 | `GET /vehicles` | `403` |
| 3 | Mint token with `permissions="fl"` (lowercase), valid otherwise | |
| 3 | Acquire token with `permissions="fl"` (lowercase) | mock returns signed JWT |
| 4 | `GET /vehicles` | `403` |
| 5 | Mint token with `permissions="FLight"`, valid otherwise | |
| 5 | Acquire token with `permissions="FLight"` | mock returns signed JWT |
| 6 | `GET /vehicles` | `403` |
| 7 | Acquire token with `permissions: ["FL", "ADMIN"]` (multi-permission array) | mock returns signed JWT |
| 8 | `GET /vehicles` | `200` (contains-match accepts `"FL"` among the values) |
**Pass criteria**: `403` for every wrong-value case.
**Pass criteria**: rows 2, 4, 6 → 403; row 8 → 200.
---
@@ -159,8 +180,94 @@
---
### NFT-SEC-10: Algorithm-pin defends against HS256-confusion → 401
**Summary**: Verifies AC-5.1 + AC-5.10 — `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` defends against the HS256-confusion attack where an attacker who learns a JWKS public key (which is, by definition, public) attempts to forge a token signed with that public key as the HMAC secret under `alg: HS256`.
**Traces to**: AC-5.1, AC-5.10
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Acquire the published JWKS public key bytes from `GET https://jwks-mock:8443/.well-known/jwks.json` (or use the mock's helper that returns those bytes for the test) | — |
| 2 | Acquire token via `POST /sign { "alg_override": "HS256" }` — the mock signs the JWT body with the public-key bytes as the HMAC secret (mimicking an attack) | mock returns HS256-signed JWT |
| 3 | `GET /vehicles` with `Authorization: Bearer T_hs256` | `401` |
| 4 | Acquire token via `POST /sign { "alg_override": "none" }` (mock emits unsigned JWT) | mock returns unsigned JWT |
| 5 | `GET /vehicles` with that token | `401` |
**Pass criteria**: both HS256-confusion attack and unsigned token are rejected with `401`.
---
### NFT-SEC-11: Unknown `kid` (rotation lag) → 401 until JWKS refresh
**Summary**: Verifies AC-5.7 — a token signed with a key whose `kid` is not in the cached JWKS is rejected; once the JWKS refreshes and includes the new `kid`, the same `kid` becomes accepted.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` has a warm JWKS cache (any previous protected request succeeded).
- `jwks-mock` `OldKeyGraceSeconds = 5`.
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `POST https://jwks-mock:8443/rotate-key {}` | mock returns `{ "newKid": "new-kid" }` |
| 2 | Immediately request token via `POST /sign {}` (signs with NEW kid, before `missions` JWKS cache refreshes) | mock returns signed JWT with header `kid: new-kid` |
| 3 | `GET /vehicles` with that token | `401` (cached JWKS doesn't yet contain `new-kid`) |
| 4 | Wait for `missions`'s JWKS cache to refresh (≤ 90s; the mock sets `Cache-Control: max-age=60` so the refresh tick is at most ~60s) | — |
| 5 | `GET /vehicles` with the same token (still valid lifetime) | `200` |
| 6 | Request token signed with the PREVIOUS `kid` within the `OldKeyGraceSeconds=5` window | mock signs with the old key |
| 7 | `GET /vehicles` with that token | `200` (both keys are in the JWKS during grace) |
| 8 | Wait > 5s, then request token signed with the OLD `kid` — mock should refuse (key already evicted from its sign-pool) | mock returns 400/410 |
**Pass criteria**: rotation completes transparently within the cache-refresh window; tokens minted with the new `kid` are rejected during the lag, accepted after.
**Max execution time**: 120s.
---
### NFT-SEC-12: Missing `JWT_JWKS_URL`/`JWT_ISSUER`/`JWT_AUDIENCE` → startup throws
**Summary**: Verifies AC-6.1 / AC-6.2 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` fail-fast for each of the four required env vars eliminates the legacy "silent dev fallback" failure mode.
**Traces to**: AC-6.1, AC-6.2, E1, E3
**Steps** (run as four separate `docker run` invocations):
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `DATABASE_URL` unset and the three JWT vars set | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `DATABASE_URL` (or `Database:Url`) |
| 2 | `docker run` with `JWT_ISSUER` unset | container exits non-zero; logs mention `JWT_ISSUER` (or `Jwt:Issuer`) |
| 3 | `docker run` with `JWT_AUDIENCE` unset | container exits non-zero; logs mention `JWT_AUDIENCE` (or `Jwt:Audience`) |
| 4 | `docker run` with `JWT_JWKS_URL` unset | container exits non-zero; logs mention `JWT_JWKS_URL` (or `Jwt:JwksUrl`) |
| 5 | `docker run` with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS) and the other three set | container STARTS (config resolution passes); first protected request returns 500 with a log line mentioning HTTPS / `RequireHttps` |
**Pass criteria**: rows 14 → process exits before HTTP server binds; row 5 → process starts but the first protected request fails at JWKS fetch time.
**Max execution time**: 60s (4 docker-run cycles).
---
### NFT-SEC-13: CORS Production-gate fail-fast (E9 lock test)
**Summary**: Verifies AC-6.11 — in `ASPNETCORE_ENVIRONMENT=Production` with empty `CorsConfig:AllowedOrigins` and `CorsConfig:AllowAnyOrigin != true`, `CorsConfigurationValidator.EnsureSafeForEnvironment` throws and the process exits non-zero.
**Traces to**: AC-6.11, E9
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `ASPNETCORE_ENVIRONMENT=Production` and **no** `CorsConfig` env vars | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `CorsConfig` / `AllowedOrigins` / Production |
| 2 | Same as 1 but with `CorsConfig__AllowAnyOrigin=true` set | container starts; logs contain a warning that CORS is permissive in Production (recommend listing explicit origins) but NO throw |
| 3 | Same as 1 but with `CorsConfig__AllowedOrigins__0=https://operator.example.com` set | container starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo |
| 4 | Same as 3, preflight from `https://attacker.example.com` | preflight responds without the allow-origin echo (the policy refuses the disallowed origin) |
| 5 | `docker run` with `ASPNETCORE_ENVIRONMENT=Development` (or unset → defaults to `Production`-no, actually unset defaults to `Production` per ASP.NET Core; use `Test` here) and no `CorsConfig` | container starts; logs contain `PermissiveDefaultWarning` |
**Pass criteria**: row 1 → fail-fast; rows 24 → start with the expected CORS posture; row 5 → start with the documented permissive fallback + warning.
**Max execution time**: 90s.
---
## Notes
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests.
- The CMMC L2 row 3 (`iss` / `aud`) gap is acknowledged but NOT remediated in this Epic; NFT-SEC-04 documents today's permissive behavior so a future enforcement change is detected.
- NFT-SEC-04 / NFT-SEC-04b / NFT-SEC-10 / NFT-SEC-11 / NFT-SEC-12 / NFT-SEC-13 are NEW scenarios added in the 2026-05-14 drift re-verification cycle. They replace / extend the old "permissive iss/aud" + "shared-secret rotation" + "dev-fallback footgun" assumptions of the pre-revision spec.
- No fuzz testing today (recommended follow-up under a separate refactor cycle).
+37 -9
View File
@@ -21,7 +21,7 @@
Three isolation tiers, by scenario type:
- **Class-scoped DB reset** (`IClassFixture<DbResetFixture>`): for scenarios that share the same seed within a test class but must not leak to other classes. Used for AC-1, AC-2, AC-4 read paths.
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects. Used for AC-6.3, AC-6.4 (idempotency), AC-6.5 (legacy drop), AC-6.6 (idempotent re-run), AC-6.7 (DB unreachable), AC-5.7 (JWT_SECRET rotation).
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects. Used for AC-6.3, AC-6.4 (idempotency), AC-6.5 (legacy drop), AC-6.6 (idempotent re-run), AC-6.7 (DB unreachable), AC-6.11 (CORS Production-gate lock test — separate `docker run` invocation), AC-5.7 (JWKS key rotation).
- **Per-test transaction roll-back is NOT used** — the system under test is a separate process and its `DataConnection` is not in the test transaction.
## Input Data Mapping
@@ -41,15 +41,15 @@ Three isolation tiers, by scenario type:
| FT-P-01 | `POST /vehicles` body per `data_parameters.md` § 2.1 | `201 Created`, `Vehicle` body, DB row exists | exact + db_query | N/A | `results_report.md` AC-1 row 1.1 |
| FT-P-02 | `POST /vehicles` body with `IsDefault:true` against `seed_one_default_vehicle` | `201`; new row default; prior row not default; default count == 1 | exact + db_query | N/A | AC-1 row 1.2 |
| FT-P-03 | `POST /vehicles/{id}/setDefault {IsDefault:true}` | `200`; default count == 1; only target row default | exact + db_query | N/A | AC-1 row 1.4 |
| FT-P-04 | `GET /vehicles` against `seed_3_vehicles_2_default` | `200`; body length == 3; PascalCase keys | exact + schema | N/A | AC-1 row 1.5 |
| FT-P-04 | `GET /vehicles` against `seed_3_vehicles_2_default` | `200`; body length == 3; PascalCase keys; **ordered by `Name` ASC** | exact + schema + ordering | N/A | AC-1 row 1.5 |
| FT-P-05 | `GET /vehicles?name=BR&isDefault=true` | `200`; body length == 1; `body[0].Name == "BR-01"` | exact | N/A | AC-1 row 1.6 |
| FT-N-01 | `GET /vehicles?name=br` (case mismatch) | `200`; body length == 0 | exact | N/A | AC-1 row 1.7 |
| FT-N-01 | `GET /vehicles?name=br` (lowercase filter against `BR-01`) | `200`; body length == 1 (filter is **case-INSENSITIVE** per AC-1.6) | exact | N/A | AC-1 row 1.7 |
| FT-N-02 | `GET /vehicles/{random uuid}` | `404`; envelope `{ statusCode:404, message }` | exact + schema | N/A | AC-1 row 1.8 |
| FT-N-03 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | `409`; row not deleted | exact + db_query | N/A | AC-1 row 1.9 |
| FT-P-06 | `DELETE /vehicles/{id}` against vehicle with 0 missions | `204`; row deleted | exact + db_query | N/A | AC-1 row 1.10 |
| FT-P-07 | `POST /missions` body per `data_parameters.md` § 2.2 | `201`; `CreatedDate ± 5s` of `now` | exact + numeric_tolerance | ±5s | AC-2 row 2.1 |
| FT-N-04 | `POST /missions {VehicleId:<random>}` | `400` (today, divergent from spec's 404) | exact | N/A | AC-2 row 2.2 |
| FT-P-08 | `GET /missions` against `seed_25_missions` | `200`; `PaginatedResponse<Mission>`; Page=1, PageSize=20, TotalCount=25, Items.length=20 | exact + schema | N/A | AC-2 row 2.3 |
| FT-P-08 | `GET /missions` against `seed_25_missions` | `200`; `PaginatedResponse<Mission>`; Page=1, PageSize=20, TotalCount=25, Items.length=20; **ordered by `CreatedDate` DESC** (newest first); `Items[0].CreatedDate >= Items[1].CreatedDate >= ...` | exact + schema + ordering | N/A | AC-2 row 2.3 |
| FT-P-09 | `GET /missions?page=2&pageSize=20` | `Page=2, Items.length=5` | exact | N/A | AC-2 row 2.4 |
| FT-P-10 | `GET /missions?fromDate=...&toDate=...` | `TotalCount=3` against the 5-row seed | exact | N/A | AC-2 row 2.5 |
| FT-P-11 | `PUT /missions/{id} {Name, VehicleId:null}` | `200`; Name updated, VehicleId preserved | exact | N/A | AC-2 row 2.7 |
@@ -70,13 +70,33 @@ NFT-* mappings (perf, resilience, security, resource-limit) are inline in the re
| External Service | Mock/Stub | How Provided | Behavior |
|-----------------|-----------|-------------|----------|
| `admin` (JWT issuer) | In-process token mint | `System.IdentityModel.Tokens.Jwt` in the consumer using `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256 | Mints valid / expired / wrong-secret / claim-missing / claim-typo tokens on demand for AC-5 + AC-9 scenarios |
| `admin` (JWT issuer + JWKS) | `jwks-mock` container | Built from `tests/Azaion.Missions.JwksMock/`, runs in the same `e2e-net` Docker network. Self-signed TLS cert; CA mounted into `missions` so it trusts the mock's JWKS HTTPS endpoint. Exposes `GET /.well-known/jwks.json` (consumed by `missions`'s `ConfigurationManager<JsonWebKeySet>`), `POST /sign` (returns ECDSA-signed JWTs to the test consumer for AC-5 + AC-9 scenarios), `POST /rotate-key` (generates a new `kid`, retains the previous public key for `OldKeyGraceSeconds` to support NFT-RES-07 transition assertions). The mock's private key never leaves its container. | Mints valid / expired / wrong-`kid` / wrong-`iss` / wrong-`aud` / wrong-`alg` (HS256 confusion) / claim-missing / claim-typo tokens on demand. Rotation tests trigger key roll without restarting `missions`. |
| `annotations` table owner | DB-only stub | Side-channel `CREATE TABLE annotations (id text PRIMARY KEY, media_id text)` then `INSERT` | Provides rows the cascade walk reads + deletes; no service running |
| `detection` table owner | DB-only stub | Side-channel `CREATE TABLE detection (id uuid PRIMARY KEY, annotation_id text)` + INSERT | Same as above |
| `media` table owner | DB-only stub | Side-channel `CREATE TABLE media (id text PRIMARY KEY, waypoint_id uuid)` + INSERT | Same |
| `autopilot` writer of `map_objects` | DB-only stub + race injector | Side-channel; for AC-3.4 race, a parallel goroutine-equivalent inserts a `map_objects` row immediately after the service's first `SELECT` (instrumented via test-only proxy) | One scenario only |
| `flight-gate`, Watchtower, suite reverse proxy, suite UI | NOT mocked | n/a | Out of scope for service-level e2e |
**JWKS mock token-minting contract** (consumed by the e2e test runner):
```http
POST https://jwks-mock:8443/sign HTTP/1.1
Content-Type: application/json
{
"iss": "https://admin-test.azaion.local", # optional; defaults to the mock's JWT_ISSUER env
"aud": "azaion-edge", # optional; defaults to JWT_AUDIENCE env
"exp_offset_seconds": 3600, # optional; default 3600 (1h). Negative for expired tokens.
"permissions": "FL", # optional; default "FL". Set to omit / "" / "ADMIN" / "fl" / "FLight" to test claim-mismatch
"alg_override": null, # optional; "HS256" forces the mock to sign with a static HMAC key for HS256-confusion tests (NFT-SEC-10)
"kid_override": null # optional; non-existent kid for unknown-key tests
}
```
Response: `{ "token": "<encoded JWT>", "kid": "<key id>" }`.
The mock signs every token with the current private key by default. `alg_override: "HS256"` is the only way to obtain an HS256 token in tests — used to verify the algorithm-pin defense (NFT-SEC-10).
## Data Validation Rules
| Data Type | Validation today (per AC-* notes) | Invalid Examples | Expected System Behavior |
@@ -86,8 +106,16 @@ NFT-* mappings (perf, resilience, security, resource-limit) are inline in the re
| Vehicle.Type | NONE (any int accepted) | `99` | accepted; carry-forward |
| Mission.Page / PageSize | NONE | `-1`, `999999` | accepted by binding; carry-forward |
| Waypoint.GeoPoint | NONE; all-null accepted | `{Lat:null, Lon:null, Mgrs:null}` | accepted (`OrderNum + Height` still required-by-shape) |
| JWT lifetime | `ValidateLifetime=true` with 1-min skew | `exp = now-2min` | `401` |
| JWT signature | HS256 + shared secret | wrong secret / tampered payload | `401` |
| JWT claim `permissions` | exact string match `"FL"` | `"fl"`, `"ADMIN"`, missing | `403` |
| JWT lifetime | `ValidateLifetime=true` with **30s** clock skew | `exp = now-60s` | `401` |
| JWT signature | ECDSA-SHA256 + admin's JWKS | tampered payload / signed with non-JWKS key | `401` |
| JWT algorithm | `ValidAlgorithms = [EcdsaSha256]` (pinned) | `alg: HS256`, `alg: RS256`, `alg: none` | `401` |
| JWT `iss` claim | exact match against `JWT_ISSUER` | anything else | `401` |
| JWT `aud` claim | exact match against `JWT_AUDIENCE` | anything else | `401` |
| JWT `kid` (header) | must resolve in cached JWKS | unknown `kid` | `401` (until next JWKS refresh tick when rotation publishes it) |
| JWT claim `permissions` | contains-match `"FL"` (multi-permission tokens accepted if one entry == `"FL"`) | `"fl"`, `"ADMIN"`, `"FLight"`, missing | `403` |
| `Authorization` header | required on all `/vehicles/*`, `/missions/*` | absent | `401` |
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | process exits with error before HTTP server binds |
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
| `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | each required at startup | any of them missing or whitespace-only | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
| `JWT_JWKS_URL` scheme | HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }` | `http://...` | passes startup config resolution, but first protected request fails 500 when JWKS fetch rejects the URL |
| `CorsConfig:AllowedOrigins` in `Production` | non-empty OR `AllowAnyOrigin == true` | empty AND `AllowAnyOrigin != true` AND `ASPNETCORE_ENVIRONMENT=Production` | `CorsConfigurationValidator.EnsureSafeForEnvironment` throws `InvalidOperationException`; startup aborts |
| `CorsConfig` in non-Production | empty allow-list permitted | n/a | falls back to `AllowAnyOrigin/Method/Header` AND logs `PermissiveDefaultWarning` |
+44 -36
View File
@@ -1,7 +1,8 @@
# Traceability Matrix
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14); re-issued in cycle-update mode after the targeted re-verification of 2026-05-14 (drift findings Phase 2).
> **Naming**: post-rename target. Tests written for the post-rename API surface — RED-status until B5B8 land. The traceability matrix below treats the documented spec as the source of truth.
> **Drift correction**: rows for AC-5, AC-6, AC-9, AC-1.5/1.6, AC-2.3, E1/E3/E4/E9 are updated below to reflect the ECDSA+JWKS JWT model, fail-fast configuration resolver, and CORS production-gate validator. Several `NOT COVERED` items in the pre-revision matrix are now `Covered` thanks to the new NFT-SEC-10..13 + NFT-RES-05 rewrite + the inverted FT-N-01.
## Acceptance Criteria Coverage
@@ -13,8 +14,8 @@
| AC-1.2 | Default-clear on create/update/setDefault | FT-P-02, FT-P-03 | Covered |
| AC-1.3 | "Exactly one default" stricter than spec (B12 pending) | covered indirectly via FT-P-02, FT-P-03 (assertions on `count == 1`) | Covered (carry-forward) |
| AC-1.4 | Default-clear NOT transaction-wrapped → race | NFT-RES-08 | Covered (probabilistic) |
| AC-1.5 | GET /vehicles is plain array (NO pagination) | FT-P-04 | Covered |
| AC-1.6 | Filter case-sensitive on `name`, exact on `isDefault` | FT-P-05, FT-N-01 | Covered |
| AC-1.5 | GET /vehicles is plain array (NO pagination), ordered by `Name` ASC | FT-P-04 | Covered |
| AC-1.6 | Filter **case-INSENSITIVE** on `name`, exact on `isDefault` | FT-P-05 (positive + lowercase), FT-N-01 (no-match negative) | Covered |
| AC-1.7 | GET /vehicles/{id} 404 | FT-N-02 | Covered |
| AC-1.8 | DELETE /vehicles/{id} 409 if referenced | FT-N-03 | Covered |
| AC-1.9 | All `/vehicles/*` require `Policy="FL"` | NFT-SEC-01, NFT-SEC-05, NFT-SEC-06 | Covered |
@@ -25,12 +26,12 @@
|-------|------------------------------|----------|----------|
| AC-2.1 | Create mission, default `CreatedDate = UtcNow` | FT-P-07 | Covered |
| AC-2.2 | Non-existent VehicleId → 400 (today; spec wants 404) | FT-N-04 | Covered (carry-forward) |
| AC-2.3 | GET /missions paginated `PaginatedResponse<Mission>` | FT-P-08, FT-P-09, FT-P-10, NFT-PERF-04 | Covered |
| AC-2.3 | GET /missions paginated `PaginatedResponse<Mission>`, ordered by `CreatedDate` DESC, name filter case-INSENSITIVE | FT-P-08 (ordering + case-INSENSITIVE), FT-P-09, FT-P-10, NFT-PERF-04 | Covered |
| AC-2.4 | GET /missions/{id} 404 | FT-N-05 | Covered |
| AC-2.5 | PUT partial update (Name update only) | FT-P-11 | Covered |
| AC-2.6 | LinqToDB does NOT eager-load `[Association]` | covered indirectly via FT-P-07/FT-P-11 (body shape assertion checks `Vehicle == null`, `Waypoints == null/[]`) | Covered |
| AC-2.7 | All `/missions/*` require `Policy="FL"` | NFT-SEC-01 | Covered |
| AC-2.8 | TOCTOU on FK → 500 | NOT directly covered as a separate test (deterministic reproduction is hard); falls under NFT-RES-08-style probabilistic family | NOT COVERED — see Uncovered Items §1 |
| AC-2.8 | TOCTOU on FK between existence check and insert — now PARTLY mitigated by DB-level FK (PG `23503`); surface today is 500 | NOT directly covered as a separate test (deterministic reproduction still requires controllable concurrency); the FK mitigation is observable indirectly via 6.10 startup-schema test asserting `REFERENCES vehicles(id)` exists | NOT COVERED — see Uncovered Items §1 |
### AC-3 — Mission cascade delete F3 (most critical)
@@ -60,22 +61,25 @@
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-5.1 | HS256 + `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | covered indirectly via NFT-SEC-02 (different secret rejected) and NFT-SEC-03 (correct secret accepted) | Covered |
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=1min` | NFT-SEC-03 | Covered |
| AC-5.3 | `ValidateIssuer=false`, `ValidateAudience=false` (today) | NFT-SEC-04 | Covered (locks today's behavior) |
| AC-5.1 | **ECDSA-SHA256** with `ValidAlgorithms = [EcdsaSha256]` (algorithm pin) | NFT-SEC-02 (signature reject), NFT-SEC-10 (HS256-confusion defense) | Covered |
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=30s` | NFT-SEC-03 | Covered |
| AC-5.3 | `ValidateIssuer=true` + `ValidateAudience=true` (CMMC L2 row 3 structurally fixed in this service) | NFT-SEC-04, NFT-SEC-04b | Covered |
| AC-5.4 | Missing header → 401 | NFT-SEC-01 | Covered |
| AC-5.5 | Invalid signature → 401 | NFT-SEC-02 | Covered |
| AC-5.6 | Expired token (outside skew) → 401 | NFT-SEC-03 | Covered |
| AC-5.7 | Old `JWT_SECRET` after rotation → 401 | NFT-RES-07 | Covered |
| AC-5.5 | Invalid signature / no matching public key → 401 | NFT-SEC-02 | Covered |
| AC-5.6 | Expired token (outside 30s skew) → 401 | NFT-SEC-03 | Covered |
| AC-5.7 | **JWKS key rotation without restart** — old kid eventually rejected, new kid eventually accepted | NFT-RES-07, NFT-SEC-11 | Covered |
| AC-5.8 | Missing `permissions=FL` claim → 403 | NFT-SEC-05 | Covered |
| AC-5.9 | Local validator never calls `admin` | NOT directly observable from outside the process; covered indirectly by `admin` not running in the test env (NFT-SEC-* still pass) | Partially covered |
| AC-5.9 | Request-path validation local after JWKS cached; cold-start synchronously fetches JWKS | NFT-SEC-* all pass with `admin` not running (only `jwks-mock` runs); the cold-start failure mode is testable by stopping `jwks-mock` and restarting `missions` then issuing the first protected request | Covered (covered-cold-start case under results_report row 5.10) |
| AC-5.10 | Algorithm pin (`alg ∉ [EcdsaSha256]` → 401) | NFT-SEC-10 | Covered |
| AC-5.11 | `iss` validation (`iss != JWT_ISSUER` → 401) | NFT-SEC-04 | Covered |
| AC-5.12 | `aud` validation (`aud != JWT_AUDIENCE` → 401) | NFT-SEC-04b | Covered |
### AC-6 — Startup + migration
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-6.1 | DATABASE_URL URL form converted via `ConvertPostgresUrl` | covered indirectly via every test that depends on a working DB connection (compose env uses URL form) | Covered |
| AC-6.2 | DATABASE_URL raw form accepted | NOT directly covered — the test environment uses URL form; can be added by an extra startup scenario with the raw form | NOT COVERED — see Uncovered Items §3 |
| AC-6.1 | Four required env vars resolved via `ResolveRequiredOrThrow` (env-first, then `IConfiguration`, else throw); URL form converted via `ConvertPostgresUrl` | results_report 6.1, 6.1b, 6.1c; NFT-SEC-12; NFT-RES-05 | Covered |
| AC-6.2 | DATABASE_URL raw form accepted; no `JWT_SECRET` legacy env consulted | results_report 6.2; NFT-SEC-12 row 4 (asserts `JWT_JWKS_URL` is consulted, not `JWT_SECRET`) | Covered |
| AC-6.3 | Migrator runs ONCE at startup, inside scope | NFT-RES-03 (idempotency assertion implies single-run + safe-restart) | Partially covered |
| AC-6.4 | 4 owned tables + 3 indexes created | NFT-RES-03 (asserts schema via `\d+` after first start) | Covered |
| AC-6.5 | Post-B9 one-shot legacy `DROP TABLE IF EXISTS` | NFT-RES-04 | Covered |
@@ -84,6 +88,8 @@
| AC-6.8 | DB missing (3D000) → process exits | NFT-RES-06 | Covered |
| AC-6.9 | `ErrorHandlingMiddleware` registered FIRST | covered indirectly via FT-N-08 + NFT-SEC-08 (any unhandled exception produces the documented envelope) | Covered |
| AC-6.10 | Listens on port 8080; edge maps host `5002:8080` | covered by every test that connects to port 5002→8080 | Covered |
| AC-6.11 | CORS Production-gate fail-fast (empty allow-list + `AllowAnyOrigin != true` → throw) | NFT-SEC-13; results_report 6.106.13 | Covered |
| AC-6.12 | `JWT_JWKS_URL` HTTPS-only at fetch time (passes startup config) | NFT-SEC-12 row 5; results_report 6.1c | Covered |
### AC-7 — Health probe
@@ -110,8 +116,8 @@
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-9.1 | Policy `"FL"` registered, satisfied by `permissions == "FL"` | every protected-endpoint test | Covered |
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight") → 403 | NFT-SEC-06 | Covered |
| AC-9.1 | Policy `"FL"` registered as `RequireClaim("permissions", "FL")`**contains-match**: a multi-permission token containing `"FL"` is accepted | every protected-endpoint test + NFT-SEC-06 step 7-8 (multi-permission accepted) | Covered |
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight", "ADMIN") → 403 | NFT-SEC-06 steps 2/4/6 | Covered |
| AC-9.3 | Policy NAME `"FL"` retains legacy wording (deferred) | not testable at runtime — documentation-only | Documentation only |
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` | covered by NFT-SEC-01 + NFT-SEC-07 (every endpoint gets the same gate; health is the only exception) | Covered |
@@ -146,7 +152,7 @@
| S1 | C# / .NET 10 | implicit in test environment | Implicitly covered |
| S2 | ASP.NET Core | implicit | Implicitly covered |
| S3S5 | Library versions | csproj / lockfile concern; NOT a behavioral test | Out of scope (build-time check) |
| S6 | Swagger NOT gated on `IsDevelopment()` | NOT directly tested; could add a single `GET /swagger` test that asserts 200 in production-like env. Carry-forward divergence | NOT COVERED — see Uncovered Items §5 |
| S6 | Swagger NOT gated on `IsDevelopment()` | NOT directly tested; could add a single `GET /swagger` test that asserts 200 in production-like env. Carry-forward divergence | NOT COVERED — see Uncovered Items §4 |
| S7 | PostgreSQL only | implicit | Implicitly covered |
| S8 | One csproj, one root namespace | csproj structure; NOT a behavioral test | Out of scope (code organization) |
| S9 | No `src/` directory | repo layout; NOT a behavioral test | Out of scope |
@@ -161,16 +167,16 @@
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| E1 | Two required env vars | implicit; NFT-RES-05 (DB unreachable) + NFT-SEC-* (JWT_SECRET behavior) | Covered |
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via NFT-RES-05 path; raw form NOT covered | NOT COVERED — see Uncovered Items §3 |
| E3 | Hardcoded dev fallbacks NOT gated on IsDevelopment() | a startup test with NO env vars set could verify fallback boot — security risk gate; carry-forward | NOT COVERED — see Uncovered Items §6 |
| E4 | JWT_SECRET shared across services | suite-level concern | Out of scope |
| E1 | **Four** required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) — fail-fast via `ResolveRequiredOrThrow` | NFT-SEC-12 (all 4 rows), NFT-RES-05 (all 5 rows), results_report 6.1b | Covered |
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via every default test; raw form covered by results_report 6.2 | Covered |
| E3 | **No hardcoded dev fallbacks**`ResolveRequiredOrThrow` throws | NFT-SEC-12, NFT-RES-05 rows 1-5 | Covered |
| E4 | Asymmetric ECDSA: no shared secret on this side; only public-key configuration | NFT-SEC-* all run against `jwks-mock` (the mock holds the private key, this service holds only public-key config) | Covered |
| E5 | Container EXPOSE 8080; edge maps 5002:8080 | implicit | Implicitly covered |
| E6 | Image tag post-B10 | build-time concern, not behavior | Out of scope |
| E7 | Entrypoint post-B5 | build-time concern | Out of scope |
| E8 | No appsettings env-specific overrides | code organization; NOT a behavioral test | Out of scope |
| E9 | CORS `AllowAnyOrigin/Method/Header` | could add a single CORS preflight test that asserts the documented permissive behavior | NOT COVERED — see Uncovered Items §7 |
| E10 | TLS termination is suite reverse proxy | suite-level concern | Out of scope |
| E9 | CORS **gated by `CorsConfigurationValidator`** — Production throws on empty allow-list | NFT-SEC-13 (all 5 rows), results_report 6.106.13 | Covered |
| E10 | TLS termination is suite reverse proxy; JWKS independently constrained to HTTPS | NFT-SEC-12 row 5 (JWKS HTTPS-only) | Covered (HTTPS half) |
### Operational (O1O10)
@@ -195,34 +201,36 @@
| AC-2 Mission CRUD | 8 | 7 | 0 | 1 (AC-2.8 TOCTOU) | 0 | 87% |
| AC-3 Cascade F3 | 7 | 5 | 1 | 1 (AC-3.7 race) | 0 | 86% |
| AC-4 Waypoint CRUD F4 | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-5 JWT | 9 | 8 | 1 | 0 | 0 | 100% |
| AC-6 Startup + migration | 10 | 8 | 1 | 1 (AC-6.2 raw conn) | 0 | 90% |
| AC-5 JWT | 12 | 12 | 0 | 0 | 0 | 100% |
| AC-6 Startup + migration | 12 | 12 | 0 | 0 | 0 | 100% |
| AC-7 Health | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-8 Wire shape | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-9 Authz | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-10 Operational | 6 | 1 | 0 | 0 | 5 | 100% (in-scope) |
| Restrictions H | 6 | 1 | 2 | 0 | 3 | 100% (in-scope) |
| Restrictions S | 15 | 4 | 2 | 0 | 9 | 100% (in-scope) |
| Restrictions E | 10 | 1 | 1 | 3 (E2, E3, E9) | 5 | 60% (in-scope) |
| Restrictions E | 10 | 7 | 1 | 0 | 2 | 100% (in-scope) |
| Restrictions O | 10 | 4 | 2 | 0 | 4 | 100% (in-scope) |
| **Total** | 112 | 67 | 11 | 6 | 28 | **93%** in-scope |
| **Total** | 117 | 78 | 10 | 3 | 26 | **97%** in-scope |
## Uncovered Items Analysis
| # | Item | Reason Not Covered | Risk | Mitigation |
|---|------|-------------------|------|-----------|
| 1 | AC-2.8 — TOCTOU on FK between existence check and insert | Deterministic reproduction requires controllable concurrency primitive that doesn't exist today (instrumented test build with `pg_advisory_lock`) | Low — failure mode is well-documented and produces a 500 (loud failure, not silent corruption); occurs only when admin races with create | Add probabilistic test (similar to NFT-RES-08) under a follow-up ticket. Document as known carry-forward. |
| 1 | AC-2.8 — TOCTOU on FK between existence check and insert | Deterministic reproduction requires controllable concurrency primitive (instrumented test build with `pg_advisory_lock`). Note: DB-level FK now produces PG error `23503` so the failure surface is consistent — only the timing of the race is hard to reproduce | Low — failure mode is well-documented and produces a 500 (loud failure, not silent corruption); occurs only when admin races with create | Add probabilistic test (similar to NFT-RES-08) under a follow-up ticket. Document as known carry-forward. |
| 2 | AC-3.7 — autopilot orphan race on `map_objects` insert after step-1 read | Same as #1 — needs controllable concurrency | Low — leaves at most one orphan row per race; cleanup on next mission delete or via manual sweep | Same mitigation as #1; add to follow-up. |
| 3 | AC-6.2 / E2 — `DATABASE_URL` raw form path | Test env uses URL form; raw form is the alternate adapter branch | Low — branch is small, well-localised in `ConvertPostgresUrl` | Add a single startup scenario with raw form. Single-line config change in test compose. |
| 4 | AC-7.4 — TCP connect fails on container down (Watchtower restarts) | Container lifecycle outside service surface | None at service level — testable at suite e2e level | Cover at suite e2e (`monorepo-e2e` skill scope) |
| 5 | S6 — Swagger NOT gated on `IsDevelopment()` | Carry-forward security finding; not part of AC | Medium — production deploy with Swagger exposed | Add a single test `GET /swagger/index.html` returns 200 in test env, with explicit comment that this LOCKS the carry-forward divergence (will fail when remediated). Suggest as follow-up. |
| 6 | E3 — Hardcoded dev fallbacks NOT gated | Carry-forward security finding | Medium — production deploy without env vars boots with well-known secret | Add a startup test with NO env vars set, assert `JWT_SECRET` claim ladder still works (locks the divergence). Suggest as follow-up. |
| 7 | E9 — CORS `AllowAnyOrigin/Method/Header` | Carry-forward; assumed safe behind reverse proxy | Low — assumed deployment topology mitigates | Add CORS preflight test that locks current behavior. Suggest as follow-up. |
| 3 | AC-7.4 — TCP connect fails on container down (Watchtower restarts) | Container lifecycle outside service surface | None at service level — testable at suite e2e level | Cover at suite e2e (`monorepo-e2e` skill scope) |
| 4 | S6 — Swagger NOT gated on `IsDevelopment()` (surviving branch of ADR-005) | Carry-forward security finding; not part of AC | Medium — production deploy with Swagger exposed | Add a single test `GET /swagger/index.html` returns 200 in test env, with explicit comment that this LOCKS the carry-forward divergence (will fail when remediated). Suggest as follow-up. |
**Recommendation**: items 1, 2 are deterministic-test improvements to land alongside a future `transaction-wrap` refactor (closes the carry-forward at the same time as the test improvement). Items 3, 5, 6, 7 are 1-test additions each — add them in Step 5 (Decompose Tests) under a "blackbox-lock-carry-forward" task.
**Resolved by the 2026-05-14 re-verification**:
- E3 (hardcoded dev fallbacks) — structurally fixed in code via `ResolveRequiredOrThrow`. Old "Uncovered §6" obsolete; now Covered by NFT-SEC-12 + NFT-RES-05.
- E9 (CORS `AllowAnyOrigin/Method/Header` in all environments) — structurally fixed by `CorsConfigurationValidator`. Old "Uncovered §7" obsolete; now Covered by NFT-SEC-13.
- AC-6.2 / E2 (`DATABASE_URL` raw form path) — covered by results_report row 6.2 as part of the cycle-update; no longer a gap.
**Recommendation**: items 1, 2 are deterministic-test improvements to land alongside a future `transaction-wrap` refactor (closes the carry-forward at the same time as the test improvement). Item 4 is a 1-test addition — add in Step 5 (Decompose Tests) under a "blackbox-lock-carry-forward" task.
## Phase 3 Coverage Gate
**Threshold**: ≥ 75% (per `cursor-meta.mdc` Quality Thresholds + `phases/03-data-validation-gate.md`).
**Achieved**: 93% in-scope.
**Verdict**: **PASS** — Phase 3 gate cleared on first iteration. The 6 uncovered items above are all low-medium risk with documented mitigations.
**Achieved**: 97% in-scope (after the 2026-05-14 drift Phase 2 re-issue).
**Verdict**: **PASS** — Phase 3 gate cleared. The 4 remaining uncovered items are all low-medium risk with documented mitigations; the previous E3 / E9 / AC-6.2 gaps were closed by the structural code fixes already in `Infrastructure/ConfigurationResolver.cs` and `Infrastructure/CorsConfigurationValidator.cs`.