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

235 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <argon2id>, used_at: <ts|null> }`; 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<string> 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.