refactor: enhance JWT authentication and CORS configuration

Updated JWT authentication to use configuration values instead of hardcoded secrets, improving security and flexibility. Enhanced CORS policy to conditionally allow origins based on configuration settings, with logging for permissive defaults. Updated README to reflect project renaming and clarify service context.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 19:48:25 +03:00
parent 2fe394d732
commit 7025f4d075
74 changed files with 8494 additions and 19 deletions
@@ -0,0 +1,64 @@
# Flow F7 — Health probe
> Trivial flow. Documented for completeness because it is the contract every external orchestrator (Watchtower, docker compose healthcheck, reverse proxy) relies on.
## Description
`GET /health` returns `{ "status": "healthy" }` with no auth. Confirms the process is up and the HTTP pipeline is serving — does NOT confirm DB connectivity, JWT validation works, or any feature endpoint is reachable.
## Preconditions
- HTTP pipeline is serving (i.e., F6 reached `app.Run()`).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Probe as Watchtower / compose / reverse proxy
participant Host as 07_host
Probe->>Host: GET /health (no Authorization header)
Host-->>Probe: 200 OK + { "status": "healthy" }
```
## Flowchart
```mermaid
flowchart LR
Start([GET /health]) --> Resp([200 OK + healthy])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Probe | `MapGet("/health")` | (no body, no auth) | HTTP GET |
| 2 | `MapGet("/health")` | Probe | `{ "status": "healthy" }` | JSON (PascalCase irrelevant — single key) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Process down | TCP layer | Probe gets `ECONNREFUSED` | Orchestrator restarts container |
| Pipeline not yet at `app.Run()` (mid-startup) | TCP layer | TCP connect succeeds but no response | Probe times out; orchestrator typically retries with backoff |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Latency | <5ms | Pure pipeline execution; no I/O |
| Throughput | bounded only by ASP.NET Core's request handling | Not load-tested |
## Future improvement (carry-forward, NOT this Epic)
Add a DB ping:
```csharp
app.MapGet("/health", async (AppDataConnection db) =>
{
await db.ExecuteAsync("SELECT 1");
return Results.Ok(new { status = "healthy" });
});
```
This would let `flight-gate` and reverse-proxy checks reflect actual readiness rather than process liveness. Today the migrator runs at startup and crashes the process on DB failure (F6), which is a coarse but workable substitute for one-shot bring-up. In a steady-state running device, a transient DB outage AFTER startup would not be caught by `/health` today.
@@ -0,0 +1,93 @@
# 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.
## 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).
## Preconditions
- `JWT_SECRET` is resolved at startup (env or hardcoded dev fallback per `architecture.md` ADR-005).
- `AddJwtAuth(jwtSecret)` was called during `Program.cs` startup (F6).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler
participant Policy as Auth policy "FL"
participant Ctrl as Feature Controller
participant Errs as 06_http_conventions
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this)
Handler->>Handler: parse token; verify HMAC-SHA256 signature using SymmetricSecurityKey(UTF-8(JWT_SECRET))
alt Signature invalid OR token expired (ClockSkew = 1 minute)
Handler-->>Client: 401 Unauthorized
else Valid token
Handler->>Handler: build ClaimsPrincipal; skip iss/aud validation
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
end
end
```
## Flowchart
```mermaid
flowchart TD
Start([Incoming request]) --> AnonEP{Endpoint requires auth?}
AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized])
Header -->|yes| Sig{HMAC-SHA256 signature valid?}
Sig -->|no| Unauth2([401 Unauthorized])
Sig -->|yes| Life{Lifetime valid? ClockSkew=1min}
Life -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal — skip iss/aud]
BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header |
| 2 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 3 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` | .NET principal object |
| 4 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 5 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` |
| Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` |
| Signature mismatch (wrong / rotated `JWT_SECRET`) | `JwtBearerHandler` | HMAC verify fails | `401`. Suite-wide secret rotation is coordinated re-deploy of every backend that shares the secret + UI re-login |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 1 min`) | `401`. Tighter than .NET's 5-min default — caller may experience earlier expiration than expected |
| `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` |
| Token signed with the well-known dev fallback secret | (silent acceptance) | None | **Security risk in production**. ADR-005 carry-forward; suite-tracked under CMMC L2 row 3 |
| Token from a third-party that knows `JWT_SECRET` | (silent acceptance) | None | **Trust model is shared-secret intra-suite**. Any third-party with the secret can mint accepted tokens. Out of this Epic's scope; suite-wide concern |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Validation latency | sub-millisecond typical | Pure HMAC + claim lookup; no I/O, no network call |
| Throughput | bounded by request throughput | No back-pressure; no token cache (no DB / network round-trip to cache) |
## Notes on `iss` / `aud` validation (suite-tracked)
`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.
@@ -0,0 +1,140 @@
# Flow F3 — Mission delete with cross-service cascade
> **Most critical flow in this service.** Touches tables this service does NOT own the schema for; not transaction-wrapped today (`architecture.md` ADR-006). Post-rename / post-B7: cascade no longer touches `orthophotos` + `gps_corrections` (those moved to the separate `gps-denied` service).
## Description
`DELETE /missions/{id}` walks the full ownership graph for one mission and tears down rows in dependency order: `map_objects` (autopilot-written, owned-here schema) → for every waypoint, the `media` / `annotations` / `detection` rows transitively related to it (cross-service tables; schemas owned by `annotations` and the detection pipeline) → `waypoints``missions`. The walk runs against a single `AppDataConnection` (one Npgsql connection from the per-request scope), but is **not wrapped in a transaction** — partial failure leaves orphan rows. See `02_mission_planning` Caveats #1.
## Preconditions
- Mission exists (`KeyNotFoundException``404` otherwise).
- Schema for the borrowed tables (`media`, `annotations`, `detection`) is present in `postgres-local`. In standard suite edge deployment all sibling services have run their own migrations on the same DB. If `annotations` or detection pipeline is absent from the deployment, the cascade fails on `relation does not exist` (`02_mission_planning` Caveats #6).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as MissionsController
participant MS as MissionService
participant DB as 04_persistence (postgres-local)
participant Annot as [[annotations service schema]]
participant Det as [[detection pipeline schema]]
participant AP as [[autopilot service schema]]
UI->>Identity: DELETE /missions/{id} + JWT
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>MS: DeleteMission(id)
MS->>DB: SELECT 1 FROM missions WHERE id = ?
alt Not found
DB-->>MS: 0 rows
MS-->>Errs: KeyNotFoundException
Errs-->>UI: 404 Not Found
else Found
MS->>AP: DELETE FROM map_objects WHERE mission_id = ?
MS->>DB: SELECT id FROM waypoints WHERE mission_id = ? → waypointIds
opt waypointIds.Any()
MS->>Annot: SELECT id FROM media WHERE waypoint_id IN (waypointIds) → mediaIds
MS->>Annot: SELECT id FROM annotations WHERE media_id IN (mediaIds) → annotationIds
MS->>Det: DELETE FROM detection WHERE annotation_id IN (annotationIds)
MS->>Annot: DELETE FROM annotations WHERE id IN (annotationIds)
MS->>Annot: DELETE FROM media WHERE id IN (mediaIds)
end
MS->>DB: DELETE FROM waypoints WHERE mission_id = ?
MS->>DB: DELETE FROM missions WHERE id = ?
MS-->>Ctrl: void
Ctrl-->>UI: 204 No Content
end
```
## Cascade order (authoritative)
```
1. DELETE FROM map_objects WHERE mission_id = ?
(autopilot writes; this service owns schema and cleanup)
2. SELECT id FROM waypoints WHERE mission_id = ? → waypointIds
3. If waypointIds.Any():
SELECT id FROM media WHERE waypoint_id IN waypointIds → mediaIds
SELECT id FROM annotations WHERE media_id IN mediaIds → annotationIds
DELETE FROM detection WHERE annotation_id IN annotationIds (cross-service)
DELETE FROM annotations WHERE id IN annotationIds (cross-service)
DELETE FROM media WHERE id IN mediaIds (cross-service)
4. DELETE FROM waypoints WHERE mission_id = ?
5. DELETE FROM missions WHERE id = ?
```
The order is FK-driven (child rows before parent) and it is the spec-defined behavior in `../../../suite/_docs/02_missions.md` § 9.
## Flowchart (with B7 — no GPS-Denied branch)
```mermaid
flowchart TD
Start([DELETE /missions/id]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT 1 FROM missions WHERE id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([404])
Exists -->|yes| MapObj[DELETE FROM map_objects WHERE mission_id=?]
MapObj --> WPs[SELECT id FROM waypoints WHERE mission_id=?]
WPs --> AnyWP{Any waypoints?}
AnyWP -->|no| DelWP[DELETE FROM waypoints]
AnyWP -->|yes| Media[SELECT id FROM media WHERE waypoint_id IN ?]
Media --> Anns[SELECT id FROM annotations WHERE media_id IN ?]
Anns --> Det[DELETE FROM detection WHERE annotation_id IN ?]
Det --> DelAnn[DELETE FROM annotations]
DelAnn --> DelMed[DELETE FROM media]
DelMed --> DelWP
DelWP --> DelMis[DELETE FROM missions]
DelMis --> Done([204 No Content])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` | mission id (URL) | path param |
| 2 | `MissionService` | `map_objects` | `DELETE` | SQL |
| 3 | `MissionService` | `waypoints` (read) | `SELECT id` | SQL → list of GUIDs in memory |
| 4 | `MissionService` | `media` (read) | `SELECT id WHERE waypoint_id IN (...)` | SQL → list of strings (TEXT PK) |
| 5 | `MissionService` | `annotations` (read) | `SELECT id WHERE media_id IN (...)` | SQL → list of strings |
| 6 | `MissionService` | `detection` / `annotations` / `media` (delete) | `DELETE WHERE ... IN (...)` | SQL |
| 7 | `MissionService` | `waypoints` / `missions` (delete) | `DELETE` | SQL |
| 8 | `MissionService` | UI | (no body) | `204 No Content` |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Mission not found | Step 1 (existence check) | `null` lookup | `KeyNotFoundException``404` |
| `relation does not exist` for `media` / `annotations` / `detection` | Steps 46 | Npgsql `PostgresException` (`42P01`) | `500`. **Indicates `annotations` or detection pipeline never migrated on this device.** Abnormal edge deployment — fix is to run those services' migrations once. See `02_mission_planning` Caveats #6 |
| Partial failure mid-cascade (network blip, lock timeout, FK violation) | Any DELETE step | Npgsql exception | `500`. **Orphan rows left behind.** Re-running the same `DELETE /missions/{id}` is partially safe — already-deleted children are no-ops, remaining children proceed; but the original mission row may already be deleted by a successful step 7 in the previous attempt, leaving step 5/6 children orphaned forever. **Mitigated by ADR-006 carry-forward** (transaction wrap) |
| `autopilot` writes a `map_object` racing this delete | Step 2 vs. concurrent insert | None | Insert may succeed AFTER `DELETE FROM map_objects` reads zero rows, leaving an orphan that survives until the next mission delete or manual cleanup. Small race window in single-operator workflow |
| Cascade depth grows because a waypoint accumulates many media rows | Step 4 / 5 result sets | None enforced | LinqToDB sends parameter list inline; PostgreSQL can take 65k params in `IN (...)`. Above ~30k waypoints / media this would need batching — not a near-term concern |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency | <50ms typical for missions with ≤100 waypoints / ≤1000 media | 47 sequential round-trips against local PostgreSQL on the same device |
| Latency on a "fat" mission (10k waypoints / 100k media) | seconds | Each `IN (...)` resolution scales linearly with the result set; PG plan is FK-driven so no full scan |
| Orphan rate | 0 once transaction-wrap lands (ADR-006) | Today: non-zero on any failure mid-cascade |
| Throughput | 1 op / mission delete; not load-tested | Operator-paced; not a hot path |
## Notes on B7 (post-GPS-Denied-removal)
Pre-B7 the cascade also included:
```
DELETE FROM gps_corrections WHERE waypoint_id IN waypointIds
DELETE FROM orthophotos WHERE mission_id = ?
```
Both branches are removed in B7. The `gps-denied` service now owns those tables and is responsible for cleaning up its own rows when missions are deleted (its own concern, its own decision — see `architecture.md` ADR-007). There is **no runtime call** from `missions` to `gps-denied` to "tell it to clean up"; the decoupling is intentional. If the `gps-denied` service is interested in the deletion event, it can poll, watch, or rely on its own per-row TTL — that is a `gps-denied`-side decision documented in `../../../suite/_docs/11_gps_denied.md`.
@@ -0,0 +1,82 @@
# Flow F2 — Mission create / read / update
> Post-rename. Today: `[Route("flights")]`, `Flight*` files.
## Description
Mission CRUD excluding delete (delete is the cross-service cascade in F3). Create / update validate that the referenced `vehicle_id` exists; list (`GET /missions`) is the only paginated endpoint in this service.
## Preconditions
- Service is running, schema in place (F6).
- Caller holds JWT with `permissions=FL` (F5).
- For create / update with `VehicleId`: the referenced vehicle exists (F1).
## Sequence Diagram (POST `/missions`)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as MissionsController
participant MS as MissionService
participant DB as 04_persistence (postgres-local)
UI->>Identity: POST /missions + JWT + { Name, VehicleId }
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>MS: CreateMission(req)
MS->>DB: SELECT 1 FROM vehicles WHERE id = @VehicleId
alt Vehicle missing
DB-->>MS: 0 rows
MS-->>Errs: throw ArgumentException("VehicleId not found")
note right of MS: Spec says 404; code returns 400. Carry-forward.
Errs-->>UI: 400 Bad Request (PascalCase error envelope)
else Vehicle exists
DB-->>MS: 1 row
MS->>DB: INSERT INTO missions (id, name, vehicle_id, created_date) VALUES (...)
DB-->>MS: row inserted
MS-->>Ctrl: Mission entity
Ctrl-->>UI: 201 Created + Mission (Vehicle / Waypoints serialize as null / [] — no eager load)
end
```
## Flowchart (GET `/missions` paginated)
```mermaid
flowchart TD
Start([GET /missions?Name=&FromDate=&ToDate=&Page=&PageSize=]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Build[Build LINQ predicate from optional filters]
Build --> Count[COUNT * over filtered set]
Count --> Page[SELECT ... ORDER BY created_date DESC LIMIT pageSize OFFSET]
Page --> Wrap[Wrap in PaginatedResponse Items, TotalCount, Page, PageSize]
Wrap --> Done([200 OK + envelope, PascalCase])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` | `CreateMissionRequest` / `UpdateMissionRequest` / `GetMissionsQuery` | JSON / query string (PascalCase) |
| 2 | `MissionService` | `vehicles` table | existence check `SELECT 1` | SQL |
| 3 | `MissionService` | `missions` table | INSERT / UPDATE / SELECT | SQL |
| 4 | `MissionService` | UI | `Mission` entity / `PaginatedResponse<Mission>` | JSON (PascalCase) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `VehicleId` missing on create / update | `MissionService.CreateMission` / `UpdateMission` | existence check returns false | `ArgumentException``400` (spec wants `404` — minor divergence, B-set carry-forward) |
| TOCTOU: vehicle deleted between existence check and insert | `MissionService.CreateMission` | FK constraint violation | Npgsql `PostgresException` → middleware → `500`. UX gap (should be `400`); rare in practice |
| Mission not found | `MissionService.GetMission` / `UpdateMission` | entity lookup `null` | `KeyNotFoundException``404` |
| Page / PageSize out of range | None enforced | n/a | LinqToDB `Skip(negative)` / `Take(0)` returns empty set; no error returned to client |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency (single mission) | <15ms typical | Two round-trips on create (existence check + insert); one on read |
| Paginated list latency | <30ms typical for ≤1000 rows | No index on `created_date` — full scan + sort. Add `ix_missions_created_date` if list latency becomes an issue |
| Throughput | Operator-paced | Not load-tested |
@@ -0,0 +1,92 @@
# Flow F6 — Service startup + schema migration
> One-shot per process start. Idempotent migrator (`CREATE ... IF NOT EXISTS`). Post-B9 the migrator additionally `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded edge devices that previously ran the legacy schema.
## Description
`Program.cs` builds the DI graph from environment, runs `DatabaseMigrator.Migrate(db)` once inside a startup scope, then starts serving HTTP. The migrator only owns the 4 tables this service is responsible for (`vehicles`, `missions`, `waypoints`, `map_objects`); the borrowed tables (`media`, `annotations`, `detection`) are migrated by their owner services in their own startups.
## Preconditions
- `DATABASE_URL` resolves (env or hardcoded dev fallback).
- `postgres-local` is reachable.
- The `azaion` database itself exists in PostgreSQL (created at provisioning time, NOT by this migrator).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Docker as Docker / Watchtower
participant Host as 07_host (Program.cs)
participant Cfg as IConfiguration
participant Identity as 05_identity
participant DI as DI container
participant Migrator as DatabaseMigrator
participant DB as postgres-local
Docker->>Host: ENTRYPOINT dotnet Azaion.Missions.dll
Host->>Cfg: read DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: read JWT_SECRET (env or hardcoded fallback)
Host->>Identity: AddJwtAuth(jwtSecret) — DI registration only, no network
Host->>DI: register controllers + middleware + scoped AppDataConnection + scoped services
Host->>DI: build Host
Host->>Migrator: scope.Resolve<AppDataConnection>(); Migrate(db)
Migrator->>DB: CREATE TABLE IF NOT EXISTS vehicles (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS missions (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS waypoints (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS map_objects (...)
Migrator->>DB: CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id
Migrator->>DB: DROP TABLE IF EXISTS orthophotos
Migrator->>DB: DROP TABLE IF EXISTS gps_corrections
note right of Migrator: B9 one-shot. Idempotent on devices that already cleaned up.
Migrator-->>Host: void
Host->>Host: register ErrorHandlingMiddleware FIRST in pipeline
Host->>Host: UseAuthentication / UseAuthorization
Host->>Host: MapControllers + MapGet("/health") + UseSwagger
Host->>Docker: app.Run() — listening on 0.0.0.0:8080
```
## Flowchart
```mermaid
flowchart TD
Start([Container start]) --> ReadCfg[Read DATABASE_URL + JWT_SECRET]
ReadCfg --> RegDI[DI registrations: controllers, middleware, scoped DB + services]
RegDI --> Build[Build Host]
Build --> OpenScope[Open startup scope]
OpenScope --> Migrate[Run DatabaseMigrator.Migrate]
Migrate --> Create["CREATE TABLE IF NOT EXISTS x4 + indexes"]
Create --> Drop["DROP TABLE IF EXISTS orthophotos, gps_corrections (B9)"]
Drop --> Pipeline[Wire pipeline: error MW first, auth, controllers, /health, Swagger]
Pipeline --> Run([app.Run on :8080])
Migrate -. on failure .-> Crash([Process exits non-zero — Watchtower restarts])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Environment / appsettings | `Program.cs` | `DATABASE_URL`, `JWT_SECRET` | string |
| 2 | `Program.cs` | DI container | service registrations | C# code |
| 3 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL |
| 4 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `postgres-local` unreachable | Migrate step | Npgsql `IOException` / `SocketException` | Process exits non-zero. Watchtower restarts; `flight-gate` prevents restart mid-mission. **Fix**: ensure `postgres-local` healthcheck passes before `missions` starts (compose `depends_on` with `condition: service_healthy`) |
| `azaion` database missing | Migrate step | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database — provisioning concern, not this service. Documented in `../../suite/_docs/00_top_level_architecture.md` |
| `DROP TABLE IF EXISTS orthophotos` fails because table is locked by `gps-denied` | B9 one-shot | Lock timeout or `55006` | Process exits, Watchtower restarts in a few seconds. **Out-of-band ordering**: deploy `gps-denied` FIRST so it has its own copy of the schema before `missions` drops the legacy tables. Documented in B9 ticket |
| One `CREATE TABLE` succeeds, the next fails | Mid-Migrate | Npgsql exception on later statement | Process exits. Each statement is individually idempotent (`IF NOT EXISTS`) so the next startup retries safely from the start. No partial-migration cleanup needed |
| Wrong PostgreSQL version (e.g., < 13) | Migrate step | Specific syntax errors in newer features | Process exits. Suite-supported version is PG 16+; older devices need a Postgres upgrade |
| `DATABASE_URL` malformed (e.g. user password contains `@`) | `ConvertPostgresUrl` | parse failure / silent mis-parse | `ConvertPostgresUrl` does NOT URL-decode user/password — caveat for credentials with `@`, `:`, `/`, `%`. `07_host` Caveats. Mitigation: avoid those chars in passwords, OR pass a raw Npgsql key=value string instead of a URL |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Cold start total time | <2 seconds typical on Jetson Orin | Migrator runs ~10 DDL statements; all are no-ops on a steady-state device |
| Cold start with legacy GPS-Denied tables present | +1 second | First-time-on-device B9 `DROP` adds two DDL statements |
| Crash recovery (Watchtower restart) | ~10 seconds | Container restart latency dominates |
@@ -0,0 +1,87 @@
# Flow F1 — Vehicle CRUD
> Post-rename / post-B7. Today: `[Route("aircrafts")]`, `Aircraft*` files. See `02_mission_planning` is unaffected by route prefix.
## Description
Operator manages the inventory of `Vehicle` rows. Six endpoints: POST, PUT, DELETE, GET-list (unpaginated by spec), GET-by-id, PATCH `/default`. Every endpoint is gated by `[Authorize(Policy = "FL")]`. The "exactly one default vehicle" exclusivity rule is **stricter than spec** — the code clears `IsDefault` on every other row before setting it on the target. See `01_vehicle_catalog` Caveats #1, tracked under Jira AZ-551 (B12).
## Preconditions
- Service is running and schema is in place (Flow F6 has completed).
- Caller holds a JWT with `permissions=FL` (Flow F5 succeeds).
## Sequence Diagram (POST `/vehicles` with `IsDefault: true`)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Pipeline as ASP.NET Pipeline
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as VehiclesController
participant Svc as VehicleService
participant DB as 04_persistence (postgres-local)
UI->>Pipeline: POST /vehicles + Bearer JWT + body
Pipeline->>Errs: enter middleware (catches exceptions)
Errs->>Identity: validate JWT + policy "FL"
alt JWT or policy fails
Identity-->>UI: 401 / 403
else authorized
Identity-->>Ctrl: ClaimsPrincipal attached
Ctrl->>Svc: CreateVehicle(req)
opt req.IsDefault == true
Svc->>DB: UPDATE vehicles SET is_default=FALSE WHERE is_default=TRUE
note right of Svc: Stricter than spec. Race-prone (no transaction). B12.
end
Svc->>DB: INSERT INTO vehicles VALUES (...)
DB-->>Svc: row id
Svc-->>Ctrl: Vehicle entity
Ctrl-->>Errs: 201 Created + Vehicle (PascalCase JSON)
Errs-->>UI: 201 Created
end
```
## Flowchart (DELETE `/vehicles/{id}`)
```mermaid
flowchart TD
Start([DELETE /vehicles/id]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT 1 FROM vehicles WHERE id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([KeyNotFoundException → 404])
Exists -->|yes| Refs[SELECT 1 FROM missions WHERE vehicle_id=?]
Refs --> InUse{Any mission references it?}
InUse -->|yes| Conflict([InvalidOperationException → 409])
InUse -->|no| Del[DELETE FROM vehicles WHERE id=?]
Del --> Done([204 No Content])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `VehiclesController` | `CreateVehicleRequest` / `UpdateVehicleRequest` / `SetDefaultRequest` / query string | JSON (PascalCase) |
| 2 | `VehicleService` | `vehicles` table | INSERT / UPDATE / DELETE | SQL |
| 3 | `vehicles` table | `VehicleService` | row(s) | LinqToDB entity mapping |
| 4 | `VehicleService` | UI | `Vehicle` entity | JSON (PascalCase) — entity returned directly, no DTO mapping |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing / invalid JWT | Pipeline | `JwtBearerHandler` | `401`; client refreshes token |
| Missing `"FL"` claim | Policy evaluator | Claim lookup | `403` |
| Vehicle not found | Service entity lookup | `null` result | `KeyNotFoundException``404` |
| Delete-with-references | `VehicleService.DeleteVehicle` | `IsAny<Mission>` true | `InvalidOperationException``409` |
| Concurrent default-set | `VehicleService.{Create,Update}Vehicle` / `SetDefault` | none (no transaction) | Race window: 2+ defaults OR zero defaults. B12 |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency (CRUD) | <10ms typical | Single round-trip against local PostgreSQL |
| Throughput | Operator-paced | Not load-tested; catalog is small in practice (tens to low hundreds of rows) |
@@ -0,0 +1,86 @@
# Flow F4 — Waypoint create / read / update / delete
> Post-rename / post-B7. Waypoint delete is a scoped variant of F3's cross-service cascade — same NO-transaction caveat applies (`architecture.md` ADR-006).
## Description
Waypoint CRUD nested under a mission (`/missions/{id}/waypoints/*`). Read-list is **unpaginated by spec**, ordered by `OrderNum`. **`UpdateWaypoint` is a full overwrite** of every field even though the request DTO looks "partial-shaped" (see `02_mission_planning` Caveats #2). Delete walks the cross-service cascade for **one** waypoint (compare F3 which walks for ALL waypoints of a mission).
## Preconditions
- Parent mission exists (`KeyNotFoundException``404` otherwise on every endpoint).
- Caller holds JWT with `permissions=FL` (F5).
- Schema in place for borrowed tables (`media`, `annotations`, `detection`) for delete.
## Sequence Diagram (DELETE one waypoint)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Ctrl as MissionsController
participant WS as WaypointService
participant DB as 04_persistence (postgres-local)
participant Annot as [[annotations service schema]]
participant Det as [[detection pipeline schema]]
UI->>Identity: DELETE /missions/{id}/waypoints/{wpId} + JWT
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>WS: DeleteWaypoint(missionId, wpId)
WS->>DB: SELECT 1 FROM waypoints WHERE mission_id=? AND id=?
alt Not found
DB-->>WS: 0 rows
WS-->>UI: 404 Not Found
else Found
WS->>Annot: SELECT id FROM media WHERE waypoint_id = ? → mediaIds
WS->>Annot: SELECT id FROM annotations WHERE media_id IN ? → annotationIds
WS->>Det: DELETE FROM detection WHERE annotation_id IN annotationIds
WS->>Annot: DELETE FROM annotations WHERE id IN annotationIds
WS->>Annot: DELETE FROM media WHERE id IN mediaIds
WS->>DB: DELETE FROM waypoints WHERE id = ?
WS-->>UI: 204 No Content
end
```
## Flowchart (PUT — full overwrite)
```mermaid
flowchart TD
Start([PUT /missions/id/waypoints/wpId + body]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT * FROM waypoints WHERE mission_id=? AND id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([404])
Exists -->|yes| Overwrite["UPDATE waypoints SET lat=?, lon=?, mgrs=?, alt=?, source=?, objective=?, order_num=?, name=? WHERE id=?"]
Overwrite --> Done([200 OK + Waypoint])
note1[NOTE: Sending partial body zeroes out missing numeric fields and resets enums to default 0. Spec uses Geopoint type with auto-conversion; code uses 3 flat fields. Carry-forward.]
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` (nested) | mission id + waypoint id (URL) + `CreateWaypointRequest` / `UpdateWaypointRequest` body | path params + JSON |
| 2 | `WaypointService` | `waypoints` table | INSERT / UPDATE / SELECT / DELETE | SQL |
| 3 | `WaypointService` | `media` / `annotations` / `detection` (delete only) | `SELECT id` then `DELETE WHERE id IN (...)` | SQL |
| 4 | `WaypointService` | UI | `Waypoint` entity / `List<Waypoint>` / `204` | JSON (PascalCase) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Parent mission not found | Service entity lookup | `null` | `KeyNotFoundException``404` |
| Waypoint not found in mission | Service entity lookup with both ids | `null` | `KeyNotFoundException``404` |
| PUT zeroes out coordinates | `WaypointService.UpdateWaypoint` | None | Body intent is "partial" but code overwrites every column → silent data loss for missing fields. Carry-forward (`02_mission_planning` Caveats #2) |
| Race on N waypoints reordered as N PUTs | Caller-side | None | No reorder endpoint exists — caller must coordinate; partially-applied reorders leave inconsistent `order_num`s (`02_mission_planning` Caveats #5) |
| Delete cascade `relation does not exist` for `media` / `annotations` / `detection` | DELETE steps | Npgsql `PostgresException` (`42P01`) | `500`. Same diagnosis as F3: abnormal edge deployment |
| Partial failure mid-delete-cascade | Same as F3 | Npgsql exception | `500` + orphan rows. ADR-006 carry-forward |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Create / read / update | <10ms typical | Single round-trip |
| List (unpaginated) | <30ms typical for ≤1000 waypoints | `ix_waypoints_mission_id` index used; sort by `order_num` is in-memory (no order index) |
| Delete (with cross-service cascade) | <30ms typical for ≤100 media rows per waypoint | 56 round-trips |