mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 21:11:08 +00:00
a77b3f8a59
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>
750 lines
35 KiB
Markdown
750 lines
35 KiB
Markdown
# 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<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
|
|
|
|
```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.
|