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>
5.6 KiB
Module: Azaion.Common.BusinessException
Purpose
Custom exception type for domain-level errors, paired with an ExceptionEnum catalog of all business error codes.
Public Interface
BusinessException
| Member | Signature | Description |
|---|---|---|
| Constructor | BusinessException(ExceptionEnum exEnum) |
Creates exception with message from ExceptionEnum's [Description] attribute |
ExceptionEnum |
ExceptionEnum ExceptionEnum { get; set; } |
The specific error code |
GetMessage |
static string GetMessage(ExceptionEnum exEnum) |
Looks up human-readable message for an error code |
ExceptionEnum
| Value | Code | Description | HTTP Status |
|---|---|---|---|
NoEmailFound |
10 | No such email found | 409 |
EmailExists |
20 | Email already exists | 409 |
WrongPassword |
30 | Passwords do not match | 409 |
PasswordLengthIncorrect |
32 | Password should be at least 12 characters | 409 |
EmailLengthIncorrect |
35 | Email is empty or invalid | 409 |
WrongEmail |
37 | (no description attribute) | 409 |
UserDisabled |
38 | User account is disabled | 409 |
AccountLocked |
50 | AZ-537 — account temporarily locked due to too many failed login attempts (carries RetryAfterSeconds) |
423 Locked |
LoginRateLimited |
51 | AZ-537 — too many login attempts per account; try again later (carries RetryAfterSeconds) |
429 Too Many Requests |
InvalidRefreshToken |
52 | AZ-531 — refresh token invalid / expired / revoked / reuse-detected | 401 Unauthorized |
SessionNotFound |
53 | AZ-535 — admin tried to revoke a non-existent session | 404 Not Found |
InvalidMissionRequest |
54 | AZ-533 — mission_id pattern fail or planned_duration_h out of bounds | 400 Bad Request |
AircraftNotFound |
55 | AZ-533 — aircraft id missing or not a CompanionPC user |
400 Bad Request |
MfaAlreadyEnabled |
56 | AZ-534 — /users/me/mfa/enroll called for a user that already has MFA on |
409 Conflict |
MfaNotEnrolling |
57 | AZ-534 — confirm called without a prior enroll | 409 Conflict |
MfaNotEnabled |
58 | AZ-534 — disable / verify-for-login called for a user without MFA | 409 Conflict |
InvalidMfaCode |
59 | AZ-534 — TOTP code (and recovery code) failed to verify | 401 Unauthorized |
NoFileProvided |
60 | No file provided | 409 |
InvalidMfaToken |
61 | AZ-534 — step-1 MFA token failed to validate (signature / audience / expiry) | 401 Unauthorized |
RetryAfterSeconds
| Member | Type | Description |
|---|---|---|
| Constructor | BusinessException(ExceptionEnum exEnum, int retryAfterSeconds) |
Cycle 2 (AZ-537) — sets RetryAfterSeconds, surfaced by BusinessExceptionHandler as a Retry-After response header. Used by AccountLocked (returns remaining lockout seconds) and LoginRateLimited (returns the window seconds). |
RetryAfterSeconds |
int? |
Optional cooldown hint; null when the exception was constructed without a window. |
Cycle 1 (2026-05-13) note —
HardwareIdMismatch = 40andBadHardware = 45were removed by AZ-197. Codes 40 and 45 should NOT be reused.Cycle 2 (2026-05-14) note —
WrongResourceName = 50was removed early in the cycle along with theGetResourceRequestvalidator. The integer 50 has since been reused forAccountLockedas part of AZ-537 (since the previous user-facing string "Wrong resource name" is no longer surfaced anywhere). This is the one deliberate exception to the "gap kept" lesson — the old code had no remaining client surface and the auth modernization wanted a tightly-clustered range of new codes.
Internal Logic
Static constructor eagerly loads all ExceptionEnum descriptions into a dictionary via EnumExtensions.GetDescriptions<ExceptionEnum>(). Messages are retrieved by dictionary lookup with fallback to ToString(). The two-arg constructor sets RetryAfterSeconds for the lockout / rate-limit paths.
Dependencies
EnumExtensions— forGetDescriptions<T>()
Consumers
BusinessExceptionHandler— catches and maps viaMapStatusCode. The default mapping is 409; cycle 2 codes use a per-enum status map (AccountLocked→ 423,LoginRateLimited→ 429, refresh/MFA validation failures → 401,SessionNotFound→ 404, mission validation failures → 400, MFA conflict states → 409). WhenRetryAfterSeconds > 0the handler also stamps aRetry-Afterresponse header.UserService— throws for the auth path (NoEmailFound,WrongPassword,EmailExists,UserDisabled,AccountLocked,LoginRateLimited)RefreshTokenService— throwsInvalidRefreshTokenon bad/expired/reuse-detectedSessionService— throwsSessionNotFoundfor admin-revoke of missing sidsMissionTokenService— throwsInvalidMissionRequest,AircraftNotFoundMfaService— throwsMfaAlreadyEnabled,MfaNotEnrolling,MfaNotEnabled,InvalidMfaCode,InvalidMfaToken,NoEmailFound,WrongPasswordResourcesService— throwsNoFileProvidedfor missing file uploadsProgram.csParseSidClaim/ParseUserIdClaimhelpers — throwInvalidRefreshToken(401) on missing or malformed claims- FluentValidation validators — reference
ExceptionEnumcodes in.WithErrorCode()
Data Models
None.
Configuration
None.
External Integrations
None.
Security
Error codes are returned to the client via BusinessExceptionHandler along with the per-enum HTTP status. The Retry-After header on lockout / rate-limit responses lets well-behaved clients back off without blind retries.
Tests
None.