Files
admin/_docs/02_document/components/01_data_layer/description.md
T
Oleksandr Bezdieniezhnykh a77b3f8a59 [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>
2026-05-14 09:22:53 +03:00

12 KiB
Raw Blame History

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) noteDetectionClass (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<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 1001000 web users + 200010000 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) 10200 ~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