Files
admin/_docs/02_document/system-flows.md
T
Oleksandr Bezdieniezhnykh a77b3f8a59 [AZ-529] [AZ-530] Cycle-2 documentation refresh
Refreshes _docs/02_document/ to reflect the cycle-2 auth-modernization
+ CMMC hardening landings (AZ-531..AZ-538). Authoritative source for
the ripple set is ripple_log_cycle2.md.

Covered:
- architecture.md (section 1 rewritten, ADRs 6-9 added)
- data_model.md (sessions, audit_events, user columns, migrations)
- system-flows.md (F1 rewritten; F11-F17 added; F2/F7/F9 minor)
- module-layout.md (cycle-2 sub-component table)
- diagrams/flows/flow_login.md (dual-token + MFA)
- components/{01_data_layer,03_auth_and_security,05_admin_api}
- modules/ (12 new, 8 modified — full Argon2id/ES256/MFA/refresh
  /mission/session/audit/jwks rollup)
- tests/{blackbox,security,traceability-matrix}

Step 13 (Update Docs) output for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:22:53 +03:00

35 KiB

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

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

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

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

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

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 noteRegisterDevice 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

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

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

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

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

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<RevokedSession>
    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

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

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.