mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 15:51:10 +00:00
[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>
This commit is contained in:
@@ -29,43 +29,66 @@
|
||||
|
||||
### Entities
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — `DetectionClass` (AZ-513) entity was added. `Resource` (AZ-183) was added then removed in the same cycle (post-cycle-1 revert; security audit F-1 + the OTA delivery model itself was deemed obsolete). The `User.Hardware` column is left in place as a tombstone (nullable, unused) per AZ-197. A UNIQUE INDEX `users_email_uidx` was added on `users.email` (security audit F-3, `env/db/06_users_email_unique.sql`).
|
||||
> **Cycle 1 (2026-05-13) note** — `DetectionClass` (AZ-513) added; `Resource` (AZ-183) added then reverted same cycle. `User.Hardware` left as a tombstone (AZ-197). UNIQUE INDEX `users_email_uidx` added on `users.email` (security audit F-3, `env/db/06_users_email_unique.sql`).
|
||||
>
|
||||
> **Cycle 2 (2026-05-14) note** — `ResourcesConfig.SuiteInstallerFolder` and `SuiteStageInstallerFolder` were removed along with the installer endpoints (`GET /resources/get-installer[/stage]`); the POCO is now a single-property class (`ResourcesFolder`).
|
||||
> **Cycle 2 — early (2026-05-14)** — `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder` removed with the installer endpoints; `ResourcesConfig` is now `ResourcesFolder`-only.
|
||||
>
|
||||
> **Cycle 2 — Auth Modernization (2026-05-14)** — significant data-layer changes:
|
||||
> - **`User`** gained `FailedLoginCount`, `LockoutUntil` (AZ-537) and `MfaEnabled`, `MfaSecret` (DataProtection-encrypted), `MfaRecoveryCodes` (jsonb of Argon2id-hashed codes), `MfaEnrolledAt`, `MfaLastUsedWindow` (AZ-534). `PasswordHash` column unchanged in shape but now contains Argon2id PHC strings; legacy SHA-384 base64 values are accepted by `Security.VerifyPassword` and lazily upgraded on next login (AZ-536).
|
||||
> - **New table `public.sessions`** (AZ-531 / AZ-535) — refresh-token rotation + revocation, mapped via `Common/Entities/Session`.
|
||||
> - **New table `public.audit_events`** (AZ-537 + AZ-534) — append-only login + MFA event log, mapped via `Common/Entities/AuditEvent`.
|
||||
> - **New `RoleEnum.Service = 60`** (AZ-535) — verifier-fleet identity used by the `revocationReaderPolicy`.
|
||||
> - **New configs**: `AuthConfig` (rate limit + lockout + Argon2id parameters), `SessionConfig` (refresh sliding + absolute lifetimes). `JwtConfig` rebuilt around ES256 (`KeysFolder`, `ActiveKid`, `AccessTokenLifetimeMinutes`, `MfaStepTokenLifetimeMinutes`); the legacy `Secret` and `TokenLifetimeHours` fields are no longer read.
|
||||
> - **Migrations** added: `07_auth_lockout_and_audit.sql`, `08_sessions.sql`, `09_sessions_logout_and_mission.sql`, `10_users_mfa.sql`.
|
||||
|
||||
```
|
||||
User:
|
||||
Id: Guid (PK)
|
||||
Email: string (required)
|
||||
PasswordHash: string (required)
|
||||
Hardware: string? (optional — TOMBSTONED by AZ-197; nullable, unused; no application code reads or writes)
|
||||
PasswordHash: string (required, Argon2id PHC; legacy SHA-384 base64 accepted on read, rehashed on next login — AZ-536)
|
||||
Hardware: string? (TOMBSTONED — AZ-197)
|
||||
Role: RoleEnum (required)
|
||||
CreatedAt: DateTime (required)
|
||||
LastLogin: DateTime? (optional)
|
||||
UserConfig: UserConfig? (optional, JSON-serialized)
|
||||
IsEnabled: bool (required)
|
||||
|
||||
UserConfig:
|
||||
QueueOffsets: UserQueueOffsets? (optional)
|
||||
|
||||
UserQueueOffsets:
|
||||
AnnotationsOffset: ulong
|
||||
AnnotationsConfirmOffset: ulong
|
||||
AnnotationsCommandsOffset: ulong
|
||||
|
||||
DetectionClass (AZ-513):
|
||||
Id: int (PK, DB-assigned identity)
|
||||
Name, ShortName, Color: string
|
||||
MaxSizeM: double
|
||||
PhotoMode: string?
|
||||
CreatedAt: DateTime
|
||||
LastLogin: DateTime?
|
||||
UserConfig: UserConfig?
|
||||
IsEnabled: bool
|
||||
FailedLoginCount: int (AZ-537 — reset on successful login)
|
||||
LockoutUntil: DateTime? (AZ-537 — UTC; "now < LockoutUntil" → AccountLocked)
|
||||
MfaEnabled: bool (AZ-534)
|
||||
MfaSecret: string? (AZ-534 — IDataProtector-encrypted base32 TOTP secret)
|
||||
MfaRecoveryCodes: List<string>? (AZ-534 — jsonb of Argon2id-hashed single-use codes)
|
||||
MfaEnrolledAt: DateTime?
|
||||
MfaLastUsedWindow: long? (AZ-534 — anti-replay; last consumed TOTP step)
|
||||
|
||||
// Resource entity — REMOVED post-cycle-1 (AZ-183 reverted). The `resources`
|
||||
// table no longer exists; see env/db/ for the current migration set.
|
||||
Session (AZ-531 / AZ-535):
|
||||
Id: Guid (PK — used as the JWT `sid` claim)
|
||||
UserId: Guid (FK to users)
|
||||
Class: string ("interactive" | "mission")
|
||||
RefreshTokenHash: byte[]? (SHA-256 of opaque refresh; null for mission sessions)
|
||||
RotatedFromTokenId: Guid? (chain pointer for reuse detection)
|
||||
IssuedAt: DateTime
|
||||
ExpiresAt: DateTime (sliding for interactive, absolute for mission)
|
||||
RevokedAt: DateTime?
|
||||
RevokedReason: string? (one of SessionRevokedReasons)
|
||||
RevokedByUserId: Guid?
|
||||
Ip: string?
|
||||
UserAgent: string?
|
||||
MfaAuthenticated: bool (AZ-534 — pinned at issue, inherited by rotations)
|
||||
AircraftId: Guid? (mission-only)
|
||||
MissionId: string? (mission-only)
|
||||
|
||||
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, ApiAdmin=1000
|
||||
// ResourceUploader is now data-only — no endpoint policy references it
|
||||
// after AZ-183 was reverted.
|
||||
AuditEvent (AZ-537 + AZ-534):
|
||||
Id: long (PK identity)
|
||||
EventType: string (one of AuditEventTypes — login_failed/success/lockout, mfa_*)
|
||||
Email: string (lowercase normalised)
|
||||
Ip: string?
|
||||
OccurredAt: DateTime (UTC)
|
||||
|
||||
DetectionClass (AZ-513): unchanged
|
||||
|
||||
RoleEnum: None=0, Operator=10, Validator=20, CompanionPC=30, Admin=40, ResourceUploader=50, Service=60 (AZ-535), ApiAdmin=1000
|
||||
// ResourceUploader is data-only since AZ-183 revert.
|
||||
// Service is the verifier-fleet identity used by revocationReaderPolicy.
|
||||
```
|
||||
|
||||
### Configuration POCOs
|
||||
@@ -75,16 +98,33 @@ ConnectionStrings:
|
||||
AzaionDb: string — read-only connection string
|
||||
AzaionDbAdmin: string — admin (read/write) connection string
|
||||
|
||||
JwtConfig:
|
||||
JwtConfig (AZ-532):
|
||||
Issuer: string
|
||||
Audience: string
|
||||
Secret: string
|
||||
TokenLifetimeHours: double
|
||||
KeysFolder: string — directory containing one PEM per kid
|
||||
ActiveKid: string — selects the signing key
|
||||
AccessTokenLifetimeMinutes: int — default 15
|
||||
MfaStepTokenLifetimeMinutes: int — default 5 (AZ-534)
|
||||
# Secret + TokenLifetimeHours: no longer read; kept only for back-compat deserialisation
|
||||
|
||||
SessionConfig (AZ-531):
|
||||
RefreshSlidingHours: int — sliding window per rotate
|
||||
RefreshAbsoluteHours: int — hard cap (no rotation past this)
|
||||
RevokedSnapshotMinutes: int — verifier-poll grace window for /sessions/revoked
|
||||
|
||||
AuthConfig (AZ-536 + AZ-537):
|
||||
PasswordHashing: { TimeCost, MemoryCostKiB, Parallelism } — Argon2id parameters
|
||||
RateLimit:
|
||||
PerIpPermitLimit: int
|
||||
PerIpWindowSeconds: int
|
||||
PerAccountWindowSeconds: int
|
||||
PerAccountFailedThreshold: int
|
||||
Lockout:
|
||||
ConsecutiveFailureThreshold: int
|
||||
LockoutSeconds: int
|
||||
|
||||
ResourcesConfig:
|
||||
ResourcesFolder: string
|
||||
# SuiteInstallerFolder / SuiteStageInstallerFolder removed in cycle 2 with the installer endpoints.
|
||||
# EncryptionMasterKey was added by AZ-183 and removed in the post-cycle-1 revert.
|
||||
```
|
||||
|
||||
## 3. External API Specification
|
||||
@@ -97,25 +137,34 @@ N/A — internal component.
|
||||
|
||||
| Query | Frequency | Hot Path | Index Needed |
|
||||
|-------|-----------|----------|--------------|
|
||||
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes — UNIQUE INDEX `users_email_uidx` on `email` (security audit F-3, `env/db/06_users_email_unique.sql`) |
|
||||
| `SELECT * FROM users WHERE email = ?` | High | Yes | Yes — UNIQUE INDEX `users_email_uidx` on `email` |
|
||||
| `SELECT * FROM users` with optional filters | Medium | No | No |
|
||||
| `UPDATE users SET ... WHERE email = ?` | Medium | No | No |
|
||||
| `INSERT INTO users` | Low | No | No (UNIQUE INDEX above also enforces single-row-per-email atomically) |
|
||||
| `INSERT INTO users` | Low | No | UNIQUE INDEX above |
|
||||
| `DELETE FROM users WHERE email = ?` | Low | No | No |
|
||||
| `SELECT * FROM sessions WHERE refresh_token_hash = ?` (AZ-531) | High | Yes | Yes — UNIQUE INDEX on `refresh_token_hash` (`08_sessions.sql`) |
|
||||
| `UPDATE sessions SET revoked_at..., revoked_reason... WHERE id = ?` (AZ-535) | Medium | No | PK |
|
||||
| `UPDATE sessions SET revoked_... WHERE user_id = ? AND revoked_at IS NULL` (AZ-535 logout/all) | Low | No | INDEX on `(user_id, revoked_at)` |
|
||||
| `UPDATE sessions SET revoked_... WHERE aircraft_id = ? AND class='mission' AND revoked_at IS NULL` (AZ-533) | Low | No | INDEX on `(aircraft_id, class, revoked_at)` |
|
||||
| `SELECT ... FROM sessions WHERE revoked_at >= ? AND expires_at > now()` (AZ-535 verifier poll) | High | Yes | INDEX on `revoked_at` |
|
||||
| `SELECT count(*) FROM audit_events WHERE event_type='login_failed' AND email=? AND occurred_at >= ?` (AZ-537) | High | Yes | INDEX on `(email, event_type, occurred_at)` |
|
||||
| `INSERT INTO audit_events (...)` (AZ-537 / AZ-534) | High | Yes | n/a |
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
| Data | Cache Type | TTL | Invalidation |
|
||||
|------|-----------|-----|-------------|
|
||||
| User by email | In-memory (LazyCache) | 4 hours | On `UpdateQueueOffsets` (post-AZ-197 — hardware paths gone) |
|
||||
| User by email | In-memory (LazyCache) | 4 hours | On `UpdateQueueOffsets`, on lazy-rehash (AZ-536), on MFA enroll/confirm/disable (AZ-534), on user enable/disable, on lockout state changes (AZ-537) |
|
||||
|
||||
> The `Resources.Latest.{arch}.{stage}` cache key (added by AZ-183) was removed in the post-cycle-1 revert.
|
||||
> Refresh tokens, sessions, and audit events are NOT cached — they are read directly from Postgres on every request. The verifier-poll snapshot (`/sessions/revoked`) is the only "edge" cache and lives in the verifier process, not in this component.
|
||||
|
||||
### Storage Estimates
|
||||
|
||||
| Table | Est. Row Count (1yr) | Row Size | Total Size | Growth Rate |
|
||||
|-------|---------------------|----------|------------|-------------|
|
||||
| `users` | 100–1000 web users + 2000–10000 CompanionPC device users (AZ-196 grows this) | ~500 bytes | ~5 MB | Medium (device fleet) |
|
||||
| `users` | 100–1000 web users + 2000–10000 CompanionPC device users | ~700 bytes (post-MFA columns) | ~7 MB | Medium |
|
||||
| `sessions` (AZ-531) | 30 d retention (`RefreshAbsoluteHours`) × N active sessions per user × pruning job | ~400 bytes | ~50 MB ceiling | High during active fleet ops; bounded by retention |
|
||||
| `audit_events` (AZ-537) | ~50 events/user/day × ~5000 users × 365 d | ~150 bytes | ~14 GB/yr | High — partition or archive after 90 d (operational follow-up) |
|
||||
| `detection_classes` (AZ-513) | 10–200 | ~250 bytes | ~50 KB | Low |
|
||||
|
||||
### Data Management
|
||||
@@ -182,12 +231,15 @@ N/A — internal component.
|
||||
|
||||
## Modules Covered
|
||||
- `Common/Configs/ConnectionStrings`
|
||||
- `Common/Configs/JwtConfig`
|
||||
- `Common/Configs/JwtConfig` *(AZ-532 — ES256 + session config)*
|
||||
- `Common/Configs/AuthConfig` *(new in cycle 2 — AZ-536 + AZ-537)*
|
||||
- `Common/Configs/ResourcesConfig`
|
||||
- `Common/Entities/User`
|
||||
- `Common/Entities/RoleEnum`
|
||||
- `Common/Entities/User` *(extended in cycle 2 — AZ-537 + AZ-534)*
|
||||
- `Common/Entities/RoleEnum` *(extended in cycle 2 — AZ-535 added `Service`)*
|
||||
- `Common/Entities/Session` *(new in cycle 2 — AZ-531 + AZ-535)*
|
||||
- `Common/Entities/AuditEvent` *(new in cycle 2 — AZ-537)*
|
||||
- `Common/Entities/DetectionClass` *(added cycle 1, AZ-513)*
|
||||
- `Common/Database/AzaionDb` (now also holds the `DetectionClasses` table; the `Resources` ITable added by AZ-183 was removed in the post-cycle-1 revert)
|
||||
- `Common/Database/AzaionDbSchemaHolder`
|
||||
- `Common/Database/AzaionDb` (`Sessions` and `AuditEvents` ITables added in cycle 2)
|
||||
- `Common/Database/AzaionDbSchemaHolder` (Session + AuditEvent mappings, jsonb for `MfaRecoveryCodes`)
|
||||
- `Common/Database/DbFactory`
|
||||
- `Services/Cache`
|
||||
|
||||
@@ -1,90 +1,181 @@
|
||||
# Authentication & Security
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 simplified `GetApiEncryptionKey` to `(email, password)` and removed `GetHWHash` outright. The hardware-binding threat model that motivated those primitives is no longer in scope (fTPM-anchored Jetsons + browser SaaS).
|
||||
> **Cycle 1 (2026-05-13) note** — AZ-197 simplified `GetApiEncryptionKey` to `(email, password)` and removed `GetHWHash` outright. The hardware-binding threat model is no longer in scope.
|
||||
>
|
||||
> **Cycle 2 (2026-05-14) note** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were all removed along with the encrypted-download endpoint. `Security` is now a one-method utility (`ToHash`) that backs SHA-384 password hashing.
|
||||
> **Cycle 2 — early (2026-05-14, batches 01-04)** — `GetApiEncryptionKey`, `EncryptTo`, and `DecryptTo` were removed along with the encrypted-download endpoint. `Security` was briefly a one-method utility (`ToHash`) wrapping SHA-384.
|
||||
>
|
||||
> **Cycle 2 — Auth Modernization (2026-05-14, AZ-531..AZ-538)** — this component was rebuilt from a single-token issuer + SHA-384 hasher into the full session/refresh/MFA/audit/mission stack described below. Old single-token, symmetric-HS256, SHA-384 paths are gone.
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: JWT token creation/validation and password hashing (`Security.ToHash`).
|
||||
**Purpose**: end-to-end authentication, authorization, session management, second factor (TOTP), token signing/verification, mission credentials, audit, and request-time abuse protection (rate limiting / lockout).
|
||||
|
||||
**Architectural Pattern**: Service + static utility — `AuthService` is a DI-managed service for JWT operations; `Security` is a static class with a single SHA-384 helper.
|
||||
**Architectural Pattern**: a cluster of focused DI-registered services backed by Postgres tables, fronted by Admin API endpoints. Token signing is asymmetric (ES256) with file-system key storage and JWKS publication. Refresh tokens use server-side rotation with reuse detection. MFA secrets are encrypted at rest via ASP.NET `IDataProtector`.
|
||||
|
||||
**Upstream dependencies**: Data Layer (JwtConfig, IUserService for GetByEmail), ASP.NET Core (IHttpContextAccessor).
|
||||
**Upstream dependencies**:
|
||||
- Data Layer (`AzaionDb`, `JwtConfig`, `SessionConfig`, `AuthConfig`, `IUserService.GetByEmail`)
|
||||
- ASP.NET Core (`IHttpContextAccessor`, `IDataProtectionProvider`, `RateLimiter` middleware)
|
||||
- File system (`JwtConfig.KeysFolder` for ES256 keys; one PEM per kid)
|
||||
|
||||
**Downstream consumers**: Admin API (token creation on login, current user resolution), User Management (password hashing for both web users and provisioned devices).
|
||||
**Downstream consumers**:
|
||||
- Admin API endpoints (`/login`, `/login/mfa`, `/refresh`, `/logout`, `/logout/all`, `/users/me/mfa/*`, `/sessions/{sid}`, `/aircraft/{id}/sessions`, `/sessions/revoked`, `/missions/sessions`, `/.well-known/jwks.json`)
|
||||
- All authorized requests (JWT bearer middleware verifies via `IJwtSigningKeyProvider` and Verifier services consult the revoked-sessions snapshot)
|
||||
- User Management (Argon2id hashing for register/update; lazy migration on login)
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### Interface: IAuthService
|
||||
### Service: `IAuthService`
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `GetCurrentUser` | (none — reads from HttpContext) | `User?` | Yes | None |
|
||||
| `CreateToken` | `User` | `string` (JWT) | No | None |
|
||||
| Method | Input | Output | Async | Notes |
|
||||
|--------|-------|--------|-------|-------|
|
||||
| `GetCurrentUser` | (HttpContext) | `User?` | Yes | Reads `ClaimTypes.Name` (email) and looks up via `IUserService.GetByEmail` |
|
||||
| `CreateToken` | `User`, `Guid sessionId`, `Guid jti`, `IEnumerable<string>? amr` | `AccessToken` (record: `Jwt`, `ExpiresAt`) | No | ES256 signed; lifetime from `JwtConfig.AccessTokenLifetimeMinutes`. Stamps `sub` (`NameIdentifier`), `email` (`Name`), `role`, `sid`, `jti`, and one `amr` claim per value (defaults to `["pwd"]`). |
|
||||
|
||||
### Static: Security
|
||||
### Service: `IRefreshTokenService` *(AZ-531)*
|
||||
|
||||
| Method | Input | Output | Notes |
|
||||
|--------|-------|--------|-------|
|
||||
| `IssueForNewLogin` | `Guid userId`, `bool mfaAuthenticated`, `CancellationToken` | `(string OpaqueToken, Session Session)` | Creates a new session family (the returned `Session.Id` is the `sid` claim) + initial refresh token. `MfaAuthenticated` is pinned on the session so refresh rotations inherit AMR strength. |
|
||||
| `Rotate` | `string opaqueToken`, `CancellationToken` | `(string OpaqueToken, Session Session)` | Validates → marks old as rotated → inserts new row in same family. Presenting an already-rotated token revokes the entire family. |
|
||||
|
||||
### Service: `ISessionService` *(AZ-535)*
|
||||
|
||||
| Method | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `RevokeBySid` | `Guid sessionId`, `Guid? byUserId`, `string reason`, `CancellationToken` | `Task<bool>` (true = was already revoked = no-op) |
|
||||
| `RevokeAllForUser` | `Guid userId`, `Guid? byUserId`, `string reason`, `CancellationToken` | `Task<int>` (rows revoked) |
|
||||
| `RevokeMissionsForAircraft` | `Guid aircraftId`, `CancellationToken` | `Task<int>` (called from `MissionTokenService.Issue` and from any successful aircraft re-login) |
|
||||
| `GetRevokedSince` | `DateTime since`, `CancellationToken` | `Task<IReadOnlyList<RevokedSession>>` (sid, exp, revokedAt, reason) |
|
||||
|
||||
### Service: `IMfaService` *(AZ-534)*
|
||||
|
||||
| Method | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `Enroll` | `Guid userId`, `string password`, `CancellationToken` | `Task<MfaEnrollResponse>` (otpauth URL, base32 secret, QR PNG bytes — DataProtection-encrypted secret persisted) |
|
||||
| `Confirm` | `Guid userId`, `string code`, `CancellationToken` | `Task` (sets `MfaEnabled=true`, generates and stores hashed recovery codes) |
|
||||
| `Disable` | `Guid userId`, `string password`, `string code`, `CancellationToken` | `Task` |
|
||||
| `IssueMfaStepToken` | `Guid userId` | `string` (short-lived JWT with `mfa_pending`, audience `mfa-step`, signed by active ES256 key) |
|
||||
| `ValidateMfaStepToken` | `string token` | `Guid userId` |
|
||||
| `VerifyForLogin` | `Guid userId`, `string code`, `CancellationToken` | `Task<string[]>` — returns the AMR array (`["pwd","mfa"]` or with `"recovery"` appended); throws `InvalidMfaCode` on failure |
|
||||
|
||||
### Service: `IMissionTokenService` *(AZ-533)*
|
||||
|
||||
| Method | Input | Output | Notes |
|
||||
|--------|-------|--------|-------|
|
||||
| `Issue` | `Guid pilotUserId`, `MissionSessionRequest`, `CancellationToken` | `Task<MissionSessionResponse>` | Validates aircraft is `CompanionPC`; auto-revokes prior mission sessions for the aircraft; inserts session row with `Class = "mission"` BEFORE signing so `sid` is bound; planned duration = absolute lifetime (no refresh). |
|
||||
|
||||
### Service: `IJwtSigningKeyProvider` *(AZ-532)*
|
||||
|
||||
| Member | Output | Notes |
|
||||
|--------|--------|-------|
|
||||
| `Active` | `JwtSigningKey` (`Kid`, `EcdsaSecurityKey SecurityKey`, `ECDsa Ecdsa`) | The signing key. Eager — constructed once at app start so missing/malformed keys fail-fast. |
|
||||
| `All` | `IReadOnlyList<JwtSigningKey>` | Drives `/.well-known/jwks.json` and `IssuerSigningKeyResolver`. All discovered keys are exposed; only `Active` signs. |
|
||||
|
||||
### Service: `IAuditLog` *(AZ-537 + AZ-534)*
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `RecordLoginSuccess(email)` / `RecordLoginFailed(email)` / `RecordLoginLockout(email)` | Persists `audit_events` rows with normalised email + caller IP. |
|
||||
| `RecordMfaEnroll/Confirm/Disable/LoginSuccess/LoginFailed/RecoveryUsed(email)` | One per MFA lifecycle event. |
|
||||
| `CountRecentFailedLogins(email, windowSeconds)` | Backs the per-account sliding-window check in `UserService.ValidateUser`. |
|
||||
|
||||
### Static: `Security` *(AZ-536 — replaces SHA-384)*
|
||||
|
||||
| Method | Input | Output | Description |
|
||||
|--------|-------|--------|-------------|
|
||||
| `ToHash` | `string` | `string` (Base64) | SHA-384 hash |
|
||||
| `HashPassword` | `string` | `string` (PHC) | Argon2id, parameters from `AuthConfig.PasswordHashing` |
|
||||
| `VerifyPassword` | `string presented`, `string stored` | `VerifyResult` (`Ok`, `NeedsRehash`) | Constant-time; recognizes legacy SHA-384 base64 strings and returns `Ok=true, NeedsRehash=true` so `UserService` can lazy-upgrade |
|
||||
|
||||
**Removed**:
|
||||
- `GetHWHash(string hardware)` — removed by AZ-197 (cycle 1).
|
||||
- `GetApiEncryptionKey(string email, string password)` — removed in cycle 2 (no remaining callers after `POST /resources/get/{dataFolder?}` was deleted).
|
||||
- `EncryptTo` / `DecryptTo` extension methods — removed in cycle 2 (no remaining callers; the only consumer was `ResourcesService.GetEncryptedResource`, also deleted).
|
||||
- `ToHash(string)` — removed by AZ-536. All callers now use `HashPassword` / `VerifyPassword`.
|
||||
- `GetHWHash`, `GetApiEncryptionKey`, `EncryptTo`, `DecryptTo` — removed earlier in cycle 2.
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
N/A — exposed through Admin API.
|
||||
Exposed via Admin API (component 05). Cycle 2 added:
|
||||
|
||||
- `POST /login` — now returns either `LoginResponse` (access + refresh + sid) or `MfaRequiredResponse` (mfa_token only when MFA is enabled). Per-IP sliding-window rate limit applied.
|
||||
- `POST /login/mfa` — completes MFA login (anonymous + per-IP rate limit; the step-1 token is the proof of mid-flow) → `LoginResponse`
|
||||
- `POST /token/refresh` — rotates refresh token + new access token (anonymous; the refresh token IS the proof)
|
||||
- `POST /logout` — revokes the caller's current `sid` (read from the access-token claim). Idempotent.
|
||||
- `POST /logout/all` — revokes every session for the caller's user
|
||||
- `POST /users/me/mfa/enroll` / `confirm` / `disable`
|
||||
- `POST /sessions/{sid:guid}/revoke` *(ApiAdmin)*
|
||||
- `GET /sessions/revoked?since=...` *(verifier role / ApiAdmin via `revocationReaderPolicy`)*
|
||||
- `POST /sessions/mission` *(authenticated; pilot's interactive token)* → mission `LoginResponse`-shaped reply
|
||||
- `GET /.well-known/jwks.json` — anonymous; serves all loaded ES256 public keys (active + retiring); cached 1h.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
No direct database access. `AuthService.GetCurrentUser` delegates to `IUserService.GetByEmail`.
|
||||
| Service | Tables touched | Pattern |
|
||||
|---------|----------------|---------|
|
||||
| `RefreshTokenService` | `public.sessions` | Insert on issue / rotate; update `RevokedAt`+`RevokedReason` on rotate / reuse-detected; index lookup by `RefreshTokenHash` |
|
||||
| `SessionService` | `public.sessions` | Update by `Sid`; bulk update by `UserId`; range read for revoked-since snapshot |
|
||||
| `MfaService` | `public.users` | Update MFA columns (`MfaEnabled`, `MfaSecret`, `MfaRecoveryCodes`, `MfaEnrolledAt`, `MfaLastUsedWindow`) |
|
||||
| `MissionTokenService` | `public.sessions`, `public.users` | Insert mission session row; lookup aircraft user |
|
||||
| `AuditLog` | `public.audit_events`, `public.users` | Insert events; update `FailedLoginCount` / `LockoutUntil` on the user |
|
||||
| `AuthService` / `UserService` | `public.users` | Reads for current-user resolution and password verify; updates on lazy rehash |
|
||||
|
||||
All tables are LinqToDB-mapped via `AzaionDbShemaHolder`; recovery codes use `jsonb`.
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**Algorithmic Complexity**: SHA-384 hashing is O(n) where n is input length; in practice it operates on short password strings only.
|
||||
**Argon2id parameters** (cycle 2 default): time=3, memory=64 MiB, parallelism=2 — overridable via `AuthConfig.PasswordHashing`. Output is a PHC-format string self-describing all parameters; verification re-derives them from the stored value.
|
||||
|
||||
**State Management**: `AuthService` is stateless (reads claims from HTTP context per request). `Security` is purely static.
|
||||
**ES256 keys**: one PEM file per kid in `JwtConfig.KeysFolder`. `ActiveKid` selects the signer; all PEMs with valid `P-256` curves are exposed via JWKS. Rotation procedure: drop a new PEM, set `ActiveKid` to it, restart. Old keys remain in JWKS until physically removed (by ops) so already-issued tokens stay verifiable.
|
||||
|
||||
**Key Dependencies**:
|
||||
**Refresh token format**: opaque random `Base64Url(32 bytes)`. Server stores SHA-256 hash + family id (`Sid`) + `RotatedFromTokenId` to support reuse detection. Sliding window per `SessionConfig.RefreshSlidingHours`; absolute cap per `SessionConfig.RefreshAbsoluteHours`.
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| System.IdentityModel.Tokens.Jwt | 7.1.2 | JWT token generation |
|
||||
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.3 | JWT middleware integration |
|
||||
**Reuse detection**: presenting an already-rotated refresh token revokes the entire family (`Sid`) with reason `RefreshReuseDetected`. The next-snapshot poll picks this up.
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- JWT token creation does not throw (malformed config would cause runtime errors at middleware level).
|
||||
- `GetCurrentUser` returns null if claims are missing or user not found.
|
||||
**MFA**:
|
||||
- Secret: 20 random bytes → base32; URL `otpauth://totp/Azaion:{email}?secret=...&issuer=Azaion`.
|
||||
- QR: PNG generated with `QRCoder` and returned as bytes (only on enroll).
|
||||
- Recovery codes: 10 codes, each `Argon2id`-hashed before storage. Single-use; checked on `VerifyForLoginAsync` after TOTP fails.
|
||||
- Step-1 token: short-lived JWT (`mfa_pending = true`, audience `mfa-step`) signed by the active ES256 key. Lifetime `JwtConfig.MfaStepTokenLifetimeMinutes`.
|
||||
- Replay defense: persisted `MfaLastUsedWindow` blocks reuse of the same TOTP window within the 30s step.
|
||||
|
||||
**Rate limiting / lockout** (AZ-537):
|
||||
- Per-IP token-bucket via ASP.NET Core `RateLimiter` on `/login`, `/login/mfa`, `/refresh`.
|
||||
- Per-account sliding window via `IAuditLog.CountRecentFailedLoginsAsync`; threshold + window from `AuthConfig.RateLimit`.
|
||||
- Lockout via `LockoutOptions`: N consecutive failures within window → `LockoutUntil` set; subsequent logins throw `AccountLocked` with `RetryAfterSeconds`.
|
||||
|
||||
**HSTS / HTTPS / CORS** (AZ-538):
|
||||
- HSTS enabled in non-Development with the standard 1y `includeSubDomains` policy.
|
||||
- HTTPS redirection in non-Development.
|
||||
- CORS narrowed to the configured admin origins; credentials allowed only for those origins.
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
None — `Security` itself is a utility consumed by other components.
|
||||
- `Program.cs` helpers: `ParseSidClaim`, `ParseUserIdClaim` (both throw `InvalidRefreshToken` on malformed/missing claims so handlers don't need to repeat the check).
|
||||
- `BusinessExceptionHandler` adds the `Retry-After` header for `AccountLocked` / `LoginRateLimited`.
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- Password hashing uses SHA-384 without per-user salt or key stretching. Not resistant to rainbow table attacks. (Unchanged by cycles 1 and 2.)
|
||||
- `GetCurrentUserEmail` assumes `ClaimTypes.Name` is always present; accessing a missing key would throw `KeyNotFoundException`.
|
||||
|
||||
**Removed in cycle 1**: hardware fingerprint hashing was a known weakness (static salt, no rotation); deleting it via AZ-197 also removed that attack surface.
|
||||
|
||||
**Removed in cycle 2**: per-user file encryption (`GetApiEncryptionKey` + `EncryptTo` + `DecryptTo`). The hardcoded encryption-key salt and the in-memory `MemoryStream` round-trip are no longer attack / performance surfaces in this codebase.
|
||||
- **Asymmetric key roll-forward only**: revoking a kid means deleting its PEM. There is no per-kid revocation list separate from the file system. Operators must coordinate kid retirement with refresh-token expiry.
|
||||
- **Verifier polling cadence**: `GET /sessions/revoked?since=` returns the snapshot since a timestamp. Verifiers must clock-skew-tolerate by stepping `since` back ~30s. Snapshot rows are pruned only after both `expiry + grace` window has passed.
|
||||
- **MFA recovery codes are single-use**: there is no `regenerate` endpoint in cycle 2. A user who burns all 10 codes and loses their authenticator must contact an admin to disable MFA via `/users/me/mfa/disable` (re-uses password + TOTP, so admin is currently NOT able to disable on behalf of the user — flagged as a follow-up).
|
||||
- **Mission tokens have no refresh**: `planned_duration_h` is the hard cap; expiry is absolute. Aircraft must re-request via the admin path on re-connect.
|
||||
- **Lazy password rehash leak window**: a successful login with a SHA-384 stored hash returns `Ok=true, NeedsRehash=true` and `UserService` re-hashes via Argon2id within the same request. If that update fails (DB error), the legacy hash stays — surfaced via logs but not blocking.
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: Data Layer (for JwtConfig, IUserService).
|
||||
**Must be implemented after**: Data Layer (configs + DB tables `users`, `sessions`, `audit_events`).
|
||||
|
||||
**Can be implemented in parallel with**: User Management (shared dependency on Data Layer).
|
||||
|
||||
**Blocks**: Admin API. (Resource Management no longer depends on this component after cycle 2 removed `EncryptTo` / `DecryptTo`.)
|
||||
**Blocks**: Admin API (every authenticated endpoint), Verifier components (consume `GET /sessions/revoked` and JWKS).
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
No explicit logging in AuthService or Security.
|
||||
- All MFA failures, lockouts, refresh-reuse events, and admin revocations log at `Warning`+ via `IAuditLog` and structured logger.
|
||||
- Successful logins log at `Information`.
|
||||
- Argon2id verification failures log only the audit row (no plaintext, no hash).
|
||||
|
||||
## Modules Covered
|
||||
- `Services/AuthService`
|
||||
- `Services/Security`
|
||||
- `Services/RefreshTokenService`
|
||||
- `Services/SessionService`
|
||||
- `Services/MfaService`
|
||||
- `Services/MissionTokenService`
|
||||
- `Services/JwtSigningKeyProvider`
|
||||
- `Services/AuditLog`
|
||||
|
||||
@@ -2,133 +2,201 @@
|
||||
|
||||
## 1. High-Level Overview
|
||||
|
||||
**Purpose**: HTTP API entry point — configures DI, middleware pipeline, authentication, authorization, CORS, Swagger, and defines all REST endpoints using ASP.NET Core Minimal API.
|
||||
**Purpose**: HTTP API entry point — configures DI, middleware pipeline, authentication, authorization, CORS, HSTS, HTTPS redirection, rate limiting, Swagger, DataProtection, and defines all REST endpoints using ASP.NET Core Minimal API.
|
||||
|
||||
**Architectural Pattern**: Composition root + Minimal API endpoints — top-level statements configure the application and map HTTP routes to service methods.
|
||||
**Architectural Pattern**: Composition root + Minimal API endpoints — top-level statements configure the application and map HTTP routes to service methods. A static `IssueDualTokens` helper centralises the access+refresh issuance pattern shared by `/login` (no MFA) and `/login/mfa` (with MFA), and a tiny `ParseSidClaim` / `ParseUserIdClaim` pair extracts session/user identity from the request principal.
|
||||
|
||||
**Upstream dependencies**: User Management (IUserService), Authentication & Security (IAuthService, Security), Resource Management (IResourcesService), Data Layer (IDbFactory, ICache, configs).
|
||||
**Upstream dependencies**: Authentication & Security (AuthService, RefreshTokenService, SessionService, MissionTokenService, MfaService, JwtSigningKeyProvider, AuditLog, Security), User Management (IUserService), Resource Management (IResourcesService), Detection Classes (IDetectionClassService), Data Layer (IDbFactory, ICache, all configs).
|
||||
|
||||
**Downstream consumers**: None (top-level entry point, consumed by HTTP clients).
|
||||
**Downstream consumers**: HTTP clients (admin web UI, verifier services, CompanionPC).
|
||||
|
||||
## 2. Internal Interfaces
|
||||
|
||||
### BusinessExceptionHandler
|
||||
|
||||
| Method | Input | Output | Async | Error Types |
|
||||
|--------|-------|--------|-------|-------------|
|
||||
| `TryHandleAsync` | `HttpContext, Exception, CancellationToken` | `bool` | Yes | None |
|
||||
| Method | Input | Output | Async |
|
||||
|--------|-------|--------|-------|
|
||||
| `TryHandleAsync` | `HttpContext`, `Exception`, `CancellationToken` | `bool` | Yes |
|
||||
|
||||
Converts `BusinessException` to HTTP 409 JSON response: `{ ErrorCode: int, Message: string }`.
|
||||
Cycle 2 (AZ-537 / AZ-531 / AZ-533 / AZ-534 / AZ-535) — the handler now maps `BusinessException` → an exception-specific HTTP status code via a `MapStatusCode` switch, preserves the legacy `409 Conflict` default, and stamps a `Retry-After` response header when `RetryAfterSeconds` is set. It also handles `BadHttpRequestException` → `400 Bad Request` with `{ ErrorCode: 0, Message }` so malformed payloads have a consistent shape with business errors.
|
||||
|
||||
| `ExceptionEnum` | HTTP status |
|
||||
|-----------------|-------------|
|
||||
| `AccountLocked` | `423 Locked` |
|
||||
| `LoginRateLimited` | `429 Too Many Requests` |
|
||||
| `InvalidRefreshToken` / `InvalidMfaCode` / `InvalidMfaToken` | `401 Unauthorized` |
|
||||
| `SessionNotFound` | `404 Not Found` |
|
||||
| `InvalidMissionRequest` / `AircraftNotFound` | `400 Bad Request` |
|
||||
| `MfaAlreadyEnabled` / `MfaNotEnrolling` / `MfaNotEnabled` | `409 Conflict` |
|
||||
| any other | `409 Conflict` (legacy default) |
|
||||
|
||||
### Static helpers in `Program.cs`
|
||||
|
||||
- `IssueDualTokens(user, authService, refreshTokens, sessionService, amr, ct)` — issues a refresh token + an access token, also auto-revokes any open mission sessions if the just-authenticated user is a `CompanionPC` (AZ-533 AC-4).
|
||||
- `ParseSidClaim(ClaimsPrincipal)` / `ParseUserIdClaim(ClaimsPrincipal)` — read `sid` / `nameid` claims; throw `BusinessException(InvalidRefreshToken)` (→ 401) on missing/malformed.
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
> **Cycle 1 (2026-05-13) note** — endpoints below reflect the post-cycle-1 surface (AZ-513 Detection Classes CRUD, AZ-196 device auto-provisioning, AZ-197 hardware-binding removal). AZ-183 (OTA) shipped in cycle 1 but was reverted later the same day after the security audit (finding F-1) — the OTA delivery model itself was deemed obsolete. For per-endpoint cycle origins see `modules/admin_api_program.md`.
|
||||
> **Cycle 2 (2026-05-14) — auth modernization**: `/login` is now multi-shape (MFA branch); `/login/mfa`, `/token/refresh`, `/logout`, `/logout/all`, `/sessions/*`, `/users/me/mfa/*`, `/.well-known/jwks.json` are all new. The legacy "single JWT" response is preserved as a `Token` getter on `LoginResponse` for compatibility with old clients (= same value as `AccessToken`).
|
||||
|
||||
### Authentication & Sessions
|
||||
|
||||
| Endpoint | Method | Auth | Cycle | Description |
|
||||
|----------|--------|------|-------|-------------|
|
||||
| `/login` | POST | Anonymous | AZ-531/534/537 | Validates credentials. Returns `LoginResponse` (access + refresh + sid) OR `MfaRequiredResponse` (`mfa_required: true`, short-lived `mfa_token`). Per-IP rate limited. |
|
||||
| `/login/mfa` | POST | Anonymous | AZ-534 | Validates the step-1 `mfa_token` + the user's TOTP / recovery code. Returns `LoginResponse`. Per-IP rate limited. |
|
||||
| `/token/refresh` | POST | Anonymous | AZ-531 | Rotates a refresh token. Reuse of a rotated token revokes the entire session family. |
|
||||
| `/logout` | POST | Authenticated | AZ-535 | Revokes the caller's current `sid` (idempotent). |
|
||||
| `/logout/all` | POST | Authenticated | AZ-535 | Revokes every active session for the caller's user. |
|
||||
| `/sessions/{sid:guid}/revoke` | POST | ApiAdmin | AZ-535 | Admin-revoke by session id. |
|
||||
| `/sessions/revoked` | GET | revocationReader (Service or ApiAdmin) | AZ-535 | Verifier-poll snapshot of revoked sessions still within their TTL. `since` is clamped to a 12 h floor to prevent table scans. |
|
||||
| `/sessions/mission` | POST | Authenticated | AZ-533 | Pilot issues a long-lived no-refresh mission token bound to one aircraft + one mission. |
|
||||
| `/.well-known/jwks.json` | GET | Anonymous | AZ-532 | All loaded ES256 public keys (active + retiring). `Cache-Control: public, max-age=3600`. |
|
||||
|
||||
### MFA
|
||||
|
||||
### Authentication
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/login` | POST | Anonymous | Validates credentials, returns JWT |
|
||||
| `/users/me/mfa/enroll` | POST | Authenticated | Starts TOTP enrollment, returns secret + otpauth URL + PNG QR. |
|
||||
| `/users/me/mfa/confirm` | POST | Authenticated | Confirms with a TOTP code. Returns `{ mfaEnabled: true }`. |
|
||||
| `/users/me/mfa/disable` | POST | Authenticated | Requires password + TOTP. Returns `{ mfaEnabled: false }`. |
|
||||
|
||||
### User Management
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/users` | POST | ApiAdmin | Creates a new user |
|
||||
| `/devices` | POST | ApiAdmin | **AZ-196**: provisions a CompanionPC device user (returns serial + email + plaintext password once) |
|
||||
| `/users/current` | GET | Authenticated | Returns current user |
|
||||
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters) |
|
||||
| `/users/queue-offsets/set` | PUT | Authenticated | Updates queue offsets |
|
||||
| `/users/{email}/set-role/{role}` | PUT | ApiAdmin | Changes user role |
|
||||
| `/users/{email}/enable` | PUT | ApiAdmin | Enables user |
|
||||
| `/users/{email}/disable` | PUT | ApiAdmin | Disables user |
|
||||
| `/users/{email}` | DELETE | ApiAdmin | Removes user |
|
||||
|
||||
**Removed by AZ-197**: `PUT /users/hardware/set` (Hardware-binding feature deleted)
|
||||
| `/users` | POST | ApiAdmin | Creates a new user (Argon2id-hashed password, AZ-536). |
|
||||
| `/devices` | POST | ApiAdmin | Provisions a CompanionPC device user (returns serial + email + plaintext password once). |
|
||||
| `/users/current` | GET | Authenticated | Returns current user. |
|
||||
| `/users` | GET | ApiAdmin | Lists users (optional email/role filters). |
|
||||
| `/users/queue-offsets/set` | PUT | Authenticated | Updates queue offsets. |
|
||||
| `/users/{email}/set-role/{role}` | PUT | ApiAdmin | Changes user role. |
|
||||
| `/users/{email}/enable` | PUT | ApiAdmin | Enables user. |
|
||||
| `/users/{email}/disable` | PUT | ApiAdmin | Disables user (revokes all active sessions for that user via `SessionService`). |
|
||||
| `/users/{email}` | DELETE | ApiAdmin | Removes user. |
|
||||
|
||||
### Resource Management
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/resources/{dataFolder?}` | POST | Authenticated | Uploads a file (up to 200 MB) |
|
||||
| `/resources/list/{dataFolder?}` | GET | Authenticated | Lists files |
|
||||
| `/resources/clear/{dataFolder?}` | POST | ApiAdmin | Clears folder |
|
||||
|
||||
**Removed by AZ-197**: `POST /resources/check` (was the hardware-binding side-effect probe).
|
||||
**Removed in post-cycle-1 revert**: `POST /get-update` and `POST /resources/publish` (AZ-183 reverted — security audit F-1; OTA delivery model itself obsolete).
|
||||
**Removed in cycle 2 (2026-05-14)**: `POST /resources/get/{dataFolder?}`, `GET /resources/get-installer`, `GET /resources/get-installer/stage` — all obsolete; the encrypted-download support stack (`Security.GetApiEncryptionKey` / `EncryptTo` / `DecryptTo`, `ResourcesService.GetEncryptedResource` / `GetInstaller`, `GetResourceRequest`, `WrongResourceName = 50`, `ResourcesConfig.SuiteInstallerFolder` / `SuiteStageInstallerFolder`) was removed with them. ADR-003 retired.
|
||||
| `/resources/{dataFolder?}` | POST | Authenticated | Uploads a file (up to 200 MB). |
|
||||
| `/resources/list/{dataFolder?}` | GET | Authenticated | Lists files. |
|
||||
| `/resources/clear/{dataFolder?}` | POST | ApiAdmin | Clears folder. |
|
||||
|
||||
### Detection Classes
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/classes` | POST | ApiAdmin | **AZ-513**: creates a detection class |
|
||||
| `/classes/{id:int}` | PATCH | ApiAdmin | **AZ-513**: partial-merge update of a detection class |
|
||||
| `/classes/{id:int}` | DELETE | ApiAdmin | **AZ-513**: deletes a detection class |
|
||||
| `/classes` | POST | ApiAdmin | Creates a detection class. |
|
||||
| `/classes/{id:int}` | PATCH | ApiAdmin | Partial-merge update. |
|
||||
| `/classes/{id:int}` | DELETE | ApiAdmin | Deletes a detection class. |
|
||||
|
||||
### Health
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/health/live` | GET | Anonymous (excluded from Swagger) | Process liveness; never touches DB. |
|
||||
| `/health/ready` | GET | Anonymous (excluded from Swagger) | Pings both DB connections with a 2 s timeout; 503 on failure. |
|
||||
|
||||
### Authorization Policies
|
||||
- **apiAdminPolicy**: requires `ApiAdmin` role (used on most admin endpoints)
|
||||
|
||||
> The `apiUploaderPolicy` was added by AZ-183 and removed in the post-cycle-1 revert along with the OTA endpoints it guarded. `RoleEnum.ResourceUploader` remains as data only.
|
||||
| Policy | Roles | Notes |
|
||||
|--------|-------|-------|
|
||||
| `apiAdminPolicy` | `ApiAdmin` | The "admin endpoints" policy. |
|
||||
| `revocationReaderPolicy` | `Service`, `ApiAdmin` | AZ-535 — verifier services authenticate as `Service`-role identities and are the only callers (besides admin) allowed to read `/sessions/revoked`. |
|
||||
|
||||
### CORS
|
||||
- Allowed origins: `https://admin.azaion.com`, `http://admin.azaion.com`
|
||||
- All methods/headers, credentials allowed
|
||||
> The `apiUploaderPolicy` from AZ-183 was removed in the post-cycle-1 revert. `RoleEnum.ResourceUploader` remains as data only.
|
||||
|
||||
### CORS, HSTS, HTTPS (AZ-538)
|
||||
|
||||
- **CORS** — single origin `https://admin.azaion.com`, `AllowAnyMethod` + `AllowAnyHeader` + `AllowCredentials`. The legacy `http://` origin combined with credentials would have permitted credentialed cleartext traffic; cycle 2 removed it.
|
||||
- **HSTS** — non-Development only: 1 y `MaxAge`, `IncludeSubDomains`, `Preload`.
|
||||
- **HTTPS redirection** — non-Development only. Development skips both so `dotnet watch` on plain HTTP keeps working.
|
||||
|
||||
### Rate limiting (AZ-537)
|
||||
|
||||
- **Per-IP** — ASP.NET Core `RateLimiter` middleware with a `SlidingWindowRateLimiter`. Policy `login-per-ip` is attached to `/login` and `/login/mfa`. Permit limit + window seconds come from `AuthConfig.RateLimit`. Rejection sets `429` and stamps `Retry-After`.
|
||||
- **Per-account** — DB-backed sliding-window check in `UserService.ValidateUser` via `IAuditLog.CountRecentFailedLogins`. Survives process restarts.
|
||||
- **Per-account lockout** — `LockoutOptions` in `AuthConfig`. N consecutive failures → `LockoutUntil`; subsequent logins throw `AccountLocked` with `RetryAfterSeconds`.
|
||||
|
||||
## 4. Data Access Patterns
|
||||
|
||||
No direct data access — delegates to service components.
|
||||
No direct data access — delegates to service components. The composition root also fail-fast checks on missing connection strings (`AzaionDb`, `AzaionDbAdmin`) and missing `JwtConfig` (`Issuer` + `Audience` required).
|
||||
|
||||
## 5. Implementation Details
|
||||
|
||||
**State Management**: Stateless — ASP.NET Core request pipeline.
|
||||
|
||||
**DI registrations added in cycle 2**:
|
||||
- `IJwtSigningKeyProvider` (singleton, eager-built before DI so it's the same instance JwtBearer's `IssuerSigningKeyResolver` uses)
|
||||
- `IRefreshTokenService`, `ISessionService`, `IMissionTokenService`, `IMfaService` (scoped)
|
||||
- `IAuditLog` (scoped)
|
||||
- `IDataProtectionProvider` via `AddDataProtection().SetApplicationName("Azaion.AdminApi")` — production deployments MUST set `DataProtection:KeysFolder` to a persistent volume so encrypted MFA secrets survive restarts.
|
||||
|
||||
**Middleware pipeline (cycle 2 order)**:
|
||||
1. `UseSwagger`/`UseSwaggerUI` (Development only)
|
||||
2. `UseHsts` + `UseHttpsRedirection` (non-Development only)
|
||||
3. `UseCors("AdminCorsPolicy")`
|
||||
4. `UseAuthentication`
|
||||
5. `UseAuthorization`
|
||||
6. `UseRateLimiter`
|
||||
7. `UseRewriter` (root → `/swagger`)
|
||||
8. Endpoint mappings
|
||||
9. `UseExceptionHandler` (registered last so the `BusinessExceptionHandler` exception-handler component runs)
|
||||
|
||||
**JWT Bearer config**:
|
||||
- `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` — pinned to ES256 so a token forged with `alg=HS256` using the public key as the HMAC secret cannot pass validation (AZ-532 AC-5).
|
||||
- `IssuerSigningKeyResolver` consults the same `IJwtSigningKeyProvider` instance the rest of the app uses; if the token has a `kid` it's matched, otherwise all loaded keys are returned.
|
||||
- `ValidateIssuer`, `ValidateAudience`, `ValidateLifetime`, `ValidateIssuerSigningKey` all true.
|
||||
|
||||
**Key Dependencies**:
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Swashbuckle.AspNetCore | 10.1.4 | Swagger/OpenAPI documentation |
|
||||
| FluentValidation.AspNetCore | 11.3.0 | Request validation pipeline |
|
||||
| Serilog | 4.1.0 | Structured logging |
|
||||
| Serilog.Sinks.Console | 6.0.0 | Console log output |
|
||||
| Serilog.Sinks.File | 6.0.0 | Rolling file log output |
|
||||
| Library | Purpose |
|
||||
|---------|---------|
|
||||
| Microsoft.AspNetCore.Authentication.JwtBearer | JWT bearer middleware |
|
||||
| Microsoft.AspNetCore.RateLimiting | Per-IP sliding window |
|
||||
| Microsoft.AspNetCore.DataProtection | Encrypt MFA secrets at rest |
|
||||
| Microsoft.AspNetCore.Rewrite | `/` → `/swagger` redirect |
|
||||
| Swashbuckle.AspNetCore | Swagger/OpenAPI |
|
||||
| FluentValidation.AspNetCore | Request validation pipeline |
|
||||
| Serilog | Structured logging (Console + rolling file) |
|
||||
|
||||
**Error Handling Strategy**:
|
||||
- `BusinessException` → `BusinessExceptionHandler` → HTTP 409 with JSON body.
|
||||
- `UnauthorizedAccessException` → thrown in resource endpoints when current user is null.
|
||||
- `FileNotFoundException` → thrown when installer not found.
|
||||
- FluentValidation errors → automatic 400 Bad Request via middleware.
|
||||
- Unhandled exceptions → default ASP.NET Core exception handling.
|
||||
- `BusinessException` → `BusinessExceptionHandler` → per-enum status code (see table above) + optional `Retry-After`.
|
||||
- `BadHttpRequestException` → `400 Bad Request` with `{ ErrorCode: 0, Message }`.
|
||||
- FluentValidation errors → 400 via `Results.ValidationProblem`.
|
||||
- Unhandled → default ASP.NET Core handling.
|
||||
|
||||
## 6. Extensions and Helpers
|
||||
|
||||
None.
|
||||
- `IssueDualTokens` static helper (Program.cs)
|
||||
- `ParseSidClaim` / `ParseUserIdClaim` static helpers (Program.cs)
|
||||
|
||||
## 7. Caveats & Edge Cases
|
||||
|
||||
**Known limitations**:
|
||||
- All endpoints are defined in a single `Program.cs` file — no route grouping or controller separation.
|
||||
- Swagger UI only available in Development environment.
|
||||
- CORS origins are hardcoded (not configurable).
|
||||
- Antiforgery disabled for resource upload endpoint.
|
||||
- Root URL (`/`) redirects to `/swagger`.
|
||||
|
||||
**Performance bottlenecks**:
|
||||
- Kestrel max request body: 200 MB — allows large file uploads but could be a memory concern.
|
||||
- All endpoints are still defined in a single `Program.cs` file — cycle 2 added significantly more endpoints; consider splitting into endpoint groups in a future cycle.
|
||||
- Swagger UI only available in Development.
|
||||
- CORS origins are hardcoded — moving to config is a follow-up.
|
||||
- `BusinessExceptionHandler` lives under namespace `Azaion.Common` despite the file path `Azaion.AdminApi/`. Documented as historical accident; do not "fix" without coordinated rename.
|
||||
- Antiforgery disabled on resource upload.
|
||||
- Kestrel max request body 200 MB.
|
||||
- The eager `JwtSigningKeyProvider` construction means a missing or malformed PEM crashes the app at startup. This is intentional — it's safer than serving requests with no signing key.
|
||||
|
||||
## 8. Dependency Graph
|
||||
|
||||
**Must be implemented after**: All other components (composition root).
|
||||
|
||||
**Can be implemented in parallel with**: Nothing — depends on all services.
|
||||
|
||||
**Blocks**: Nothing.
|
||||
|
||||
## 9. Logging Strategy
|
||||
|
||||
| Log Level | When | Example |
|
||||
|-----------|------|---------|
|
||||
| WARN | Business exception caught | `BusinessExceptionHandler` logs the exception |
|
||||
| INFO | Serilog minimum level | General application events |
|
||||
| Log Level | When | Notes |
|
||||
|-----------|------|-------|
|
||||
| `Warning` | Business exception caught by `BusinessExceptionHandler` | Includes the full exception |
|
||||
| `Warning` | `BadHttpRequestException` caught | |
|
||||
| `Information` | Default for everything else | Serilog minimum level |
|
||||
|
||||
**Log format**: Serilog structured logging with context enrichment.
|
||||
|
||||
**Log storage**: Console + rolling file (`logs/log.txt`, daily rotation).
|
||||
|
||||
## Modules Covered
|
||||
|
||||
Reference in New Issue
Block a user