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
Data Layer
1. High-Level Overview
Purpose: Provides database access, ORM mapping, entity definitions, configuration binding, and in-memory caching for the entire application.
Architectural Pattern: Repository/Factory — DbFactory creates short-lived AzaionDb connections with a read/write separation pattern.
Upstream dependencies: None (leaf component).
Downstream consumers: User Management, Authentication & Security, Resource Management.
2. Internal Interfaces
Interface: IDbFactory
| Method | Input | Output | Async | Error Types |
|---|---|---|---|---|
Run<T> |
Func<AzaionDb, Task<T>> |
T |
Yes | ArgumentException (empty conn string) |
Run |
Func<AzaionDb, Task> |
void | Yes | ArgumentException |
RunAdmin |
Func<AzaionDb, Task> |
void | Yes | ArgumentException |
Interface: ICache
| Method | Input | Output | Async | Error Types |
|---|---|---|---|---|
GetFromCacheAsync<T> |
string key, Func<Task<T>>, TimeSpan? |
T |
Yes | None |
Invalidate |
string key |
void | No | None |
Entities
Cycle 1 (2026-05-13) note —
DetectionClass(AZ-513) added;Resource(AZ-183) added then reverted same cycle.User.Hardwareleft as a tombstone (AZ-197). UNIQUE INDEXusers_email_uidxadded onusers.email(security audit F-3,env/db/06_users_email_unique.sql).Cycle 2 — early (2026-05-14) —
ResourcesConfig.SuiteInstallerFolder/SuiteStageInstallerFolderremoved with the installer endpoints;ResourcesConfigis nowResourcesFolder-only.Cycle 2 — Auth Modernization (2026-05-14) — significant data-layer changes:
UsergainedFailedLoginCount,LockoutUntil(AZ-537) andMfaEnabled,MfaSecret(DataProtection-encrypted),MfaRecoveryCodes(jsonb of Argon2id-hashed codes),MfaEnrolledAt,MfaLastUsedWindow(AZ-534).PasswordHashcolumn unchanged in shape but now contains Argon2id PHC strings; legacy SHA-384 base64 values are accepted bySecurity.VerifyPasswordand lazily upgraded on next login (AZ-536).- New table
public.sessions(AZ-531 / AZ-535) — refresh-token rotation + revocation, mapped viaCommon/Entities/Session.- New table
public.audit_events(AZ-537 + AZ-534) — append-only login + MFA event log, mapped viaCommon/Entities/AuditEvent.- New
RoleEnum.Service = 60(AZ-535) — verifier-fleet identity used by therevocationReaderPolicy.- New configs:
AuthConfig(rate limit + lockout + Argon2id parameters),SessionConfig(refresh sliding + absolute lifetimes).JwtConfigrebuilt around ES256 (KeysFolder,ActiveKid,AccessTokenLifetimeMinutes,MfaStepTokenLifetimeMinutes); the legacySecretandTokenLifetimeHoursfields 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, 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
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)
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)
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
ConnectionStrings:
AzaionDb: string — read-only connection string
AzaionDbAdmin: string — admin (read/write) connection string
JwtConfig (AZ-532):
Issuer: string
Audience: string
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
3. External API Specification
N/A — internal component.
4. Data Access Patterns
Queries
| Query | Frequency | Hot Path | Index Needed |
|---|---|---|---|
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 | 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, on lazy-rehash (AZ-536), on MFA enroll/confirm/disable (AZ-534), on user enable/disable, on lockout state changes (AZ-537) |
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 | ~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
Seed data: Default admin user (admin@azaion.com, ApiAdmin role) and uploader user (uploader@azaion.com, ResourceUploader role) — see env/db/02_structure.sql.
Rollback: Standard PostgreSQL transactions; linq2db creates a new connection per Run/RunAdmin call.
5. Implementation Details
State Management: Stateless factory pattern. DbFactory is a singleton holding pre-built DataOptions. Cache state is in-memory per process.
Key Dependencies:
| Library | Version | Purpose |
|---|---|---|
| linq2db | 5.4.1 | ORM for PostgreSQL access |
| Npgsql | 10.0.1 | PostgreSQL ADO.NET provider |
| LazyCache | 2.4.0 | In-memory cache with async support |
| Newtonsoft.Json | 13.0.4 | JSON serialization for UserConfig (bumped from 13.0.1 by security audit D-1, GHSA-5crp-9r3c-p9vr) |
Error Handling Strategy:
DbFactory.LoadOptionsthrowsArgumentExceptionon empty connection strings (fail-fast at startup).- Database exceptions from linq2db/Npgsql propagate unhandled to callers.
6. Extensions and Helpers
| Helper | Purpose | Used By |
|---|---|---|
StringExtensions.ToSnakeCase |
PascalCase → snake_case column mapping | AzaionDbSchemaHolder |
EnumExtensions.GetDescriptions |
Enum → description dictionary | BusinessException |
QueryableExtensions.WhereIf |
Conditional LINQ filters | UserService |
7. Caveats & Edge Cases
Known limitations:
- No connection pooling configuration exposed; relies on Npgsql defaults.
AzaionDbSchemaHoldermapping schema is static — adding new entities requires code changes.- Cache TTL (4 hours) is hardcoded, not configurable.
Potential race conditions:
- Cache invalidation after write: there's a small window where stale data could be served between the DB write and cache invalidation.
Performance bottlenecks:
DbFactorycreates a new connection per operation. For high-throughput scenarios, connection reuse or batching would be needed.
8. Dependency Graph
Must be implemented after: None (leaf component).
Can be implemented in parallel with: Security & Cryptography (no dependency).
Blocks: User Management, Authentication, Resource Management, Admin API.
9. Logging Strategy
| Log Level | When | Example |
|---|---|---|
| INFO | SQL trace | SELECT * FROM users WHERE email = @p1 (via linq2db TraceLevel.Info) |
Log format: Plaintext SQL output to console.
Log storage: Console (via Console.WriteLine in DbFactory.LoadOptions trace callback).
Modules Covered
Common/Configs/ConnectionStringsCommon/Configs/JwtConfig(AZ-532 — ES256 + session config)Common/Configs/AuthConfig(new in cycle 2 — AZ-536 + AZ-537)Common/Configs/ResourcesConfigCommon/Entities/User(extended in cycle 2 — AZ-537 + AZ-534)Common/Entities/RoleEnum(extended in cycle 2 — AZ-535 addedService)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(SessionsandAuditEventsITables added in cycle 2)Common/Database/AzaionDbSchemaHolder(Session + AuditEvent mappings, jsonb forMfaRecoveryCodes)Common/Database/DbFactoryServices/Cache