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>
7.8 KiB
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 forISessionService/IMissionTokenService; newrevocationReaderPolicy(Service|ApiAdmin); new endpoints/logout,/logout/all,/sessions/{sid}/revoke,/sessions/revoked,/sessions/mission;/login+/token/refreshnow triggerRevokeMissionsForAircraftwhen the authenticated user is aCompanionPCAzaion.AdminApi/BusinessExceptionHandler.cs— mapSessionNotFound→ 404,InvalidMissionRequest/AircraftNotFound→ 400Azaion.Common/BusinessException.cs— addSessionNotFound = 53,InvalidMissionRequest = 54,AircraftNotFound = 55Azaion.Common/Entities/Session.cs— addRevokedByUserId,Class,AircraftId;RefreshHashmade nullable;SessionRevokedReasonsextended withLoggedOutAll,AdminRevoked,PostFlightReconnect; newSessionClasses { Interactive, Mission }Azaion.Common/Entities/RoleEnum.cs— addService = 60(verifier identity)Azaion.Common/Requests/MissionSessionRequest.cs— new;MissionSessionRequest/MissionSessionResponse/ValidRegionAzaion.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 withmission_id/aircraft_id/token_class/valid_regionclaims and narrowedaud=satellite-provider; persists session row BEFORE returning token
Migrations / infra
env/db/09_sessions_logout_and_mission.sql— new; ALTER TABLE addsrevoked_by_user_id,class,aircraft_id; drops NOT NULL onrefresh_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— addCountActiveSessionsForUser,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 /logoutrevokes the caller's session, idempotent —AC1_Logout_revokes_caller_session_and_blocks_refresh+AC1_Logout_is_idempotent - AC-2:
POST /logout/allrevokes 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-providerclaims; 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-NNNenforced; 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
-
refresh_hashnullable, not a separatemission_sessionstable. Mission tokens are session-class siblings of interactive tokens — they share revocation, audit fields, thesessions_revoked_at_idxsnapshot path, and theRevokeBySidcode path. Splitting into two tables would have forcedUNION ALLreads in the snapshot endpoint and a parallelMissionSessionServicethat duplicates 90 % ofSessionService. Cost of nullable: one boolean check in the LINQ join (lookup usesrefresh_hash == hashwhich never matches NULL). Cost avoided: an entire second persistence path. -
Servicerole separate fromCompanionPC. Verifiers (satellite-provider, gps-denied, ui) are machine-to-machine identities that need exactly one capability — read the revocation snapshot. ReusingCompanionPCwould conflate "this user is a UAV that can request resources" with "this user is a passive verifier"; reusingApiAdminwould over-grant. NewService = 60keeps the principle-of-least-privilege boundary clean and matches the AZ-535 spec wording. -
Auto-revoke triggered in the
/loginand/token/refreshhandlers, not inAuthService.CreateToken. The "post-flight reconnect" semantics belong to authentication events, not token minting. Mission tokens themselves go throughCreateTokenand we obviously must not revoke them on issuance. Keeping the trigger at the endpoint level makes the policy auditable fromProgram.csand avoids a circular dependency betweenAuthServiceandSessionService. -
Snapshot endpoint floors
sinceatnow - 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. -
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.
-
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 onRequireAuthorizationonly; the comment inProgram.csmakes the dependency explicit so AZ-534's PR will surface this site.
Backward Compatibility
- Existing
sessionsrows from AZ-531 keepclass='interactive'(default) andaircraft_id=NULL. No data migration needed. - The
SessionRowE2E helper used byRefreshTokenFlowTestsdoes 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.