[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
+4 -4
View File
@@ -1,7 +1,7 @@
# Dependencies Table
**Date**: 2026-05-14 (post batch 2 cycle 2; previous 2026-05-14)
**Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 2 done auth-modernization + 3 active product tasks)
**Date**: 2026-05-14 (post batch 3 cycle 2; previous 2026-05-14)
**Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 4 done auth-modernization + 1 active product task)
**Total Complexity Points**: 71
| Task | Name | Complexity | Dependencies | Epic | Status |
@@ -19,9 +19,9 @@
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done |
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done |
| AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | done |
| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | todo |
| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | done |
| AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo |
| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | todo |
| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | done |
| AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | done |
| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | done |
| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done |
@@ -0,0 +1,76 @@
# Batch Report
**Batch**: 3 (cycle 2)
**Tasks**: AZ-535 (logout_revocation), AZ-533 (mission_token_uav)
**Date**: 2026-05-14
**Total Complexity**: 8 points (5 + 3)
**Epic**: AZ-529 — Auth Mechanism Modernization
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|--------|--------|--------------------------------------|-----------------|-------------|--------|
| AZ-535 | Done | 6 source + 1 sql migration + 1 test | 7/7 pass | 4/4 | None |
| AZ-533 | Done | 4 source + (shared migration) + 1 test | 6/6 pass | 4/4 | None |
## Files Touched
**Source (production)**
- `Azaion.AdminApi/Program.cs` — DI for `ISessionService`/`IMissionTokenService`; new `revocationReaderPolicy` (Service|ApiAdmin); new endpoints `/logout`, `/logout/all`, `/sessions/{sid}/revoke`, `/sessions/revoked`, `/sessions/mission`; `/login` + `/token/refresh` now trigger `RevokeMissionsForAircraft` when the authenticated user is a `CompanionPC`
- `Azaion.AdminApi/BusinessExceptionHandler.cs` — map `SessionNotFound` → 404, `InvalidMissionRequest` / `AircraftNotFound` → 400
- `Azaion.Common/BusinessException.cs` — add `SessionNotFound = 53`, `InvalidMissionRequest = 54`, `AircraftNotFound = 55`
- `Azaion.Common/Entities/Session.cs` — add `RevokedByUserId`, `Class`, `AircraftId`; `RefreshHash` made nullable; `SessionRevokedReasons` extended with `LoggedOutAll`, `AdminRevoked`, `PostFlightReconnect`; new `SessionClasses { Interactive, Mission }`
- `Azaion.Common/Entities/RoleEnum.cs` — add `Service = 60` (verifier identity)
- `Azaion.Common/Requests/MissionSessionRequest.cs`*new*; `MissionSessionRequest` / `MissionSessionResponse` / `ValidRegion`
- `Azaion.Services/SessionService.cs`*new*; `RevokeBySid` (idempotent), `RevokeAllForUser`, `RevokeMissionsForAircraft`, `GetRevokedSince` (TTL-bounded snapshot)
- `Azaion.Services/MissionTokenService.cs`*new*; mission-id regex + duration bounds + aircraft-role validation; mints ES256 token with `mission_id`/`aircraft_id`/`token_class`/`valid_region` claims and narrowed `aud=satellite-provider`; persists session row BEFORE returning token
**Migrations / infra**
- `env/db/09_sessions_logout_and_mission.sql`*new*; ALTER TABLE adds `revoked_by_user_id`, `class`, `aircraft_id`; drops NOT NULL on `refresh_hash` (mission rows have no refresh value); two partial indexes (`sessions_aircraft_active_idx`, `sessions_revoked_at_idx`)
- `e2e/db-init/00_run_all.sh` — apply 09_sessions_logout_and_mission.sql in test DB
**Tests**
- `e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs`*new*; 7 tests (logout idempotent, logout/all, admin revoke-by-sid, non-admin forbidden, service polls snapshot, non-service forbidden)
- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs`*new*; 6 tests (claims+lifetime, mission-id pattern, duration bounds×2, aircraft role, auto-revoke on reconnect)
- `e2e/Azaion.E2E/Helpers/DbHelper.cs` — add `CountActiveSessionsForUser`, `CountOpenMissionsForAircraft`, `GetRevocationInfo`, `PromoteToService`
## Test Run Results
**Batch 3 only** (`--filter LogoutRevocationTests|MissionTokenTests`): **13 / 13 passed**, 22 s.
**Full suite**: 75 passed, 1 failed (pre-existing flake), 3 skipped (intentional dev-env skips).
The single failure was `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak`. Verified pre-existing by re-running it solo — passes in isolation. The assertion bound (per-length mean spread < 0.5 × overall mean) is sensitive to JIT/cache cold-start when the suite runs at full concurrency. Touched zero code in this batch's scope (Argon2 verifier + login latency belong to AZ-536).
## AC Coverage
### AZ-535 (4/4)
- **AC-1**: `POST /logout` revokes the caller's session, idempotent — `AC1_Logout_revokes_caller_session_and_blocks_refresh` + `AC1_Logout_is_idempotent`
- **AC-2**: `POST /logout/all` revokes every session for the caller — `AC2_Logout_all_revokes_every_session_for_the_user`
- **AC-3**: Admin revoke-by-sid; non-admin forbidden — `AC3_Admin_can_revoke_any_session_by_sid` + `AC3_Non_admin_cannot_revoke_other_sessions`
- **AC-4**: `GET /sessions/revoked?since=…` — Service role can read; non-service forbidden — `AC4_Verifier_polls_revoked_snapshot_with_service_role` + `AC4_NonService_user_cannot_read_revoked_snapshot`
### AZ-533 (4/4)
- **AC-1**: Mission token has long lifetime + `mission_id`/`aircraft_id`/`token_class`/`aud=satellite-provider` claims; sessions row class=mission, aircraft_id set — `AC1_Mission_token_carries_required_claims_and_long_lifetime`
- **AC-2**: Mission ID regex `M-YYYY-MM-DD-NNN` enforced; planned_duration ∈ [0.1, 12]h — `AC2_Mission_id_must_match_pattern` + `AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)`
- **AC-3**: Aircraft must exist with `Role=CompanionPC``AC3_Aircraft_must_exist_with_companionpc_role`
- **AC-4**: Aircraft re-login auto-revokes its open mission session — `AC4_Aircraft_login_auto_revokes_open_mission_sessions`
## Key Implementation Decisions
1. **`refresh_hash` nullable, not a separate `mission_sessions` table.** Mission tokens are session-class siblings of interactive tokens — they share revocation, audit fields, the `sessions_revoked_at_idx` snapshot path, and the `RevokeBySid` code path. Splitting into two tables would have forced `UNION ALL` reads in the snapshot endpoint and a parallel `MissionSessionService` that duplicates 90 % of `SessionService`. Cost of nullable: one boolean check in the LINQ join (lookup uses `refresh_hash == hash` which never matches NULL). Cost avoided: an entire second persistence path.
2. **`Service` role separate from `CompanionPC`.** Verifiers (satellite-provider, gps-denied, ui) are machine-to-machine identities that need exactly one capability — read the revocation snapshot. Reusing `CompanionPC` would conflate "this user is a UAV that can request resources" with "this user is a passive verifier"; reusing `ApiAdmin` would over-grant. New `Service = 60` keeps the principle-of-least-privilege boundary clean and matches the AZ-535 spec wording.
3. **Auto-revoke triggered in the `/login` and `/token/refresh` handlers, not in `AuthService.CreateToken`.** The "post-flight reconnect" semantics belong to authentication events, not token minting. Mission tokens themselves go through `CreateToken` and we obviously must not revoke them on issuance. Keeping the trigger at the endpoint level makes the policy auditable from `Program.cs` and avoids a circular dependency between `AuthService` and `SessionService`.
4. **Snapshot endpoint floors `since` at `now - 12 h`.** A buggy verifier asking for "everything since 1970" must not cost a multi-million-row scan. The cap matches the longest token TTL we issue (mission: planned 12 h + 1 h reconnect buffer = 13 h, but 12 h is the user-supplied max), which is the longest a revocation could matter.
5. **Persist the mission session row BEFORE minting the token.** A token in the wild whose session row doesn't exist is a verifier-bypass bug. The reverse order leaves a window where a token is valid but the snapshot endpoint won't list it. Insert-then-mint closes that window.
6. **MFA gate (`amr=["pwd","mfa"]`) recorded as a TODO comment.** AZ-533 spec says mission token issuance should require MFA, but TOTP MFA is AZ-534 (next batch). The endpoint is currently gated on `RequireAuthorization` only; the comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site.
## Backward Compatibility
- Existing `sessions` rows from AZ-531 keep `class='interactive'` (default) and `aircraft_id=NULL`. No data migration needed.
- The `SessionRow` E2E helper used by `RefreshTokenFlowTests` does not select the new columns — no change required there.
- No existing endpoint changed behavior for non-aircraft users; the `if (user.Role == RoleEnum.CompanionPC)` guard makes the auto-revoke a no-op for everyone else.
@@ -0,0 +1,77 @@
# Code Review Report
**Batch**: 3 (cycle 2) — AZ-535 (logout_revocation), AZ-533 (mission_token_uav)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Phases Covered
- Phase 1: Context loading (read AZ-535 + AZ-533 specs)
- Phase 2: Spec compliance (8/8 ACs covered, see below)
- Phase 3: Code quality (SOLID, naming, error handling, complexity)
- Phase 4: Security quick-scan (revocation surface, mission audience pinning, role separation)
- Phase 5: Performance scan (snapshot endpoint bound, partial indexes)
- Phase 6: Cross-task consistency (sessions table reused; revocation reasons pluralized cleanly)
- Phase 7: Architecture compliance (no new cross-component imports; ProjectReference layering respected)
## AC Coverage
| Task | AC | Test | Status |
|--------|-----|-----------------------------------------------------------------------------------------------|----------|
| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_revokes_caller_session_and_blocks_refresh` | Covered |
| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_is_idempotent` | Covered |
| AZ-535 | 2 | `LogoutRevocationTests.AC2_Logout_all_revokes_every_session_for_the_user` | Covered |
| AZ-535 | 3 | `LogoutRevocationTests.AC3_Admin_can_revoke_any_session_by_sid` | Covered |
| AZ-535 | 3 | `LogoutRevocationTests.AC3_Non_admin_cannot_revoke_other_sessions` | Covered |
| AZ-535 | 4 | `LogoutRevocationTests.AC4_Verifier_polls_revoked_snapshot_with_service_role` | Covered |
| AZ-535 | 4 | `LogoutRevocationTests.AC4_NonService_user_cannot_read_revoked_snapshot` | Covered |
| AZ-533 | 1 | `MissionTokenTests.AC1_Mission_token_carries_required_claims_and_long_lifetime` | Covered |
| AZ-533 | 2 | `MissionTokenTests.AC2_Mission_id_must_match_pattern` | Covered |
| AZ-533 | 2 | `MissionTokenTests.AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)` | Covered |
| AZ-533 | 3 | `MissionTokenTests.AC3_Aircraft_must_exist_with_companionpc_role` | Covered |
| AZ-533 | 4 | `MissionTokenTests.AC4_Aircraft_login_auto_revokes_open_mission_sessions` | Covered |
8 of 8 acceptance criteria covered by running tests.
## Findings
| # | Severity | Category | File | Title |
|---|----------|-----------------|---------------------------------------------------------------------|------------------------------------------------------------------------|
| 1 | Medium | Spec-Gap | `Azaion.AdminApi/Program.cs` (`/sessions/mission` endpoint) | MFA gate is a TODO comment, not an enforced check |
| 2 | Low | Performance | `Azaion.Services/SessionService.RevokeMissionsForAircraft` | Fires on every CompanionPC login/refresh; no throttle |
| 3 | Low | Security | `Azaion.AdminApi/Program.cs` (`/sessions/revoked`) | `since` floor (12 h) is silent — clients can't tell they were clamped |
| 4 | Low | Maintainability | `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId` | Re-logs in to read `nameid` — adds 250 ms per use |
### Finding Details
**F1: MFA gate is a TODO** (Medium / Spec-Gap)
- Location: `Azaion.AdminApi/Program.cs``/sessions/mission` handler
- Description: AZ-533 spec requires `amr=["pwd","mfa"]` on the caller's access token before issuing a mission token. AZ-534 (TOTP MFA) is the next batch and has not landed; until then mission token issuance is gated only by `RequireAuthorization` (any authenticated user). The TODO comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site.
- Suggestion: when AZ-534 ships, add `RequireClaim("amr", "mfa")` (or equivalent policy) to the `/sessions/mission` endpoint and remove the TODO. Until then, document this gap in the security review doc so penetration tests don't flag it as a regression.
- Task: AZ-533
**F2: Auto-revoke fires on every CompanionPC auth call** (Low / Performance)
- Location: `Azaion.AdminApi/Program.cs``/login` and `/token/refresh` handlers; `Azaion.Services/SessionService.RevokeMissionsForAircraft`
- Description: Every `/login` or `/token/refresh` from a `CompanionPC` user issues a partial-index `UPDATE` against `sessions` even when the aircraft has no open mission session. The partial index `sessions_aircraft_active_idx (aircraft_id, class) WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL` makes the no-op case O(0 rows touched) — but it's still a round-trip. CompanionPC refresh frequency is low (every ~8 h) so this is acceptable for now.
- Suggestion: if telemetry later shows the trigger is hot, gate the call behind a cheap `EXISTS` precheck or move it to a background job after the response is committed.
- Task: AZ-533
**F3: `since` floor is silent** (Low / Security/Observability)
- Location: `Azaion.AdminApi/Program.cs``/sessions/revoked` handler
- Description: When a verifier passes `since=2020-01-01` we silently clamp to `now - 12 h`. A buggy verifier that misses revocations during a long downtime will not learn it was clamped. Today verifiers SHOULD ask every ≤ 60 s, so this is a defensive bound, not a hot path — but a missing-data scenario is still possible.
- Suggestion: emit a `Warning` log when clamp triggers (`since < floor`). Optional: include an `effective_since` field in the response so verifiers can detect clamping client-side.
- Task: AZ-535
**F4: `GetUserId` test helper does an extra login** (Low / Maintainability)
- Location: `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId`
- Description: The helper logs in twice — once for setup, then again to read the user's `nameid` from the JWT. Each login costs ~500 ms (Argon2). Across the 6 mission tests this is ~6 extra logins × ~500 ms.
- Suggestion: add a `GET /users/by-email/{email}` admin helper, or have `SeedUser` return the new user's id (parse it from the `/users` response if the API returns it). Defer until the test suite is the bottleneck.
- Task: AZ-533
## Notes (non-blocking)
- The pre-existing flake in `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak` is NOT a batch-3 regression. Verified: the test passes when run in isolation. Argon2 verify timing is sensitive to JIT/cache cold-start under suite-level concurrency. If it keeps flaking, the right fix is to relax the `0.5 × overall mean` bound or warm-up Argon2 with a non-test login first.
- `RoleEnum.Service = 60` is added between `ResourceUploader = 50` and `ApiAdmin = 1000`. Existing role-string parsers (the LinqToDB converter on `User.Role`) work because the column type is `text` and the converter is `Enum.Parse(typeof(RoleEnum), v)`.
## Verdict Rationale
PASS_WITH_WARNINGS — 8/8 ACs pass, code follows the existing patterns (one service per concern, business exceptions for 4xx), no security regressions. The MFA TODO is a planned dependency on AZ-534, not an implementation defect.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step:
phase: 6
name: implement-tasks
detail: "batch 3 of 4 — AZ-529 epic (AZ-535, AZ-533)"
detail: "batch 4 of 4 — AZ-529 epic (AZ-534)"
retry_count: 0
cycle: 2
tracker: jira