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
+3
View File
@@ -26,3 +26,6 @@ Thumbs.db
appsettings.*.json appsettings.*.json
!appsettings.json !appsettings.json
!appsettings.Development.json !appsettings.Development.json
## Test results (produced by scripts/run-tests.sh and run-performance-tests.sh)
test-results/
+20
View File
@@ -13,6 +13,10 @@ public static class JwtExtensions
public const string JwtAudienceConfigKey = "Jwt:Audience"; public const string JwtAudienceConfigKey = "Jwt:Audience";
public const string JwtJwksUrlEnvVar = "JWT_JWKS_URL"; public const string JwtJwksUrlEnvVar = "JWT_JWKS_URL";
public const string JwtJwksUrlConfigKey = "Jwt:JwksUrl"; public const string JwtJwksUrlConfigKey = "Jwt:JwksUrl";
public const string JwtJwksAutoRefreshSecondsEnvVar = "JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS";
public const string JwtJwksAutoRefreshSecondsConfigKey = "Jwt:JwksAutoRefreshIntervalSeconds";
public const string JwtJwksRefreshSecondsEnvVar = "JWT_JWKS_REFRESH_INTERVAL_SECONDS";
public const string JwtJwksRefreshSecondsConfigKey = "Jwt:JwksRefreshIntervalSeconds";
public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration)
{ {
@@ -23,6 +27,17 @@ public static class JwtExtensions
var audience = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtAudienceEnvVar, JwtAudienceConfigKey, "JWT audience"); var audience = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtAudienceEnvVar, JwtAudienceConfigKey, "JWT audience");
var jwksUrl = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtJwksUrlEnvVar, JwtJwksUrlConfigKey, "JWKS URL"); var jwksUrl = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtJwksUrlEnvVar, JwtJwksUrlConfigKey, "JWKS URL");
// Optional interval overrides. Production leaves both unset and inherits
// the library defaults (AutomaticRefreshInterval = 12h, RefreshInterval =
// 5min). Tests set them to small values so JWKS rotation can be observed
// inside the CI wall-clock budget.
var autoRefreshSeconds = ConfigurationResolver.ResolveOptionalPositiveIntOrThrow(
configuration, JwtJwksAutoRefreshSecondsEnvVar, JwtJwksAutoRefreshSecondsConfigKey,
"JWKS automatic refresh interval (seconds)");
var refreshSeconds = ConfigurationResolver.ResolveOptionalPositiveIntOrThrow(
configuration, JwtJwksRefreshSecondsEnvVar, JwtJwksRefreshSecondsConfigKey,
"JWKS refresh interval (seconds)");
// JwtBearer's stock ConfigurationManager targets the full OIDC discovery // JwtBearer's stock ConfigurationManager targets the full OIDC discovery
// document; admin only exposes JWKS, so we wire a JWKS-only retriever. // document; admin only exposes JWKS, so we wire a JWKS-only retriever.
// The manager caches the document and refreshes on the default schedule // The manager caches the document and refreshes on the default schedule
@@ -32,6 +47,11 @@ public static class JwtExtensions
new JwksRetriever(), new JwksRetriever(),
new HttpDocumentRetriever { RequireHttps = true }); new HttpDocumentRetriever { RequireHttps = true });
if (autoRefreshSeconds is int autoSec)
jwksConfigManager.AutomaticRefreshInterval = TimeSpan.FromSeconds(autoSec);
if (refreshSeconds is int refreshSec)
jwksConfigManager.RefreshInterval = TimeSpan.FromSeconds(refreshSec);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
+3 -1
View File
@@ -10,5 +10,7 @@ ARG CI_COMMIT_SHA=unknown
ENV AZAION_REVISION=$CI_COMMIT_SHA ENV AZAION_REVISION=$CI_COMMIT_SHA
WORKDIR /app WORKDIR /app
COPY --from=build /app . COPY --from=build /app .
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["dotnet", "Azaion.Flights.dll"] ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]
+29
View File
@@ -24,4 +24,33 @@ public static class ConfigurationResolver
return value; return value;
} }
// Optional positive-integer override (e.g. JWKS refresh interval tuning for tests).
// Returns null when unset/whitespace so callers keep library defaults.
// Throws when set-but-unparseable or non-positive, so a typo can never silently
// weaken behavior.
public static int? ResolveOptionalPositiveIntOrThrow(
IConfiguration configuration,
string envVar,
string configKey,
string humanLabel)
{
ArgumentNullException.ThrowIfNull(configuration);
var raw = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrWhiteSpace(raw))
raw = configuration[configKey];
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
{
throw new InvalidOperationException(
$"{humanLabel} is set to '{raw}' which is not a positive integer. " +
$"Set {envVar} (or {configKey}) to a positive integer count of seconds, or unset it to use the library default.");
}
return parsed;
}
} }
+24 -19
View File
@@ -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.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.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.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.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-sensitively on `Name` and exactly on `IsDefault` | `VehicleService.GetVehicles` query expression | | 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.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.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` | | 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.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.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.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.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.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.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** ## AC-3 — Mission delete with cross-service cascade (F3) — **most critical**
@@ -50,7 +50,7 @@
| # | Criterion | Verification | | # | Criterion | Verification |
|---|-----------|--------------| |---|-----------|--------------|
| AC-4.1 | All routes are nested: `GET/POST/PUT/DELETE /missions/{missionId}/waypoints[/{wpId}]` | `MissionsController` route attributes | | 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.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.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` | | 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 | | # | Criterion | Verification |
|---|-----------|--------------| |---|-----------|--------------|
| AC-5.1 | Algorithm: HMAC-SHA256 (HS256) with `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | `Auth/JwtExtensions.cs` | | 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.FromMinutes(1)` (tighter than .NET's 5-minute default) | `Auth/JwtExtensions.cs` | | 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 = 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.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 → 401 | `JwtBearerHandler` | | AC-5.4 | Missing `Authorization` header on a `[Authorize]` route → 401 | `JwtBearerHandler` |
| AC-5.5 | Invalid signature → 401 | HMAC verify fails | | 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 1-min skew applied) → 401 | `ValidateLifetime` | | AC-5.6 | Expired token (with 30s skew applied) → 401 | `ValidateLifetime = true` |
| AC-5.7 | Token signed with old `JWT_SECRET` (rotation) → 401 across the entire device until coordinated re-deploy | shared-secret model | | 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, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`5_identity` description.md) | | AC-5.8 | Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`05_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.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) ## AC-6 — Service startup + schema migration (F6)
| # | Criterion | Verification | | # | Criterion | Verification |
|---|-----------|--------------| |---|-----------|--------------|
| AC-6.1 | `Program.cs` reads `DATABASE_URL` (env or fallback) → `ConvertPostgresUrl` → Npgsql connection string | `Program.cs` `ConvertPostgresUrl` | | 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` reads `JWT_SECRET` (env or fallback) → `AddJwtAuth(jwt)` | `Program.cs` `AddJwtAuth` | | 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.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.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;` (B9 one-shot, post-B9 only) | `Database/DatabaseMigrator.cs` post-B9 | | 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.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.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.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.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.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) ## AC-7 — Health probe (F7)
@@ -111,7 +116,7 @@
| # | Criterion | Verification | | # | 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.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.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` | | AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` — every protected endpoint has the same gate | `Controllers/{Vehicles,Missions}Controller.cs` |
+65 -55
View File
@@ -7,14 +7,21 @@
## 1. Configuration input (env vars) ## 1. Configuration input (env vars)
| Variable | Type | Required | Default (dev fallback) | Source order | Format / constraints | Used by | All four required values are resolved through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow(envName, configKey)`. Resolution order is **env var first**, then `IConfiguration` config key, else **throw `InvalidOperationException` at startup**. There are NO hardcoded dev fallbacks anymore.
|----------|------|----------|------------------------|--------------|----------------------|---------|
| `DATABASE_URL` | string | yes (production) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | Either `postgresql://user:pass@host:port/db` (converted via local helper `ConvertPostgresUrl`) OR a raw Npgsql connection string | `Program.cs` (DI registration of `AppDataConnection`) |
| `JWT_SECRET` | string | yes (production) | `development-secret-key-min-32-chars!!` | same as above | UTF-8 string, ≥32 chars (`SymmetricSecurityKey` accepts shorter but `JwtBearer` HS256 requires ≥32 bytes) | `Program.cs` `AddJwtAuth` |
| `AZAION_REVISION` | string | no | (build-time) | Dockerfile `ARG` baked from `CI_COMMIT_SHA` | git SHA | Dockerfile only; surfaced via `docker inspect` |
| `ASPNETCORE_URLS` | string | no | `http://+:8080` | ASP.NET Core convention | URL list | ASP.NET Core host |
**Important**: ADR-005 carry-forward — neither Swagger UI mounting nor the dev fallbacks for `JWT_SECRET` / `DATABASE_URL` are gated on `IsDevelopment()`. A production deploy without the env vars set will silently boot with the well-known dev secret; tracked at suite level (CMMC L2 row 3, AZ-487 / AZ-494). | Variable | Config key | Type | Required | Resolution | Format / constraints | Used by |
|----------|------------|------|----------|------------|----------------------|---------|
| `DATABASE_URL` | `Database:Url` | string | yes (always) | `ResolveRequiredOrThrow` | Either `postgresql://user:pass@host:port/db` (converted via local helper `ConvertPostgresUrl`; NO URL-decoding of user/password — credentials with `@`, `:`, `/`, `%` need raw Npgsql form) OR a raw Npgsql connection string | `Program.cs` (DI registration of `AppDataConnection`) |
| `JWT_ISSUER` | `Jwt:Issuer` | string | yes (always) | `ResolveRequiredOrThrow` | The expected `iss` claim value on every accepted JWT; usually the `admin` service's stable identifier | `Program.cs`, `Auth/JwtExtensions.cs``ValidIssuer` |
| `JWT_AUDIENCE` | `Jwt:Audience` | string | yes (always) | `ResolveRequiredOrThrow` | The expected `aud` claim value on every accepted JWT; usually the suite-wide audience identifier shared by all backend validators | `Program.cs`, `Auth/JwtExtensions.cs``ValidAudience` |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | string | yes (always) | `ResolveRequiredOrThrow` | **HTTPS URL** to `admin`'s JWKS endpoint. `HttpDocumentRetriever { RequireHttps = true }` rejects `http://` at fetch time (not at startup config resolution). Cached via `ConfigurationManager<JsonWebKeySet>`, refreshed on the default schedule | `Program.cs`, `Auth/JwtExtensions.cs` |
| `ASPNETCORE_ENVIRONMENT` | (built-in) | string | no | ASP.NET Core convention | Case-insensitive match on `Production` triggers the CORS strict gate in `CorsConfigurationValidator` | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| `CorsConfig:AllowedOrigins` | (config) | string list | conditionally required | `IConfiguration.GetSection("CorsConfig").Get<CorsConfig>()` | List of allowed origins. In `Production`, MUST be non-empty OR `AllowAnyOrigin=true`, else startup throws | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| `CorsConfig:AllowAnyOrigin` | (config) | bool | no | same | Opt-in to permissive CORS in production explicitly (use sparingly) | same |
| `AZAION_REVISION` | — | string | no | Dockerfile `ARG` baked from `CI_COMMIT_SHA` | git SHA | Dockerfile only; surfaced via `docker inspect` |
| `ASPNETCORE_URLS` | — | string | no | ASP.NET Core convention | URL list (default `http://+:8080`) | ASP.NET Core host |
**Important**: The legacy `JWT_SECRET` env var is no longer consulted. The ADR-005 "dev fallback secret silently accepted in production" failure mode is structurally eliminated; only the unconditional-Swagger branch of ADR-005 survives.
## 2. HTTP request DTOs (post-B6 shapes) ## 2. HTTP request DTOs (post-B6 shapes)
@@ -44,7 +51,7 @@ public class UpdateVehicleRequest { // all properties nullable
} }
public class GetVehiclesQuery { public class GetVehiclesQuery {
public string? Name { get; set; } // case-sensitive contains public string? Name { get; set; } // case-INSENSITIVE contains (LOWER(name) LIKE %lower(input)%)
public bool? IsDefault { get; set; } // exact match public bool? IsDefault { get; set; } // exact match
} }
@@ -70,11 +77,12 @@ public class UpdateMissionRequest { // partial update
} }
public class GetMissionsQuery { public class GetMissionsQuery {
public string? Name { get; set; } public string? Name { get; set; } // case-INSENSITIVE contains
public DateTime? FromDate { get; set; } public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; } public DateTime? ToDate { get; set; }
public int Page { get; set; } = 1; public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20; public int PageSize { get; set; } = 20;
// Results ordered by CreatedDate DESC (newest first).
} }
``` ```
@@ -112,65 +120,67 @@ public class UpdateWaypointRequest { // identical SHAPE to Creat
## 3. Persisted data — owned tables (post-B7+B9) ## 3. Persisted data — owned tables (post-B7+B9)
FKs in this section are **declared as DB-level `REFERENCES` constraints** in `DatabaseMigrator.cs`, not just logical. Date columns use PostgreSQL `TIMESTAMP` (no timezone, NOT `TIMESTAMPTZ`) — `DateTime.Kind` is normalized to `Unspecified` on read.
### 3.1 `vehicles` (owned) ### 3.1 `vehicles` (owned)
| Column | Type | Nullable | Notes | | Column | Type | Nullable | Default | Notes |
|--------|------|----------|-------| |--------|------|----------|---------|-------|
| `id` | UUID | NO | primary key | | `id` | UUID | NO | — | primary key |
| `type` | INTEGER | NO | `VehicleType` enum int (Plane / Copter / UGV / GuidedMissile) | | `type` | INTEGER | NO | `0` | `VehicleType` enum int (Plane / Copter / UGV / GuidedMissile) |
| `model` | TEXT | NO | | | `model` | TEXT | NO | — | |
| `name` | TEXT | NO | | | `name` | TEXT | NO | — | |
| `fuel_type` | INTEGER | NO | `FuelType` enum int | | `fuel_type` | INTEGER | NO | `0` | `FuelType` enum int |
| `battery_capacity` | NUMERIC | NO | | | `battery_capacity` | NUMERIC | NO | `0` | |
| `engine_consumption` | NUMERIC | NO | | | `engine_consumption` | NUMERIC | NO | `0` | |
| `engine_consumption_idle` | NUMERIC | NO | | | `engine_consumption_idle` | NUMERIC | NO | `0` | |
| `is_default` | BOOLEAN | NO | "exactly one default" enforced by `VehicleService` (stricter than spec — B12 decision) | | `is_default` | BOOLEAN | NO | `FALSE` | "exactly one default" enforced by `VehicleService` (stricter than spec — B12 decision) |
### 3.2 `missions` (owned) ### 3.2 `missions` (owned)
| Column | Type | Nullable | Notes | | Column | Type | Nullable | Default | Notes |
|--------|------|----------|-------| |--------|------|----------|---------|-------|
| `id` | UUID | NO | primary key | | `id` | UUID | NO | — | primary key |
| `created_date` | TIMESTAMPTZ | NO | server-assigned `UtcNow` if not supplied | | `created_date` | TIMESTAMP | NO | `NOW()` | server-assigned `UtcNow` if not supplied; `TIMESTAMP` (no timezone) |
| `name` | TEXT | NO | | | `name` | TEXT | NO | — | |
| `vehicle_id` | UUID | NO | logical FK to `vehicles.id`; existence-checked in service, no DB-level FK constraint declared in migrator | | `vehicle_id` | UUID | NO | — | `REFERENCES vehicles(id)` — DB-level FK; PostgreSQL error `23503` raised if parent vehicle was deleted between service-layer existence check and insert |
Index: `ix_missions_vehicle_id` on `vehicle_id`. Index: `ix_missions_vehicle_id` on `vehicle_id`.
### 3.3 `waypoints` (owned) ### 3.3 `waypoints` (owned)
| Column | Type | Nullable | Notes | | Column | Type | Nullable | Default | Notes |
|--------|------|----------|-------| |--------|------|----------|---------|-------|
| `id` | UUID | NO | primary key | | `id` | UUID | NO | — | primary key |
| `mission_id` | UUID | NO | logical FK to `missions.id` | | `mission_id` | UUID | NO | — | `REFERENCES missions(id)` — DB-level FK |
| `lat` | NUMERIC | YES | spec divergence — see § 2.3 | | `lat` | NUMERIC | YES | — | spec divergence — see § 2.3 |
| `lon` | NUMERIC | YES | spec divergence | | `lon` | NUMERIC | YES | — | spec divergence |
| `mgrs` | TEXT | YES | spec divergence | | `mgrs` | TEXT | YES | — | spec divergence |
| `waypoint_source` | INTEGER | NO | `WaypointSource` enum int | | `waypoint_source` | INTEGER | NO | `0` | `WaypointSource` enum int |
| `waypoint_objective` | INTEGER | NO | `WaypointObjective` enum int | | `waypoint_objective` | INTEGER | NO | `0` | `WaypointObjective` enum int |
| `order_num` | INTEGER | NO | listing order | | `order_num` | INTEGER | NO | `0` | listing order |
| `height` | NUMERIC | NO | metres | | `height` | NUMERIC | NO | `0` | metres |
Index: `ix_waypoints_mission_id` on `mission_id`. Index: `ix_waypoints_mission_id` on `mission_id`.
### 3.4 `map_objects` (owned schema; written by `autopilot`) ### 3.4 `map_objects` (owned schema; written by `autopilot`)
| Column | Type | Nullable | Notes | | Column | Type | Nullable | Default | Notes |
|--------|------|----------|-------| |--------|------|----------|---------|-------|
| `id` | UUID | NO | primary key | | `id` | UUID | NO | — | primary key |
| `mission_id` | UUID | NO | logical FK to `missions.id` | | `mission_id` | UUID | NO | — | `REFERENCES missions(id)` — DB-level FK |
| `h3_index` | TEXT | NO | Uber H3 hex grid cell | | `h3_index` | TEXT | NO | — | Uber H3 hex grid cell |
| `mgrs` | TEXT | NO | | | `mgrs` | TEXT | NO | — | |
| `lat` | NUMERIC | YES | | | `lat` | NUMERIC | YES | — | |
| `lon` | NUMERIC | YES | | | `lon` | NUMERIC | YES | — | |
| `class_num` | INTEGER | NO | detection class id | | `class_num` | INTEGER | NO | `0` | detection class id |
| `label` | TEXT | NO | | | `label` | TEXT | NO | `''` | |
| `size_width_m` | NUMERIC | NO | | | `size_width_m` | NUMERIC | NO | `0` | |
| `size_length_m` | NUMERIC | NO | | | `size_length_m` | NUMERIC | NO | `0` | |
| `confidence` | NUMERIC | NO | 0..1 | | `confidence` | NUMERIC | NO | `0` | 0..1 |
| `object_status` | INTEGER | NO | `ObjectStatus` enum int | | `object_status` | INTEGER | NO | `0` | `ObjectStatus` enum int |
| `first_seen_at` | TIMESTAMPTZ | NO | | | `first_seen_at` | TIMESTAMP | NO | `NOW()` | `TIMESTAMP` (no timezone) |
| `last_seen_at` | TIMESTAMPTZ | NO | | | `last_seen_at` | TIMESTAMP | NO | `NOW()` | `TIMESTAMP` (no timezone) |
Index: `ix_map_objects_mission_id` on `mission_id`. Index: `ix_map_objects_mission_id` on `mission_id`.
@@ -221,13 +231,13 @@ Per `_docs/02_document/modules/enums.md`, integer values are NOT range-validated
| Endpoint | Method | Body / Query | Returns | | Endpoint | Method | Body / Query | Returns |
|----------|--------|--------------|---------| |----------|--------|--------------|---------|
| `/vehicles` | GET | `?name=&isDefault=` | `List<Vehicle>` (PascalCase JSON; not paginated) | | `/vehicles` | GET | `?name=&isDefault=` | `List<Vehicle>` (PascalCase JSON; not paginated; ordered by `Name` ASC; `name` filter is case-INSENSITIVE) |
| `/vehicles/{id}` | GET | — | `Vehicle` | | `/vehicles/{id}` | GET | — | `Vehicle` |
| `/vehicles` | POST | `CreateVehicleRequest` | `Vehicle` (created) | | `/vehicles` | POST | `CreateVehicleRequest` | `Vehicle` (created) |
| `/vehicles/{id}` | PUT | `UpdateVehicleRequest` (partial) | `Vehicle` (updated) | | `/vehicles/{id}` | PUT | `UpdateVehicleRequest` (partial) | `Vehicle` (updated) |
| `/vehicles/{id}/setDefault` | POST | `SetDefaultRequest` | `Vehicle` | | `/vehicles/{id}/setDefault` | POST | `SetDefaultRequest` | `Vehicle` |
| `/vehicles/{id}` | DELETE | — | 204 / 409 if referenced | | `/vehicles/{id}` | DELETE | — | 204 / 409 if referenced |
| `/missions` | GET | `?name=&fromDate=&toDate=&page=&pageSize=` | `PaginatedResponse<Mission>` | | `/missions` | GET | `?name=&fromDate=&toDate=&page=&pageSize=` | `PaginatedResponse<Mission>` (ordered by `CreatedDate` DESC; `name` filter case-INSENSITIVE) |
| `/missions/{id}` | GET | — | `Mission` | | `/missions/{id}` | GET | — | `Mission` |
| `/missions` | POST | `CreateMissionRequest` | `Mission` (created) | | `/missions` | POST | `CreateMissionRequest` | `Mission` (created) |
| `/missions/{id}` | PUT | `UpdateMissionRequest` (partial) | `Mission` (updated) | | `/missions/{id}` | PUT | `UpdateMissionRequest` (partial) | `Mission` (updated) |
@@ -49,9 +49,9 @@
| 1.2 | Same as 1.1 but `IsDefault:true` against a DB containing one prior `vehicles` row with `is_default=true` | Create default — must demote prior default first (AC-1.2) | `status_code: 201`; new row has `IsDefault:true`; the prior default row now has `is_default=false`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true == 1` | exact (status), db_query (count, prior row state) | N/A | N/A | | 1.2 | Same as 1.1 but `IsDefault:true` against a DB containing one prior `vehicles` row with `is_default=true` | Create default — must demote prior default first (AC-1.2) | `status_code: 201`; new row has `IsDefault:true`; the prior default row now has `is_default=false`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true == 1` | exact (status), db_query (count, prior row state) | N/A | N/A |
| 1.3 | Same as 1.2 but inject a concurrent `INSERT vehicles (..., is_default=true)` between the service's `UPDATE … SET is_default=FALSE` and its `INSERT` | TOCTOU race window (AC-1.4) | `status_code: 201`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true >= 2` is observable in at least one race interleaving | db_query (count) | N/A | N/A | | 1.3 | Same as 1.2 but inject a concurrent `INSERT vehicles (..., is_default=true)` between the service's `UPDATE … SET is_default=FALSE` and its `INSERT` | TOCTOU race window (AC-1.4) | `status_code: 201`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true >= 2` is observable in at least one race interleaving | db_query (count) | N/A | N/A |
| 1.4 | `POST /vehicles/{id}/setDefault` body `{ IsDefault:true }` against `id` of a non-default row | Promote existing vehicle to default (AC-1.2) | `status_code: 200`; body `Vehicle` with `IsDefault:true`; previous default has `is_default=false`; default count == 1 | exact (status, body), db_query (count) | N/A | N/A | | 1.4 | `POST /vehicles/{id}/setDefault` body `{ IsDefault:true }` against `id` of a non-default row | Promote existing vehicle to default (AC-1.2) | `status_code: 200`; body `Vehicle` with `IsDefault:true`; previous default has `is_default=false`; default count == 1 | exact (status, body), db_query (count) | N/A | N/A |
| 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows | List all vehicles (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names | exact (status, length), schema (array, not paginated), exact (case) | N/A | N/A | | 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows (`BR-01`, `BR-02`, `MQ-9` inserted in any order) | List all vehicles ordered by Name ASC (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) | exact (status, length, ordering), schema (array, not paginated), exact (case) | N/A | N/A |
| 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]` | Filter by name substring + is_default (AC-1.6) | `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A | | 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]`; also `?name=br` (lowercase) | Case-INSENSITIVE substring filter + exact `is_default` (AC-1.6) | both queries: `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
| 1.7 | `GET /vehicles?name=br` against DB with `"BR-01"` only | Case-sensitive name filter (AC-1.6) | `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A | | 1.7 | `GET /vehicles?name=ZZ` (substring absent from all names); also `?name=zz` (lowercase) | No-match path of case-INSENSITIVE filter (AC-1.6) | both queries: `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A |
| 1.8 | `GET /vehicles/{id}` with `id` not in DB | Vehicle not found (AC-1.7) | `status_code: 404`; body matches `{ statusCode:404, message: <non-empty string> }` (camelCase by accidental match per AC-8.2) | exact (status), schema (envelope shape), exact (case) | N/A | N/A | | 1.8 | `GET /vehicles/{id}` with `id` not in DB | Vehicle not found (AC-1.7) | `status_code: 404`; body matches `{ statusCode:404, message: <non-empty string> }` (camelCase by accidental match per AC-8.2) | exact (status), schema (envelope shape), exact (case) | N/A | N/A |
| 1.9 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | Vehicle in use → 409 (AC-1.8) | `status_code: 409`; body envelope `{ statusCode:409, message:<non-empty> }`; `db.vehicles WHERE id={id}` still exists (count==1) | exact (status, envelope shape), db_query | N/A | N/A | | 1.9 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | Vehicle in use → 409 (AC-1.8) | `status_code: 409`; body envelope `{ statusCode:409, message:<non-empty> }`; `db.vehicles WHERE id={id}` still exists (count==1) | exact (status, envelope shape), db_query | N/A | N/A |
| 1.10 | `DELETE /vehicles/{id}` against vehicle referenced by 0 missions | Vehicle deletable | `status_code: 204`; `db.vehicles WHERE id={id}` count == 0 | exact (status), db_query | N/A | N/A | | 1.10 | `DELETE /vehicles/{id}` against vehicle referenced by 0 missions | Vehicle deletable | `status_code: 204`; `db.vehicles WHERE id={id}` count == 0 | exact (status), db_query | N/A | N/A |
@@ -64,7 +64,7 @@
|---|-------|-------------------|-----------------|------------|-----------|---------------| |---|-------|-------------------|-----------------|------------|-----------|---------------|
| 2.1 | `POST /missions` body `{ Name:"Recon-01", VehicleId:<existing>, CreatedDate:null }`, JWT `FL` | Create mission with default created date (AC-2.1) | `status_code: 201`; body `Mission` with server-assigned `Id`, `CreatedDate` set to a UTC timestamp within `now ± 5s`; `Name == "Recon-01"`; `VehicleId` echoes input | exact (status, fields), numeric_tolerance (CreatedDate ± 5s) | ±5s | N/A | | 2.1 | `POST /missions` body `{ Name:"Recon-01", VehicleId:<existing>, CreatedDate:null }`, JWT `FL` | Create mission with default created date (AC-2.1) | `status_code: 201`; body `Mission` with server-assigned `Id`, `CreatedDate` set to a UTC timestamp within `now ± 5s`; `Name == "Recon-01"`; `VehicleId` echoes input | exact (status, fields), numeric_tolerance (CreatedDate ± 5s) | ±5s | N/A |
| 2.2 | `POST /missions` body `{ Name:"Recon-02", VehicleId:<random uuid>, CreatedDate:null }` | Vehicle not found (AC-2.2) | `status_code: 400` (today via `ArgumentException`; spec wants 404 — divergence carry-forward) | exact (status) | N/A | N/A | | 2.2 | `POST /missions` body `{ Name:"Recon-02", VehicleId:<random uuid>, CreatedDate:null }` | Vehicle not found (AC-2.2) | `status_code: 400` (today via `ArgumentException`; spec wants 404 — divergence carry-forward) | exact (status) | N/A | N/A |
| 2.3 | `GET /missions` no query, DB has 25 missions | Default pagination (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20` | schema, exact (counts) | N/A | N/A | | 2.3 | `GET /missions` no query, DB has 25 missions with deterministic `CreatedDate` values | Default pagination ordered by `CreatedDate` DESC (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC); `?name=re` (lowercase) against missions named `"Recon-*"` returns `TotalCount > 0` (case-INSENSITIVE) | schema, exact (counts, ordering, case-insensitive match) | N/A | N/A |
| 2.4 | `GET /missions?page=2&pageSize=20` against same 25-row DB | Second page | `body.Page == 2`; `body.PageSize == 20`; `body.Items.length == 5` | exact (counts) | N/A | N/A | | 2.4 | `GET /missions?page=2&pageSize=20` against same 25-row DB | Second page | `body.Page == 2`; `body.PageSize == 20`; `body.Items.length == 5` | exact (counts) | N/A | N/A |
| 2.5 | `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` against DB with 3 January missions and 2 February missions | Date range filter | `body.TotalCount == 3`; `body.Items.length == 3` | exact (counts) | N/A | N/A | | 2.5 | `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` against DB with 3 January missions and 2 February missions | Date range filter | `body.TotalCount == 3`; `body.Items.length == 3` | exact (counts) | N/A | N/A |
| 2.6 | `GET /missions/{id}` with `id` not in DB | Not found (AC-2.4) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A | | 2.6 | `GET /missions/{id}` with `id` not in DB | Not found (AC-2.4) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
@@ -100,18 +100,23 @@ Test data fixtures live in `expected_results/fixture_cascade_F3.sql` (seed scrip
### AC-5 — JWT bearer validation ### AC-5 — JWT bearer validation
JWT fixtures use `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256, claims include `permissions=FL` unless noted. JWT fixtures are obtained via `POST https://jwks-mock:8443/sign { ... }` — the mock signs with its current ECDSA-P-256 private key, publishes the matching public key in its JWKS at `https://jwks-mock:8443/.well-known/jwks.json`. `missions` is configured with `JWT_ISSUER=https://admin-test.azaion.local`, `JWT_AUDIENCE=azaion-edge`, `JWT_JWKS_URL=https://jwks-mock:8443/.well-known/jwks.json`. Default claims include `permissions=FL` unless noted.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File | | # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------| |---|-------|-------------------|-----------------|------------|-----------|---------------|
| 5.1 | `GET /vehicles` without `Authorization` header | Missing token (AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A | | 5.1 | `GET /vehicles` without `Authorization` header | Missing token (AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2 | `GET /vehicles` with `Authorization: Bearer <token signed by different secret>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A | | 5.2 | `GET /vehicles` with `Authorization: Bearer <token whose signature byte was flipped>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.3 | `GET /vehicles` with token where `exp` is `now - 120s` (outside 1-min skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A | | 5.2b | `GET /vehicles` with token signed by an ECDSA keypair NOT present in the published JWKS | No matching public key (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.4 | `GET /vehicles` with token where `exp` is `now - 30s` (inside 1-min skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A | | 5.3 | `GET /vehicles` with token where `exp = now - 60s` (outside 30s skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.5 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A | | 5.4 | `GET /vehicles` with token where `exp = now - 15s` (inside 30s skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
| 5.6 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions == "ADMIN"` | Wrong claim value | `status_code: 403` | exact (status) | N/A | N/A | | 5.5 | `GET /vehicles` with valid signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with no `iss` and no `aud` claim, otherwise valid | `ValidateIssuer/ValidateAudience = false` (AC-5.3) | `status_code: 200` | exact (status) | N/A | N/A | | 5.6 | `GET /vehicles` with valid signature + lifetime, `permissions == "ADMIN"`; also `"fl"`, `"FLight"` | Wrong claim value (AC-9.2) | each: `status_code: 403` | exact (status) | N/A | N/A |
| 5.8 | Restart service with rotated `JWT_SECRET`, replay a previously valid token | Cross-rotation invalidation (AC-5.7) | `status_code: 401` | exact (status) | N/A | N/A | | 5.6b | `GET /vehicles` with valid signature + lifetime, `permissions: ["FL", "ADMIN"]` (multi-permission array) | Contains-match policy accepts (AC-9.1) | `status_code: 200` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with token where `iss = "https://attacker.example.com"`, otherwise valid | Wrong issuer (AC-5.11) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.7b | `GET /vehicles` with token where `aud = "wrong-audience"`, otherwise valid | Wrong audience (AC-5.12) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.8 | JWKS key rotation: `POST jwks-mock:8443/rotate-key`, immediately replay a token signed with the old `kid` AFTER the JWKS cache refresh tick (≤ 90s) and AFTER `OldKeyGraceSeconds=5` elapses | Cross-rotation invalidation (AC-5.7) — **no missions restart** required | `status_code: 401`; `missions` container's `StartedAt` timestamp unchanged | exact (status, startup timestamp) | N/A | N/A |
| 5.9 | `GET /vehicles` with token forged using `alg: HS256` against the JWKS public key bytes (HS256-confusion attack) | Algorithm pin defense (AC-5.1, AC-5.10) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.10 | Cold start: stop `jwks-mock`, restart `missions`, immediately `GET /vehicles` with a valid (pre-stop-acquired) token | Cold-start dependency on admin reachability (AC-5.9) | `status_code: 500`; log contains JWKS fetch error mentioning HTTPS / connection refused / timeout | exact (status), log_assertion (substring) | N/A | N/A |
### AC-6 — Service startup + schema migration ### AC-6 — Service startup + schema migration
@@ -119,8 +124,10 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File | | # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------| |---|-------|-------------------|-----------------|------------|-----------|---------------|
| 6.1 | Start service with `DATABASE_URL=postgresql://u:p@h:5432/d` (URL form) | URL conversion (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a connection error | log_assertion (no error), exact (health 200) | N/A | N/A | | 6.1 | Start service with **all four required env vars set correctly** (`DATABASE_URL=postgresql://u:p@h:5432/d` URL form, plus `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) | URL conversion + fail-fast config resolution (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a config/connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form) | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A | | 6.1b | Start service with any one of `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` unset | Fail-fast on missing required config (AC-6.1, AC-6.2, E3) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` referencing the missing env var or config key | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.1c | Start service with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS); other three set correctly | HTTPS-only JWKS retriever (AC-6.12) | container STARTS (config resolution passes); first protected request returns `status_code: 500` with log line mentioning `RequireHttps` / HTTPS | exact (start ok, first-request 500), log_assertion | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form), other three set | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A |
| 6.3 | Start service against an empty `azaion` database, inspect schema after startup | Migrator creates 4 owned tables + 3 indexes (AC-6.4) | `SELECT to_regclass(t)` returns non-NULL for each of `vehicles, missions, waypoints, map_objects`; index list contains `ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id` | set_equals (table set), set_contains (index set) | N/A | N/A | | 6.3 | Start service against an empty `azaion` database, inspect schema after startup | Migrator creates 4 owned tables + 3 indexes (AC-6.4) | `SELECT to_regclass(t)` returns non-NULL for each of `vehicles, missions, waypoints, map_objects`; index list contains `ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id` | set_equals (table set), set_contains (index set) | N/A | N/A |
| 6.4 | Start service twice in a row against the same DB | Idempotency (AC-6.6) | second startup completes with same exit code as first; no `relation already exists` error in logs | exact (exit code), log_assertion (no error) | N/A | N/A | | 6.4 | Start service twice in a row against the same DB | Idempotency (AC-6.6) | second startup completes with same exit code as first; no `relation already exists` error in logs | exact (exit code), log_assertion (no error) | N/A | N/A |
| 6.5 | Pre-create `orthophotos` and `gps_corrections` tables, then start a post-B9 service | One-shot legacy drop (AC-6.5, AC-10.5) | both tables are absent after startup; `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` both return NULL | exact | N/A | N/A | | 6.5 | Pre-create `orthophotos` and `gps_corrections` tables, then start a post-B9 service | One-shot legacy drop (AC-6.5, AC-10.5) | both tables are absent after startup; `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` both return NULL | exact | N/A | N/A |
@@ -128,6 +135,10 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| 6.7 | Start service against a postgres instance where the `azaion` database does NOT exist | DB missing (AC-6.8) | process exits with non-zero exit code; logger emits message containing Npgsql `3D000` | exact (non-zero), log_assertion (substring `3D000`) | N/A | N/A | | 6.7 | Start service against a postgres instance where the `azaion` database does NOT exist | DB missing (AC-6.8) | process exits with non-zero exit code; logger emits message containing Npgsql `3D000` | exact (non-zero), log_assertion (substring `3D000`) | N/A | N/A |
| 6.8 | Make any handler throw `InvalidOperationException`, observe response | `ErrorHandlingMiddleware` registered FIRST (AC-6.9) | response: `status_code: 409`; envelope is the camelCase `{ statusCode, message }`; logger captured stack | exact (status, envelope), log_assertion | N/A | N/A | | 6.8 | Make any handler throw `InvalidOperationException`, observe response | `ErrorHandlingMiddleware` registered FIRST (AC-6.9) | response: `status_code: 409`; envelope is the camelCase `{ statusCode, message }`; logger captured stack | exact (status, envelope), log_assertion | N/A | N/A |
| 6.9 | Start service, run `curl localhost:8080` from inside container | Listens on port 8080 (AC-6.10) | TCP connect succeeds; `/health` returns `200` | exact | N/A | N/A | | 6.9 | Start service, run `curl localhost:8080` from inside container | Listens on port 8080 (AC-6.10) | TCP connect succeeds; `/health` returns `200` | exact | N/A | N/A |
| 6.10 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, empty `CorsConfig:AllowedOrigins`, `CorsConfig:AllowAnyOrigin != true` | CORS Production-gate fail-fast (AC-6.11, E9) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` mentioning `CorsConfig` and `Production` | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.11 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowAnyOrigin=true` | Production with explicit any-origin (AC-6.11, E9) | service starts; logs may include a warning about permissive CORS in Production but no throw | log_assertion (no throw) | N/A | N/A |
| 6.12 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowedOrigins=["https://operator.example.com"]` | Production with explicit allow-list (AC-6.11, E9) | service starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo; preflight from `https://attacker.example.com` returns without the echo | exact (preflight echo present / absent) | N/A | N/A |
| 6.13 | Start service with `ASPNETCORE_ENVIRONMENT=Test` (or any non-Production), empty `CorsConfig:AllowedOrigins` | Non-Production permissive fallback (AC-6.11, E9) | service starts; logs contain `PermissiveDefaultWarning`; `OPTIONS /vehicles` from any origin gets `200` with echo | log_assertion (warning), exact (preflight) | N/A | N/A |
### AC-7 — Health probe ### AC-7 — Health probe
@@ -154,8 +165,8 @@ Bootstrap fixtures use a Postgres container started fresh per scenario.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File | | # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------| |---|-------|-------------------|-----------------|------------|-----------|---------------|
| 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` | Policy "FL" satisfies (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403) | exact (status set) | N/A | N/A | | 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` (single string OR array containing `"FL"`) | Policy "FL" satisfies via contains-match (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403); a token with `permissions: ["FL", "ADMIN"]` is ALSO accepted | exact (status set) | N/A | N/A |
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A | | 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` or `"ADMIN"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A |
| 9.3 | `GET /health` with NO `Authorization` header | Health is exempt (AC-9.4 contrast) | `status_code: 200` | exact (status) | N/A | N/A | | 9.3 | `GET /health` with NO `Authorization` header | Health is exempt (AC-9.4 contrast) | `status_code: 200` | exact (status) | N/A | N/A |
### AC-10 — Operational invariants (verifiable observables) ### AC-10 — Operational invariants (verifiable observables)
+5 -5
View File
@@ -10,7 +10,7 @@
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment: it is the local source of truth for the operator's vehicle inventory, mission plans, and ordered waypoints, and it is the orchestrator of the cross-service cascade-delete that keeps the rest of the device's edge stack consistent when missions or waypoints are removed. `missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment: it is the local source of truth for the operator's vehicle inventory, mission plans, and ordered waypoints, and it is the orchestrator of the cross-service cascade-delete that keeps the rest of the device's edge stack consistent when missions or waypoints are removed.
It is one of ~6 backend services running side-by-side **on each customer device** (Jetson Orin / OrangePI / operator-PC). All edge services share **one local PostgreSQL** on the device; each migrates only the tables it owns. JWTs are minted by the central `admin` service and validated locally with a shared HMAC secret — `missions` never calls `admin` back. It is one of ~6 backend services running side-by-side **on each customer device** (Jetson Orin / OrangePI / operator-PC). All edge services share **one local PostgreSQL** on the device; each migrates only the tables it owns. JWTs are minted by the central `admin` service using ECDSA-SHA256 and validated locally against `admin`'s JWKS, which `missions` fetches once at startup (and refreshes on schedule) and caches; request-path validation is local and does not call back.
## What problem does it solve? ## What problem does it solve?
@@ -20,7 +20,7 @@ When a human operator plans, runs, and tears down missions on the edge:
2. **Mission planning** — the operator must define a named mission against a chosen vehicle and lay out an ordered set of waypoints (lat/lon or MGRS, with per-waypoint source / objective / height). 2. **Mission planning** — the operator must define a named mission against a chosen vehicle and lay out an ordered set of waypoints (lat/lon or MGRS, with per-waypoint source / objective / height).
3. **Mission lifecycle** — when a mission or waypoint is deleted, every downstream artefact (media uploaded by `annotations`, AI annotations, AI detections, autopilot-emitted `map_objects`) MUST be cleaned up in FK-safe order so the local DB doesn't accumulate orphans. 3. **Mission lifecycle** — when a mission or waypoint is deleted, every downstream artefact (media uploaded by `annotations`, AI annotations, AI detections, autopilot-emitted `map_objects`) MUST be cleaned up in FK-safe order so the local DB doesn't accumulate orphans.
4. **Cross-service cohesion** — sibling services (`autopilot`, `annotations`, detection pipeline, `gps-denied`, `ui`) must be able to read mission/waypoint data without round-trips to the central admin and without their own copies. The shared local DB is that contract. 4. **Cross-service cohesion** — sibling services (`autopilot`, `annotations`, detection pipeline, `gps-denied`, `ui`) must be able to read mission/waypoint data without round-trips to the central admin and without their own copies. The shared local DB is that contract.
5. **Local trust** — the operator's device may be intermittently offline from the central network. Authn/authz cannot depend on a live `admin` callback; tokens validate locally with a shared secret. 5. **Local trust** — the operator's device may be intermittently offline from the central network. Once the JWKS has been cached, authn/authz does not depend on a live `admin` callback; tokens validate locally against the cached ECDSA public keys. `admin` must be reachable at the moment of the first JWKS fetch after a cold start (then again periodically on the manager's refresh schedule).
## Who are the users? ## Who are the users?
@@ -31,7 +31,7 @@ When a human operator plans, runs, and tears down missions on the edge:
| **`annotations`** | Sibling edge service (owns `media` / `annotations` table schemas) | Indirect — `missions` cascade-deletes from `media` / `annotations` when missions/waypoints are removed | | **`annotations`** | Sibling edge service (owns `media` / `annotations` table schemas) | Indirect — `missions` cascade-deletes from `media` / `annotations` when missions/waypoints are removed |
| **Detection pipeline** | Sibling edge service (owns the `detection` table schema) | Indirect — same pattern as `annotations` | | **Detection pipeline** | Sibling edge service (owns the `detection` table schema) | Indirect — same pattern as `annotations` |
| **`gps-denied`** *(post-B7)* | Sibling edge service (owns `orthophotos` + `gps_corrections`) | None at runtime — references `mission_id` / `waypoint_id` as plain GUIDs in its own tables; manages its own cleanup | | **`gps-denied`** *(post-B7)* | Sibling edge service (owns `orthophotos` + `gps_corrections`) | None at runtime — references `mission_id` / `waypoint_id` as plain GUIDs in its own tables; manages its own cleanup |
| **`admin`** | Central .NET service (token issuer) | One-way: issues JWTs that this service validates locally. `admin` outages do NOT take this service down (until tokens expire) | | **`admin`** | Central .NET service (token issuer + JWKS publisher) | This service fetches admin's public JWKS once at startup and on the `ConfigurationManager` refresh schedule; request-path validation does not call back. `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 |
There are **no application-level admin / superuser roles inside this service** — every protected endpoint is gated by the single `"FL"` permission. The role → permission matrix is suite-level (`../../suite/_docs/00_roles_permissions.md`). There are **no application-level admin / superuser roles inside this service** — every protected endpoint is gated by the single `"FL"` permission. The role → permission matrix is suite-level (`../../suite/_docs/00_roles_permissions.md`).
@@ -55,13 +55,13 @@ Seven flows make up the runtime surface (`_docs/02_document/system-flows.md`):
- **F2** Mission create / read / update - **F2** Mission create / read / update
- **F3** Mission delete + **cross-service cascade** (the most critical flow; not transaction-wrapped today — ADR-006) - **F3** Mission delete + **cross-service cascade** (the most critical flow; not transaction-wrapped today — ADR-006)
- **F4** Waypoint create / read / update / delete (delete is a scoped F3) - **F4** Waypoint create / read / update / delete (delete is a scoped F3)
- **F5** JWT bearer validation (cross-cutting; local HS256 only) - **F5** JWT bearer validation (cross-cutting; local ECDSA-SHA256 against admin's cached JWKS; `iss` + `aud` validated; `alg` pinned)
- **F6** Service startup + idempotent schema migration - **F6** Service startup + idempotent schema migration
- **F7** Anonymous `GET /health` probe - **F7** Anonymous `GET /health` probe
## Cross-cutting contracts owned here ## Cross-cutting contracts owned here
1. **JWT validation contract** — trust admin-issued tokens via shared HMAC secret (`JWT_SECRET`); reject everything else with `401`/`403`. 1. **JWT validation contract** — trust admin-issued tokens via ECDSA-SHA256 signature verification against admin's published JWKS (cached locally), with `iss == JWT_ISSUER` + `aud == JWT_AUDIENCE` + `exp` (30s skew) all enforced and `alg` pinned to `EcdsaSha256`; reject everything else with `401`/`403`.
2. **Mission ownership graph & cascade-delete** — only place in the system that knows `mission → {map_objects, waypoints → media → annotations → detection}`. 2. **Mission ownership graph & cascade-delete** — only place in the system that knows `mission → {map_objects, waypoints → media → annotations → detection}`.
3. **Suite-standard wire shapes** *(currently divergent — see `_docs/02_document/architecture.md` ADR-002)* — error envelope and `PaginatedResponse<T>` are shared with `annotations`, `admin`, `satellite-provider`. 3. **Suite-standard wire shapes** *(currently divergent — see `_docs/02_document/architecture.md` ADR-002)* — error envelope and `PaginatedResponse<T>` are shared with `annotations`, `admin`, `satellite-provider`.
+8 -8
View File
@@ -40,16 +40,16 @@
| # | Restriction | Evidence | | # | Restriction | Evidence |
|---|-------------|----------| |---|-------------|----------|
| E1 | Two required env vars at runtime: `DATABASE_URL`, `JWT_SECRET` | `Program.cs` `Environment.GetEnvironmentVariable` | | E1 | Four required env vars at runtime: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Each resolved via `Infrastructure/ConfigurationResolver.cs` `ResolveRequiredOrThrow` (env-first, then `IConfiguration` config key `Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`, else throw at startup). The legacy `JWT_SECRET` env var is no longer consulted | `Program.cs`, `Auth/JwtExtensions.cs`, `Infrastructure/ConfigurationResolver.cs` |
| E2 | `DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL OR a raw Npgsql connection string (local helper `ConvertPostgresUrl`) | `Program.cs` `ConvertPostgresUrl` | | E2 | `DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL OR a raw Npgsql connection string (local helper `ConvertPostgresUrl`). `ConvertPostgresUrl` does NOT URL-decode user/password — credentials with `@`, `:`, `/`, `%` need raw Npgsql form | `Program.cs` `ConvertPostgresUrl` |
| E3 | Hardcoded development fallbacks: `JWT_SECRET=development-secret-key-min-32-chars!!`, `DATABASE_URL=Host=localhost;Database=azaion;Username=postgres;Password=changeme` (NOT gated on `IsDevelopment()`) — ADR-005 carry-forward; production deploys MUST override | `Program.cs` | | E3 | **No hardcoded development fallbacks.** `ResolveRequiredOrThrow` throws `InvalidOperationException` at startup if any of `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` is missing or whitespace-only. ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Infrastructure/ConfigurationResolver.cs`; `Program.cs` |
| E4 | `JWT_SECRET` is shared across `admin` + every backend service on the same edge device — rotation requires a coordinated re-deploy across all of them | `_docs/02_document/components/05_identity/description.md`; suite arch doc | | E4 | JWT signature validation is asymmetric (ECDSA-SHA256) against the JWKS at `JWT_JWKS_URL`. `admin` holds the private key; this service caches the public JWKS via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` (fetched at startup, refreshed on default schedule, HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }`). **JWKS rotation does NOT require a coordinated redeploy** — consumers pick up the new keys at the next refresh tick | `Auth/JwtExtensions.cs`; `_docs/02_document/components/05_identity/description.md` |
| E5 | Container `EXPOSE 8080`; edge compose maps host port `5002:8080` | `Dockerfile`; suite `_infra/_compose/` | | E5 | Container `EXPOSE 8080`; edge compose maps host port `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
| E6 | Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` post-B10 (was `azaion/flights:*-arm` pre-B10) | `.woodpecker/build-arm.yml` (post-B10) | | E6 | Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` post-B10 (was `azaion/flights:*-arm` pre-B10) | `.woodpecker/build-arm.yml` (post-B10) |
| E7 | Entrypoint: `dotnet Azaion.Missions.dll` post-B5 (was `Azaion.Flights.dll` pre-B5) | `Dockerfile` (post-B5) | | E7 | Entrypoint: `dotnet Azaion.Missions.dll` post-B5 (was `Azaion.Flights.dll` pre-B5) | `Dockerfile` (post-B5) |
| E8 | No environment-specific overrides in `appsettings.*.json` — single config flow via env vars | `Program.cs`; no `appsettings.Production.json` in repo | | E8 | No environment-specific overrides in `appsettings.*.json` today, but `IConfiguration` lookups (e.g. `Database:Url`, `Jwt:Issuer`) are wired so adding `appsettings.*.json` later requires no code changes | `Program.cs`; no `appsettings.*.json` in repo |
| E9 | CORS: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in **all** environments (assumed safe behind suite reverse proxy) | `Program.cs` | | E9 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs`. In `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) startup THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. In non-Production environments, an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. The "all environments permissive" claim no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| E10 | TLS termination is the suite reverse proxy's responsibility — container exposes plain HTTP on `:8080` | `Dockerfile`; suite arch doc | | E10 | TLS termination is the suite reverse proxy's responsibility — container exposes plain HTTP on `:8080`. The JWKS fetch itself is independently constrained to HTTPS (`RequireHttps = true`) | `Dockerfile`; suite arch doc; `Auth/JwtExtensions.cs` |
## Operational restrictions ## Operational restrictions
@@ -79,5 +79,5 @@
| Orthophoto / live-GPS / GPS correction | `gps-denied` (separate service after B7) | ADR-007 | | Orthophoto / live-GPS / GPS correction | `gps-denied` (separate service after B7) | ADR-007 |
| TLS / HTTPS termination | Suite reverse proxy | `_docs/02_document/architecture.md` § 7 | | TLS / HTTPS termination | Suite reverse proxy | `_docs/02_document/architecture.md` § 7 |
| Schema rename / column drop / type change | Future migration tool (ADR-004 carry-forward) | Today's `IF NOT EXISTS` migrator can't reshape existing schema; B9's `DROP TABLE IF EXISTS` is the single explicit destructive step | | Schema rename / column drop / type change | Future migration tool (ADR-004 carry-forward) | Today's `IF NOT EXISTS` migrator can't reshape existing schema; B9's `DROP TABLE IF EXISTS` is the single explicit destructive step |
| `iss` / `aud` JWT validation | Suite-level remediation (CMMC L2 row 3, AZ-487 / AZ-494) | Out of this Epic; consistent with shared-secret model today | | `iss` / `aud` JWT validation | **Now implemented in this service's code** (`ValidateIssuer=true` against `JWT_ISSUER`, `ValidateAudience=true` against `JWT_AUDIENCE`). The CMMC L2 row 3 finding is structurally fixed here; suite-level docs may still describe the legacy model and have a separate sync task pending | n/a — no longer a carry-forward in this repo |
| camelCase wire-shape migration | Suite-wide cutover (ADR-002 carry-forward) | All-or-nothing; UI + autopilot consume PascalCase today | | camelCase wire-shape migration | Suite-wide cutover (ADR-002 carry-forward) | All-or-nothing; UI + autopilot consume PascalCase today |
+47 -27
View File
@@ -7,36 +7,45 @@
## 1. Authentication ## 1. Authentication
**Mechanism**: JWT bearer (HS256) with **local validation only** — this service never calls back to the issuing `admin` service. **Mechanism**: JWT bearer (**ECDSA-SHA256**) with public-key validation against `admin`'s JWKS endpoint. After the first successful JWKS fetch, request-path validation is purely local (no per-request call to `admin`).
**Trust model**: a single shared HMAC secret (`JWT_SECRET`) is provisioned to `admin` (issuer) and to every backend service on each edge device (validators). Rotation requires a coordinated re-deploy across all of them. **Trust model**: `admin` holds the ECDSA **private** key; every backend service on each edge device validates with the corresponding **public** keys, fetched from `admin`'s JWKS endpoint (`JWT_JWKS_URL`). Rotation publishes a new `kid` in the JWKS; consumers pick it up at the next refresh tick — **NO coordinated redeploy** required (one of the primary operational wins over the legacy HS256 model).
**Validation parameters** (`Auth/JwtExtensions.cs`): **Validation parameters** (`Auth/JwtExtensions.cs`):
| Parameter | Value | Notes | | Parameter | Value | Notes |
|-----------|-------|-------| |-----------|-------|-------|
| Algorithm | HS256 (`SymmetricSecurityKey(UTF-8(JWT_SECRET))`) | Symmetric → asymmetric switch is suite-wide concern, not in this Epic | | `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` | Algorithm pin — defends against HS256-confusion attacks (an attacker who learns the JWKS public key cannot forge tokens signed with `alg: HS256` using that key as the HMAC secret) |
| `IssuerSigningKeyResolver` | Pulls keys from cached `JsonWebKeySet` retrieved via `ConfigurationManager<JsonWebKeySet>` | Lazily fetches on first protected request; cache refreshes on the manager's default schedule matched against admin's `Cache-Control: public, max-age=3600` |
| JWKS HTTP transport | `HttpDocumentRetriever { RequireHttps = true }` | HTTPS-only — a misconfigured `JWT_JWKS_URL = http://...` fails at fetch time, not at startup config resolution |
| `ValidateLifetime` | `true` | Tokens with `exp` in the past are rejected | | `ValidateLifetime` | `true` | Tokens with `exp` in the past are rejected |
| `ClockSkew` | `TimeSpan.FromMinutes(1)` | Tighter than .NET's 5-min default | | `ClockSkew` | `TimeSpan.FromSeconds(30)` | Tighter than .NET's 5-min default AND tighter than the legacy 1-min setting |
| `ValidateIssuer` | **`false`** | Known CMMC L2 finding (suite-tracked AZ-487/AZ-494); consistent with shared-secret trust | | `ValidateIssuer` | **`true`** with `ValidIssuer = <resolved JWT_ISSUER>` | **CMMC L2 row 3 finding structurally fixed in this service's code.** Suite-level docs may still describe the legacy "disabled" model and have a separate sync task pending |
| `ValidateAudience` | **`false`** | Same finding as above | | `ValidateAudience` | **`true`** with `ValidAudience = <resolved JWT_AUDIENCE>` | Same as above |
| `ValidateIssuerSigningKey` | `true` | Always required when `ValidateLifetime`/`ValidateIssuer` are set explicitly | | `ValidateIssuerSigningKey` | `true` (implicit via `IssuerSigningKeyResolver`) | Required for asymmetric validation |
**Failure outcomes**: **Failure outcomes**:
| Condition | HTTP code | | Condition | HTTP code |
|-----------|-----------| |-----------|-----------|
| Missing `Authorization` header | 401 | | Missing `Authorization` header on `[Authorize]` route | 401 |
| Invalid signature | 401 | | Invalid signature (no public key in cached JWKS verifies the token) | 401 |
| Expired token (with 1-min skew) | 401 | | Token `kid` not in cached JWKS (rotation lag before refresh tick) | 401 (resolved on next JWKS refresh) |
| Token signed with old `JWT_SECRET` after rotation | 401 (until coordinated re-deploy + re-login) | | Token `alg``[EcdsaSha256]` (e.g. forged `alg: HS256`) | 401 (algorithm pin) |
| Valid signature + lifetime, but missing `permissions=FL` claim | 403 | | Expired token (with 30s skew) | 401 |
| `iss` claim ≠ `JWT_ISSUER` | 401 |
| `aud` claim ≠ `JWT_AUDIENCE` | 401 |
| Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim | 403 |
| `JWT_JWKS_URL` uses `http://` (not `https://`) | 500 on first protected request (HTTPS-only retriever throws); NOT caught at startup |
| `admin` unreachable AT the time of the first protected request after cold start | 500 on that first request (synchronous JWKS fetch fails); resolves once admin is reachable |
**`admin` outage**: tokens issued before the outage continue to validate locally. This service does **not** require `admin` to be reachable for any flow. Once issued tokens expire, new logins fail at `admin`'s end (UI concern), but this service stays up. **`admin` outage AFTER JWKS cached**: tokens issued before the outage continue to validate locally against the cached public keys. This service does **not** require `admin` to be reachable for any request-path flow once the JWKS cache is warm. Once issued tokens expire, new logins fail at `admin`'s end (UI concern), but this service stays up until the cache itself expires and a refresh fails.
**`admin` outage AT cold start**: the first protected request triggers a synchronous JWKS HTTPS GET; if `admin` is unreachable at that moment, the request fails 500. This is a **new failure mode** introduced by the ECDSA-JWKS switch and is the cost of the rotation-without-redeploy operational win.
## 2. Authorization ## 2. Authorization
**Single named policy**: `"FL"`. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy is satisfied by a `permissions` claim with value `"FL"`. **Single named policy**: `"FL"`. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy is built as `AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` satisfied when ANY `permissions` claim on the principal equals `"FL"`. A multi-permission token (`permissions: ["FL", "SOMETHING_ELSE"]`) is accepted.
**Role → permission matrix** is suite-level (`../../suite/_docs/00_roles_permissions.md`); this service does NOT enforce roles, only the `FL` permission. **Role → permission matrix** is suite-level (`../../suite/_docs/00_roles_permissions.md`); this service does NOT enforce roles, only the `FL` permission.
@@ -59,9 +68,9 @@
**Secrets management**: **Secrets management**:
- `JWT_SECRET` and `DATABASE_URL` are env vars (with hardcoded dev fallbacks). See § 6 below. - This service no longer holds a JWT signing secret. It holds **only** public-key configuration (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) plus `DATABASE_URL`. Whichever side gets compromised, that compromise no longer affects token signing.
- No secret manager (Vault, AWS SM, K8s Secrets) — secrets are baked into the device's docker compose env at provisioning time. - All four required values (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) are resolved through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow` (env-var-first, then `IConfiguration` key, else THROW). **No hardcoded dev fallbacks** — the ADR-005 "dev fallback secret silently accepted in production" failure mode is structurally eliminated. ADR-005 now only covers the unconditional-Swagger branch.
- No runtime gate prevents startup with the dev fallback in production (ADR-005 carry-forward). - No secret manager (Vault, AWS SM, K8s Secrets) — config values are baked into the device's docker compose env at provisioning time.
## 4. Input validation ## 4. Input validation
@@ -80,9 +89,13 @@ This is a **carry-forward concern** — input-shape testing is not a security ga
## 5. CORS ## 5. CORS
**Open in every environment**: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in `Program.cs`. Spec does not mandate a CORS policy. **Gated by `Infrastructure/CorsConfigurationValidator.cs`** at startup:
The closed edge network behind the suite reverse proxy is the deployment-shape mitigation. Worth confirming on the **first production rollout** that the upstream proxy whitelists origins; if not, this is a finding to surface. - In `Production` (case-insensitive match on `ASPNETCORE_ENVIRONMENT`): startup **THROWS `InvalidOperationException`** when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. The "open in all environments" failure mode is structurally eliminated.
- In non-Production environments: the same empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds.
- An explicit `AllowedOrigins` list narrows CORS to those origins in every environment.
The closed edge network behind the suite reverse proxy is still the deployment-shape backstop, but the application now refuses to start in production without an explicit policy decision.
## 6. Production-deploy footguns ## 6. Production-deploy footguns
@@ -90,17 +103,23 @@ These are explicit security-relevant risks the code carries today, all tracked a
| Footgun | Where | Mitigation | | Footgun | Where | Mitigation |
|---------|-------|------------| |---------|-------|------------|
| **Dev fallback for `JWT_SECRET`** silently accepted in production if env var unset | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Suite-level remediation pending; recommend "fail-fast at startup if `JWT_SECRET` is unset OR equals the well-known dev fallback" | | **Cold-start dependency on `admin` reachability**: first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL`. If `admin` is unreachable at that moment, the request fails 500. Once cached, request-path is local-only | `Auth/JwtExtensions.cs` `ConfigurationManager<JsonWebKeySet>` | Document operational expectation; consider pre-warming the JWKS cache during startup if cold-start failure modes become disruptive |
| **Dev fallback for `DATABASE_URL`** silently accepted in production if env var unset | `Program.cs` | Same pattern as `JWT_SECRET`; misconfigured deploy hits localhost Postgres on the device, which usually doesn't exist → process exits, but the failure mode is loud (crash) not silent | | **`JWT_JWKS_URL` misconfigured as `http://`** passes startup config resolution but fails at first JWKS fetch → 500 | `Auth/JwtExtensions.cs` `HttpDocumentRetriever { RequireHttps = true }` | Detected at runtime, not startup; recommend a startup smoke check that validates the URL scheme before serving traffic |
| **Swagger UI mounted unconditionally** | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Reverse-proxy-level allowlist on `/swagger` is the suite-level mitigation; verify on first production rollout | | **Swagger UI mounted unconditionally** | `Program.cs` (no `IsDevelopment` gate, ADR-005 surviving branch) | Reverse-proxy-level allowlist on `/swagger` is the suite-level mitigation; verify on first production rollout |
| **CORS `AllowAnyOrigin/Method/Header`** in production | `Program.cs` | Reverse-proxy origin whitelist is the suite-level mitigation | | **CORS allow-list empty in non-Production** falls back to permissive (`AllowAnyOrigin/Method/Header`) with a startup warning | `Infrastructure/CorsConfigurationValidator.cs` | Document explicit `CorsConfig:AllowedOrigins` for staging/dev too if permissive is undesirable. **Production fails-fast** — no remediation needed there |
| **No HTTPS redirection** | `Program.cs` (no `app.UseHttpsRedirection()`) | Reverse proxy enforces TLS upstream | | **No HTTPS redirection** | `Program.cs` (no `app.UseHttpsRedirection()`) | Reverse proxy enforces TLS upstream |
| **Stack trace logged for unhandled 500s** | `Middleware/ErrorHandlingMiddleware.cs` `LogError(ex, ...)` | Stack is logged only — NOT returned in the HTTP response body (the 500 body is the generic `"Internal server error"` message from middleware) | | **Stack trace logged for unhandled 500s** | `Middleware/ErrorHandlingMiddleware.cs` `LogError(ex, ...)` | Stack is logged only — NOT returned in the HTTP response body (the 500 body is the generic `"Internal server error"` message from middleware) |
| **JWT `iss`/`aud` validation disabled** | `Auth/JwtExtensions.cs` | CMMC L2 row 3 finding; tracked at suite level under AZ-487 / AZ-494 |
| **Cascade-delete is NOT transaction-wrapped** (data-integrity, not auth) | `Services/MissionService.cs`, `Services/WaypointService.cs` (ADR-006) | One-line fix queued; recommended to land with B6 | | **Cascade-delete is NOT transaction-wrapped** (data-integrity, not auth) | `Services/MissionService.cs`, `Services/WaypointService.cs` (ADR-006) | One-line fix queued; recommended to land with B6 |
| **Hardcoded permission string `"FL"`** in feature controllers | `Controllers/{Vehicles,Missions}Controller.cs` | Risk: typo silently turns into permanent 403; mitigation by code review + `module-layout.md` | | **Hardcoded permission string `"FL"`** in feature controllers | `Controllers/{Vehicles,Missions}Controller.cs` | Risk: typo silently turns into permanent 403; mitigation by code review + `module-layout.md` |
| **Permission code `"FL"` retains legacy "Flight" wording** post-rename | `Auth/JwtExtensions.cs` | Fleet-wide auth change deferred (not in this Epic); TODO in `../../suite/_docs/00_roles_permissions.md` | | **Permission code `"FL"` retains legacy "Flight" wording** post-rename | `Auth/JwtExtensions.cs` | Fleet-wide auth change deferred (not in this Epic); TODO in `../../suite/_docs/00_roles_permissions.md` |
**Removed from this list** (previously listed, now structurally fixed by code, not by mitigation):
- ❌ Dev fallback for `JWT_SECRET``JWT_SECRET` env var is no longer consulted; the resolver throws on missing required config.
- ❌ Dev fallback for `DATABASE_URL` — same resolver throws on missing required config.
- ❌ CORS `AllowAnyOrigin/Method/Header` in production — production startup throws on empty allow-list with `AllowAnyOrigin != true`.
- ❌ JWT `iss`/`aud` validation disabled — both are now validated; CMMC L2 row 3 finding structurally fixed in this service's code.
## 7. Audit logging ## 7. Audit logging
**None at the application level today.** The only structured log emitted by application code is `_logger.LogError(ex, "Unhandled exception")` in `ErrorHandlingMiddleware` for 500s. There is: **None at the application level today.** The only structured log emitted by application code is `_logger.LogError(ex, "Unhandled exception")` in `ErrorHandlingMiddleware` for 500s. There is:
@@ -114,7 +133,7 @@ Production incident response on this service today requires grep-by-timestamp co
## 8. Threat model summary (one-paragraph) ## 8. Threat model summary (one-paragraph)
The deployment shape — closed edge network, single operator per device, suite reverse proxy enforcing TLS and origin allowlisting upstream, Watchtower restart on crash — is the **primary defence-in-depth layer** for everything not handled by HS256 JWT validation and the `FL` permission gate. The known weak points (dev fallbacks not gated on `IsDevelopment()`, no input validation, no application-level audit log, `iss`/`aud` not validated) are documented and tracked, with the most critical ones (CMMC L2 row 3, default-vehicle race) under suite-level or B-ticket Jira IDs. This Epic (rename + GPS-Denied removal) does **not** change the security posture; it preserves every current invariant. The deployment shape — closed edge network, single operator per device, suite reverse proxy enforcing TLS and origin allowlisting upstream, Watchtower restart on crash — remains the primary defence-in-depth layer, but the application-layer auth posture has materially improved: ECDSA-SHA256 JWT validation against `admin`'s JWKS (with algorithm pinning, `iss`/`aud` validation, HTTPS-only JWKS fetch, and a 30s clock skew), production-gated CORS, and fail-fast required-config resolution have collectively eliminated the dev-fallback, iss/aud-disabled, and CORS-permissive-in-prod footguns that the legacy HS256 model carried. Residual application-level weak points (no input validation, no per-user audit, hardcoded `"FL"` string, cold-start dependency on `admin` reachability for the first protected request, non-transactional cascade delete) are documented and tracked. The CMMC L2 row 3 finding is structurally closed in this service's code; suite-level documentation may still describe the legacy posture and has a separate sync task pending.
## 9. References ## 9. References
@@ -123,10 +142,11 @@ The deployment shape — closed edge network, single operator per device, suite
| Auth registration | `Auth/JwtExtensions.cs` | | Auth registration | `Auth/JwtExtensions.cs` |
| Authorization attribute usage | `Controllers/AircraftsController.cs` (post-B6: `VehiclesController.cs`), `Controllers/FlightsController.cs` (post-B6: `MissionsController.cs`) | | Authorization attribute usage | `Controllers/AircraftsController.cs` (post-B6: `VehiclesController.cs`), `Controllers/FlightsController.cs` (post-B6: `MissionsController.cs`) |
| Error envelope (no stack-leak) | `Middleware/ErrorHandlingMiddleware.cs` | | Error envelope (no stack-leak) | `Middleware/ErrorHandlingMiddleware.cs` |
| Env var resolution + dev fallbacks | `Program.cs` | | Env / config resolution (fail-fast) | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` |
| CORS validation | `Infrastructure/CorsConfigurationValidator.cs` |
| CMMC L2 scorecard | `../../suite/_docs/05_security/cmmc_l2_scorecard.md` | | CMMC L2 scorecard | `../../suite/_docs/05_security/cmmc_l2_scorecard.md` |
| Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` | | Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` |
| ADR-005 (Swagger + dev fallbacks) | `_docs/02_document/architecture.md` § 8 | | ADR-005 (Swagger unconditional, surviving branch) | `_docs/02_document/architecture.md` § 8 |
| ADR-002 (PascalCase wire shape) | `_docs/02_document/architecture.md` § 8 | | ADR-002 (PascalCase wire shape) | `_docs/02_document/architecture.md` § 8 |
| Component identity description | `_docs/02_document/components/05_identity/description.md` | | Component identity description | `_docs/02_document/components/05_identity/description.md` |
| Component http-conventions description | `_docs/02_document/components/06_http_conventions/description.md` | | Component http-conventions description | `_docs/02_document/components/06_http_conventions/description.md` |
+9 -7
View File
@@ -10,7 +10,7 @@
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment — vehicle inventory (Plane / Copter / UGV / GuidedMissile), mission plans, ordered waypoints, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. `missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment — vehicle inventory (Plane / Copter / UGV / GuidedMissile), mission plans, ordered waypoints, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed.
**Runtime topology**: exactly one container per device (Jetson Orin / OrangePI / operator-PC), co-located with `annotations`, the detection pipeline, `autopilot`, `gps-denied`, and the React `ui`. All edge services share **one local PostgreSQL** on the device; each migrates and writes only the tables it owns. JWTs are minted by the central `admin` service and validated locally with a shared HMAC secret — `missions` never calls back. **Runtime topology**: exactly one container per device (Jetson Orin / OrangePI / operator-PC), co-located with `annotations`, the detection pipeline, `autopilot`, `gps-denied`, and the React `ui`. All edge services share **one local PostgreSQL** on the device; each migrates and writes only the tables it owns. JWTs are minted by the central `admin` service (ECDSA-signed) and validated locally by `missions` against `admin`'s JWKS endpoint — request-path validation is local after the JWKS is cached, but the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `admin`. Key rotation publishes a new `kid` in `admin`'s JWKS and propagates to validators on the cache-refresh tick (no coordinated redeploy).
### Component interaction (high-level) ### Component interaction (high-level)
@@ -37,7 +37,7 @@ flowchart LR
ui -- "REST + JWT" --> c01 ui -- "REST + JWT" --> c01
ui -- "REST + JWT" --> c02 ui -- "REST + JWT" --> c02
admin -. "shared HMAC secret (token only)" .-> i05 admin -- "JWKS over HTTPS (lazy fetch + refresh)" --> i05
c01 --> p04 c01 --> p04
c02 --> p04 c02 --> p04
c02 -. "cross-service cascade delete" .-> annotations c02 -. "cross-service cascade delete" .-> annotations
@@ -70,9 +70,9 @@ The dominant pattern is **thin ASP.NET Core controller → service class → lin
| 01 | `01_vehicle_catalog` | Vehicle CRUD + `is_default` exclusivity. Controller `[Authorize(Policy="FL")]``VehicleService``ITable<Vehicle>` | ASP.NET Core, linq2db `ITable<Vehicle>`, `[Authorize]` | Single owner of the inventory abstraction; same exact pattern as `02_mission_planning` so engineers context-switch cheaply | "Exactly one default" is enforced by clear-then-set without a transaction → race window (B12 decision pending); no input validation on `Name`/`BatteryCapacity` (carry-forward) | Spec § 6.1 (Vehicle Catalog), suite roles `FL` | `[Authorize(Policy="FL")]` on every action; no per-method authz | One service file + one controller (~190 LoC together) | **Good** — matches operator-paced load, vertical scale only | | 01 | `01_vehicle_catalog` | Vehicle CRUD + `is_default` exclusivity. Controller `[Authorize(Policy="FL")]``VehicleService``ITable<Vehicle>` | ASP.NET Core, linq2db `ITable<Vehicle>`, `[Authorize]` | Single owner of the inventory abstraction; same exact pattern as `02_mission_planning` so engineers context-switch cheaply | "Exactly one default" is enforced by clear-then-set without a transaction → race window (B12 decision pending); no input validation on `Name`/`BatteryCapacity` (carry-forward) | Spec § 6.1 (Vehicle Catalog), suite roles `FL` | `[Authorize(Policy="FL")]` on every action; no per-method authz | One service file + one controller (~190 LoC together) | **Good** — matches operator-paced load, vertical scale only |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + the **cross-service cascade-delete walk**. Existence-checks `vehicle_id` on create/update; paginates `GET /missions` (the only paginated endpoint). | ASP.NET Core, linq2db, `PaginatedResponse<T>` (`06_http_conventions`) | One canonical place that knows the full mission ownership graph; cascade walks `map_objects → media → annotations → detection → waypoints → missions` in FK order | **Cascade is NOT transaction-wrapped** (ADR-006) → partial failure leaves orphans; `UpdateWaypoint` is a full overwrite even though DTO looks partial; `vehicle_id` missing returns `400` (spec wants `404`); LinqToDB does not eager-load `[Association]` so `Vehicle` and `Waypoints` serialize null/empty | Spec § 6.2 (Mission Planning + Waypoints), spec § cascade contract | `[Authorize(Policy="FL")]` on every action; **no audit log**, no correlation id | Two service files + one controller (~370 LoC together); sequential I/O (47 round-trips per cascade) — single-digit ms typical against local Postgres | **Acceptable today; will need transaction wrap (one-line) before SLO commitments** | | 02 | `02_mission_planning` | Mission + Waypoint CRUD + the **cross-service cascade-delete walk**. Existence-checks `vehicle_id` on create/update; paginates `GET /missions` (the only paginated endpoint). | ASP.NET Core, linq2db, `PaginatedResponse<T>` (`06_http_conventions`) | One canonical place that knows the full mission ownership graph; cascade walks `map_objects → media → annotations → detection → waypoints → missions` in FK order | **Cascade is NOT transaction-wrapped** (ADR-006) → partial failure leaves orphans; `UpdateWaypoint` is a full overwrite even though DTO looks partial; `vehicle_id` missing returns `400` (spec wants `404`); LinqToDB does not eager-load `[Association]` so `Vehicle` and `Waypoints` serialize null/empty | Spec § 6.2 (Mission Planning + Waypoints), spec § cascade contract | `[Authorize(Policy="FL")]` on every action; **no audit log**, no correlation id | Two service files + one controller (~370 LoC together); sequential I/O (47 round-trips per cascade) — single-digit ms typical against local Postgres | **Acceptable today; will need transaction wrap (one-line) before SLO commitments** |
| 04 | `04_persistence` | `AppDataConnection : DataConnection` exposes `ITable<T>` for every persisted entity (4 owned post-B7+B9 + 3 borrowed read-only stubs). `DatabaseMigrator` runs `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` at startup; B9 adds a one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded devices | linq2db 6.2.0, Npgsql 10.0.2, raw `Execute` for DDL | Lightweight; no migration tool dependency; idempotent every restart; `ITable<T>` lets cross-component reads/cascades stay typed | No schema versioning; column drops / type changes need manual SQL or a future migration tool; no connection-pool tuning beyond Npgsql defaults | Spec § database schema, suite ER diagram (post-B7) | DB credentials are env-driven (`DATABASE_URL`); no column-level encryption; relies on PG-level access control | One file for the connection (~70 LoC) + one for the migrator (~120 LoC post-B9) | **Good for current schema scale (4 owned tables)**; will become limiting when schema starts evolving frequently | | 04 | `04_persistence` | `AppDataConnection : DataConnection` exposes `ITable<T>` for every persisted entity (4 owned post-B7+B9 + 3 borrowed read-only stubs). `DatabaseMigrator` runs `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` at startup; B9 adds a one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded devices | linq2db 6.2.0, Npgsql 10.0.2, raw `Execute` for DDL | Lightweight; no migration tool dependency; idempotent every restart; `ITable<T>` lets cross-component reads/cascades stay typed | No schema versioning; column drops / type changes need manual SQL or a future migration tool; no connection-pool tuning beyond Npgsql defaults | Spec § database schema, suite ER diagram (post-B7) | DB credentials are env-driven (`DATABASE_URL`); no column-level encryption; relies on PG-level access control | One file for the connection (~70 LoC) + one for the migrator (~120 LoC post-B9) | **Good for current schema scale (4 owned tables)**; will become limiting when schema starts evolving frequently |
| 05 | `05_identity` | `JwtExtensions.AddJwtAuth` registers `JwtBearer` with HMAC-SHA256 + the named policy `"FL"` (1-min clock skew). Validation is local; this service never calls `admin` | `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5, `SymmetricSecurityKey` | `admin` outage does NOT take this service down (until tokens expire); zero-trip auth = lowest possible auth latency | `iss` / `aud` validation **disabled** (CMMC L2 row 3, AZ-487 / AZ-494 — suite-tracked, NOT in this Epic); the policy code `"FL"` retains the legacy "Flight" wording even after the service rename (fleet-wide auth change deferred); user-id claim is parsed but **not consumed** anywhere (no per-user audit) | Spec § auth, `../../suite/_docs/00_roles_permissions.md` | Shared HMAC secret (`JWT_SECRET`); rotation requires coordinated re-deploy across every backend that shares the secret | One file (~60 LoC) | **Good for the deployment shape (closed edge network behind a reverse proxy)**; `iss`/`aud` gap is a documented and tracked finding | | 05 | `05_identity` | `JwtExtensions.AddJwtAuth(issuer, audience, jwksUrl)` registers `JwtBearer` with **ECDSA-SHA256** (algorithm pin), iss + aud validation, `ClockSkew = 30s`, and the named policy `"FL"`. Signing keys are pulled from `admin`'s JWKS via `ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever { RequireHttps = true }` | `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5, `Microsoft.IdentityModel.Protocols`, `JsonWebKeySet` | `admin` outage AFTER the JWKS is cached does NOT take this service down; key rotation publishes a new `kid` and propagates on the refresh tick — **no coordinated redeploy**; iss + aud + alg-pin closes the CMMC L2 row 3 finding in this service's code | First protected request after a cold start triggers a synchronous JWKS fetch → if `admin` is unreachable at that exact moment the request 500s (new failure mode vs the legacy local-only model); the policy code `"FL"` retains the legacy "Flight" wording (fleet-wide auth change deferred); user-id claim is parsed but **not consumed** anywhere (no per-user audit) | Spec § auth, `../../suite/_docs/00_roles_permissions.md` | Asymmetric: `admin` holds the private key; this service holds only public-key configuration + the `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` env vars (no shared secret on this side anymore) | One file (~80 LoC) | **Good**; the cold-start dependency on `admin` reachability is the cost of the rotation-without-redeploy operational win |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` (global exception → JSON envelope) + `PaginatedResponse<T>` + the **dead** `ErrorResponse` DTO | ASP.NET Core middleware, `System.Text.Json` (defaults) | Single chokepoint for HTTP wire shape — error mapping is uniform across components | **Two divergences from the suite spec carry forward** (ADR-002): entity/DTO bodies are PascalCase (no `JsonNamingPolicy.CamelCase`); error envelope misses spec's `errors` field. The error envelope IS already camelCase by accidental match (anonymous-object literal). The `ErrorResponse` DTO is dead on the wire and has the wrong shape (`List<string>?` instead of spec's `object?` keyed by field name) | Spec § Error Response Format, § Pagination | `LogError(ex, ...)` only — no PII redaction (none in payload today); fallback `500` body shows the generic message, NOT the stack trace (logged only) | One middleware file + two DTO files (~80 LoC together) | **Acceptable until the suite-wide camelCase migration**; cutover is all-or-nothing because UI + autopilot consume PascalCase today | | 06 | `06_http_conventions` | `ErrorHandlingMiddleware` (global exception → JSON envelope) + `PaginatedResponse<T>` + the **dead** `ErrorResponse` DTO | ASP.NET Core middleware, `System.Text.Json` (defaults) | Single chokepoint for HTTP wire shape — error mapping is uniform across components | **Two divergences from the suite spec carry forward** (ADR-002): entity/DTO bodies are PascalCase (no `JsonNamingPolicy.CamelCase`); error envelope misses spec's `errors` field. The error envelope IS already camelCase by accidental match (anonymous-object literal). The `ErrorResponse` DTO is dead on the wire and has the wrong shape (`List<string>?` instead of spec's `object?` keyed by field name) | Spec § Error Response Format, § Pagination | `LogError(ex, ...)` only — no PII redaction (none in payload today); fallback `500` body shows the generic message, NOT the stack trace (logged only) | One middleware file + two DTO files (~80 LoC together) | **Acceptable until the suite-wide camelCase migration**; cutover is all-or-nothing because UI + autopilot consume PascalCase today |
| 07 | `07_host` | `Program.cs` composition root: env → connection string adapter, JWT registration, scoped DI for `AppDataConnection` + service classes, run migrator at startup, mount middleware in correct order, `MapGet("/health")`, mount Swagger | ASP.NET Core minimal host APIs | One file you can read top-to-bottom in one sitting; environment-fallback adapter (`ConvertPostgresUrl`) makes `dotnet run` zero-config in dev | **Swagger UI + dev fallbacks are NOT gated on `IsDevelopment()`** (ADR-005) a misconfigured production deploy silently boots with `JWT_SECRET=development-secret-key-min-32-chars!!`; CORS is `AllowAnyOrigin/Method/Header` in every environment (assumed safe behind suite reverse proxy) | Spec § service composition; container `EXPOSE 8080`; Watchtower restart contract | Hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL` (security finding tracked at suite level) | One file (~150 LoC) | **Acceptable in the closed edge environment**; the unconditional Swagger + dev fallbacks are debt that should be paid when the service moves to a less trusted network | | 07 | `07_host` | `Program.cs` composition root: resolve four required config values via `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`); env → Npgsql connection string adapter (`ConvertPostgresUrl`); JWT registration; scoped DI for `AppDataConnection` + service classes; run migrator at startup; `CorsConfigurationValidator.EnsureSafeForEnvironment` gating CORS; mount middleware in correct order; `MapGet("/health")`; mount Swagger | ASP.NET Core minimal host APIs, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs` | One file you can read top-to-bottom in one sitting; **fail-fast on missing required config** — no silent boot with insecure defaults; **CORS gated by environment** — Production refuses an empty allow-list unless `AllowAnyOrigin=true` | Swagger UI is still NOT gated on `IsDevelopment()` (surviving branch of ADR-005); a misconfigured `JWT_JWKS_URL = http://...` passes config resolution but fails at first JWKS fetch (detected at runtime, not startup) | Spec § service composition; container `EXPOSE 8080`; Watchtower restart contract | Required config is loud-fail (`InvalidOperationException`) on absence; **no hardcoded dev fallbacks anywhere**. Swagger surviving branch remains a tracked carry-forward | One file (~180 LoC) plus the two `Infrastructure/*.cs` helpers (~70 LoC together) | **Good** the security posture is materially improved over the pre-2026-05 state |
### 2.2 Cross-cutting design choices ### 2.2 Cross-cutting design choices
@@ -81,7 +81,7 @@ The dominant pattern is **thin ASP.NET Core controller → service class → lin
| One PostgreSQL per device, shared by all edge services (ADR-001) | 6× operational overhead saved per device; cross-service cascade is physically possible in one DB connection | **Implemented** | | One PostgreSQL per device, shared by all edge services (ADR-001) | 6× operational overhead saved per device; cross-service cascade is physically possible in one DB connection | **Implemented** |
| Manual cascade-delete in code, NOT `ON DELETE CASCADE` (ADR-003) | Schema-level cascade would couple `annotations` / detection schemas to this service's lifecycle | **Implemented** (transaction-wrap missing — ADR-006 carry-forward) | | Manual cascade-delete in code, NOT `ON DELETE CASCADE` (ADR-003) | Schema-level cascade would couple `annotations` / detection schemas to this service's lifecycle | **Implemented** (transaction-wrap missing — ADR-006 carry-forward) |
| `CREATE TABLE IF NOT EXISTS` schema bootstrap (ADR-004), no migration tool | 4-table schema; no column drops or type changes; restart-driven deploy via Watchtower | **Implemented** (B9 adds the one explicit `DROP TABLE IF EXISTS` block for fielded devices) | | `CREATE TABLE IF NOT EXISTS` schema bootstrap (ADR-004), no migration tool | 4-table schema; no column drops or type changes; restart-driven deploy via Watchtower | **Implemented** (B9 adds the one explicit `DROP TABLE IF EXISTS` block for fielded devices) |
| Local JWT validation, no callback to `admin` (ADR-005, F5) | Zero auth-related coupling at runtime; `admin` outage doesn't take this service down | **Implemented** (`iss`/`aud` validation disabled — suite-tracked) | | JWT validation against `admin` JWKS, request-path local after cache (ADR-005, F5) | Asymmetric trust + rotation-without-redeploy; closes the CMMC L2 iss/aud finding in this service's code while keeping `admin` off the per-request hot path | **Implemented** (ECDSA-SHA256 with algorithm pinning, iss + aud validation, HTTPS-only JWKS retrieval, cold-start synchronous fetch trade-off documented) |
| One csproj, one root namespace (ADR-008); layering by convention not by compiler | Service is small enough that 6 csprojs add more navigation cost than safety value | **Implemented** (post-B5); enforcement via `module-layout.md` § Allowed Dependencies + `/code-review` Phase 7 | | One csproj, one root namespace (ADR-008); layering by convention not by compiler | Service is small enough that 6 csprojs add more navigation cost than safety value | **Implemented** (post-B5); enforcement via `module-layout.md` § Allowed Dependencies + `/code-review` Phase 7 |
| GPS-Denied moved to a sibling service (ADR-007, B7+B9) | Different scaling + deployment cadence; GPS-Denied owns its tables and lifecycle | **Doc-only today**; B7 (code) + B9 (DB migration) close the gap | | GPS-Denied moved to a sibling service (ADR-007, B7+B9) | Different scaling + deployment cadence; GPS-Denied owns its tables and lifecycle | **Doc-only today**; B7 (code) + B9 (DB migration) close the gap |
@@ -117,7 +117,7 @@ Cross-component reads happen via the shared `AppDataConnection` (e.g. `02_missio
### 3.2 What the autodev `existing-code` flow will produce ### 3.2 What the autodev `existing-code` flow will produce
- **Step 3 (Test Spec)** → `_docs/02_document/tests/traceability-matrix.md` + per-flow scenario files for F1F7. The 8 ADRs and 7 carry-forward concerns from `architecture.md` are the seed set for test scenarios. - **Step 3 (Test Spec)** → `_docs/02_document/tests/traceability-matrix.md` + per-flow scenario files for F1F7. The 8 ADRs and 7 carry-forward concerns from `architecture.md` are the seed set for test scenarios.
- **Step 4 (Code Testability Revision)** → minimal, surgical fixes if the codebase blocks tests from running (env-driven `DATABASE_URL` already lands here; hardcoded dev fallbacks in `Program.cs` are the prime candidate). Scope: smallest set of changes; deeper refactors deferred to Step 8. - **Step 4 (Code Testability Revision)** → minimal, surgical fixes if the codebase blocks tests from running. The 2026-05-14 re-verification confirmed that the JWT/CORS/Config evolution actually made the code MORE testable than the docs described (env-first `ResolveRequiredOrThrow`, JWKS retrievable via an in-process ECDSA keypair + ephemeral JWKS HTTP service mock, explicit CORS config), so this step is expected to land "all scenarios testable as-is". Scope: smallest set of changes; deeper refactors deferred to Step 8.
- **Step 5 (Decompose Tests)** → per-test task files in `_docs/02_tasks/todo/`, plus `_test_infrastructure.md`. - **Step 5 (Decompose Tests)** → per-test task files in `_docs/02_tasks/todo/`, plus `_test_infrastructure.md`.
- **Step 6 (Implement Tests)** → `tests/Azaion.Missions.Tests/` sibling project (xUnit is the suite-standard choice; per `coderule.mdc` "follow the established directory structure", no `src/` layer). - **Step 6 (Implement Tests)** → `tests/Azaion.Missions.Tests/` sibling project (xUnit is the suite-standard choice; per `coderule.mdc` "follow the established directory structure", no `src/` layer).
- **Step 7 (Run Tests)** → green test suite forms the safety net for Step 8 (Refactor) and every Phase B feature cycle thereafter. - **Step 7 (Run Tests)** → green test suite forms the safety net for Step 8 (Refactor) and every Phase B feature cycle thereafter.
@@ -133,7 +133,7 @@ These are obvious test seams given the F1F7 flows and the 7 carry-forward con
| 2 | `MissionService.CreateMission / UpdateMission``vehicle_id` existence check + spec-vs-code `400` vs `404` divergence | Locks in the current behaviour so the spec-conformance fix is intentional, not accidental | | 2 | `MissionService.CreateMission / UpdateMission``vehicle_id` existence check + spec-vs-code `400` vs `404` divergence | Locks in the current behaviour so the spec-conformance fix is intentional, not accidental |
| 2 | `VehicleService.SetDefault` / Create / Update — "exactly one default" race | B12 decision (spec-vs-code stricter behaviour) — tests pin whichever resolution the user picks | | 2 | `VehicleService.SetDefault` / Create / Update — "exactly one default" race | B12 decision (spec-vs-code stricter behaviour) — tests pin whichever resolution the user picks |
| 2 | `ErrorHandlingMiddleware` mapping (`KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`, fallthrough → 500) | Wire-shape contract used by every flow | | 2 | `ErrorHandlingMiddleware` mapping (`KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`, fallthrough → 500) | Wire-shape contract used by every flow |
| 3 | JWT validation — accept valid HS256 / reject invalid signature / reject expired (with 1-min skew) / reject missing-`FL` claim | F5 cross-cutting; pins the local-validation contract | | 3 | JWT validation — accept valid ECDSA-SHA256 / reject `alg ∉ [EcdsaSha256]` (HS256-confusion) / reject invalid signature / reject mismatched `kid` / reject expired (with 30s skew) / reject `iss != JWT_ISSUER` / reject `aud != JWT_AUDIENCE` / reject missing-`FL` claim / JWKS rotation picks up new `kid` on refresh tick | F5 cross-cutting; pins the asymmetric-validation contract |
| 3 | `DatabaseMigrator.Migrate` — idempotent on a fresh DB, idempotent on already-migrated DB, B9 `DROP` on a fielded-legacy DB | F6; tests guard the only explicit destructive step | | 3 | `DatabaseMigrator.Migrate` — idempotent on a fresh DB, idempotent on already-migrated DB, B9 `DROP` on a fielded-legacy DB | F6; tests guard the only explicit destructive step |
--- ---
@@ -164,6 +164,7 @@ These are obvious test seams given the F1F7 flows and the 7 carry-forward con
| Mission planning | `Controllers/FlightsController.cs` (post-B6/B8: `Controllers/MissionsController.cs`), `Services/FlightService.cs` (post-B6: `MissionService.cs`), `Services/WaypointService.cs` | | Mission planning | `Controllers/FlightsController.cs` (post-B6/B8: `Controllers/MissionsController.cs`), `Services/FlightService.cs` (post-B6: `MissionService.cs`), `Services/WaypointService.cs` |
| Persistence | `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`, `Database/Entities/*.cs` | | Persistence | `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`, `Database/Entities/*.cs` |
| Identity | `Auth/JwtExtensions.cs` | | Identity | `Auth/JwtExtensions.cs` |
| Configuration / CORS gates | `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| HTTP conventions | `Middleware/ErrorHandlingMiddleware.cs`, `DTOs/PaginatedResponse.cs`, `DTOs/ErrorResponse.cs` | | HTTP conventions | `Middleware/ErrorHandlingMiddleware.cs`, `DTOs/PaginatedResponse.cs`, `DTOs/ErrorResponse.cs` |
| Container | `Dockerfile` | | Container | `Dockerfile` |
| CI | `.woodpecker/build-arm.yml` | | CI | `.woodpecker/build-arm.yml` |
@@ -182,6 +183,7 @@ These are obvious test seams given the F1F7 flows and the 7 carry-forward con
| Data model | `_docs/02_document/data_model.md` | | Data model | `_docs/02_document/data_model.md` |
| Glossary (confirmed by user) | `_docs/02_document/glossary.md` | | Glossary (confirmed by user) | `_docs/02_document/glossary.md` |
| Verification log (drift mapping) | `_docs/02_document/04_verification_log.md` | | Verification log (drift mapping) | `_docs/02_document/04_verification_log.md` |
| Drift findings (2026-05-14 re-verification) | `_docs/02_document/05_drift_findings_2026-05-14.md` |
| Deployment notes | `_docs/02_document/deployment/{containerization,ci_cd_pipeline,environment_strategy,observability}.md` | | Deployment notes | `_docs/02_document/deployment/{containerization,ci_cd_pipeline,environment_strategy,observability}.md` |
### 5.3 Suite-level cross-references ### 5.3 Suite-level cross-references
+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 | | 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 | | 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 | | 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"` | | 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 | `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 | | 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 | | 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 ## 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 | | 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 | | 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) ## 5. Stale-folder check (resolved)
@@ -142,9 +164,9 @@ The git status snapshot at session start showed 11 untracked component folders u
## 7. Summary ## 7. Summary
- The forward-looking documentation is **internally consistent** with respect to the rename + GPS-Denied removal it describes (B5B12). - 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. - 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: 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. - 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. - 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. > **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) ### 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) | | 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) | | 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) | | 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 | | 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 | | 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. - **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). - **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. - **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`. - **F6 Startup + schema migration** — `Program → DatabaseMigrator.Migrate → app.Run`.
- **F7 Health probe** — anonymous `GET /health`; process-liveness only. - **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`]* - **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`)]* - **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`]* - **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`)]* - **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]* - **`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). - 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. - 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. - `"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`. - `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). - 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 | | 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 | | 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 | | `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 | | `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 | | 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 | | 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 | | 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) | | 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) | | 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) | | 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 | | 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) | | `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_SECRET` | `development-secret-key-min-32-chars!!` (hardcoded fallback) | Provisioned secret shared across `admin` + every backend service on the device | | `JWT_ISSUER` | Operator-supplied (e.g. `https://admin.azaion.dev/`). **Required at startup** | Set by Edge compose to the central admin issuer |
| Logging | Console / Debug (ASP.NET Core defaults) | Console only (no Serilog / structured logging configured today) | | `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) | | 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) | | 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/`. 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 ## 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`. **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**: **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. - **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. - **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**: `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. - **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. **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 ## 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. - No version table; the migrator is idempotent and runs every startup.
- Acceptable today; will become a real problem if the schema starts evolving frequently. - 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**: **Alternatives considered**:
1. **Gate both on `IsDevelopment()`** — 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. **Fail-fast at startup if `JWT_SECRET` is unset** — 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**: **Consequences**:
- Swagger UI is exposed on every deployment. The reverse proxy may or may not whitelist it; verify on first production rollout. - 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) ### 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). **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. > **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 ## 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 ## 2. Internal Interface
```csharp ```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 | | Env var | Config key | Purpose |
|--------|-------------| |---------|------------|---------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` | | `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) │ │ Operator UI │ POST /login │ admin (.NET, remote) │
│ (React, edge) │ ──────────────► │ central user DB │ │ (React, edge) │ ──────────────► │ central user DB │
│ │ ◄────────────── │ mints HS256 JWT │ │ ◄────────────── │ ECDSA-signs JWT,
│ │ Bearer JWT │ (claim: permissions) │ │ Bearer JWT │ exposes JWKS
└──────────┬──────────┘ └─────────────────────┘ └──────────┬──────────┘ └─────────────────────┘
│ Bearer JWT (the SAME token reused for every service) │ Bearer JWT
│ /.well-known/jwks.json
├──────────────────► annotations (.NET, edge) -- ANN claim │ │ (HTTPS, fetched once at startup,
├──────────────────► missions (.NET, edge) -- FL claim ◄── this service │ │ cached by ConfigurationManager,
├──────────────────► satellite-provider (.NET, remote) -- ADM claim │ │ refreshed on default schedule)
└──────────────────► (any future .NET service) └────────────► 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 ## 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 ## 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 ## 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**: - `jwksUrl` — resolved at startup from `JWT_JWKS_URL` / `Jwt:JwksUrl` (fail-fast if missing).
- `ValidateIssuerSigningKey = true` - 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.
- `ValidateLifetime = true` (with `ClockSkew = 1 minute` -- tighter than .NET's 5-minute default) - `HttpDocumentRetriever { RequireHttps = true }` — non-HTTPS JWKS URLs are rejected at configuration time.
- `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`.
**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**: **Key Dependencies**:
| Library | Version | Purpose | | Library | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer middleware + handler | | `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 ## 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 ## 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. 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. 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. 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`. 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. **`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`. 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 ## 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`. **Can be implemented in parallel with**: `04_persistence`, `06_http_conventions`.
@@ -99,4 +136,4 @@ None.
## 10. Logging Strategy ## 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) # 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. **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). > **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 ## 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. **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 ## 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 ## 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. - **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 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. - **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. - **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. - **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`. - **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 { MISSION {
uuid id PK uuid id PK
timestamp created_date timestamp created_date "PG TIMESTAMP (no TZ), DEFAULT NOW()"
text name text name
uuid vehicle_id FK uuid vehicle_id FK "REFERENCES vehicles(id), NO ACTION on delete"
} }
WAYPOINT { WAYPOINT {
uuid id PK uuid id PK
uuid mission_id FK uuid mission_id FK "REFERENCES missions(id), NO ACTION on delete"
decimal lat "nullable" decimal lat "nullable"
decimal lon "nullable" decimal lon "nullable"
text mgrs "nullable" text mgrs "nullable"
int waypoint_source "WaypointSource enum" int waypoint_source "WaypointSource enum, DEFAULT 0"
int waypoint_objective "WaypointObjective enum" int waypoint_objective "WaypointObjective enum, DEFAULT 0"
int order_num int order_num "DEFAULT 0"
decimal height decimal height "DEFAULT 0"
} }
MAP_OBJECT { MAP_OBJECT {
uuid id PK 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 h3_index "Uber H3 hex grid"
text mgrs text mgrs
decimal lat "nullable" decimal lat "nullable"
decimal lon "nullable" decimal lon "nullable"
int class_num int class_num "DEFAULT 0"
text label text label "DEFAULT ''"
decimal size_width_m decimal size_width_m "DEFAULT 0"
decimal size_length_m decimal size_length_m "DEFAULT 0"
decimal confidence decimal confidence "DEFAULT 0"
int object_status "ObjectStatus enum" int object_status "ObjectStatus enum, DEFAULT 0"
timestamp first_seen_at timestamp first_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
timestamp last_seen_at timestamp last_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
} }
MEDIA { MEDIA {
text id PK "XxHash64-based; computed by annotations service" 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 ### 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). - **`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. - **`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. - **`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. - **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) ### Cross-service-table invariants (cascade only)
@@ -1,15 +1,16 @@
# Flow F5 — JWT bearer validation # 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 ## 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 ## Preconditions
- `JWT_SECRET` is resolved at startup (env or hardcoded dev fallback per `architecture.md` ADR-005). - `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(jwtSecret)` was called during `Program.cs` startup (F6). - `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 ## Sequence Diagram
@@ -19,6 +20,9 @@ sequenceDiagram
participant Client as UI / Operator API client participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler 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 Policy as Auth policy "FL"
participant Ctrl as Feature Controller participant Ctrl as Feature Controller
participant Errs as 06_http_conventions participant Errs as 06_http_conventions
@@ -26,11 +30,28 @@ sequenceDiagram
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt> Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this) Errs->>Handler: hand off (anonymous endpoints skip this)
Handler->>Handler: parse token; verify HMAC-SHA256 signature using SymmetricSecurityKey(UTF-8(JWT_SECRET)) Handler->>Handler: parse token header — check alg ∈ ValidAlgorithms (EcdsaSha256)
alt Signature invalid OR token expired (ClockSkew = 1 minute) 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 Handler-->>Client: 401 Unauthorized
else Valid token else Signature valid
Handler->>Handler: build ClaimsPrincipal; skip iss/aud validation 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") Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL" alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden Policy-->>Client: 403 Forbidden
@@ -39,6 +60,8 @@ sequenceDiagram
Ctrl-->>Client: business response Ctrl-->>Client: business response
end end
end end
end
end
``` ```
## Flowchart ## Flowchart
@@ -49,11 +72,20 @@ flowchart TD
AnonEP -->|no| Forward([Forward to controller]) AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?} AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized]) 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 -->|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 -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal — skip iss/aud] Life -->|yes| BuildPrincipal[Build ClaimsPrincipal]
BuildPrincipal --> Policy{permissions claim == FL?} BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden]) Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward Policy -->|yes| Forward
@@ -64,10 +96,13 @@ flowchart TD
| Step | From | To | Data | Format | | Step | From | To | Data | Format |
|------|------|----|------|--------| |------|------|----|------|--------|
| 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header | | 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header |
| 2 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token | | 2 | `JwtBearerHandler` | `ConfigurationManager` | request for cached `JsonWebKeySet` (or refresh) | in-process |
| 3 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` | .NET principal object | | 3 | `HttpDocumentRetriever` (on cold cache) | `admin` | `GET /.well-known/jwks.json` over HTTPS | HTTP |
| 4 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag | | 4 | `admin` | `HttpDocumentRetriever` | JWKS JSON document | application/json |
| 5 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) | | 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 ## 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` | | Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` |
| Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` | | 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 | | 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 |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 1 min`) | `401`. Tighter than .NET's 5-min default — caller may experience earlier expiration than expected | | 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` | | `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 | | `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 |
| 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 | | `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 ## Performance Expectations
| Metric | Target | Notes | | Metric | Target | Notes |
|--------|--------|-------| |--------|--------|-------|
| Validation latency | sub-millisecond typical | Pure HMAC + claim lookup; no I/O, no network call | | Validation latency (warm cache) | sub-millisecond typical | Pure ECDSA verify + claim lookup; no I/O |
| Throughput | bounded by request throughput | No back-pressure; no token cache (no DB / network round-trip to cache) | | 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 ## 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. - `postgres-local` is reachable.
- The `azaion` database itself exists in PostgreSQL (created at provisioning time, NOT by this migrator). - 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 ## Sequence Diagram
@@ -26,9 +27,12 @@ sequenceDiagram
participant DB as postgres-local participant DB as postgres-local
Docker->>Host: ENTRYPOINT dotnet Azaion.Missions.dll Docker->>Host: ENTRYPOINT dotnet Azaion.Missions.dll
Host->>Cfg: read DATABASE_URL → ConvertPostgresUrl → Npgsql connection string Host->>Cfg: ResolveRequiredOrThrow DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: read JWT_SECRET (env or hardcoded fallback) Host->>Cfg: ResolveRequiredOrThrow JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URL — throw on any miss
Host->>Identity: AddJwtAuth(jwtSecret) — DI registration only, no network 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: register controllers + middleware + scoped AppDataConnection + scoped services
Host->>DI: build Host Host->>DI: build Host
Host->>Migrator: scope.Resolve<AppDataConnection>(); Migrate(db) Host->>Migrator: scope.Resolve<AppDataConnection>(); Migrate(db)
@@ -41,8 +45,9 @@ sequenceDiagram
Migrator->>DB: DROP TABLE IF EXISTS gps_corrections Migrator->>DB: DROP TABLE IF EXISTS gps_corrections
note right of Migrator: B9 one-shot. Idempotent on devices that already cleaned up. note right of Migrator: B9 one-shot. Idempotent on devices that already cleaned up.
Migrator-->>Host: void 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: register ErrorHandlingMiddleware FIRST in pipeline
Host->>Host: UseAuthentication / UseAuthorization Host->>Host: UseCors / UseAuthentication / UseAuthorization
Host->>Host: MapControllers + MapGet("/health") + UseSwagger Host->>Host: MapControllers + MapGet("/health") + UseSwagger
Host->>Docker: app.Run() — listening on 0.0.0.0:8080 Host->>Docker: app.Run() — listening on 0.0.0.0:8080
``` ```
@@ -51,15 +56,24 @@ sequenceDiagram
```mermaid ```mermaid
flowchart TD flowchart TD
Start([Container start]) --> ReadCfg[Read DATABASE_URL + JWT_SECRET] Start([Container start]) --> ResolveDB[ResolveRequiredOrThrow DATABASE_URL]
ReadCfg --> RegDI[DI registrations: controllers, middleware, scoped DB + services] 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] 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] OpenScope --> Migrate[Run DatabaseMigrator.Migrate]
Migrate --> Create["CREATE TABLE IF NOT EXISTS x4 + indexes"] Migrate --> Create["CREATE TABLE IF NOT EXISTS x4 + indexes"]
Create --> Drop["DROP TABLE IF EXISTS orthophotos, gps_corrections (B9)"] Create --> Drop["DROP TABLE IF EXISTS orthophotos, gps_corrections (B9)"]
Drop --> Pipeline[Wire pipeline: error MW first, auth, controllers, /health, Swagger] Drop --> Pipeline[Wire pipeline: error MW first, auth, controllers, /health, Swagger]
Pipeline --> Run([app.Run on :8080]) 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]) Migrate -. on failure .-> Crash([Process exits non-zero — Watchtower restarts])
``` ```
@@ -67,15 +81,18 @@ flowchart TD
| Step | From | To | Data | Format | | Step | From | To | Data | Format |
|------|------|----|------|--------| |------|------|----|------|--------|
| 1 | Environment / appsettings | `Program.cs` | `DATABASE_URL`, `JWT_SECRET` | string | | 1 | Environment / IConfiguration | `Program.cs` (via `ConfigurationResolver`) | `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` | string (required) |
| 2 | `Program.cs` | DI container | service registrations | C# code | | 2 | Environment / IConfiguration | `Program.cs` | `CorsConfig:AllowedOrigins` (string[]), `CorsConfig:AllowAnyOrigin` (bool) | optional |
| 3 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL | | 3 | `Program.cs` | DI container | service registrations + JWT bearer + JWKS `ConfigurationManager` | C# code |
| 4 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener | | 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 Scenarios
| Error | Where | Detection | Recovery | | 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`) | | `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` | | `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 | | `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 ## 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 ## Public Interface
```csharp ```csharp
public static class JwtExtensions { 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 ## Internal Logic
1. `AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...)` configures token validation: 1. **Resolve three required values** via `ConfigurationResolver.ResolveRequiredOrThrow`:
- `IssuerSigningKey = SymmetricSecurityKey(UTF-8(jwtSecret))` -> **HS256 / shared-secret** validation. - `JWT_ISSUER` / `Jwt:Issuer` — expected `iss` claim value.
- `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.) - `JWT_AUDIENCE` / `Jwt:Audience` — expected `aud` claim value.
- `ValidateIssuerSigningKey = true`, `ValidateLifetime = true`. - `JWT_JWKS_URL` / `Jwt:JwksUrl` — HTTPS URL of `admin`'s JWKS document.
- `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"`.
`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 ## 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 ## Dependencies
- `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`) - `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`)
- `Microsoft.IdentityModel.Tokens` (transitive -- `SymmetricSecurityKey`, `TokenValidationParameters`) - `Microsoft.IdentityModel.Protocols` (`ConfigurationManager<T>`, `HttpDocumentRetriever`, `IConfigurationRetriever<T>`, `IDocumentRetriever`)
- `System.Text` (for `Encoding.UTF8`) - `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 ## Consumers
- `Program.cs` -- `builder.Services.AddJwtAuth(jwtSecret)` is called once at startup. - `Program.cs` `builder.Services.AddJwtAuth(builder.Configuration)` is called once at startup.
- Controllers reference the policy indirectly via `[Authorize(Policy = "FL")]` (used on both `VehiclesController` and `MissionsController`). - Controllers reference the policies indirectly via `[Authorize(Policy = "FL")]` and (until B7) `[Authorize(Policy = "GPS")]`.
## Configuration ## 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 ## 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 ## Security
- **Algorithm**: HMAC-SHA256 via `SymmetricSecurityKey`. The token issuer (`admin`) must use the SAME secret to sign -- there is no public-key flow. 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.
- **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. 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`.
- **Clock skew tolerance**: 1 minute (tight, intentional). 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.
- The fallback secret in `Program.cs` is hardcoded. It MUST be overridden in production. 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 ## 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 ## 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`. 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. **No authentication scheme name override** -- uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent. 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 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. 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: - `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`. - 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`. - 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)` - `missions.vehicle_id REFERENCES vehicles(id)`
- `waypoints.mission_id REFERENCES missions(id)` - `waypoints.mission_id REFERENCES missions(id)`
- `map_objects.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 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. - **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.
+57 -29
View File
@@ -27,26 +27,37 @@ global using LinqToDB.Data;
```text ```text
1. WebApplicationBuilder = WebApplication.CreateBuilder(args) 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. If it begins with "postgresql://" -> ConvertPostgresUrl() to Npgsql key=value form.
3. Resolve JWT_SECRET (Configuration -> Env -> fallback) 3. Register services (scoped where applicable):
4. Register services (scoped where applicable):
- AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString) - AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString)
- MissionService, WaypointService, VehicleService <- scoped - MissionService, WaypointService, VehicleService <- scoped
- AddJwtAuth(jwtSecret) -> JWT bearer + "FL" policy - AddJwtAuth(builder.Configuration) -> resolves JWT_ISSUER + JWT_AUDIENCE + JWT_JWKS_URL
- AddCors with default policy = AllowAnyOrigin/Method/Header (each via ResolveRequiredOrThrow). Registers JWT bearer + "FL" + "GPS" policies.
- AddControllers, AddEndpointsApiExplorer, AddSwaggerGen (GPS policy is removed in Jira B7.)
5. Build the WebApplication. 4. Resolve CORS configuration:
6. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db). - allowedOrigins = Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? []
7. Configure pipeline (order matters): - 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> a. UseMiddleware<ErrorHandlingMiddleware>
b. UseCors b. UseCors
c. UseAuthentication c. UseAuthentication
d. UseAuthorization d. UseAuthorization
e. UseSwagger, UseSwaggerUI e. UseSwagger, UseSwaggerUI (unconditional — see ADR-005)
f. MapControllers f. MapControllers
g. MapGet("/health", () => Results.Ok({status:"healthy"})) g. MapGet("/health", () => Results.Ok({status:"healthy"}))
8. app.Run() 11. app.Run()
ConvertPostgresUrl(url): ConvertPostgresUrl(url):
parses postgresql://user[:pass]@host[:port]/db into parses postgresql://user[:pass]@host[:port]/db into
@@ -54,6 +65,14 @@ ConvertPostgresUrl(url):
(defaults port to 5432; absent password becomes empty) (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 ## Dependencies
- All internal namespaces: `Azaion.Missions.{Auth, Database, Middleware, Services}`. - All internal namespaces: `Azaion.Missions.{Auth, Database, Middleware, Services}`.
@@ -66,26 +85,34 @@ ConvertPostgresUrl(url):
## Configuration ## Configuration
| Env / Config Key | Required? | Default | | Env var | Config key | Required? | Default | Notes |
|------------------|-----------|---------| |---------|------------|-----------|---------|-------|
| `DATABASE_URL` | No (has dev fallback) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` | | `DATABASE_URL` | `Database:Url` | **Yes** | — (throws at startup if unset) | Either Npgsql key=value form OR a `postgresql://` URI (converted via `ConvertPostgresUrl`) |
| `JWT_SECRET` | No (has dev fallback) | `development-secret-key-min-32-chars!!` | | `JWT_ISSUER` | `Jwt:Issuer` | **Yes** | — (throws at startup if unset) | Expected `iss` claim value (per `modules/auth.md`) |
| `AZAION_REVISION` | Set by Dockerfile from `CI_COMMIT_SHA` | `unknown` (build arg default) | | `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 ## External Integrations
- PostgreSQL (read/write) via Npgsql. - **PostgreSQL** (read/write) via Npgsql. Connection string resolved at startup; connection pool managed by 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. - **`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 ## 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. - **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 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. - **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. - **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. - **No HTTPS redirection** middleware (`UseHttpsRedirection`) TLS is assumed to terminate at an upstream reverse proxy. The container `EXPOSE 8080` is plain HTTP.
- **`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). - **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 ## Tests
@@ -94,8 +121,9 @@ None present.
## Notes / Smells ## 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. 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. 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. **`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. 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. **`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. 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. **`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. 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. **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). 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, "renames_doc_only_until_jira_lands": true,
"jira_epic": "AZ-EPIC (rename flights -> missions; multi-vehicle; drop GPS-Denied)", "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"] "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", "step_4_5_glossary_vision": "confirmed",
@@ -63,5 +103,13 @@
"verification_log": "_docs/02_document/04_verification_log.md", "verification_log": "_docs/02_document/04_verification_log.md",
"verification_corrections_inline": 9, "verification_corrections_inline": 9,
"verification_drift_flagged": 2, "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: 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). 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. 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. 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`. 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**: **Key sequence steps**:
1. Request arrives at the ASP.NET Core pipeline with `Authorization: Bearer <jwt>`. 1. Request arrives at the ASP.NET Core pipeline with `Authorization: Bearer <jwt>`.
2. `JwtBearerHandler`: 2. `JwtBearerHandler`:
- Parse the token. - Parse the token header.
- Verify HMAC-SHA256 signature with `SymmetricSecurityKey(UTF-8(JWT_SECRET))`. - Reject unless `alg ∈ ValidAlgorithms` (pinned to `EcdsaSha256` — defends against HS256-confusion).
- Verify `lifetime` (`ClockSkew = 1 minute` tighter than .NET's 5-minute default). - 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`.
- **Skip** `iss` / `aud` validation (`ValidateIssuer = false`, `ValidateAudience = false` — known CMMC L2 finding, suite-tracked under AZ-487 / AZ-494, see `05_identity` § Implementation Details). - Verify ECDSA-SHA256 signature against the matching public key.
3. If signature or lifetime fails: `401 Unauthorized` (without ever invoking the controller). - 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. 4. If valid: parse claims into `ClaimsPrincipal`; attach to the request.
5. Authorization policy `"FL"` evaluator checks for a `permissions` claim with value `"FL"`. 5. Authorization policy `"FL"` evaluator checks for a `permissions` claim with value `"FL"`.
6. If absent: `403 Forbidden`. 6. If absent: `403 Forbidden`.
@@ -190,11 +191,15 @@ See `diagrams/flows/flow_jwt_validation.md`.
| Error | Where | Detection | Recovery | | Error | Where | Detection | Recovery |
|-------|-------|-----------|----------| |-------|-------|-----------|----------|
| Missing `Authorization` header | Pipeline | `JwtBearerHandler` | `401` | | Missing `Authorization` header | Pipeline | `JwtBearerHandler` | `401` |
| Invalid signature | Pipeline | HMAC verify fails | `401` | | Forged `alg: HS256` token | Pipeline | `ValidAlgorithms` pin | `401`. Pin defense — see `05_identity` Caveats #6 |
| Expired token | Pipeline | `ValidateLifetime` (with 1min skew) | `401`; client re-authenticates with `admin` | | Invalid signature | Pipeline | ECDSA verify fails | `401` |
| Token signed with old `JWT_SECRET` (rotation) | Pipeline | HMAC verify fails | `401`; coordinated re-deploy across all backends sharing the secret + UI re-login | | `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` | | `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. **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**: **Key sequence steps**:
1. Container starts → entrypoint `dotnet Azaion.Missions.dll`. 1. Container starts → entrypoint `dotnet Azaion.Missions.dll`.
2. `Program.cs` reads `DATABASE_URL``ConvertPostgresUrl` → Npgsql connection string. 2. `Program.cs` resolves `DATABASE_URL` via `ConfigurationResolver.ResolveRequiredOrThrow``ConvertPostgresUrl` → Npgsql connection string.
3. Reads `JWT_SECRET``AddJwtAuth(jwt)` (DI registration; no network). 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. Registers controllers, middleware, scoped `AppDataConnection`, scoped service classes. 4. Reads `CorsConfig:AllowedOrigins` + `CorsConfig:AllowAnyOrigin`; runs `CorsConfigurationValidator.EnsureSafeForEnvironment` (throws in `Production` with implicit-permissive config); registers the CORS policy (permissive OR `WithOrigins`).
5. Builds the host. Opens a single startup scope and calls `DatabaseMigrator.Migrate(db)`: 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 vehicles (...)`.
- `CREATE TABLE IF NOT EXISTS missions (...)`. - `CREATE TABLE IF NOT EXISTS missions (...)`.
- `CREATE TABLE IF NOT EXISTS waypoints (...)`. - `CREATE TABLE IF NOT EXISTS waypoints (...)`.
- `CREATE TABLE IF NOT EXISTS map_objects (...)`. - `CREATE TABLE IF NOT EXISTS map_objects (...)`.
- `CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id ...` and similar. - `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;`. - **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. Registers `ErrorHandlingMiddleware` FIRST in the pipeline; mounts CORS, auth, controllers, `MapGet("/health")`, Swagger UI.
7. `app.Run()` — ready to serve HTTP on port 8080. 8. `app.Run()` — ready to serve HTTP on port 8080.
**Error scenarios**: **Error scenarios**:
| Error | Where | Detection | Recovery | | 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 | | 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 |
| `azaion` database does not exist | Step 5 | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database (provisioning concern, not this service) | | CORS misconfigured in `Production` (empty origins + `AllowAnyOrigin != true`) | Step 4 | `EnsureSafeForEnvironment` throws | Process exits with `MissingOriginsMessage`. **Fix**: set `CorsConfig:AllowedOrigins` or explicit `AllowAnyOrigin=true` |
| `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 | | `postgres-local` unreachable | Step 6 | Npgsql connection failure | Process exits non-zero. Watchtower restarts the container; `flight-gate` prevents restart mid-mission |
| 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 | | `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 **Traces to**: AC-1.5
**Category**: Vehicle CRUD **Category**: Vehicle CRUD
**Preconditions**: **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` **Input data**: `GET /vehicles`
@@ -90,16 +90,16 @@
| Step | Consumer Action | Expected System Response | | 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. **Expected outcome**: results_report.md AC-1 row 1.5.
**Max execution time**: 2s. **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 **Traces to**: AC-1.6
**Category**: Vehicle CRUD **Category**: Vehicle CRUD
@@ -113,6 +113,7 @@
| Step | Consumer Action | Expected System Response | | Step | Consumer Action | Expected System Response |
|------|----------------|------------------------| |------|----------------|------------------------|
| 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` | | 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. **Expected outcome**: results_report.md AC-1 row 1.6.
**Max execution time**: 2s. **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 **Traces to**: AC-2.3, AC-8.7
**Category**: Mission CRUD **Category**: Mission CRUD
**Preconditions**: **Preconditions**:
- `seed_25_missions` - `seed_25_missions` with deterministic `CreatedDate` values spanning January-February 2026
**Input data**: `GET /missions` **Input data**: `GET /missions`
@@ -180,7 +181,8 @@
| Step | Consumer Action | Expected System Response | | 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. **Expected outcome**: results_report.md AC-2 row 2.3.
**Max execution time**: 2s. **Max execution time**: 2s.
@@ -421,24 +423,26 @@
## Negative Scenarios ## 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 **Traces to**: AC-1.6
**Category**: Vehicle CRUD (negative) **Category**: Vehicle CRUD (negative)
**Preconditions**: **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**: **Steps**:
| Step | Consumer Action | Expected System Response | | 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. **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. **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` | | `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` | | `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` | | `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: 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.
- `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. 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.
- `flight-gate`, Watchtower, suite reverse proxy: not required for service-level e2e.
### Networks ### Networks
| Network | Services | Purpose | | 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 ### Volumes
@@ -43,8 +43,9 @@ No external mock services are required:
### docker-compose structure ### 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 ```yaml
# Outline only — not runnable code (the actual scripts/run-tests.sh wires this up)
services: services:
postgres-test: postgres-test:
image: postgres:16-alpine image: postgres:16-alpine
@@ -52,39 +53,51 @@ services:
POSTGRES_DB: azaion POSTGRES_DB: azaion
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test POSTGRES_PASSWORD: postgres-test
healthcheck: # healthcheck: pg_isready
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
interval: 1s jwks-mock:
timeout: 1s build: { context: tests/Azaion.Missions.JwksMock }
retries: 30 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: missions:
build: build: { context: . }
context: .
environment: environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion 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: depends_on:
postgres-test: postgres-test: { condition: service_healthy }
condition: service_healthy jwks-mock: { condition: service_healthy }
e2e-consumer: e2e-consumer:
build: build: { context: tests/Azaion.Missions.E2E.Tests }
context: tests/Azaion.Missions.E2E.Tests
environment: environment:
MISSIONS_BASE_URL: http://missions:8080 MISSIONS_BASE_URL: http://missions:8080
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
JWT_SECRET: test-secret-32-chars-min!!!!!!!!! JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
depends_on: JWT_ISSUER: https://admin-test.azaion.local
missions: JWT_AUDIENCE: azaion-edge
condition: service_started
volumes: 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 ## 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`. **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 | | 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 | | 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 | | Waypoint API | HTTP/1.1 JSON | `http://missions:8080/missions/{id}/waypoints[/{wpId}]` | same |
| Health | HTTP/1.1 JSON | `http://missions:8080/health` | anonymous | | Health | HTTP/1.1 JSON | `http://missions:8080/health` | anonymous |
| DB side-channel (assertions only) | TCP/Postgres wire | `postgres-test:5432` | `postgres:postgres-test` | | 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 ### 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 `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 `AppDataConnection` instantiation; the side-channel uses raw Npgsql `NpgsqlCommand` only.
- No file-system overlap; `e2e-consumer` is a separate container. - 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 ## CI/CD Integration
@@ -125,4 +141,50 @@ Categories: `BLACKBOX`, `PERF`, `RES`, `SEC`, `RES_LIM`. `Traces` is a comma-sep
## Hardware Assessment ## 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. **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.7 **Traces to**: AC-6.1, AC-6.2, AC-6.7, E3, E4
**Preconditions**: **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** (each row is a separate `docker run` invocation; each times out at 30s):
**Steps**:
| Step | Action | Expected Behavior | | Step | Action | Expected Behavior |
|------|--------|------------------| |------|--------|------------------|
| 1 | `docker compose stop postgres-test` | | | 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 compose up -d missions` | | | 2 | `docker run` with `DATABASE_URL` unset; the three JWT vars set correctly | same shape; logs mention `DATABASE_URL` or `Database:Url` |
| 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 | | 3 | `docker run` with `JWT_ISSUER=""` (whitespace-only); other three set | same shape; logs mention `JWT_ISSUER` or `Jwt:Issuer` |
| 4 | `docker logs missions` | Contains an Npgsql connection error message (e.g., `Connection refused`) | | 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. **Pass criteria**: rows 15 → fail-fast at config resolution; row 6 → fail at DB-connect AFTER config resolution succeeded.
**Max execution time**: 60s. **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 **Traces to**: AC-5.7
**Preconditions**: **Preconditions**:
- `missions` running with `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!` - `missions` running with warm JWKS cache (any previous protected request succeeded).
- Token `T1` minted with the same secret, valid for 1h - `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**: **Steps**:
| Step | Action | Expected Behavior | | Step | Action | Expected Behavior |
|------|--------|------------------| |------|--------|------------------|
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` | | 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` (cached JWKS knows `kid_v1`) |
| 2 | `docker compose stop missions` | | | 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 | `docker compose run -e JWT_SECRET=rotated-secret-32-chars-min!!!!! -d missions` | | | 3 | Immediately request token `T2` signed with `kid_v2` via `POST /sign {}` | mock returns JWT with header `kid: kid_v2` |
| 4 | Wait for `GET /health` 200 | | | 4 | Immediately `GET /vehicles` with `Authorization: Bearer T2` (BEFORE `missions` JWKS cache refresh) | `401` (cache still only has `kid_v1`) |
| 5 | `GET /vehicles` with `Authorization: Bearer T1` (same token as step 1) | `401` | | 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 | Mint token `T2` with the new secret, `GET /vehicles` with `T2` | `200` | | 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. **Pass criteria**: rotation propagates without restart; `T2` (new kid) eventually accepted; `T1` (old kid) eventually rejected; `missions` startup timestamp unchanged.
**Max execution time**: 90s. **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 # 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. > **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 ### 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 **Traces to**: AC-5.5
**Steps**: **Steps**:
| Step | Consumer Action | Expected Response | | 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` | | 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 ### 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 **Traces to**: AC-5.2, AC-5.6
**Steps**: **Steps**:
| Step | Consumer Action | Expected Response | | 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` | | 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` | | 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
**Pass criteria**: `T_exp` rejected; `T_skew` accepted. **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. **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 **Traces to**: AC-5.3, AC-5.11
**Steps**: **Steps**:
| Step | Consumer Action | Expected Response | | Step | Consumer Action | Expected Response |
|------|----------------|------------------| |------|----------------|------------------|
| 1 | Mint token with NO `iss` and NO `aud` claim, valid signature + lifetime, `permissions=FL` | | | 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 | `200` | | 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. **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.2 **Traces to**: AC-9.1, AC-9.2
**Steps**: **Steps**:
| Step | Consumer Action | Expected Response | | 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` | | 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` | | 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` | | 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 ## Notes
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests. - 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). - 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: 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. - **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. - **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 ## 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-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-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-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-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-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-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-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-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-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-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-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 | | 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 | | 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 | | `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 | | `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 | | `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 | | `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 | | `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 Validation Rules
| Data Type | Validation today (per AC-* notes) | Invalid Examples | Expected System Behavior | | 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 | | Vehicle.Type | NONE (any int accepted) | `99` | accepted; carry-forward |
| Mission.Page / PageSize | NONE | `-1`, `999999` | accepted by binding; 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) | | 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 lifetime | `ValidateLifetime=true` with **30s** clock skew | `exp = now-60s` | `401` |
| JWT signature | HS256 + shared secret | wrong secret / tampered payload | `401` | | JWT signature | ECDSA-SHA256 + admin's JWKS | tampered payload / signed with non-JWKS key | `401` |
| JWT claim `permissions` | exact string match `"FL"` | `"fl"`, `"ADMIN"`, missing | `403` | | 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` | | `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 # 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. > **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 ## 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.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.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.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.5 | GET /vehicles is plain array (NO pagination), ordered by `Name` ASC | FT-P-04 | Covered |
| AC-1.6 | Filter case-sensitive on `name`, exact on `isDefault` | FT-P-05, FT-N-01 | 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.7 | GET /vehicles/{id} 404 | FT-N-02 | Covered |
| AC-1.8 | DELETE /vehicles/{id} 409 if referenced | FT-N-03 | 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 | | 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.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.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.4 | GET /missions/{id} 404 | FT-N-05 | Covered |
| AC-2.5 | PUT partial update (Name update only) | FT-P-11 | 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.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.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) ### AC-3 — Mission cascade delete F3 (most critical)
@@ -60,22 +61,25 @@
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage | | 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.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=1min` | NFT-SEC-03 | Covered | | AC-5.2 | `ValidateLifetime=true`, `ClockSkew=30s` | NFT-SEC-03 | Covered |
| AC-5.3 | `ValidateIssuer=false`, `ValidateAudience=false` (today) | NFT-SEC-04 | Covered (locks today's behavior) | | 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.4 | Missing header → 401 | NFT-SEC-01 | Covered |
| AC-5.5 | Invalid signature → 401 | NFT-SEC-02 | Covered | | AC-5.5 | Invalid signature / no matching public key → 401 | NFT-SEC-02 | Covered |
| AC-5.6 | Expired token (outside skew) → 401 | NFT-SEC-03 | Covered | | AC-5.6 | Expired token (outside 30s skew) → 401 | NFT-SEC-03 | Covered |
| AC-5.7 | Old `JWT_SECRET` after rotation → 401 | NFT-RES-07 | 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.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-6 — Startup + migration
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage | | 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.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 | 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.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.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.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 | | 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.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.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.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 ### AC-7 — Health probe
@@ -110,8 +116,8 @@
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage | | 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.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") → 403 | NFT-SEC-06 | 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.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 | | 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 | | S1 | C# / .NET 10 | implicit in test environment | Implicitly covered |
| S2 | ASP.NET Core | implicit | 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) | | 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 | | S7 | PostgreSQL only | implicit | Implicitly covered |
| S8 | One csproj, one root namespace | csproj structure; NOT a behavioral test | Out of scope (code organization) | | 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 | | S9 | No `src/` directory | repo layout; NOT a behavioral test | Out of scope |
@@ -161,16 +167,16 @@
| Restriction ID | Restriction (short) | Test IDs | Coverage | | Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------| |---------------|---------------------|----------|----------|
| E1 | Two required env vars | implicit; NFT-RES-05 (DB unreachable) + NFT-SEC-* (JWT_SECRET behavior) | Covered | | 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 NFT-RES-05 path; raw form NOT covered | NOT COVERED — see Uncovered Items §3 | | 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 | 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 | | E3 | **No hardcoded dev fallbacks**`ResolveRequiredOrThrow` throws | NFT-SEC-12, NFT-RES-05 rows 1-5 | Covered |
| E4 | JWT_SECRET shared across services | suite-level concern | Out of scope | | 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 | | E5 | Container EXPOSE 8080; edge maps 5002:8080 | implicit | Implicitly covered |
| E6 | Image tag post-B10 | build-time concern, not behavior | Out of scope | | E6 | Image tag post-B10 | build-time concern, not behavior | Out of scope |
| E7 | Entrypoint post-B5 | build-time concern | 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 | | 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 | | 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 | suite-level concern | Out of scope | | 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) ### Operational (O1O10)
@@ -195,34 +201,36 @@
| AC-2 Mission CRUD | 8 | 7 | 0 | 1 (AC-2.8 TOCTOU) | 0 | 87% | | 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-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-4 Waypoint CRUD F4 | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-5 JWT | 9 | 8 | 1 | 0 | 0 | 100% | | AC-5 JWT | 12 | 12 | 0 | 0 | 0 | 100% |
| AC-6 Startup + migration | 10 | 8 | 1 | 1 (AC-6.2 raw conn) | 0 | 90% | | AC-6 Startup + migration | 12 | 12 | 0 | 0 | 0 | 100% |
| AC-7 Health | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) | | AC-7 Health | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-8 Wire shape | 7 | 7 | 0 | 0 | 0 | 100% | | AC-8 Wire shape | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-9 Authz | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) | | AC-9 Authz | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-10 Operational | 6 | 1 | 0 | 0 | 5 | 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 H | 6 | 1 | 2 | 0 | 3 | 100% (in-scope) |
| Restrictions S | 15 | 4 | 2 | 0 | 9 | 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) | | 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 ## Uncovered Items Analysis
| # | Item | Reason Not Covered | Risk | Mitigation | | # | 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. | | 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. | | 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 | 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. |
| 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. |
**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 ## Phase 3 Coverage Gate
**Threshold**: ≥ 75% (per `cursor-meta.mdc` Quality Thresholds + `phases/03-data-validation-gate.md`). **Threshold**: ≥ 75% (per `cursor-meta.mdc` Quality Thresholds + `phases/03-data-validation-gate.md`).
**Achieved**: 93% in-scope. **Achieved**: 97% in-scope (after the 2026-05-14 drift Phase 2 re-issue).
**Verdict**: **PASS** — Phase 3 gate cleared on first iteration. The 6 uncovered items above are all low-medium risk with documented mitigations. **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`.
@@ -0,0 +1,59 @@
# Deferred to Step 8 (Refactor)
Items surfaced during Step 4 (Code Testability Revision) that are NOT testability blockers and would push beyond the "minimal, surgical" scope defined by the existing-code flow.
These items remain valid candidates for the optional Step 8 Refactor (or for a follow-up Phase B feature cycle once Phase A is complete).
---
## D-REF-01: Active JWKS refresh on unknown `kid`
**File**: `Auth/JwtExtensions.cs` (`IssuerSigningKeyResolver` lambda)
**Current behavior**: When a request arrives with a JWT whose `kid` header does not match any key in the locally-cached JWKS, the resolver returns an empty enumerable. The token is rejected with 401. The JWKS cache will not refresh until the next `AutomaticRefreshInterval` tick.
**Production impact**: After `admin` rotates its signing key, clients holding new-kid tokens are rejected for up to `AutomaticRefreshInterval` (currently library default = 12 hours). This is a real outage window after operator-driven key rotation.
**Proposed change** (Step 8): When `kid` is non-empty and no key matches, call `jwksConfigManager.RequestRefresh()` BEFORE returning empty, then return empty for THIS request only (the next request will see the refreshed cache). The `RefreshInterval` (default 5 min) bounds the refresh rate so a flood of bad-`kid` tokens cannot DOS the JWKS endpoint.
**Why deferred**: C01 (the optional refresh-interval env vars) is sufficient to make the documented tests pass. Active-refresh-on-miss is a production correctness improvement; per Step 4's "MINIMAL, SURGICAL" scope, it is intentionally out of scope.
---
## D-REF-02: Transaction-wrap cascade deletes
**File(s)**: `Services/FlightService.cs` (`DeleteFlight`), `Services/WaypointService.cs` (`DeleteWaypoint`), `Services/AircraftService.cs` (`CreateAircraft`, `SetDefault`, `UpdateAircraft`)
**Current behavior**: Cascade deletes and default-vehicle toggles execute as a series of independent `DELETE`/`UPDATE` statements without `BeginTransactionAsync`. Documented carry-forwards:
- AC-1.4 (default-vehicle clear+set not transactional → 2+ defaults under race)
- AC-3.3 (cross-table cascade not transactional → orphans on failure)
- AC-2.8 (TOCTOU between existence check and insert)
- AC-3.7 (autopilot orphan race on `map_objects` insert after step-1 read)
**Proposed change** (Step 8): Wrap each multi-statement block in `await using var tx = await db.BeginTransactionAsync()` + `await tx.CommitAsync()`. Add retry-with-jitter for serialization failures. Tests for the transactional variants would replace NFT-RES-08 (probabilistic) with deterministic transaction-rollback assertions.
**Why deferred**: Adding transactional boundaries is a structural change that affects every cascade path and requires coordinated schema review (FK actions, isolation level). The documented test spec already accepts the carry-forward via probabilistic tests and "Uncovered Items §1–§2" with documented mitigations.
---
## D-REF-03: Swagger gate on `IsDevelopment()`
**File**: `Program.cs` (lines 7475: `app.UseSwagger(); app.UseSwaggerUI();`)
**Current behavior**: Swagger UI is exposed in every environment, including Production. Carry-forward security finding S6 in `restrictions.md`.
**Proposed change** (Step 8): Wrap both calls in `if (app.Environment.IsDevelopment())`. No behavioral change in tests (the test environment uses `ASPNETCORE_ENVIRONMENT=Test`, which is NOT `Development` — but this matches the suite-level expectation that Swagger is a dev-only tool).
**Why deferred**: This is a Production-only security hardening; it does not block any documented test. Step 8 or a Phase B security task.
---
## D-REF-04: Single composite-FK existence check in `CreateFlight` / `CreateWaypoint`
**File(s)**: `Services/FlightService.cs` (`CreateFlight`), `Services/WaypointService.cs` (`CreateWaypoint`)
**Current behavior**: `CreateFlight` checks `Aircrafts.AnyAsync(...)` then INSERTs `Flight`. `CreateWaypoint` checks `Flights.AnyAsync(...)` then INSERTs `Waypoint`. The DB-level FK now catches the TOCTOU race (PG `23503`) but the API surface still does an extra round trip.
**Proposed change** (Step 8): Skip the explicit existence check on `CreateFlight` / `CreateWaypoint`; rely on the DB FK constraint and translate `PostgresException SqlState=23503` to `ArgumentException` in `ErrorHandlingMiddleware` (or service-layer catch). One fewer round trip per insert; race window eliminated structurally.
**Why deferred**: Affects error-message wording for non-existent parent IDs and requires `ErrorHandlingMiddleware` to learn about Postgres error codes. The test suite is structured around the current wording. Step 8 territory.
@@ -0,0 +1,56 @@
# List of Changes
**Run**: 01-testability-refactoring
**Mode**: guided
**Source**: autodev-testability-analysis (existing-code Phase A, Step 4)
**Date**: 2026-05-14
## Summary
Two minimal, surgical changes to make the documented test suite (`_docs/02_document/tests/`) actually executable against the running service. Without these, NFT-RES-07 + NFT-SEC-11 (JWKS rotation) cannot complete inside the 15-min CI gate, and EVERY JWT-dependent test fails at the TLS handshake against `jwks-mock` because the self-signed CA mounted into the container is never registered with the OS trust store. Both gaps are infrastructure-shaped — they do not change business logic, route shapes, validation semantics, DB schema, or any AC contract.
## Changes
### C01: Expose JWKS `ConfigurationManager` refresh intervals via optional env vars
- **File(s)**: `Auth/JwtExtensions.cs`
- **Problem**: `ConfigurationManager<JsonWebKeySet>` is constructed with library defaults — `AutomaticRefreshInterval = 12h` and `RefreshInterval = 5min`. Test scenario NFT-RES-07 ("JWKS key rotation — no missions restart required") expects rotation to propagate within ~90s; NFT-SEC-11 ("Unknown kid (rotation lag) → 401 until JWKS refresh") expects an unknown kid to be accepted within the same window after rotation. Neither can complete inside the 15-min CI wall-clock budget at the current defaults. The current `IssuerSigningKeyResolver` also does not call `RequestRefresh()` on cache-miss, so even a `kid` that was just published cannot be picked up until the next automatic refresh cycle.
- **Change**: Resolve two optional env vars / config keys via the existing `ConfigurationResolver`:
- `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS` (config key `Jwt:JwksAutoRefreshIntervalSeconds`) → if set and parses to a positive integer, sets `jwksConfigManager.AutomaticRefreshInterval = TimeSpan.FromSeconds(value)`. Default (unset): library default (12h) — production semantics unchanged.
- `JWT_JWKS_REFRESH_INTERVAL_SECONDS` (config key `Jwt:JwksRefreshIntervalSeconds`) → if set and parses to a positive integer, sets `jwksConfigManager.RefreshInterval = TimeSpan.FromSeconds(value)`. Default (unset): library default (5min).
Add a NEW resolver method `ConfigurationResolver.ResolveOptionalIntOrDefault(...)` returning `int?` for the parse-or-null path (parallel to the existing `ResolveRequiredOrThrow`). Whitespace-only or non-integer values throw `InvalidOperationException` at startup (fail-fast — consistent with the existing resolver contract); only unset/absent is treated as "use default".
In `docker-compose.test.yml` `missions` service, add `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"` and `JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"`.
- **Rationale**: Production stays at the library defaults (12h cache) — no behavioral change. Tests get a refresh tick fast enough to observe rotation. The knob is the minimum-surface fix; alternatives like calling `RequestRefresh()` from inside `IssuerSigningKeyResolver` on cache-miss would be a production correctness improvement (faster recovery from operator-driven key rotation in `admin`), but that crosses into Step 8 (Refactor) territory and is intentionally deferred per `_docs/04_refactoring/01-testability-refactoring/deferred_to_refactor.md`.
- **Constraint Fit**:
- AC-5.7 (JWKS rotation without restart) — preserved; this change makes the AC observable in tests within the 15-min budget.
- AC-5.9 (request-path validation local after JWKS cached) — preserved; the cache still operates entirely in-process between refresh ticks.
- E1 / E3 (no hardcoded dev fallbacks, fail-fast on required config) — preserved; the new env vars are OPTIONAL, and bad values (non-integer, whitespace) throw at startup — they never silently default.
- E4 (asymmetric ECDSA, no shared secret) — unchanged.
- Restriction set in `_docs/00_problem/restrictions.md` — no new restriction; the optional knob is a documented operator override, not a behavior shift.
- **Risk**: low — both new env vars default to "unset → library default". Production behavior is bit-for-bit identical unless an operator opts in.
- **Dependencies**: none
### C02: Register mounted CA certificates at container startup
- **File(s)**: `Dockerfile` (runtime stage), NEW `docker-entrypoint.sh` at repo root
- **Problem**: `docker-compose.test.yml` mounts the `jwks-mock` self-signed CA into the runtime container at `/usr/local/share/ca-certificates/jwks-mock-ca.crt`, but the runtime stage never invokes `update-ca-certificates` and the `ENTRYPOINT` execs `dotnet` directly. Debian's `/etc/ssl/certs/ca-certificates.crt` bundle is therefore never regenerated; .NET's `HttpDocumentRetriever` (used by `ConfigurationManager<JsonWebKeySet>`) rejects the HTTPS handshake to `https://jwks-mock:8443/...` with `RemoteCertificateNotAvailable`. EVERY JWT-dependent test fails at the first protected request — the service cannot fetch JWKS.
- **Change**:
- Add a small `docker-entrypoint.sh` at repo root that runs `update-ca-certificates --fresh >/dev/null 2>&1 || true` then `exec "$@"`. The `|| true` is acceptable here because `update-ca-certificates` only fails in pathological cases (e.g. unwriteable `/etc/ssl/`); the underlying TLS handshake will still produce a loud, informative error if trust is missing — we do not silence the actual security signal.
- Update `Dockerfile` runtime stage to `COPY docker-entrypoint.sh /docker-entrypoint.sh`, `RUN chmod +x /docker-entrypoint.sh`, and change `ENTRYPOINT ["dotnet", "Azaion.Flights.dll"]` to `ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]`.
- No code change to the application.
- **Rationale**: Mounted CAs are how a sysadmin trusts a private CA on a Debian system; running `update-ca-certificates` at start is the standard pattern. Production benefits too: any operator deploying behind an enterprise PKI can mount their CA and it will be trusted, without rebuilding the image. The entrypoint wrapper is idempotent — if no extra CAs are mounted, the bundle stays identical to the base image's default.
- **Constraint Fit**:
- AC-5.* — preserved; the JWT validation logic itself is untouched. This change enables the validator to actually reach the JWKS endpoint over HTTPS.
- AC-6.12 (HTTPS-only JWKS URL) — preserved and now observable; the HTTPS handshake will SUCCEED against trusted CAs and FAIL against untrusted ones, which is the documented behavior.
- E4 / E10 (TLS termination at suite reverse proxy; JWKS independently constrained to HTTPS) — preserved; this change is downstream of E10.
- Container image size / startup cost: adds ~1ms (one `update-ca-certificates` invocation per cold start). Acceptable.
- **Risk**: low — `update-ca-certificates` is idempotent and ships pre-installed in the .NET runtime base image (`mcr.microsoft.com/dotnet/aspnet:10.0` is Debian-based). The only failure mode is a read-only `/etc/ssl/`, which would also break the existing image. The `|| true` guard avoids masking unrelated `dotnet` errors but does NOT mask TLS errors (the actual JWKS HTTPS call will still surface a `RemoteCertificateNotAvailable` if trust is genuinely broken).
- **Dependencies**: none
## Deferred to Step 8 (Refactor)
The following items were CONSIDERED during Step 4 analysis but rejected as out-of-scope for testability (recorded in `deferred_to_refactor.md`):
- **Active JWKS refresh on unknown `kid`** — change `IssuerSigningKeyResolver` to call `jwksConfigManager.RequestRefresh()` when no key matches the supplied `kid`. This is a production correctness improvement (recovery from operator-driven rotation falls from ~12h to a single round-trip) but is OUT of scope for testability — C01's interval knob alone is sufficient to make the documented tests pass.
- **Transaction-wrap cascade deletes** (AC-3.3, AC-3.7, AC-2.8) — addressing the TOCTOU + orphan races requires `BeginTransactionAsync` + retry; structural change deferred to Step 8 as already noted in `restrictions.md` and traceability-matrix Uncovered Items §1–§2.
- **Swagger production-gate** (S6 carry-forward) — single `IsDevelopment()` check; structural footgun but not a testability blocker. Deferred.
@@ -0,0 +1,62 @@
# Testability Changes Summary (01-testability-refactoring)
**Date**: 2026-05-14
**Trigger**: autodev existing-code flow, Step 4 (Code Testability Revision)
**Build status**: `dotnet build -c Release` — 0 warnings, 0 errors. No lint findings on modified files.
Applied 2 change(s):
## Config extraction
- **C01** — extended JWT configuration in `Auth/JwtExtensions.cs` + `Infrastructure/ConfigurationResolver.cs` + `docker-compose.test.yml`: added two NEW optional env vars (`JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS`, `JWT_JWKS_REFRESH_INTERVAL_SECONDS`) and corresponding config keys (`Jwt:JwksAutoRefreshIntervalSeconds`, `Jwt:JwksRefreshIntervalSeconds`) that, when set, override the JWKS `ConfigurationManager`'s `AutomaticRefreshInterval` and `RefreshInterval`. Production leaves both unset → library defaults (12h / 5min). The test compose sets them to 30s / 10s so NFT-RES-07 (JWKS rotation) and NFT-SEC-11 (unknown-kid lag) can complete inside the 15-minute CI gate. A new `ConfigurationResolver.ResolveOptionalPositiveIntOrThrow` enforces the same fail-fast contract as `ResolveRequiredOrThrow` — typos and non-positive values throw at startup, never silently default. Risk: low.
## Container infrastructure
- **C02** — added `docker-entrypoint.sh` at repo root + adjusted `Dockerfile` runtime stage: container now runs `update-ca-certificates --fresh` (when available) before `exec`ing `dotnet Azaion.Flights.dll`. This makes the test-mounted `jwks-mock` self-signed CA actually trusted by the OS bundle that .NET's `HttpClient` (used by `HttpDocumentRetriever`) reads from. Without this, EVERY JWT-dependent test failed at the HTTPS handshake against `jwks-mock` (cert untrusted). Production benefits too — operators behind enterprise PKIs can mount a CA via volume without rebuilding the image. The wrapper is a no-op when no extra CAs are mounted. Risk: low.
## Files touched
| File | Change |
|------|--------|
| `Auth/JwtExtensions.cs` | +2 const pairs (env var + config key); +2 optional resolves; +2 conditional assignments to `jwksConfigManager.AutomaticRefreshInterval` / `RefreshInterval` |
| `Infrastructure/ConfigurationResolver.cs` | +`ResolveOptionalPositiveIntOrThrow` method (parallel to existing required resolver, returns `int?`, throws on bad parse / non-positive) |
| `docker-compose.test.yml` | +`JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"` + `JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"` under `missions.environment`; comment block documents the rationale |
| `Dockerfile` | Runtime stage: `COPY docker-entrypoint.sh /docker-entrypoint.sh`, `RUN chmod +x …`, `ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]` |
| `docker-entrypoint.sh` | NEW (repo root, executable). Runs `update-ca-certificates --fresh` then `exec "$@"`. |
## Files NOT touched
- `Services/{Aircraft,Flight,Waypoint}Service.cs` — already DI-driven; no testability gap
- `Database/{AppDataConnection,DatabaseMigrator}.cs` — already takes connection as argument
- `Controllers/*Controller.cs` — pure DI, no static state
- `Middleware/ErrorHandlingMiddleware.cs` — already DI-driven
- `Infrastructure/CorsConfigurationValidator.cs` — already pure utility taking config in
- `Program.cs` — every config value already resolved via env / fail-fast; CORS already gated
- Any production restriction surface (`restrictions.md`, ACs, schema) — all preserved
## Deferred to Step 8 / Refactor
See `deferred_to_refactor.md` in this folder. Summary:
- D-REF-01: active JWKS refresh on unknown `kid` (production correctness — currently up to 12h lag after operator-driven key rotation in `admin`)
- D-REF-02: transactional cascade deletes (AC-1.4 / AC-2.8 / AC-3.3 / AC-3.7 carry-forwards)
- D-REF-03: Swagger production gate (S6 carry-forward)
- D-REF-04: single composite-FK existence check on create (eliminates one round trip + TOCTOU race)
## Verification
- `dotnet build -c Release`**PASS** (0 warnings, 0 errors).
- `ReadLints` on `Auth/JwtExtensions.cs` + `Infrastructure/ConfigurationResolver.cs`**PASS** (no findings).
- No new `using` directives required; no new NuGet packages added.
- Existing `_docs/02_document/tests/environment.md` description of the test stack is unchanged — the new env vars and CA wiring are infrastructure-only and do not alter any observable AC contract.
## Constraint preservation matrix
| Constraint | Preservation evidence |
|------------|-----------------------|
| AC-5.* (JWT validation semantics) | Unchanged — only the JWKS cache refresh cadence is tunable; algorithm pin, `iss`/`aud`/`alg`/`exp` validation, signing-key resolution all identical |
| AC-5.7 (JWKS rotation without restart) | NOW OBSERVABLE within 15-min CI gate (C01) |
| AC-6.1 / E1 / E3 (fail-fast on required config, no dev fallbacks) | Preserved — new optional vars use the same fail-fast resolver pattern; bad values throw |
| AC-6.12 (HTTPS-only JWKS URL) | Preserved + reachable now (C02 makes the HTTPS handshake actually work against the test mock) |
| E4 (asymmetric ECDSA, no shared secret) | Unchanged |
| E10 (TLS termination at suite reverse proxy; JWKS independently HTTPS) | Preserved; the entrypoint wrapper only affects CA trust, never disables TLS |
| Production behavior | Bit-for-bit identical when the new env vars are unset and no CA is mounted (the dominant production case) |
+6 -6
View File
@@ -2,13 +2,13 @@
## Current Step ## Current Step
flow: existing-code flow: existing-code
step: 3 step: 5
name: Test Spec name: Decompose Tests
status: in_progress status: not_started
sub_step: sub_step:
phase: 2 phase: 0
name: test-scenario-specification name: blocked-on-tracker-auth
detail: "" detail: "atlassian MCP not connected; tracker decision A/B/C/D pending"
retry_count: 0 retry_count: 0
cycle: 1 cycle: 1
tracker: jira tracker: jira
@@ -0,0 +1,50 @@
# Leftover: Step 5 (Decompose Tests) blocked on tracker auth
**Recorded**: 2026-05-14T20:51:00Z (Thursday)
**Blocker**: `user-atlassian-mcp` returns "Not connected" (verified via `getAccessibleAtlassianResources`).
**Type**: tracker availability — NOT a deferrable "non-user blocker"; the autodev tracker rule (`.cursor/rules/tracker.mdc` Tracker Availability Gate) requires explicit user decision (Retry / `tracker: local`).
## What is pending
Step 5 (Decompose Tests, tests-only mode) needs to run:
1. **Step 1t** — Test Infrastructure Bootstrap → creates `todo/[TRACKER-ID]_test_infrastructure.md` + matching Jira ticket
2. **Step 3** — Blackbox Test Task Decomposition → produces one task file per blackbox/perf/res/sec/res-lim scenario referenced in `_docs/02_document/tests/*.md`. Estimated 1220 task files based on the current spec spread (FT-P-01…FT-P-18, FT-N-01…FT-N-08, NFT-PERF-01…NFT-PERF-04, NFT-RES-01…NFT-RES-08, NFT-SEC-01…NFT-SEC-13, NFT-RES-LIM-01…NFT-RES-LIM-04).
3. **Step 4** — Cross-Verification → produces `_docs/02_tasks/_dependencies_table.md` and verifies AC/restriction coverage.
Each task file must have a Jira ticket created inline (per `.cursor/skills/decompose/SKILL.md` Save Timing table) and then be renamed from numeric prefix to `AZ-<id>` prefix.
## Inputs ready
- `_docs/02_document/tests/environment.md`
- `_docs/02_document/tests/test-data.md`
- `_docs/02_document/tests/blackbox-tests.md`
- `_docs/02_document/tests/performance-tests.md`
- `_docs/02_document/tests/resilience-tests.md`
- `_docs/02_document/tests/security-tests.md`
- `_docs/02_document/tests/resource-limit-tests.md` (need to verify exists)
- `_docs/02_document/tests/traceability-matrix.md` ✓ (post-2026-05-14 drift Phase 2 re-issue, 97% in-scope coverage)
- `_docs/00_problem/{problem,restrictions,acceptance_criteria}.md` ✓ (post-drift-revision)
## Resolution paths
The next `/autodev` invocation MUST resolve one of:
- **(preferred) Retry auth**: User authenticates `user-atlassian-mcp` via Cursor's MCP UI; autodev then proceeds normally and creates AZ-prefixed task files with live Jira tickets.
- **`tracker: local` mode** (only with explicit user acceptance): tasks are written with numeric prefix + `Tracker: pending` header marker; state file's `tracker:` field is changed to `local`; a future invocation with a working Jira MCP runs a "Tracker Pending Sync" to back-fill tickets and rename the files.
## Step 4 deliverables (already applied — DO NOT redo)
- `Auth/JwtExtensions.cs` — JWKS refresh-interval optional config (C01)
- `Infrastructure/ConfigurationResolver.cs``ResolveOptionalPositiveIntOrThrow` helper (C01)
- `Dockerfile` + new `docker-entrypoint.sh` — runs `update-ca-certificates` at container start (C02)
- `docker-compose.test.yml` — passes 30s / 10s JWKS refresh intervals to `missions` (C01)
- `_docs/04_refactoring/01-testability-refactoring/{list-of-changes,deferred_to_refactor,testability_changes_summary}.md`
`dotnet build -c Release` clean (0 warnings, 0 errors). `ReadLints` clean on edited files.
## Replay procedure when Atlassian MCP is back
1. On next `/autodev`, the Bootstrap step (B1) reads this leftover, verifies MCP connectivity via `getAccessibleAtlassianResources`, and either:
- **MCP works** → delete this leftover, autodev proceeds to Step 5 normally.
- **MCP still down** → autodev surfaces the Choose A/B/C/D again (see `protocols.md`).
2. If the user chose `tracker: local` in the interim and tasks were created with numeric prefixes, the next "Tracker Pending Sync" walks `_docs/02_tasks/todo/*.md` looking for `Tracker: pending` headers, creates the matching Jira ticket per task, rewrites the header, and renames the file from `NN_xxx.md` to `AZ-<id>_xxx.md`.
+140
View File
@@ -0,0 +1,140 @@
## Test compose stack for the missions service.
## Naming: post-rename target. Pre-rename code path runs the same compose against the
## existing Azaion.Flights.csproj entrypoint -- tests will be RED until B5-B8 land.
## Documented in _docs/02_document/tests/environment.md.
##
## Post-2026-05-14 drift re-verification: JWT model is ECDSA-SHA256 with JWKS
## fetched from the `admin` service. Tests provide a `jwks-mock` container that
## stands in for `admin` -- it holds a fixed ECDSA P-256 keypair, serves the
## public key as JWKS over HTTPS at `https://jwks-mock/.well-known/jwks.json`,
## and signs test tokens on demand at `https://jwks-mock/sign`. The consumer
## fetches signed tokens from the mock; missions validates them against the
## mock's JWKS. The private key never leaves the mock container.
services:
postgres-test:
image: postgres:16-alpine
container_name: missions-postgres-test
environment:
POSTGRES_DB: azaion
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
interval: 1s
timeout: 1s
retries: 30
networks:
- e2e-net
tmpfs:
## Ephemeral PG data; recreated per `docker compose down -v`.
- /var/lib/postgresql/data
jwks-mock:
## Build context populated by Step 6 (Implement Tests). The mock is a tiny
## ASP.NET Core / Python / Node app that:
## - Holds a fixed ECDSA P-256 keypair (in-memory; never exported).
## - Serves `GET /.well-known/jwks.json` over HTTPS with `Cache-Control:
## public, max-age=60` (60s instead of admin's 3600s so tests can observe
## rotation within a single 15-minute CI window).
## - Serves `POST /sign` over HTTPS accepting a claims JSON body and
## returning a signed JWT (ECDSA-SHA256) for test consumption.
## - Supports `POST /rotate-key` to generate a new keypair with a new
## `kid`; the prior public key stays in the JWKS for `OldKeyGraceSeconds`
## to verify the rotation transition (used by NFT-RES-07).
## - Self-signs its TLS certificate; the `missions` container trusts the
## mock's CA via a mounted volume at /etc/ssl/certs/jwks-mock-ca.crt.
## - Image tag: `azaion/jwks-mock:test`. Until built, run-tests.sh prints
## a clear "jwks-mock not yet built" message.
build:
context: tests/Azaion.Missions.JwksMock
dockerfile: Dockerfile
container_name: missions-jwks-mock
environment:
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
OLD_KEY_GRACE_SECONDS: 5
healthcheck:
test: ["CMD-SHELL", "wget -q --no-check-certificate -O - https://127.0.0.1:8443/.well-known/jwks.json || exit 1"]
interval: 2s
timeout: 1s
retries: 30
networks:
- e2e-net
missions:
build:
context: .
container_name: missions-sut
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
## Shorten the JWKS cache so NFT-RES-07 + NFT-SEC-11 can observe rotation
## within the 15-minute CI wall-clock budget. Production leaves both
## unset and inherits the library defaults (12h / 5min).
JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"
JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_ENVIRONMENT: Test
## CORS: Test environment (NOT Production) -- empty allow-list falls back
## to permissive with a PermissiveDefaultWarning log line (per
## CorsConfigurationValidator). Production-gate scenarios (E9 lock test)
## set ASPNETCORE_ENVIRONMENT=Production and assert startup THROWS.
## The jwks-mock CA cert is mounted so missions can validate the mock's
## TLS cert when fetching JWKS over HTTPS. The container ENTRYPOINT runs
## update-ca-certificates on startup so the mounted CA is trusted by the
## OS bundle that .NET HttpClient reads from.
volumes:
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
ports:
- "5002:8080"
depends_on:
postgres-test:
condition: service_healthy
jwks-mock:
condition: service_healthy
healthcheck:
## Per AC-7.1, /health is anonymous. Container is "healthy" once /health returns 200.
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health || exit 1"]
interval: 2s
timeout: 1s
retries: 30
networks:
- e2e-net
e2e-consumer:
## Build context placeholder -- populated by Step 6 (Implement Tests) when the
## test csproj is created. Until then, run-tests.sh detects the absence and
## prints a clear "test project not yet created" message.
build:
context: tests/Azaion.Missions.E2E.Tests
dockerfile: Dockerfile
container_name: missions-e2e
environment:
MISSIONS_BASE_URL: http://missions:8080
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
## Consumer fetches test tokens from jwks-mock instead of minting locally:
## the private key never leaves the mock container, so tests can't
## accidentally sign with a key that doesn't match the mock's published JWKS.
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
depends_on:
missions:
condition: service_healthy
jwks-mock:
condition: service_healthy
volumes:
- ./test-results:/app/results
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
networks:
- e2e-net
profiles:
- test
networks:
e2e-net:
name: missions-e2e-net
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Container startup wrapper for the missions service.
#
# Registers any CA certificates mounted into /usr/local/share/ca-certificates/
# with the system trust store, then execs the original ENTRYPOINT command.
#
# Why this exists:
# .NET HttpClient (used by the JwtBearer JWKS retriever) trusts only CAs in
# /etc/ssl/certs/ca-certificates.crt on Debian-based images. A CA file
# dropped into /usr/local/share/ca-certificates/ is NOT picked up until
# `update-ca-certificates` regenerates the bundle. Because the test harness
# mounts the jwks-mock CA at runtime (not build time), we have to run this
# on every container start.
#
# Production semantics:
# When no extra CAs are mounted, `update-ca-certificates --fresh` is a
# no-op that rewrites the bundle from the OS-provided certs unchanged.
# Operators deploying behind an enterprise PKI can mount their CA and have
# it trusted without rebuilding the image.
#
# Error handling:
# We `|| true` only the CA-update step itself (the only failure mode is a
# read-only /etc/ssl/, which would break the existing image too). We do NOT
# swallow errors from the wrapped dotnet command -- those propagate normally
# via `exec`. A genuinely broken TLS trust chain still surfaces loudly when
# the JWKS HTTPS handshake fails.
set -eu
if command -v update-ca-certificates >/dev/null 2>&1; then
update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
exec "$@"
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
## Performance test runner for the missions service.
## Scenarios: NFT-PERF-01 (cascade P50 <=50ms), NFT-PERF-03 (health P50 <=10ms),
## NFT-PERF-02 (cascade with full chain, regression baseline), NFT-PERF-04
## (mission list P95, regression baseline). Spec lives in
## _docs/02_document/tests/performance-tests.md.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
RESULTS_DIR="$PROJECT_ROOT/test-results"
TEST_PROJECT_DIR="$PROJECT_ROOT/tests/Azaion.Missions.E2E.Tests"
KEEP_RUNNING=false
for arg in "$@"; do
case "$arg" in
--keep-running) KEEP_RUNNING=true ;;
-h|--help)
cat <<USAGE
Usage: scripts/run-performance-tests.sh [--keep-running]
Runs NFT-PERF-* scenarios from _docs/02_document/tests/performance-tests.md
against the dockerized missions service.
--keep-running Do NOT teardown the docker compose stack on exit (useful
for inspecting timing artifacts).
Thresholds (read from performance-tests.md):
NFT-PERF-01 cascade-delete P50 <= 50ms
NFT-PERF-02 cascade-delete-full P50 <= 200ms (provisional)
NFT-PERF-03 /health P50 <= 10ms
NFT-PERF-04 /missions paginated P95 <= 100ms (provisional)
USAGE
exit 0 ;;
*)
echo "unknown arg: $arg" >&2
exit 64 ;;
esac
done
cleanup() {
local exit_code=$?
if [ "$KEEP_RUNNING" = "true" ]; then
echo "[run-perf] --keep-running set; leaving compose stack up." >&2
else
echo "[run-perf] tearing down compose stack..." >&2
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
trap cleanup EXIT
mkdir -p "$RESULTS_DIR"
## --- Install dependencies ---
command -v docker >/dev/null 2>&1 || {
echo "[run-perf] ERROR: docker is required but not installed on PATH." >&2
exit 2
}
docker compose version >/dev/null 2>&1 || {
echo "[run-perf] ERROR: docker compose v2 plugin is required." >&2
exit 2
}
if [ ! -d "$TEST_PROJECT_DIR" ]; then
cat >&2 <<MSG
[run-perf] WARNING: test project not yet created at:
$TEST_PROJECT_DIR
Performance scenarios are defined in _docs/02_document/tests/performance-tests.md
but no test code exists yet to execute them. Step 6 (Implement Tests) introduces
the [Trait("Category","Perf")] test methods this script invokes.
MSG
exit 0
fi
## --- Start system under test ---
echo "[run-perf] starting compose stack..." >&2
docker compose -f "$COMPOSE_FILE" up -d --build postgres-test missions
echo "[run-perf] waiting for missions /health (timeout 60s)..." >&2
ATTEMPTS=0
until [ "$ATTEMPTS" -ge 60 ]; do
if curl -sf http://localhost:5002/health >/dev/null 2>&1; then
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
sleep 1
done
if [ "$ATTEMPTS" -ge 60 ]; then
echo "[run-perf] ERROR: missions did not become healthy within 60s." >&2
exit 3
fi
## --- Run perf scenarios ---
## The scenarios live in the same xUnit project as the blackbox suite, but are
## tagged [Trait("Category","Perf")] so they only run under this filter. Each
## scenario reports its computed P50/P95 to test-results/perf.csv.
echo "[run-perf] running performance scenarios..." >&2
docker compose -f "$COMPOSE_FILE" --profile test build e2e-consumer
docker compose -f "$COMPOSE_FILE" --profile test run --rm \
-e PERF_RESULTS_FILE=/app/results/perf.csv \
e2e-consumer dotnet test \
/app/Azaion.Missions.E2E.Tests.csproj \
--filter Category=Perf \
--logger "trx;LogFileName=perf.trx"
TEST_EXIT=$?
## --- Compare against thresholds ---
## The xUnit Perf tests already enforce per-scenario thresholds (NFT-PERF-* `Pass
## criteria` in performance-tests.md). A failed assertion -> non-zero TEST_EXIT.
## This script just propagates the verdict; per-scenario detail is in perf.csv.
if [ "$TEST_EXIT" -eq 0 ]; then
echo "[run-perf] ALL THRESHOLDS MET." >&2
if [ -f "$RESULTS_DIR/perf.csv" ]; then
echo "[run-perf] per-scenario detail: $RESULTS_DIR/perf.csv" >&2
fi
else
echo "[run-perf] THRESHOLD FAILURES (exit code $TEST_EXIT)." >&2
echo "[run-perf] missions logs (last 50 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=50 missions >&2 || true
fi
exit "$TEST_EXIT"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
## Test runner for the missions service (blackbox + unit tests).
## Documented in _docs/02_document/tests/environment.md (Hardware Assessment -> Docker mode).
## Naming: post-rename target. Pre-rename code path runs the same script -- tests
## will be RED until B5-B8 land.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
RESULTS_DIR="$PROJECT_ROOT/test-results"
TEST_PROJECT_DIR="$PROJECT_ROOT/tests/Azaion.Missions.E2E.Tests"
UNIT_ONLY=false
KEEP_RUNNING=false
for arg in "$@"; do
case "$arg" in
--unit-only) UNIT_ONLY=true ;;
--keep-running) KEEP_RUNNING=true ;;
-h|--help)
cat <<USAGE
Usage: scripts/run-tests.sh [--unit-only] [--keep-running]
--unit-only Skip blackbox / e2e suite (run unit tests only). Currently
the missions service has no unit tests, so this is a no-op
until Step 6 (Implement Tests) introduces a test project.
--keep-running Do NOT teardown the docker compose stack on exit (useful
for debugging a failing test).
USAGE
exit 0 ;;
*)
echo "unknown arg: $arg" >&2
exit 64 ;;
esac
done
cleanup() {
local exit_code=$?
if [ "$KEEP_RUNNING" = "true" ]; then
echo "[run-tests] --keep-running set; leaving compose stack up." >&2
else
echo "[run-tests] tearing down compose stack..." >&2
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
trap cleanup EXIT
mkdir -p "$RESULTS_DIR"
## --- Install dependencies ---
## docker compose handles per-image dependency resolution (dotnet restore inside
## the missions Dockerfile, dotnet restore inside the e2e-consumer Dockerfile).
## Verify Docker + Compose are available on the host.
command -v docker >/dev/null 2>&1 || {
echo "[run-tests] ERROR: docker is required but not installed on PATH." >&2
exit 2
}
docker compose version >/dev/null 2>&1 || {
echo "[run-tests] ERROR: docker compose v2 plugin is required." >&2
exit 2
}
if [ ! -d "$TEST_PROJECT_DIR" ]; then
cat >&2 <<MSG
[run-tests] WARNING: test project not yet created at:
$TEST_PROJECT_DIR
This is expected until Step 6 (Implement Tests) of the autodev existing-code
flow has run. The compose stack will be brought up so you can manually exercise
the missions service via curl, but no automated tests will execute and the
exit code will be 0 (vacuous success).
To create the test project, follow:
- _docs/02_document/tests/environment.md (consumer app spec)
- the Step 6 task tickets that decompose-tests will produce
MSG
echo "[run-tests] starting compose stack (without e2e-consumer)..." >&2
docker compose -f "$COMPOSE_FILE" up -d postgres-test missions
docker compose -f "$COMPOSE_FILE" ps
exit 0
fi
## --- Build images and start system under test ---
echo "[run-tests] building images..." >&2
docker compose -f "$COMPOSE_FILE" build postgres-test missions
echo "[run-tests] starting postgres-test + missions..." >&2
docker compose -f "$COMPOSE_FILE" up -d postgres-test missions
echo "[run-tests] waiting for missions /health (timeout 60s)..." >&2
ATTEMPTS=0
until [ "$ATTEMPTS" -ge 60 ]; do
if curl -sf http://localhost:5002/health >/dev/null 2>&1; then
echo "[run-tests] missions is healthy." >&2
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
sleep 1
done
if [ "$ATTEMPTS" -ge 60 ]; then
echo "[run-tests] ERROR: missions did not become healthy within 60s." >&2
docker compose -f "$COMPOSE_FILE" logs missions >&2 || true
exit 3
fi
if [ "$UNIT_ONLY" = "true" ]; then
echo "[run-tests] --unit-only: missions service has no unit tests today." >&2
echo "[run-tests] (Step 6 may add a tests/Azaion.Missions.UnitTests/ project later.)" >&2
exit 0
fi
## --- Blackbox / e2e tests ---
echo "[run-tests] building e2e-consumer..." >&2
docker compose -f "$COMPOSE_FILE" --profile test build e2e-consumer
echo "[run-tests] running e2e suite via dotnet test..." >&2
## --abort-on-container-exit + --exit-code-from propagate the e2e-consumer exit
## code back to this script so the CI gate fires correctly.
docker compose -f "$COMPOSE_FILE" --profile test up \
--abort-on-container-exit \
--exit-code-from e2e-consumer \
e2e-consumer postgres-test missions
TEST_EXIT=$?
## --- Summary ---
if [ "$TEST_EXIT" -eq 0 ]; then
echo "[run-tests] ALL PASS." >&2
else
echo "[run-tests] FAILURES detected (exit code $TEST_EXIT)." >&2
echo "[run-tests] missions logs (last 50 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=50 missions >&2 || true
echo "[run-tests] e2e-consumer logs (last 100 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=100 e2e-consumer >&2 || true
fi
exit "$TEST_EXIT"