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
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: <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.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)
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.