[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:22:53 +03:00
parent c2c659ef62
commit a77b3f8a59
35 changed files with 3624 additions and 468 deletions
+187 -49
View File
@@ -1,24 +1,72 @@
# 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, not null"
varchar password_hash "not null"
text hardware "nullable"
varchar hardware_hash "nullable"
varchar role "not null (text enum)"
varchar user_config "nullable (JSON)"
timestamp created_at "not null, default now()"
timestamp last_login "nullable"
bool is_enabled "not null, default true"
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"
}
```
The system has a single table (`users`). There are no foreign key relationships.
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`
@@ -26,56 +74,147 @@ The system has a single table (`users`). There are no foreign key relationships.
| Column | Type | Nullable | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | `uuid` | No | (application-generated) | Primary key, `Guid.NewGuid()` |
| `email` | `varchar(160)` | No | — | Unique user identifier |
| `password_hash` | `varchar(255)` | No | — | SHA-384 hash, Base64-encoded |
| `hardware` | `text` | Yes | null | Raw hardware fingerprint string |
| `hardware_hash` | `varchar(120)` | Yes | null | Defined in DDL but not used by application code |
| `role` | `varchar(20)` | No | — | Text representation of `RoleEnum` |
| `user_config` | `varchar(512)` | Yes | null | JSON-serialized `UserConfig` object |
| `created_at` | `timestamp` | No | `now()` | Account creation time |
| `last_login` | `timestamp` | Yes | null | Last hardware check / resource access time |
| `is_enabled` | `bool` | No | `true` | Account active flag |
| `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 |
### ORM Mapping (linq2db)
### Indexes
Column names are auto-converted from PascalCase to snake_case via `AzaionDbSchemaHolder`:
- `User.PasswordHash``password_hash`
- `User.CreatedAt``created_at`
| Index | Type | Columns |
|-------|------|---------|
| `users_pkey` | PK | `id` |
| `users_email_uidx` | UNIQUE | `email` |
Special mappings:
- `Role`: stored as text, converted to/from `RoleEnum` via `Enum.Parse`
- `UserConfig`: stored as nullable JSON string, serialized/deserialized via `Newtonsoft.Json`
## 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_reader` | SELECT on `users` |
| `azaion_admin` | SELECT, INSERT, UPDATE, DELETE on `users` |
| `azaion_superadmin` | Superuser (DB owner) |
| `azaion_admin` | INSERT, SELECT, USAGE+SELECT on the sequence |
| `azaion_reader` | SELECT |
### Seed Data
> **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.
Two default users (from `env/db/02_structure.sql`):
## Table: `detection_classes`
| Email | Role |
|-------|------|
| `admin@azaion.com` | `ApiAdmin` |
| `uploader@azaion.com` | `ResourceUploader` |
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/`:
1. `00_install.sh` — PostgreSQL installation and configuration
2. `01_permissions.sql` — Role creation (superadmin, admin, reader)
3. `02_structure.sql` — Table creation + seed data
4. `03_add_timestamp_columns.sql` — Adds `created_at`, `last_login`, `is_enabled` columns
| 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. Schema changes are applied manually via SQL scripts.
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
## UserConfig JSON Schema (unchanged)
```json
{
@@ -87,10 +226,9 @@ No ORM migration framework is used. Schema changes are applied manually via SQL
}
```
Stored in the `user_config` column. Deserialized to `UserConfig``UserQueueOffsets` on read. Default empty `UserConfig` is created when the field is null or empty.
## Observations / Caveats
## Observations
- The `hardware_hash` column exists in the DDL but is not referenced in application code. The application stores the raw hardware string in `hardware` and computes hashes at runtime.
- No unique constraint on `email` column in the DDL — uniqueness is enforced at the application level (`UserService.RegisterUser` checks for duplicates before insert).
- `user_config` is limited to `varchar(512)`, which could be insufficient if queue offsets grow or additional config fields are added.
- `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.