# 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` | `Func>` | `T` | Yes | `ArgumentException` (empty conn string) | | `Run` | `Func` | void | Yes | `ArgumentException` | | `RunAdmin` | `Func` | void | Yes | `ArgumentException` | ### Interface: ICache | Method | Input | Output | Async | Error Types | |--------|-------|--------|-------|-------------| | `GetFromCacheAsync` | `string key, Func>, 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.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 — 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, 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? (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.LoadOptions` throws `ArgumentException` on 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. - `AzaionDbSchemaHolder` mapping 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**: - `DbFactory` creates 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/ConnectionStrings` - `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` *(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` (`Sessions` and `AuditEvents` ITables added in cycle 2) - `Common/Database/AzaionDbSchemaHolder` (Session + AuditEvent mappings, jsonb for `MfaRecoveryCodes`) - `Common/Database/DbFactory` - `Services/Cache`