[AZ-535] [AZ-533] Logout/revocation surface + UAV mission tokens
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

AZ-535: POST /logout (caller's session), /logout/all (all sessions for user),
admin POST /sessions/{sid}/revoke, and verifier-only GET /sessions/revoked
snapshot. New Service role gates the snapshot. Idempotent revoke; reason +
revoked_by_user_id audited per row.

AZ-533: POST /sessions/mission mints a long-lived no-refresh ES256 token bound
to one aircraft + one mission. Audience narrowed to satellite-provider, hard
12 h cap, persisted as class='mission' so the existing logout/revoke surface
covers it. Successful CompanionPC /login or /token/refresh auto-revokes that
aircraft's open mission session (post-flight reconnect).

Schema: 09_sessions_logout_and_mission.sql adds revoked_by_user_id, class,
aircraft_id; drops NOT NULL on refresh_hash for mission rows; adds two partial
indexes for the auto-revoke and snapshot hot paths.

Tests: 13 new e2e tests, all green; full suite 75/76 (1 pre-existing flake in
PasswordHashingTests AC5 timing assertion, unrelated to this batch).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 05:51:23 +03:00
parent 51a293dbcc
commit 8e7c602f51
19 changed files with 1210 additions and 25 deletions
@@ -0,0 +1,102 @@
# Mission-Token Issuance for Disconnected UAV Operations
**Task**: AZ-533_mission_token_uav
**Name**: Mission-token issuance for disconnected UAV operations
**Description**: New `POST /sessions/mission` endpoint that issues a single long-lived (≤11 h) access token for one specific flight. Narrowly scoped (`mission_id`, `aircraft_id`, `aud`), one-shot, auto-revoked on aircraft reconnect. Solves the "10 h offline UAV vs 15 min ground access token" tension without weakening interactive-session security.
**Complexity**: 5 points
**Dependencies**: AZ-531 (needs `sessions` table for revocation tracking). Can implement in parallel; final wiring depends on AZ-531.
**Component**: Admin API + Services + DataAccess
**Tracker**: AZ-533
**Epic**: AZ-529
## Problem
UAV missions can fly up to 10 h fully offline (no Starlink, no admin reachability). Standard short-lived access tokens (15 min) plus refresh-on-network are physically impossible during flight. Today's solution would be "set JWT lifetime to 4 h and pray", which is both too short for full missions and too long for ground operations — a single lifetime can't satisfy both.
## Outcome
- New endpoint `POST /sessions/mission` (auth: existing interactive access token, MFA proven within last 15 min by virtue of refresh chain).
- Body: `{ mission_id, aircraft_id, planned_duration_h, requested_scope }`.
- Returns: a single long-lived access token (no refresh) with custom claims:
```json
{
"sub": "<pilot-or-aircraft-user-id>",
"iss": "AzaionApi",
"aud": "satellite-provider",
"exp": "now + planned_duration_h + 1h",
"mission_id": "M-2026-05-14-042",
"aircraft_id": "UAV-117",
"valid_region": { "...bbox..." : "..." },
"permissions": ["GPS"],
"sid": "<session-id>",
"jti": "<token-id>",
"token_class": "mission"
}
```
- Mission tokens are recorded in `sessions` table with `class='mission'` so logout/revocation works.
- On post-flight reconnect (any successful auth call from the same `aircraft_id`), all open mission sessions for that aircraft are auto-revoked.
## Scope
### Included
- `MissionSessionRequest` / `MissionSessionResponse` DTOs in `Azaion.Common/Requests/`.
- Validation: `planned_duration_h` ∈ [0.1, 12]; `mission_id` matches `M-YYYY-MM-DD-NNN`; `aircraft_id` exists in users table with `Role=CompanionPC`.
- Auto-revoke-on-reconnect logic in middleware (cheap: index on `sessions(aircraft_id, class, revoked_at)`).
- Tests: happy path, scope-narrowing, max-duration cap, auto-revoke on next call.
### Excluded
- Hardware binding (mTLS / DPoP / `cnf` claim) — separate future ticket. This ticket gets the lifetime + scope right; hardware binding is a hardening pass.
- Verifier-side enforcement of `mission_id`/`valid_region`/`aircraft_id` claims — filed under satellite-provider once admin ships.
- Pre-flight ground station UX (file/load mission token onto UAV) — client/UI concern.
## Acceptance Criteria
**AC-1: Mission token issued with correct lifetime**
Given an authenticated pilot session and `planned_duration_h=9`
When `POST /sessions/mission` is called
Then response includes a single access token with `exp ≈ now + 10h` (±60s), no refresh token, `token_class="mission"`.
**AC-2: Hard cap enforced**
Given `planned_duration_h=15`
When called
Then 400 with detail `"planned_duration_h must be ≤ 12"`.
**AC-3: Scope claims present**
Given a request with `mission_id` and `aircraft_id`
When the returned token is decoded
Then `mission_id`, `aircraft_id`, `aud="satellite-provider"`, `permissions`, `sid`, `jti` all present.
**AC-4: Auto-revoke on reconnect**
Given aircraft UAV-117 has an open mission session M-001
When UAV-117 calls any `/token/refresh` or `/login` endpoint successfully
Then the M-001 mission session is marked `revoked_reason='post_flight_reconnect'` and that token stops working.
**AC-5: Issued only against an authenticated session**
Given no auth header
When `POST /sessions/mission` is called
Then 401.
**AC-6: Auth claim chain proven (MFA step-up)**
Given the requesting access token has `amr=["pwd"]` only (no MFA)
When `POST /sessions/mission` is called (after AZ-534 ships)
Then 403 with detail `"mission tokens require step-up MFA"`. Until AZ-534 ships, AC-6 is enforced as a TODO comment in code; do not block this ticket on AZ-534.
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-1 | Pilot session, 9h request | POST /sessions/mission | exp ≈ now+10h, no refresh, class=mission | — |
| AC-2 | 15h request | POST /sessions/mission | 400 with cap message | — |
| AC-3 | Mission token from AC-1 | Decode claims | mission_id, aircraft_id, aud, sid, jti present | — |
| AC-4 | Open mission for UAV-117 | UAV-117 calls /token/refresh | Mission revoked, token dead | — |
| AC-5 | No auth header | POST /sessions/mission | 401 | — |
| AC-6 | amr=["pwd"] token (post-AZ-534) | POST /sessions/mission | 403 step-up required | — |
## Risks / Notes
- Long-lived tokens are dangerous if leaked. Hardware binding is the right long-term answer; document this as known-risk in `_docs/05_security/security_report.md`.
- The `valid_region` bbox is informational until satellite-provider enforces it. Document the planned enforcement in the cross-workspace coordination note.
@@ -0,0 +1,82 @@
# Logout Endpoint + Revocation Surface for Verifiers
**Task**: AZ-535_logout_revocation
**Name**: Logout endpoint + revocation surface for verifiers
**Description**: Add `POST /logout`, `POST /logout/all`, admin-only `POST /sessions/{sid}/revoke`, and a `GET /sessions/revoked?since=<ts>` snapshot endpoint that verifiers (satellite-provider, gps-denied, ui) poll to maintain a local denylist. Without this, JWTs cannot be revoked before `exp`.
**Complexity**: 3 points
**Dependencies**: AZ-531 (needs the `sessions` table); coordinate `jti`/`sid` claim stamping
**Component**: Admin API + Services + DataAccess
**Tracker**: AZ-535
**Epic**: AZ-529
## Problem
With stateless JWT validation, logout doesn't actually exist. Calling `/logout` on admin can clear admin's session, but satellite-provider, gps-denied, and any other verifier keep accepting the same token until `exp`. There is no way to forcibly kick a session in real time (e.g. "GPS permission revoked, end the flight").
## Outcome
- `POST /logout` endpoint: revokes the caller's current session (refresh + all access tokens minted from it). Idempotent.
- `POST /logout/all` endpoint: revokes every session for the caller's user (full "sign out everywhere").
- `POST /sessions/{sid}/revoke` (admin-only): revoke any session by id ("GPS permission revoked, kill flight UAV-117 mission M-042").
- Verifiers consume revocation via either:
- **Pull mode (default)**: `GET /sessions/revoked?since=<unix-ts>` returns `[{ jti, sid, exp }]`. Verifiers poll every 30 s and maintain a local denylist with TTL = token's remaining lifetime.
- **Push mode (optional)**: a Redis pub/sub channel `auth:revoked` for sub-second propagation. Pull is mandatory; push is best-effort acceleration.
## Scope
### Included
- `POST /logout`, `POST /logout/all`, `POST /sessions/{sid}/revoke` handlers in `Azaion.AdminApi/Program.cs`.
- `GET /sessions/revoked?since=<ts>` endpoint authenticated via service-to-service JWT issued to each verifier identity (each verifier has a dedicated `Role=Service` user).
- Update `sessions` table with `revoked_at`, `revoked_reason`, `revoked_by_user_id` (these columns may already be present from AZ-531; if so, this ticket only adds `revoked_by_user_id`).
- Snapshot endpoint must auto-prune entries whose `exp < now()` so the response stays bounded.
- Tests: logout works, all-logout works, admin-revoke works, revoked endpoint returns recent revocations and excludes expired.
### Excluded
- Verifier-side denylist consumption (per-verifier ticket, filed when admin ships).
- Redis pub/sub push channel — nice-to-have; pull-based snapshot is the contract.
- Per-permission revocation in real time (e.g. "revoke just GPS, keep session alive") — architecturally requires moving permissions out of the JWT; future ticket.
## Acceptance Criteria
**AC-1: /logout revokes the session**
Given a valid access + refresh token pair
When `POST /logout` is called with the access token
Then the session row is marked `revoked_at=now()`, `revoked_reason='user_logout'`. The refresh token stops working.
**AC-2: /logout/all revokes every session for the user**
Given user U has 3 active sessions
When `POST /logout/all` is called from any one of them
Then all 3 sessions are revoked.
**AC-3: Admin can revoke any session by id**
Given user U has session SID-X
When an Admin-role JWT calls `POST /sessions/SID-X/revoke`
Then SID-X is marked revoked with `revoked_by_user_id` = the admin's id.
**AC-4: /sessions/revoked snapshot returns recent revocations**
Given 5 sessions revoked in the last hour, 2 of which already expired
When `GET /sessions/revoked?since=<1h-ago>` is called by an authenticated verifier
Then response is the 3 non-expired ones, with `[{ jti, sid, exp }]`. `Cache-Control: no-cache` (this is real-time data).
**AC-5: Idempotent logout**
Given a session already revoked
When `POST /logout` is called again with the same token
Then 200 with `{ already_revoked: true }`. No DB write.
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-1 | Active session | POST /logout | Session revoked, refresh dead | — |
| AC-2 | User with 3 sessions | POST /logout/all from any | All 3 revoked | — |
| AC-3 | Admin JWT, target SID-X | POST /sessions/SID-X/revoke | SID-X revoked with admin id | — |
| AC-4 | 5 revoked (2 expired) | GET /sessions/revoked?since=… | Returns 3 non-expired | — |
| AC-5 | Already-revoked session | POST /logout again | 200 already_revoked, no DB write | — |
## Risks / Notes
- The pull endpoint must NOT leak revocations across users to non-admin callers. Verifier identity is service-level (each verifier has a dedicated `Role=Service` user with read-revocations permission); they get the global feed. Regular users only see their own sessions if a future endpoint is added.
- 30 s polling means up to 30 s of "stale token works" after logout. Documented as acceptable; for sub-second, deploy the optional Redis push.
- Coordinate auto-prune cadence to keep snapshot < 5 KB even at high revocation rates.