mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 13:01:08 +00:00
chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful
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:
@@ -14,8 +14,8 @@
|
||||
| AC-1.2 | If `IsDefault == true` on create or update or `SetDefault`, the service runs `UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE` BEFORE inserting/updating with `IsDefault = true` | `VehicleService.{CreateVehicle, UpdateVehicle, SetDefault}` — clear-then-set pattern |
|
||||
| AC-1.3 | "Exactly one default" is **stricter than spec** (B12 decision pending — `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`) | code reflects current behaviour; B12 ticket AZ-551 records the resolution decision |
|
||||
| AC-1.4 | The clear-then-set is **NOT** transaction-wrapped → race window can leave 2+ defaults or zero defaults | `VehicleService` — no `db.BeginTransactionAsync`; tracked in `_docs/02_document/components/01_vehicle_catalog/description.md` Caveats #1 |
|
||||
| AC-1.5 | `GET /vehicles` returns a plain `List<Vehicle>` (NO pagination, NO total count) — matches spec endpoint 13 | `VehicleService.GetVehicles` |
|
||||
| AC-1.6 | `GET /vehicles?name=&isDefault=` filters case-sensitively on `Name` and exactly on `IsDefault` | `VehicleService.GetVehicles` query expression |
|
||||
| AC-1.5 | `GET /vehicles` returns a plain `List<Vehicle>` (NO pagination, NO total count) ordered by `Name` ASC | `VehicleService.GetVehicles` `OrderBy(a => a.Name)` |
|
||||
| AC-1.6 | `GET /vehicles?name=&isDefault=` filters **case-INSENSITIVELY** on `Name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`) and exactly on `IsDefault` | `VehicleService.GetVehicles` `a.Name.ToLower().Contains(query.Name.ToLower())` |
|
||||
| AC-1.7 | `GET /vehicles/{id}` returns 404 (`KeyNotFoundException` → `ErrorHandlingMiddleware`) when id absent | `VehicleService.GetVehicle` |
|
||||
| AC-1.8 | `DELETE /vehicles/{id}` returns 409 (`InvalidOperationException` → `ErrorHandlingMiddleware`) when any mission references the vehicle | `VehicleService.DeleteVehicle` `IsAny<Mission>` check |
|
||||
| AC-1.9 | Every `/vehicles/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `VehiclesController` |
|
||||
@@ -26,12 +26,12 @@
|
||||
|---|-----------|--------------|
|
||||
| AC-2.1 | `POST /missions { Name, VehicleId, CreatedDate? }` creates a row and returns the created `Mission` | `MissionService.CreateMission`; default `CreatedDate = UtcNow` if null |
|
||||
| AC-2.2 | `POST /missions` with non-existent `VehicleId` returns `400 Bad Request` (today, via `ArgumentException`) — **spec wants `404`** | `MissionService.CreateMission` existence check; carry-forward divergence |
|
||||
| AC-2.3 | `GET /missions?name=&fromDate=&toDate=&page=&pageSize=` returns `PaginatedResponse<Mission>` (the only paginated endpoint in this service) | `MissionService.GetMissions`; default `page=1`, `pageSize=20` |
|
||||
| AC-2.3 | `GET /missions?name=&fromDate=&toDate=&page=&pageSize=` returns `PaginatedResponse<Mission>` (the only paginated endpoint in this service), ordered by `CreatedDate` DESC (newest first); `name` filter is **case-INSENSITIVE** (`LOWER(name) LIKE %lower(input)%`) | `MissionService.GetMissions` `OrderByDescending(f => f.CreatedDate)`; `f.Name.ToLower().Contains(query.Name.ToLower())`; default `page=1`, `pageSize=20` |
|
||||
| AC-2.4 | `GET /missions/{id}` returns 404 when id absent | `MissionService.GetMission` |
|
||||
| AC-2.5 | `PUT /missions/{id}` applies partial update — non-null fields in `UpdateMissionRequest` overwrite, null fields are preserved | `MissionService.UpdateMission` |
|
||||
| AC-2.6 | LinqToDB does NOT eager-load `[Association]` — `Mission.Vehicle` and `Mission.Waypoints` serialize as `null` / `[]` on the wire | `Database/Entities/Mission.cs`; verified observation |
|
||||
| AC-2.7 | Every `/missions/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` |
|
||||
| AC-2.8 | TOCTOU on `VehicleId` deletion between existence check and insert produces `Npgsql PostgresException` → 500 (UX gap — spec wants 400) | `MissionService.CreateMission`; tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats |
|
||||
| AC-2.8 | TOCTOU on `VehicleId` deletion between existence check and insert is **partly mitigated by DB-level FK** — `missions.vehicle_id REFERENCES vehicles(id)` causes PostgreSQL to reject the insert with error code `23503` if the parent was deleted between check and insert. Surface today: `Npgsql PostgresException` (code `23503`) → `ErrorHandlingMiddleware` fallthrough → 500 (UX gap — spec wants 400). Mitigation in app code (wrap check + insert in a transaction OR map `23503` to 400) is carry-forward — tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats | `MissionService.CreateMission`; `Database/DatabaseMigrator.cs` (FK declaration) |
|
||||
|
||||
## AC-3 — Mission delete with cross-service cascade (F3) — **most critical**
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
| # | Criterion | Verification |
|
||||
|---|-----------|--------------|
|
||||
| AC-4.1 | All routes are nested: `GET/POST/PUT/DELETE /missions/{missionId}/waypoints[/{wpId}]` | `MissionsController` route attributes |
|
||||
| AC-4.2 | Parent mission missing → 404 (`KeyNotFoundException`) | `WaypointService.*` initial existence check |
|
||||
| AC-4.2 | **Create**: parent mission missing → 404 (`KeyNotFoundException("Mission not found")`) via an explicit `db.Missions.AnyAsync(m => m.Id == missionId)` check before insert. **Update / Delete**: the check is collapsed into a single composite `WHERE w.MissionId == missionId AND w.Id == waypointId` predicate; if no row matches (parent missing OR child missing OR mismatched parent/child pair) → 404 with the same `Waypoint not found` message. The two error cases (parent vs child) are NOT distinguishable from the response | `WaypointService.{CreateWaypoint, UpdateWaypoint, DeleteWaypoint}` |
|
||||
| AC-4.3 | `GET /missions/{id}/waypoints` is **unpaginated**, ordered by `OrderNum` ASC (matches spec endpoint 6) | `WaypointService.GetWaypoints` `OrderBy(w => w.OrderNum)` |
|
||||
| AC-4.4 | `PUT /missions/{id}/waypoints/{wpId}` is a **full overwrite** of every field even though the request DTO looks "partial-shaped" — non-nullable enums/numerics in `UpdateWaypointRequest` mean every field gets replaced (inconsistent with vehicle's nullable partial-update pattern) | `Services/WaypointService.cs` `UpdateWaypoint` + `DTOs/UpdateWaypointRequest.cs` |
|
||||
| AC-4.5 | `DELETE /missions/{id}/waypoints/{wpId}` walks the same cascade as F3, scoped to one waypoint (`detection` → `annotations` → `media` → `waypoints`) | `WaypointService.DeleteWaypoint` |
|
||||
@@ -61,30 +61,35 @@
|
||||
|
||||
| # | Criterion | Verification |
|
||||
|---|-----------|--------------|
|
||||
| AC-5.1 | Algorithm: HMAC-SHA256 (HS256) with `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | `Auth/JwtExtensions.cs` |
|
||||
| AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromMinutes(1)` (tighter than .NET's 5-minute default) | `Auth/JwtExtensions.cs` |
|
||||
| AC-5.3 | `ValidateIssuer = false` and `ValidateAudience = false` — known CMMC L2 finding (suite-tracked under AZ-487 / AZ-494) | `Auth/JwtExtensions.cs`; `_docs/02_document/architecture.md` § 7 |
|
||||
| AC-5.4 | Missing `Authorization` header → 401 | `JwtBearerHandler` |
|
||||
| AC-5.5 | Invalid signature → 401 | HMAC verify fails |
|
||||
| AC-5.6 | Expired token (with 1-min skew applied) → 401 | `ValidateLifetime` |
|
||||
| AC-5.7 | Token signed with old `JWT_SECRET` (rotation) → 401 across the entire device until coordinated re-deploy | shared-secret model |
|
||||
| AC-5.8 | Valid signature + lifetime, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`5_identity` description.md) |
|
||||
| AC-5.9 | The local validator never calls back to `admin`; `admin` outage does NOT take this service down (until issued tokens expire) | `Auth/JwtExtensions.cs` — pure local validation |
|
||||
| AC-5.1 | Algorithm: **ECDSA-SHA256** asymmetric signature validation against public keys retrieved from `admin`'s JWKS. `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` is pinned — defends against HS256-confusion (an attacker who learns the JWKS public key cannot forge tokens with `alg: HS256` using that key as the HMAC secret) | `Auth/JwtExtensions.cs` `TokenValidationParameters.ValidAlgorithms` |
|
||||
| AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromSeconds(30)` (tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting) | `Auth/JwtExtensions.cs` `ClockSkew = TimeSpan.FromSeconds(30)` |
|
||||
| AC-5.3 | `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`; `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. The CMMC L2 row 3 finding is structurally fixed in this service's code; the suite-level docs may still describe the legacy "iss/aud disabled" model and have a separate sync task pending | `Auth/JwtExtensions.cs` |
|
||||
| AC-5.4 | Missing `Authorization` header on a `[Authorize]` route → 401 | `JwtBearerHandler` |
|
||||
| AC-5.5 | Invalid signature → 401 (ECDSA verify fails against every cached public key whose `kid` matches the token header) | `Auth/JwtExtensions.cs` `IssuerSigningKeyResolver` + `JwtBearerHandler` |
|
||||
| AC-5.6 | Expired token (with 30s skew applied) → 401 | `ValidateLifetime = true` |
|
||||
| AC-5.7 | Token's `kid` not in cached JWKS → 401. JWKS rotation publishes a new `kid`; the cached manager refreshes on the default schedule (matches admin's `Cache-Control: public, max-age=3600`). **No coordinated redeploy** is needed for rotation | `ConfigurationManager<JsonWebKeySet>` refresh |
|
||||
| AC-5.8 | Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`05_identity/description.md`) |
|
||||
| AC-5.9 | Request-path validation does NOT call `admin`; only the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL` (which must be HTTPS — `HttpDocumentRetriever { RequireHttps = true }`). Once cached, the manager refreshes on its default schedule. `admin` outage AFTER the JWKS has been cached does NOT take this service down until cache + tokens expire; `admin` outage AT the time of the first JWKS fetch causes the first protected request to fail 500 | `Auth/JwtExtensions.cs` `ConfigurationManager<JsonWebKeySet>` |
|
||||
| AC-5.10 | Token header with `alg ∉ [EcdsaSha256]` (e.g. forged `alg: HS256`, or genuine but unsupported `alg: RS256`) → 401 — algorithm pin defense | `Auth/JwtExtensions.cs` `ValidAlgorithms` |
|
||||
| AC-5.11 | `iss` claim ≠ resolved `JWT_ISSUER` → 401 | `Auth/JwtExtensions.cs` `ValidateIssuer` + `ValidIssuer` |
|
||||
| AC-5.12 | `aud` claim ≠ resolved `JWT_AUDIENCE` → 401 | `Auth/JwtExtensions.cs` `ValidateAudience` + `ValidAudience` |
|
||||
|
||||
## AC-6 — Service startup + schema migration (F6)
|
||||
|
||||
| # | Criterion | Verification |
|
||||
|---|-----------|--------------|
|
||||
| AC-6.1 | `Program.cs` reads `DATABASE_URL` (env or fallback) → `ConvertPostgresUrl` → Npgsql connection string | `Program.cs` `ConvertPostgresUrl` |
|
||||
| AC-6.2 | `Program.cs` reads `JWT_SECRET` (env or fallback) → `AddJwtAuth(jwt)` | `Program.cs` `AddJwtAuth` |
|
||||
| AC-6.1 | `Program.cs` resolves **four** required configuration values via `Infrastructure/ConfigurationResolver.cs` → `ResolveRequiredOrThrow`: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Resolution order per key is env-var-first, then `IConfiguration` config key (`Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`), else THROW `InvalidOperationException` at startup. **No hardcoded development fallbacks** — ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` |
|
||||
| AC-6.2 | `Program.cs` calls `AddJwtAuth(issuer, audience, jwksUrl)` (NOT `AddJwtAuth(secret)`). The legacy `JWT_SECRET` env var / config key is no longer consulted anywhere in the codebase. JWKS is fetched lazily on the first protected request via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever { RequireHttps = true }` | `Program.cs`, `Auth/JwtExtensions.cs` |
|
||||
| AC-6.3 | `DatabaseMigrator.Migrate` runs ONCE at startup, INSIDE a single startup scope (not per-request) | `Program.cs` `using var scope = app.Services.CreateScope(); ... DatabaseMigrator.Migrate(db)` |
|
||||
| AC-6.4 | Migrator runs `CREATE TABLE IF NOT EXISTS` for the 4 owned tables (`vehicles`, `missions`, `waypoints`, `map_objects`) and `CREATE INDEX IF NOT EXISTS` for 3 indexes | `Database/DatabaseMigrator.cs` |
|
||||
| AC-6.5 | Migrator runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` (B9 one-shot, post-B9 only) | `Database/DatabaseMigrator.cs` post-B9 |
|
||||
| AC-6.4 | Migrator runs `CREATE TABLE IF NOT EXISTS` for the 4 owned tables (`vehicles`, `missions`, `waypoints`, `map_objects`) using PostgreSQL `TIMESTAMP` (no timezone) for date columns, with explicit `REFERENCES` for FKs (`missions.vehicle_id → vehicles(id)`, `waypoints.mission_id → missions(id)`, `map_objects.waypoint_id → waypoints(id)`), and `CREATE INDEX IF NOT EXISTS` for 3 indexes | `Database/DatabaseMigrator.cs` |
|
||||
| AC-6.5 | Migrator runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` unconditionally (B9 one-shot kept idempotent indefinitely — re-running on a fresh DB is a no-op) | `Database/DatabaseMigrator.cs` |
|
||||
| AC-6.6 | Migrator is idempotent — every startup runs the same statements; `IF NOT EXISTS` makes them safe to re-run | `Database/DatabaseMigrator.cs` |
|
||||
| AC-6.7 | `postgres-local` unreachable at startup → process exits non-zero; Watchtower restarts the container; `flight-gate` prevents restart mid-mission | `Program.cs` (no DB error swallow); suite arch doc |
|
||||
| AC-6.8 | `azaion` database does not exist → process exits with Npgsql `3D000`; database creation is a provisioning concern, NOT this service | suite-level concern |
|
||||
| AC-6.9 | After migrator, `ErrorHandlingMiddleware` is registered FIRST in the pipeline — wraps every subsequent middleware exception | `Program.cs` middleware order |
|
||||
| AC-6.10 | Service serves on port 8080 inside the container (`EXPOSE 8080`); edge compose maps host `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
|
||||
| AC-6.11 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs` at startup: in `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) the host THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`; in non-Production environments the same empty allow-list falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
|
||||
| AC-6.12 | The JWKS HTTPS-only constraint (`HttpDocumentRetriever { RequireHttps = true }`) means a misconfigured `JWT_JWKS_URL = http://...` will pass startup config resolution (any non-empty string is accepted by `ResolveRequiredOrThrow`) but cause the first protected request to fail at JWKS-fetch time → 500. Detected only at runtime, not at startup | `Auth/JwtExtensions.cs` `HttpDocumentRetriever` |
|
||||
|
||||
## AC-7 — Health probe (F7)
|
||||
|
||||
@@ -111,7 +116,7 @@
|
||||
|
||||
| # | Criterion | Verification |
|
||||
|---|-----------|--------------|
|
||||
| AC-9.1 | One named policy `"FL"` is registered in `Auth/JwtExtensions.cs`; satisfied by a `permissions` claim equal to `"FL"` | `Auth/JwtExtensions.cs` |
|
||||
| AC-9.1 | One named policy `"FL"` is registered in `Auth/JwtExtensions.cs`; satisfied by a `permissions` claim **containing** `"FL"` (`AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` matches when ANY `permissions` claim value equals `"FL"`, so a multi-permission token `permissions: ["FL","SOMETHING_ELSE"]` is accepted) | `Auth/JwtExtensions.cs` `AddPolicy("FL")` |
|
||||
| AC-9.2 | The string `"FL"` is hardcoded in feature controllers — a typo silently turns into a permanent 403 (no compile-time check) | `Controllers/{Vehicles,Missions}Controller.cs`; `_docs/02_document/module-layout.md` § Verification Needed #4 |
|
||||
| AC-9.3 | The policy NAME `"FL"` retains the legacy "Flight" wording even after the service rename to `missions` — fleet-wide auth change deferred (NOT in this Epic) | `Auth/JwtExtensions.cs`; `../../suite/_docs/00_roles_permissions.md` TODO |
|
||||
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` — every protected endpoint has the same gate | `Controllers/{Vehicles,Missions}Controller.cs` |
|
||||
|
||||
Reference in New Issue
Block a user