Files
admin/_docs/02_document/data_model.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

Azaion Admin API — Data Model

Cycle 2 (2026-05-14) — Auth Modernization: this doc is rewritten to reflect Postgres state after migrations 07, 08, 09, 10. Three new tables/columns clusters were added: account-lockout + audit (AZ-537), refresh-token sessions + revocation + mission tokens (AZ-531/535/533), TOTP MFA (AZ-534).

Entity-Relationship Diagram

erDiagram
    USERS {
        uuid id PK
        varchar email "unique"
        varchar password_hash "Argon2id PHC; legacy SHA-384 base64 lazily upgraded"
        text hardware "tombstoned (AZ-197)"
        varchar role
        varchar user_config "JSON"
        timestamp created_at
        timestamp last_login
        bool is_enabled
        int failed_login_count "AZ-537"
        timestamp lockout_until "AZ-537"
        bool mfa_enabled "AZ-534"
        text mfa_secret "AZ-534, IDataProtector-encrypted"
        jsonb mfa_recovery_codes "AZ-534"
        timestamp mfa_enrolled_at "AZ-534"
        bigint mfa_last_used_window "AZ-534"
    }

    SESSIONS {
        uuid id PK
        uuid user_id FK
        text refresh_hash "nullable for missions"
        uuid family_id "AZ-531 reuse-detection key"
        timestamp issued_at
        timestamp last_used_at
        timestamp expires_at
        timestamp revoked_at
        varchar revoked_reason
        uuid parent_session_id FK
        timestamp family_started_at
        uuid revoked_by_user_id FK "AZ-535"
        varchar class "AZ-533: interactive | mission"
        uuid aircraft_id FK "AZ-533"
        bool mfa_authenticated "AZ-534"
    }

    AUDIT_EVENTS {
        bigserial id PK
        varchar event_type
        timestamp occurred_at
        varchar email
        varchar ip
        text metadata
    }

    DETECTION_CLASSES {
        int id PK
        varchar name
        varchar short_name
        varchar color
        double max_size_m
        varchar photo_mode
        timestamp created_at
    }

    USERS ||--o{ SESSIONS : owns
    USERS ||--o{ SESSIONS : "revoked_by (AZ-535)"
    USERS ||--o{ SESSIONS : "aircraft (AZ-533)"
    SESSIONS ||--o{ SESSIONS : "rotated_from (AZ-531)"

Table: users

Columns

Column Type Nullable Default Description
id uuid No (application-generated) Primary key
email varchar(160) No Unique (UNIQUE INDEX users_email_uidx, security audit F-3)
password_hash varchar(255) No AZ-536: Argon2id PHC string. Legacy SHA-384 base64 strings are accepted on verify and lazily re-hashed to Argon2id on next successful login.
hardware text Yes null TOMBSTONED (AZ-197)
role varchar(20) No Text representation of RoleEnum (now includes Service — AZ-535)
user_config varchar(512) Yes null JSON-serialized UserConfig
created_at timestamp No now()
last_login timestamp Yes null Updated on successful login
is_enabled bool No true Setting to false triggers SessionService.RevokeAllForUser
failed_login_count int No 0 AZ-537: incremented on failed login; reset on success or lockout release
lockout_until timestamp Yes null AZ-537: UTC; now() < lockout_untilBusinessException(AccountLocked) with Retry-After
mfa_enabled boolean No false AZ-534
mfa_secret text Yes null AZ-534: base32 TOTP secret, IDataProtector-encrypted (purpose Azaion.Mfa.Secret), then base64
mfa_recovery_codes jsonb Yes null AZ-534: array of `{ hash: , used_at: <ts
mfa_enrolled_at timestamp Yes null AZ-534
mfa_last_used_window bigint Yes null AZ-534: last accepted RFC 6238 step counter; anti-replay

Indexes

Index Type Columns
users_pkey PK id
users_email_uidx UNIQUE email

Table: sessions (AZ-531 + AZ-535 + AZ-533 + AZ-534)

One row per issued refresh token. Mission tokens are also rows here (class='mission', refresh_hash null).

Columns

Column Type Nullable Default Description
id uuid No (application) PK; used as the JWT sid claim
user_id uuid No FK → users.id ON DELETE CASCADE
refresh_hash text Yes SHA-256 of opaque refresh token. Required for class='interactive'; null for class='mission' (AZ-533)
family_id uuid No AZ-531: shared by every rotation in the same login session; reuse detection revokes by family_id
issued_at timestamp No now()
last_used_at timestamp No now() Updated on rotate
expires_at timestamp No Sliding for interactive (SessionConfig.RefreshSlidingHours), absolute for mission (planned_duration_h)
revoked_at timestamp Yes null Set on rotate (rotated), reuse detection (reuse_detected), logout (logged_out), logout/all (logged_out_all), admin revoke (admin_revoked), aircraft reconnect (aircraft_reconnected), user disable, refresh expiry sweep
revoked_reason varchar(64) Yes null One of SessionRevokedReasons constants
parent_session_id uuid Yes null FK → sessions.id; rotation chain pointer
family_started_at timestamp No now() Hard cap is family_started_at + RefreshAbsoluteHours
revoked_by_user_id uuid Yes null AZ-535: who revoked (admin id, system, or self for logout)
class varchar(32) No 'interactive' AZ-533: interactive or mission
aircraft_id uuid Yes null AZ-533: FK → users.id; only set for class='mission'
mfa_authenticated boolean No false AZ-534: pinned at issue; refresh rotations inherit it

Indexes

Index Type Columns Notes
sessions_pkey PK id
sessions_refresh_hash_idx UNIQUE refresh_hash O(1) lookup on rotate; nulls allowed (mission rows)
sessions_family_active_idx partial family_id WHERE revoked_at IS NULL Reuse-detection family revoke; logout-all
sessions_aircraft_active_idx partial (aircraft_id, class) WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL AZ-533 auto-revoke-on-reconnect
sessions_revoked_at_idx partial revoked_at WHERE revoked_at IS NOT NULL AZ-535 verifier-poll snapshot

Lifecycle

  • Issue (interactive): RefreshTokenService.IssueForNewLogin inserts a row with new id and family_id; mfa_authenticated reflects the login path.
  • Rotate: RefreshTokenService.Rotate updates the existing row's revoked_at+revoked_reason='rotated' and inserts a new row in the same family_id with parent_session_id pointing to the old row.
  • Reuse detected: presenting a refresh token whose row already has revoked_reason='rotated' → the entire family_id is revoked with reason='reuse_detected'.
  • Logout: SessionService.RevokeBySid(sid, caller, 'logged_out'). Idempotent.
  • Logout all: SessionService.RevokeAllForUser(userId, caller, 'logged_out_all').
  • Admin revoke: SessionService.RevokeBySid(sid, admin, 'admin_revoked').
  • Mission issue: MissionTokenService.Issue inserts row with class='mission', aircraft_id set, refresh_hash=null, expires_at = now + planned_duration_h. Before signing the access token, prior mission rows for that aircraft_id are revoked with reason='aircraft_reconnected' (also called from successful login of a CompanionPC user).

Table: audit_events (AZ-537 + AZ-534)

Append-only log used by the per-account sliding-window rate limit (AZ-537 AC-2) and as evidence for security audits.

Columns

Column Type Nullable Default Description
id bigserial No identity PK
event_type varchar(64) No One of: login_failed, login_success, login_lockout, mfa_enroll, mfa_confirm, mfa_disable, mfa_login_success, mfa_login_failed, mfa_recovery_used
occurred_at timestamp No now()
email varchar(160) Yes null Lowercase normalised on insert
ip varchar(64) Yes null HttpContext.Connection.RemoteIpAddress
metadata text Yes null Reserved (no current writer)

Indexes

Index Columns
audit_events_pkey id
audit_events_event_type_email_idx (event_type, email, occurred_at DESC)

Permissions

Role Privileges
azaion_admin INSERT, SELECT, USAGE+SELECT on the sequence
azaion_reader SELECT

Retention: not yet partitioned. With ~50 events/user/day × ~5000 users × 365 d this is ~14 GB/yr; consider time-partition + 90-day archive in a future cycle.

Table: detection_classes

Unchanged in cycle 2. See _docs/03_implementation/batch_06_report.md for the original AZ-513 spec.

ORM Mapping (linq2db)

Column names auto-converted from PascalCase → snake_case via AzaionDbSchemaHolder. Special mappings introduced in cycle 2:

  • Session.RevokedReason → enum-like text constants in SessionRevokedReasons (string-keyed; not a Postgres enum)
  • Session.Class → string constants in SessionClasses ("interactive", "mission")
  • User.MfaRecoveryCodesjsonb via Newtonsoft.Json serialization (List on the read path; the persisted shape is [{ hash, used_at }])
  • AuditEvent.EventType → string constants in AuditEventTypes
  • User.Role → text via Enum.Parse (now also recognises Service)

Permissions (post-cycle-2)

Role Tables Notes
azaion_reader SELECT on users, sessions, audit_events, detection_classes Used by the read-only IDbFactory.Run path
azaion_admin SELECT/INSERT/UPDATE/DELETE on users; SELECT/INSERT/UPDATE on sessions; SELECT/INSERT on audit_events; full DML on detection_classes Used by IDbFactory.RunAdmin. Note: no DELETE on sessions — revocation is logical via revoked_at
azaion_superadmin DB owner Migrations only

Schema Migration History

Schema is managed via SQL scripts in env/db/:

File Cycle Description
00_install.sh baseline Postgres install + roles
01_permissions.sql baseline Role grants
02_structure.sql baseline users table + seed data (admin@azaion.com, uploader@azaion.com)
03_add_timestamp_columns.sql baseline created_at, last_login, is_enabled
04_detection_classes.sql cycle 1 (AZ-513) detection_classes
06_users_email_unique.sql post-cycle-1 Security audit F-3: UNIQUE on users.email
07_auth_lockout_and_audit.sql cycle 2 (AZ-537) users.failed_login_count, users.lockout_until, audit_events
08_sessions.sql cycle 2 (AZ-531) sessions table + indexes
09_sessions_logout_and_mission.sql cycle 2 (AZ-535+533) sessions.revoked_by_user_id, class, aircraft_id; relax refresh_hash NOT NULL; aircraft + revoked_at indexes
10_users_mfa.sql cycle 2 (AZ-534) users.mfa_*, sessions.mfa_authenticated

No ORM migration framework is used — scripts are applied in numeric order by env/db/00_install.sh. Numbers are not contiguous (05 is missing) by design — kept as gaps so cherry-picks land in their original slot.

UserConfig JSON Schema (unchanged)

{
  "QueueOffsets": {
    "AnnotationsOffset": 0,
    "AnnotationsConfirmOffset": 0,
    "AnnotationsCommandsOffset": 0
  }
}

Observations / Caveats

  • users.user_config is still varchar(512). With cycle 2 not adding to UserConfig, this is unchanged but remains a future-growth concern.
  • sessions.refresh_hash UNIQUE INDEX accepts multiple NULLs (Postgres semantics) — that's intentional for mission rows.
  • audit_events has no FK to users because it must survive user deletion (post-incident forensics).
  • The Service role is data-only on the user table; no provisioning UI exists yet — verifier accounts are seeded out-of-band.