# Azaion Admin API — System Flows > **Cycle 1 (2026-05-13) note** — F4 (Hardware Check) was deleted by AZ-197; F8 Detection Classes (AZ-513), F9 Device Auto-Provisioning (AZ-196) added; F10 OTA reverted after security audit F-1. > > **Cycle 2 — early (2026-05-14)** — F3 (Encrypted Resource Download) and F6 (Installer Download) removed entirely as obsolete. ADR-003 retired. > > **Cycle 2 — Auth Modernization (2026-05-14)** — F1 was rebuilt around the new dual-token + MFA model (AZ-531/532/534/536/537). Six new flows were added: F11 Refresh Token Rotation (AZ-531), F12 Logout / Revocation (AZ-535), F13 Mission Token Issuance (AZ-533), F14 MFA Enrollment & Confirmation (AZ-534), F15 Verifier Revocation Snapshot (AZ-535), F16 Account Lockout & Per-IP Rate Limit (AZ-537). The legacy single-token narrative is no longer accurate. ## Flow Inventory | # | Flow Name | Trigger | Primary Components | Criticality | |---|-----------|---------|-------------------|-------------| | F1 | User Login (dual token + MFA) | `POST /login` (+ `/login/mfa`) | Admin API, User Mgmt, Auth & Security | **Critical** | | F2 | User Registration | `POST /users` | Admin API, User Mgmt | High | | ~~F3~~ | ~~Encrypted Resource Download~~ | — | — | **REMOVED — cycle 2 early** | | ~~F4~~ | ~~Hardware Check~~ | — | — | **REMOVED — AZ-197** | | F5 | Resource Upload | `POST /resources` | Admin API, Resource Mgmt | Medium | | ~~F6~~ | ~~Installer Download~~ | — | — | **REMOVED — cycle 2 early** | | F7 | User Management (CRUD) | Various `/users/*` | Admin API, User Mgmt | Medium | | F8 | Detection Classes CRUD | `POST/PATCH/DELETE /classes` | Admin API, DetectionClassService | High | | F9 | Device Auto-Provisioning | `POST /devices` | Admin API, User Mgmt | High | | ~~F10~~ | ~~OTA Update Check & Publish~~ | — | — | **REMOVED — post-cycle-1** | | **F11** | **Refresh Token Rotation** *(AZ-531)* | `POST /token/refresh` | Admin API, RefreshTokenService, AuthService, SessionService | **Critical** | | **F12** | **Logout / Revocation** *(AZ-535)* | `POST /logout`, `/logout/all`, `/sessions/{sid}/revoke` | Admin API, SessionService | High | | **F13** | **Mission Token Issuance** *(AZ-533)* | `POST /sessions/mission` | Admin API, MissionTokenService, SessionService, AuthService | High | | **F14** | **MFA Enrollment & Confirmation** *(AZ-534)* | `POST /users/me/mfa/{enroll,confirm,disable}` | Admin API, MfaService, AuditLog | High | | **F15** | **Verifier Revocation Snapshot** *(AZ-535)* | `GET /sessions/revoked?since=` | Admin API, SessionService | **Critical** for verifier fleet | | **F16** | **Account Lockout & Rate Limit** *(AZ-537)* | (cross-cuts F1) | Admin API rate-limiter middleware, UserService, AuditLog | High | | **F17** | **JWKS Publication** *(AZ-532)* | `GET /.well-known/jwks.json` | Admin API, JwtSigningKeyProvider | **Critical** for verifier fleet | ## Flow Dependencies | Flow | Depends On | Shares Data With | |------|-----------|-----------------| | F1 | F17 (signing keys must exist), F16 (rate limit gate) | F11 (refresh chain), F12 (sid is the revocation key), F14 (MFA branch) | | F2 | — | F1 (created users can log in) | | F5 | F1 / F11 (access token) | — | | F7 | F1 / F11 + ApiAdmin | F12 (disabling a user revokes their sessions) | | F8 | F1 / F11 + ApiAdmin | UI | | F9 | F1 / F11 + ApiAdmin | F1 (provisioned devices later log in) | | F11 | F1 (created the family) | F12 (rotation is the same row store) | | F12 | F1 / F11 (sid claim) | F15 (revoked rows surface here) | | F13 | F1 / F11 (pilot's interactive token) | F12 (auto-revoke prior aircraft mission rows) | | F14 | F1 (caller is authenticated) | F1 (the MFA branch consumes enrolled state) | | F15 | — (verifier role only) | F12 (consumes revocation rows) | | F16 | — | F1, F11 (gates them) | | F17 | — | F1, F11, F13, F14 (every signed token), F15 (verifiers cache JWKS) | --- ## Flow F1: User Login (dual token + MFA) *(rebuilt cycle 2)* ### Description A user submits email/password credentials. The system enforces per-IP and per-account rate limits + lockout (F16), verifies the password with constant-time Argon2id (lazily migrating from SHA-384 if needed — AZ-536), and either: - (no MFA) issues a short-lived ES256 access token + opaque refresh token bound to a new session row, OR - (MFA enabled) issues a short-lived `mfa_token` (JWT, audience `mfa-step`, signed by the active ES256 key) and waits for `POST /login/mfa` to complete the second factor. ### Preconditions - User account exists, is enabled, and is not within an active lockout window - Per-IP rate-limit bucket has remaining permits - Per-account sliding-window failed-login count is below threshold - For the MFA branch: user has previously enrolled and confirmed MFA (F14) ### Sequence Diagram ```mermaid sequenceDiagram participant Client participant API as Admin API participant RL as RateLimiter (per-IP, AZ-537) participant US as UserService participant AL as AuditLog participant Sec as Security (Argon2id, AZ-536) participant DB as PostgreSQL participant Mfa as MfaService participant RT as RefreshTokenService participant Auth as AuthService participant SS as SessionService Client->>API: POST /login {email, password} API->>RL: per-IP sliding window check alt rate-limited RL-->>Client: 429 + Retry-After end API->>US: ValidateUser(request) US->>DB: SELECT users WHERE email=? (read conn) US->>AL: CountRecentFailedLogins(email, window) alt account locked OR per-account threshold exceeded US-->>API: BusinessException(AccountLocked / LoginRateLimited, RetryAfterSeconds) API-->>Client: 423 / 429 + Retry-After end US->>Sec: VerifyPassword(presented, stored) alt VerifyResult.Ok=false US->>AL: RecordLoginFailed US->>DB: UPDATE failed_login_count, lockout_until US-->>API: WrongPassword (or NoEmailFound) API-->>Client: 409 end alt VerifyResult.NeedsRehash=true US->>Sec: HashPassword (Argon2id) US->>DB: UPDATE password_hash (lazy migrate) end US->>AL: RecordLoginSuccess US->>DB: UPDATE failed_login_count=0, last_login=now() US-->>API: User entity alt user.MfaEnabled API->>Mfa: IssueMfaStepToken(userId) Mfa-->>API: short-lived JWT (mfa_pending=true) API-->>Client: 200 OK {mfa_required: true, mfa_token, expires_in: 300} else API->>RT: IssueForNewLogin(userId, mfaAuthenticated=false) RT->>DB: INSERT INTO sessions (new id, family_id=id, refresh_hash, expires_at, mfa_authenticated=false) RT-->>API: (opaqueRefreshToken, Session) API->>Auth: CreateToken(user, sessionId=Session.Id, jti=new, amr=["pwd"]) Auth-->>API: AccessToken (ES256) opt user.Role == CompanionPC API->>SS: RevokeMissionsForAircraft(user.Id) // F13 / AZ-533 AC-4 end API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken, RefreshExp} end Note over Client,API: MFA branch only: Client->>API: POST /login/mfa {mfa_token, code} API->>RL: per-IP sliding window check API->>Mfa: ValidateMfaStepToken(mfa_token) -> userId API->>US: GetById(userId) API->>Mfa: VerifyForLogin(userId, code) -> amr Mfa->>DB: TOTP verify against decrypted mfa_secret OR recovery code consume Mfa->>AL: RecordMfaLoginSuccess (or MfaRecoveryUsed) API->>RT: IssueForNewLogin(userId, mfaAuthenticated=true) API->>Auth: CreateToken(user, sessionId, jti, amr=["pwd","mfa"]) API-->>Client: 200 OK LoginResponse ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Per-IP limit exceeded | Rate-limiter middleware | sliding window | 429 + `Retry-After` | | Account locked | UserService.ValidateUser | `now() < lockout_until` | 423 `AccountLocked` (code 50) + `Retry-After` | | Per-account threshold | UserService.ValidateUser | failed-login count over window | 429 `LoginRateLimited` (code 51) + `Retry-After` | | Email not found | UserService.ValidateUser | No DB record | 409 `NoEmailFound` (code 10) | | Wrong password | UserService.ValidateUser | `VerifyPassword.Ok=false` | 409 `WrongPassword` (code 30) — also increments `failed_login_count` | | User disabled | UserService.ValidateUser | `is_enabled=false` | 409 `UserDisabled` (code 38) | | MFA token invalid | MfaService.ValidateMfaStepToken | bad signature / wrong audience / expired | 401 `InvalidMfaToken` (code 61) | | MFA code wrong | MfaService.VerifyForLogin | TOTP and recovery both miss | 401 `InvalidMfaCode` (code 59) — `mfa_login_failed` audit row | --- ## Flow F2: User Registration ### Description An admin creates a new user account with email, password, and role. ### Preconditions - Caller has ApiAdmin role - Email is not already registered ### Sequence Diagram ```mermaid sequenceDiagram participant Admin participant API as Admin API participant VAL as FluentValidation participant US as UserService participant DB as PostgreSQL Admin->>API: POST /users {email, password, role} API->>VAL: Validate RegisterUserRequest VAL-->>API: OK API->>US: RegisterUser(request) US->>DB: SELECT user WHERE email = ? DB-->>US: null (no duplicate) US->>US: Hash password (Argon2id, AZ-536) US->>DB: INSERT user (admin connection) DB-->>US: OK US-->>API: void API-->>Admin: 200 OK ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Validation failure | FluentValidation | Email < 8 chars, bad format, password < 8 chars | 400 Bad Request | | Duplicate email | UserService.RegisterUser | Existing user found | 409: EmailExists (code 20) | --- ## Flow F3: Encrypted Resource Download — REMOVED (cycle 2, 2026-05-14) The `POST /resources/get/{dataFolder?}` endpoint and its supporting stack (`Security.GetApiEncryptionKey`, `Security.EncryptTo`, `Security.DecryptTo`, `ResourcesService.GetEncryptedResource`, `GetResourceRequest` DTO + validator, `ExceptionEnum.WrongResourceName` (50)) were removed as obsolete. Per-user file encryption is no longer part of the system; resource files are now stored as plain bytes and only ever leave the server through the upload (F5) and admin clear paths. ADR-003 in `architecture.md` was retired in the same change. --- ## Flow F4: Hardware Check (REMOVED by AZ-197) The hardware-fingerprint binding flow (`POST /resources/check`, `UserService.CheckHardwareHash`, `Security.GetHWHash`, error code 40 `HardwareIdMismatch`, error code 45 `BadHardware`) was removed entirely in cycle 1. Reason: the threat the binding mitigated (credential reuse via desktop installers) was eliminated by the architectural shift to fTPM-secured Jetsons + browser-only SaaS access. See `_docs/03_implementation/batch_06_report.md` and the obsolete diagram `diagrams/flows/flow_hardware_check.md`. --- ## Flow F5: Resource Upload ### Description An authenticated user uploads a file to a specified resource folder on the server. ### Preconditions - User is authenticated (JWT) - File size <= 200 MB ### Sequence Diagram ```mermaid sequenceDiagram participant User participant API as Admin API participant RS as ResourcesService participant FS as Filesystem User->>API: POST /resources/{folder} (multipart/form-data) API->>RS: SaveResource(folder, file) RS->>FS: Create directory (if needed) RS->>FS: Delete existing file (same name) RS->>FS: Write file FS-->>RS: OK RS-->>API: void API-->>User: 200 OK ``` --- ## Flow F6: Installer Download — REMOVED (cycle 2, 2026-05-14) The `GET /resources/get-installer` and `GET /resources/get-installer/stage` endpoints, the `ResourcesService.GetInstaller` method, the `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` configuration properties, and their environment-variable rows in every config artifact (`appsettings.json`, `.env.example`, `secrets/*.public.env`, `docker-compose.test.yml`) were removed. The installer-shipping era is over in the target architecture (browser SaaS + fTPM Jetsons); installer artefacts are no longer served from the Admin API. --- ## Flow F7: User Management (CRUD) ### Description Admin operations: list users, change role, enable/disable, update queue offsets, delete user. (The "set hardware" operation was removed by AZ-197 — see F4.) ### Preconditions - Caller has ApiAdmin role (for most operations) All operations follow the same pattern: API endpoint → UserService method → DbFactory.RunAdmin → PostgreSQL UPDATE/DELETE. Cache is invalidated for affected user keys after writes. > **Cycle 2 cross-cut**: `PUT /users/{email}/disable` now also calls `SessionService.RevokeAllForUser` so disabling a user instantly cuts every active session. Verifiers pick this up via F15 within their poll cadence. --- ## Flow F8: Detection Classes CRUD *(AZ-513, 2026-05-13)* ### Description ApiAdmin manages the detection-class catalogue exposed to operators in the UI: create new entries, partial-merge edits, delete entries. The UI's existing add/delete affordances start working end-to-end once this flow exists; the in-place edit affordance arrives via UI cycle AZ-512. ### Preconditions - Caller has ApiAdmin role (`apiAdminPolicy`) - `detection_classes` table exists in the admin DB ### Sequence Diagram ```mermaid sequenceDiagram participant Client participant API as Admin API participant VAL as FluentValidation participant DCS as DetectionClassService participant DB as PostgreSQL Client->>API: POST /classes {name, shortName, color, maxSizeM, photoMode?} API->>VAL: Validate CreateDetectionClassRequest VAL-->>API: OK / 400 API->>DCS: Create(request) DCS->>DB: InsertWithInt32IdentityAsync (admin conn) DB-->>DCS: new id DCS-->>API: DetectionClass {id, …} API-->>Client: 200 OK {DetectionClass} Client->>API: PATCH /classes/{id} {…partial fields} API->>VAL: Validate UpdateDetectionClassRequest VAL-->>API: OK / 400 API->>DCS: Update(id, request) alt id exists DCS->>DB: UPDATE row applying non-null fields (admin conn) DCS-->>API: DetectionClass API-->>Client: 200 OK {DetectionClass} else id missing DCS-->>API: null API-->>Client: 404 Not Found end Client->>API: DELETE /classes/{id} API->>DCS: Delete(id) DCS->>DB: DELETE WHERE id = ? (admin conn) alt deleted > 0 DCS-->>API: true API-->>Client: 204 No Content else DCS-->>API: false API-->>Client: 404 Not Found end ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Not authenticated | API | No JWT | 401 Unauthorized | | Wrong role | API | Non-ApiAdmin JWT | 403 Forbidden | | Validation failure | FluentValidation | Field bounds violated | 400 Bad Request | | Missing id (PATCH/DELETE) | DetectionClassService | Row not found | 404 Not Found | --- ## Flow F9: Device Auto-Provisioning *(AZ-196, 2026-05-13)* ### Description ApiAdmin requests a fresh CompanionPC device user. The server allocates the next sequential serial (`azj-NNNN`), generates a 32-char hex password, persists the user with an Argon2id hash (cycle 2 — AZ-536), and returns the plaintext credentials exactly once. The provisioning script (out-of-tree) embeds the values into the device's `device.conf`. ### Preconditions - Caller has ApiAdmin role (`apiAdminPolicy`) ### Sequence Diagram ```mermaid sequenceDiagram participant Admin participant API as Admin API participant US as UserService participant DB as PostgreSQL Admin->>API: POST /devices (no body) API->>US: RegisterDevice() US->>DB: SELECT TOP 1 email FROM users WHERE role = 'CompanionPC' ORDER BY created_at DESC DB-->>US: lastEmail (or null) US->>US: nextNumber = parse(lastEmail.suffix) + 1 (or 0) US->>US: serial = "azj-" + nextNumber.PadLeft(4) US->>US: password = ToHex(RandomBytes(16)) // 32 hex chars US->>DB: INSERT user {Email=serial@domain, PasswordHash=Argon2id(password), Role=CompanionPC, IsEnabled=true} (admin conn) DB-->>US: OK US-->>API: RegisterDeviceResponse {Serial, Email, Password} API-->>Admin: 200 OK {Serial, Email, Password} ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Not authenticated / wrong role | API | JWT missing or non-ApiAdmin | 401 / 403 | | Email already exists | UserService.RegisterUser (called by RegisterDevice) | DB UNIQUE INDEX `users_email_uidx` violation translated to `EmailExists` (5) | 409 — caller retries (the next call recomputes a fresh `azj-NNNN`) | > **Implementation note** — `RegisterDevice` reuses `UserService.RegisterUser` for the row insert (post-security-audit consolidation, finding F-3). The `users.email` column has a UNIQUE INDEX (`env/db/06_users_email_unique.sql`); concurrent provisioning calls that race on the same serial surface the violation atomically. --- ## Flow F10: OTA Update Check & Publish *(REMOVED — post-cycle-1 revert)* The `POST /get-update` and `POST /resources/publish` endpoints, the `IResourceUpdateService` / `ResourceUpdateService` / `ResourceColumnEncryption` types, the `Resource` entity, the `resources` table, the `apiUploaderPolicy`, and the `ResourcesConfig.EncryptionMasterKey` field were all removed shortly after AZ-183 shipped. Reasons: 1. Security audit finding F-1 — `/get-update` was registered with `.RequireAuthorization()` (any authenticated caller) and returned the per-resource decrypted `EncryptionKey` in the response body, defeating the at-rest column encryption. 2. The OTA delivery model is itself a leftover from the installer-shipping era; the target architecture (browser-only SaaS + fTPM-secured Jetsons) does not need it. The `apiUploaderPolicy` definition was removed from `Program.cs`; the `RoleEnum.ResourceUploader` enum value remains as data (the seed `uploader@azaion.com` user still uses it for negative-auth tests) but is no longer wired to any endpoint. --- ## Flow F11: Refresh Token Rotation *(AZ-531, 2026-05-14)* ### Description The client presents an opaque refresh token; the server validates it, rotates it (marks the old row as `revoked_reason='rotated'`), inserts a new row in the same `family_id`, and mints a new ES256 access token. Reuse of an already-rotated token revokes the entire family with `reason='reuse_detected'` (and triggers F15 surfacing for verifiers). ### Preconditions - Refresh token is well-formed and corresponds to a non-revoked, non-expired session row - The session is within both the sliding window (`SessionConfig.RefreshSlidingHours`) and the absolute cap (`SessionConfig.RefreshAbsoluteHours` measured from `family_started_at`) ### Sequence Diagram ```mermaid sequenceDiagram participant Client participant API as Admin API participant RT as RefreshTokenService participant US as UserService participant Auth as AuthService participant SS as SessionService participant DB as PostgreSQL Client->>API: POST /token/refresh {refreshToken} API->>RT: Rotate(opaqueToken) RT->>DB: SELECT * FROM sessions WHERE refresh_hash = SHA256(token) alt row missing RT-->>API: 401 InvalidRefreshToken end alt row.revoked_reason = 'rotated' (reuse!) RT->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='reuse_detected' WHERE family_id = row.family_id AND revoked_at IS NULL RT-->>API: 401 InvalidRefreshToken end alt row.revoked_at IS NOT NULL OR row.expires_at <= now RT-->>API: 401 InvalidRefreshToken end RT->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='rotated', last_used_at=now WHERE id = row.id RT->>DB: INSERT INTO sessions (new id, family_id=row.family_id, refresh_hash=SHA256(newToken), parent_session_id=row.id, expires_at=now+sliding, mfa_authenticated=row.mfa_authenticated) RT-->>API: (newOpaqueToken, newSession) API->>US: GetById(newSession.UserId) US-->>API: User API->>Auth: CreateToken(user, sessionId=newSession.Id, jti=new, amr= ['pwd','mfa'] if mfaAuthenticated else ['pwd']) Auth-->>API: AccessToken opt user.Role == CompanionPC API->>SS: RevokeMissionsForAircraft(user.Id) end API-->>Client: 200 OK LoginResponse {AccessToken, AccessExp, RefreshToken=newOpaqueToken, RefreshExp=newSession.ExpiresAt} ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Token missing / not in DB | RefreshTokenService.Rotate | `SHA256(token)` not found | 401 `InvalidRefreshToken` | | Reuse detected | RefreshTokenService.Rotate | row already `revoked_reason='rotated'` | 401 `InvalidRefreshToken` + entire family revoked (visible via F15) | | Sliding window expired | RefreshTokenService.Rotate | `expires_at <= now()` | 401 `InvalidRefreshToken` | | Absolute cap exceeded | RefreshTokenService.Rotate | `now() - family_started_at > RefreshAbsoluteHours` | 401 `InvalidRefreshToken` | | User missing (race with deletion) | API | `UserService.GetById` returns null | 401 `InvalidRefreshToken` | --- ## Flow F12: Logout / Revocation *(AZ-535, 2026-05-14)* ### Description Three endpoints share `SessionService.RevokeBySid` / `RevokeAllForUser`: - `POST /logout` — revoke caller's current `sid` (idempotent; returns `{ alreadyRevoked }`) - `POST /logout/all` — revoke every active session for the caller's user - `POST /sessions/{sid}/revoke` *(ApiAdmin)* — admin revoke-by-sid All revocations write `revoked_at`, `revoked_reason`, and `revoked_by_user_id`; the rows surface to verifiers via F15 within the next poll window. ### Preconditions - `/logout` / `/logout/all` — caller is authenticated; the access token's `sid` claim is well-formed - `/sessions/{sid}/revoke` — caller is `ApiAdmin` ### Sequence Diagram ```mermaid sequenceDiagram participant Client participant API as Admin API participant SS as SessionService participant DB as PostgreSQL Note over Client,API: Self logout Client->>API: POST /logout (Bearer access) API->>API: ParseSidClaim(user) -> sid API->>API: ParseUserIdClaim(user) -> caller API->>SS: RevokeBySid(sid, caller, 'logged_out') SS->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='logged_out', revoked_by_user_id=caller WHERE id=sid AND revoked_at IS NULL SS-->>API: alreadyRevoked: bool API-->>Client: 200 OK { alreadyRevoked } Note over Client,API: Logout-all Client->>API: POST /logout/all API->>SS: RevokeAllForUser(caller, caller, 'logged_out_all') SS->>DB: UPDATE ... WHERE user_id=caller AND revoked_at IS NULL SS-->>API: int (rows revoked) API-->>Client: 200 OK { revoked } Note over Client,API: Admin revoke-by-sid Client->>API: POST /sessions/{sid}/revoke (ApiAdmin) API->>SS: RevokeBySid(sid, admin, 'admin_revoked') SS->>DB: UPDATE ... WHERE id=sid AND revoked_at IS NULL SS-->>API: alreadyRevoked: bool API-->>Client: 200 OK { alreadyRevoked } ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Missing/malformed `sid` claim | ParseSidClaim | not a Guid | 401 `InvalidRefreshToken` | | Sid not in DB (admin path) | SessionService.RevokeBySid | row not found | 404 `SessionNotFound` | | Already revoked | SessionService.RevokeBySid | UPDATE affected 0 rows | 200 OK with `alreadyRevoked: true` (idempotent) | --- ## Flow F13: Mission Token Issuance *(AZ-533, 2026-05-14)* ### Description A pilot (an authenticated interactive user) requests a long-lived no-refresh access token bound to one aircraft and one mission. Before signing the token, the server inserts a `class='mission'` session row (so `sid` is bound), and revokes any previously-active mission sessions for that aircraft (`reason='aircraft_reconnected'`). ### Preconditions - Caller is authenticated (interactive token; AMR can be `["pwd"]` or `["pwd","mfa"]` — F1 follow-up tightens this to require `mfa` once policy is set) - `request.aircraftId` resolves to an existing user with `Role = CompanionPC` - `request.missionId` matches the validation pattern; `request.plannedDurationH` is within bounds ### Sequence Diagram ```mermaid sequenceDiagram participant Pilot participant API as Admin API participant MTS as MissionTokenService participant SS as SessionService participant US as UserService participant Auth as AuthService participant DB as PostgreSQL Pilot->>API: POST /sessions/mission {aircraftId, missionId, plannedDurationH, region} API->>MTS: Issue(pilotId, request) MTS->>US: GetById(aircraftId) (read conn) alt aircraft missing or wrong role MTS-->>API: 400 AircraftNotFound end MTS->>SS: RevokeMissionsForAircraft(aircraftId) // AC-4 SS->>DB: UPDATE sessions SET revoked_at=now, revoked_reason='aircraft_reconnected' WHERE aircraft_id=? AND class='mission' AND revoked_at IS NULL MTS->>DB: INSERT INTO sessions (id, user_id=aircraftId, class='mission', aircraft_id=aircraftId, refresh_hash=NULL, expires_at=now + plannedDurationH) MTS->>Auth: CreateToken(aircraftUser, sessionId=newSid, jti, amr=['pwd','mission']) Auth-->>MTS: AccessToken MTS-->>API: MissionSessionResponse {access_token, expires_at, mission_id, aircraft_id} API-->>Pilot: 200 OK ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Validation failure | FluentValidation / MissionTokenService | bad `mission_id` pattern, `plannedDurationH` out of bounds | 400 `InvalidMissionRequest` (code 54) | | Aircraft not a CompanionPC | MissionTokenService.Issue | role mismatch | 400 `AircraftNotFound` (code 55) | --- ## Flow F14: MFA Enrollment & Confirmation *(AZ-534, 2026-05-14)* ### Description Three-step user-initiated lifecycle: 1. **Enroll** — server generates a new TOTP secret, encrypts it via `IDataProtector` (purpose `Azaion.Mfa.Secret`), persists with `mfa_enabled=false`, returns base32 secret + otpauth URL + QR PNG bytes. 2. **Confirm** — client submits a TOTP code; on success server flips `mfa_enabled=true`, generates 10 single-use Argon2id-hashed recovery codes, and returns them once. 3. **Disable** — requires both password + a current TOTP; server clears all MFA columns. ### Preconditions - Caller is authenticated - For Confirm: a prior Enroll call left the encrypted secret on the user - For Disable: `mfa_enabled = true` ### Sequence Diagram ```mermaid sequenceDiagram participant User participant API as Admin API participant Mfa as MfaService participant DP as IDataProtector participant Sec as Security (Argon2id) participant AL as AuditLog participant DB as PostgreSQL Note over User,API: ENROLL User->>API: POST /users/me/mfa/enroll {password} API->>Mfa: Enroll(userId, password) Mfa->>DB: SELECT user Mfa->>Sec: VerifyPassword(presented, stored) Mfa->>Mfa: Generate 20-byte secret, base32 encode Mfa->>DP: Protect(base32) -> encrypted base64 Mfa->>DB: UPDATE users SET mfa_secret = encrypted, mfa_enrolled_at = now, mfa_enabled=false Mfa->>AL: RecordMfaEnroll Mfa-->>API: MfaEnrollResponse { secret_base32, otpauth_url, qr_png } API-->>User: 200 OK Note over User,API: CONFIRM User->>API: POST /users/me/mfa/confirm {code} API->>Mfa: Confirm(userId, code) Mfa->>DP: Unprotect(stored) -> base32 secret Mfa->>Mfa: TOTP verify alt code wrong Mfa-->>API: 401 InvalidMfaCode end Mfa->>Mfa: Generate 10 recovery codes Mfa->>Sec: HashPassword each (Argon2id) Mfa->>DB: UPDATE users SET mfa_enabled=true, mfa_recovery_codes = jsonb([{ hash, used_at=null } x10]), mfa_last_used_window=current_step Mfa->>AL: RecordMfaConfirm Mfa-->>API: { recovery_codes: [...] } API-->>User: 200 OK { mfaEnabled: true, recovery_codes } Note over User,API: DISABLE User->>API: POST /users/me/mfa/disable {password, code} API->>Mfa: Disable(userId, password, code) Mfa->>Sec: VerifyPassword Mfa->>Mfa: TOTP verify Mfa->>DB: UPDATE users SET mfa_enabled=false, mfa_secret=NULL, mfa_recovery_codes=NULL, mfa_enrolled_at=NULL, mfa_last_used_window=NULL Mfa->>AL: RecordMfaDisable Mfa-->>API: ok API-->>User: 200 OK { mfaEnabled: false } ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Already enrolled (Enroll) | MfaService.Enroll | `mfa_enabled=true` | 409 `MfaAlreadyEnabled` (code 56) | | Not enrolling (Confirm) | MfaService.Confirm | `mfa_secret IS NULL` | 409 `MfaNotEnrolling` (code 57) | | Not enabled (Disable) | MfaService.Disable | `mfa_enabled=false` | 409 `MfaNotEnabled` (code 58) | | Wrong password | Sec.VerifyPassword | hash mismatch | 409 `WrongPassword` (code 30) | | Wrong TOTP code | MfaService TOTP path | code/window miss | 401 `InvalidMfaCode` (code 59) | --- ## Flow F15: Verifier Revocation Snapshot *(AZ-535, 2026-05-14)* ### Description A `Service`-role identity (verifier fleet) polls `GET /sessions/revoked?since={iso8601}` periodically. The server returns every session whose `revoked_at >= since` and `expires_at > now()` so verifiers can deny tokens whose `sid` appears in the snapshot. The `since` parameter is **clamped to a 12-hour floor** server-side so a buggy verifier asking for "everything since 1970" doesn't trigger a multi-million-row table scan. Verifiers should clock-skew-tolerate by stepping `since` back ~30s on each poll. ### Preconditions - Caller has role `Service` or `ApiAdmin` (`revocationReaderPolicy`) ### Sequence Diagram ```mermaid sequenceDiagram participant Verifier participant API as Admin API participant SS as SessionService participant DB as PostgreSQL Verifier->>API: GET /sessions/revoked?since=2026-05-14T05:30:00Z API->>API: clamp since to max(now-12h, since) API->>SS: GetRevokedSince(effectiveSince) SS->>DB: SELECT id, expires_at, revoked_at, revoked_reason FROM sessions WHERE revoked_at >= ? AND expires_at > now() ORDER BY revoked_at DB-->>SS: rows (uses sessions_revoked_at_idx) SS-->>API: IReadOnlyList API-->>Verifier: 200 OK [{ sid, exp, revokedAt, reason }, ...] + Cache-Control: no-cache ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Wrong role | API authorization | not Service/ApiAdmin | 403 Forbidden | | `since` missing | API | bind null `DateTime?` | clamp falls back to `now-12h` | --- ## Flow F16: Account Lockout & Per-IP Rate Limit *(AZ-537, 2026-05-14)* ### Description Cross-cuts F1 and F11. Two layers: 1. **Per-IP** — ASP.NET Core `RateLimiter` middleware (`SlidingWindowRateLimiter`) attached to `/login` and `/login/mfa` via the `login-per-ip` policy. Rejection sets `429` and stamps `Retry-After` from the lease metadata. 2. **Per-account + lockout** — DB-backed in `UserService.ValidateUser`: - Read `failed_login_count` and `lockout_until` from `users`. - If `now() < lockout_until` → throw `BusinessException(AccountLocked, RetryAfterSeconds = LockoutUntil - now)`. - Else: count `audit_events` rows where `event_type='login_failed' AND email=? AND occurred_at >= now - PerAccountWindowSeconds`. If over threshold → throw `BusinessException(LoginRateLimited, RetryAfterSeconds = PerAccountWindowSeconds)`. - On wrong password: `RecordLoginFailed` + UPDATE `failed_login_count = failed_login_count + 1`. If new count >= `ConsecutiveFailureThreshold` → set `lockout_until = now + LockoutSeconds`, `RecordLoginLockout`, throw `AccountLocked`. - On success: `RecordLoginSuccess` + UPDATE `failed_login_count = 0`, `lockout_until = NULL`. ### Preconditions - `AuthConfig.RateLimit.*` and `AuthConfig.Lockout.*` are non-zero - `audit_events` table exists ### Sequence Diagram ```mermaid sequenceDiagram participant Client participant Mid as RateLimiter middleware participant API as Admin API participant US as UserService participant AL as AuditLog participant DB as PostgreSQL Client->>Mid: POST /login {email, password} Mid->>Mid: SlidingWindow per-IP check alt no permits Mid-->>Client: 429 + Retry-After end Mid->>API: forward API->>US: ValidateUser US->>DB: SELECT users (read) US->>AL: CountRecentFailedLogins(email, window) alt account locked OR threshold exceeded US->>AL: RecordLoginFailed (or RecordLoginLockout if newly locked) US-->>API: BusinessException(AccountLocked / LoginRateLimited, RetryAfterSeconds) API-->>Client: 423 / 429 + Retry-After end US->>US: VerifyPassword alt wrong password US->>AL: RecordLoginFailed US->>DB: UPDATE failed_login_count++; lockout_until = now + LockoutSeconds (if newly over) US-->>API: BusinessException(WrongPassword) API-->>Client: 409 end US->>AL: RecordLoginSuccess US->>DB: UPDATE failed_login_count = 0, lockout_until = NULL, last_login = now US-->>API: User ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | Per-IP limit | RateLimiter middleware | sliding window | 429 + `Retry-After` | | Account locked | UserService.ValidateUser | `now < lockout_until` | 423 `AccountLocked` + `Retry-After` | | Per-account threshold | UserService.ValidateUser | `audit_events` count over window | 429 `LoginRateLimited` + `Retry-After` | --- ## Flow F17: JWKS Publication *(AZ-532, 2026-05-14)* ### Description `GET /.well-known/jwks.json` (anonymous) returns the JSON Web Key Set containing one entry per loaded ES256 key. Verifiers cache for 1 hour (`Cache-Control: public, max-age=3600`). ### Preconditions - `JwtConfig.KeysFolder` exists with at least one well-formed P-256 PEM - `JwtConfig.ActiveKid` matches one of the loaded files (the others are still served, allowing verifiers to validate already-issued tokens during a key rotation) ### Sequence Diagram ```mermaid sequenceDiagram participant Verifier participant API as Admin API participant JKP as JwtSigningKeyProvider participant FS as Filesystem Note over JKP,FS: At app startup API->>JKP: ctor (eager) JKP->>FS: scan KeysFolder/*.pem JKP->>JKP: validate P-256 curve, build EcdsaSecurityKey list JKP-->>API: ready (or fail-fast if 0 keys) Note over Verifier,API: Per-poll Verifier->>API: GET /.well-known/jwks.json API->>JKP: All JKP-->>API: list of JwtSigningKey API->>API: project to JWK { kty:EC, crv:P-256, kid, use:sig, alg:ES256, x, y } API-->>Verifier: 200 OK { keys: [...] } + Cache-Control: public, max-age=3600 ``` ### Error Scenarios | Error | Where | Detection | Recovery | |-------|-------|-----------|----------| | No keys / malformed PEM | JwtSigningKeyProvider ctor | startup crash (intentional) | Operator fix + restart | | Wrong curve in PEM | JwtSigningKeyProvider ctor | startup crash | Operator fix + restart | > **Rotation procedure**: drop a new PEM into `KeysFolder`, set `JwtConfig:ActiveKid` to the new kid, restart. Already-issued tokens remain verifiable until their `exp`. Old PEMs are physically removed only after the longest possible token TTL has elapsed.