# 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 ```mermaid 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_until` → `BusinessException(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: }`; single-use enforced by setting `used_at` | | `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.MfaRecoveryCodes` → `jsonb` 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) ```json { "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.