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>
12 KiB
Admin API
1. High-Level Overview
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. 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: 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: HTTP clients (admin web UI, verifier services, CompanionPC).
2. Internal Interfaces
BusinessExceptionHandler
| Method | Input | Output | Async |
|---|---|---|---|
TryHandleAsync |
HttpContext, Exception, CancellationToken |
bool |
Yes |
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 aCompanionPC(AZ-533 AC-4).ParseSidClaim(ClaimsPrincipal)/ParseUserIdClaim(ClaimsPrincipal)— readsid/nameidclaims; throwBusinessException(InvalidRefreshToken)(→ 401) on missing/malformed.
3. External API Specification
Cycle 2 (2026-05-14) — auth modernization:
/loginis now multi-shape (MFA branch);/login/mfa,/token/refresh,/logout,/logout/all,/sessions/*,/users/me/mfa/*,/.well-known/jwks.jsonare all new. The legacy "single JWT" response is preserved as aTokengetter onLoginResponsefor compatibility with old clients (= same value asAccessToken).
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
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/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 (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. |
Detection Classes
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/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
| 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. |
The
apiUploaderPolicyfrom AZ-183 was removed in the post-cycle-1 revert.RoleEnum.ResourceUploaderremains as data only.
CORS, HSTS, HTTPS (AZ-538)
- CORS — single origin
https://admin.azaion.com,AllowAnyMethod+AllowAnyHeader+AllowCredentials. The legacyhttp://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 watchon plain HTTP keeps working.
Rate limiting (AZ-537)
- Per-IP — ASP.NET Core
RateLimitermiddleware with aSlidingWindowRateLimiter. Policylogin-per-ipis attached to/loginand/login/mfa. Permit limit + window seconds come fromAuthConfig.RateLimit. Rejection sets429and stampsRetry-After. - Per-account — DB-backed sliding-window check in
UserService.ValidateUserviaIAuditLog.CountRecentFailedLogins. Survives process restarts. - Per-account lockout —
LockoutOptionsinAuthConfig. N consecutive failures →LockoutUntil; subsequent logins throwAccountLockedwithRetryAfterSeconds.
4. Data Access Patterns
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'sIssuerSigningKeyResolveruses)IRefreshTokenService,ISessionService,IMissionTokenService,IMfaService(scoped)IAuditLog(scoped)IDataProtectionProviderviaAddDataProtection().SetApplicationName("Azaion.AdminApi")— production deployments MUST setDataProtection:KeysFolderto a persistent volume so encrypted MFA secrets survive restarts.
Middleware pipeline (cycle 2 order):
UseSwagger/UseSwaggerUI(Development only)UseHsts+UseHttpsRedirection(non-Development only)UseCors("AdminCorsPolicy")UseAuthenticationUseAuthorizationUseRateLimiterUseRewriter(root →/swagger)- Endpoint mappings
UseExceptionHandler(registered last so theBusinessExceptionHandlerexception-handler component runs)
JWT Bearer config:
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]— pinned to ES256 so a token forged withalg=HS256using the public key as the HMAC secret cannot pass validation (AZ-532 AC-5).IssuerSigningKeyResolverconsults the sameIJwtSigningKeyProviderinstance the rest of the app uses; if the token has akidit's matched, otherwise all loaded keys are returned.ValidateIssuer,ValidateAudience,ValidateLifetime,ValidateIssuerSigningKeyall true.
Key Dependencies:
| 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→ per-enum status code (see table above) + optionalRetry-After.BadHttpRequestException→400 Bad Requestwith{ ErrorCode: 0, Message }.- FluentValidation errors → 400 via
Results.ValidationProblem. - Unhandled → default ASP.NET Core handling.
6. Extensions and Helpers
IssueDualTokensstatic helper (Program.cs)ParseSidClaim/ParseUserIdClaimstatic helpers (Program.cs)
7. Caveats & Edge Cases
- All endpoints are still defined in a single
Program.csfile — 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.
BusinessExceptionHandlerlives under namespaceAzaion.Commondespite the file pathAzaion.AdminApi/. Documented as historical accident; do not "fix" without coordinated rename.- Antiforgery disabled on resource upload.
- Kestrel max request body 200 MB.
- The eager
JwtSigningKeyProviderconstruction 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).
Blocks: Nothing.
9. Logging Strategy
| 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
AdminApi/ProgramAdminApi/BusinessExceptionHandler