Files
HIPCTF2/docs/database/users.md
T
2026-07-23 13:28:26 +00:00

79 lines
3.6 KiB
Markdown

---
type: database
title: User Table
description: The `user` table — accounts, roles, and statuses.
tags: [database, user, auth]
timestamp: 2026-07-23T13:27:25Z
---
# Schema
| Column | Type | Description |
|----------------|----------|------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4 generated on creation. |
| `username` | TEXT | Unique login name. |
| `password_hash`| TEXT | Argon2id hash (memory/time/parallelism from env config). |
| `role` | TEXT | `'admin'` or `'player'`. Default `'player'`. |
| `status` | TEXT | `'enabled'` or `'disabled'`. Default `'enabled'`. |
| `created_at` | TEXT | ISO 8601 timestamp (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`). |
# Constraints
- Unique on `username`.
- `role` is enforced application-side (see `AdminService.updateUserRole`).
- `status` is checked by `AuthService.login` and `AuthService.refresh`
— disabled users cannot log in or refresh.
# Invariants
- The application enforces the **last-admin invariant**: at least one
enabled user with `role = 'admin'` must always exist. Attempts to
demote or delete the last admin are rejected by
`UsersService.enforceLastAdminInvariant` (used by
`AdminService.updateUserRole` and `AdminService.deleteUser`).
- A deployment-wide database safety net backs the invariant up at the
SQLite level via two triggers created by the
`AddUserLastAdminTriggers1700000000800` migration:
`trg_user_last_admin_update` (fires on `UPDATE OF role` when the old
row was admin, the new role is not, and no other admin row exists)
and `trg_user_last_admin_delete` (fires on `DELETE` of an admin row
when no other admin row exists). Both abort the offending statement
with `RAISE(ABORT, 'LAST_ADMIN')`. They are recreated by the restore
rebuild after the candidate database is rebuilt — see
[Admin System Endpoints — POST /restore/commit](/api/admin-system.md).
# First-admin bootstrap
When the `user` table is empty of admins, the dedicated
`POST /api/v1/setup/create-admin` endpoint (see
[Setup Endpoint](/api/setup.md)) creates the very first admin and
auto-issues a session. Once any admin row exists the endpoint rejects
further attempts with `409 SYSTEM_INITIALIZED`, so the route is
effectively hidden in production.
The legacy `POST /api/v1/auth/register-first-admin` route is preserved
for compatibility but the canonical first-admin flow now lives in the
`SetupModule`.
# Concurrent bootstrap safety
Two near-simultaneous `POST /api/v1/setup/create-admin` requests cannot
both succeed. `SetupService.createAdmin` serializes callers on an
in-process promise chain (`#bootstrapChain` + private
`runBootstrap()` in `backend/src/modules/setup/setup.service.ts:111-130`)
so that the first request commits inside its `DataSource.transaction`
before the second request's transaction executes. The loser's
transaction observes `adminCount > 0` and throws
`ApiError(SYSTEM_INITIALIZED, 409)`; the winner is the only request that
creates a `UserEntity(role='admin')` row and mints a refresh token.
The lock is per-process; multi-replica deployments rely on the unique
index and transaction guard, not on this chain. The regression test is
`tests/backend/setup-create-admin.spec.ts` ("only one of two concurrent
first-admin requests succeeds").
# See also
- [Auth and Settings Tables](/database/auth-settings.md)
- [REST API Overview](/api/rest-overview.md)
- [Admin Endpoints](/api/admin.md)