Files
HIPCTF2/docs/api/admin-system.md
T
2026-07-23 13:28:26 +00:00

317 lines
14 KiB
Markdown

---
type: api
title: Admin System Endpoints
description: Admin-only destructive System operations: backup download, restore validate/commit, re-authenticated confirmation tokens, reset-scores, and wipe-challenges.
resource: backend/src/modules/admin/system/admin-system.controller.ts
tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens]
timestamp: 2026-07-23T13:27:25Z
---
# Overview
All handlers below are mounted under
`/api/v1/admin/system` and require a JWT-protected admin session
(`AdminGuard` + `@Roles('admin')`). Every destructive operation is
gated by a single-use confirmation token issued by the re-authentication
endpoint:
```
client ──reauth──▶ POST /confirmations ──token──▶ client
client ──commit──▶ POST /restore/commit | /scores/reset | /challenges/wipe
```
The token table is documented at
[/database/admin-operation-tokens.md](/database/admin-operation-tokens.md).
# Endpoints
| Method | Path | Source |
|--------|--------------------------------------------|-----------------------------------------------------------|
| `GET` | `/api/v1/admin/system/backup` | `admin-system.controller.ts``BackupService.build()` |
| `POST` | `/api/v1/admin/system/restore/validate` | `admin-system.controller.ts``RestoreService.stageArchive()` |
| `POST` | `/api/v1/admin/system/restore/commit` | `admin-system.controller.ts``RestoreService.commitRestore()` + `AuthService.revokeAllRefreshSessions()` |
| `POST` | `/api/v1/admin/system/confirmations` | `admin-system.controller.ts``AuthService.reauthenticateAdmin()` + `ConfirmationTokenService.issue()` |
| `POST` | `/api/v1/admin/system/scores/reset` | `admin-system.controller.ts``DangerZoneService.resetScores()` |
| `POST` | `/api/v1/admin/system/challenges/wipe` | `admin-system.controller.ts``DangerZoneService.wipeChallenges()` |
## GET /backup
Streams a complete platform snapshot as a JSON attachment. The body has
the shape:
```jsonc
{
"format": "hipctf-system-backup",
"version": 1,
"exportedAt": "2026-07-23T12:00:00.000Z",
"tables": { "user": [...], "challenge": [...], /* every application table */ },
"tableCounts": { "user": 12, "challenge": 4 },
"uploads": [
{ "path": "challenges/foo.bin",
"originalFilename": "foo.bin",
"sizeBytes": 1024,
"sha256": "...", "dataBase64": "..." }
]
}
```
Internal tables (`admin_operation_token`, `migrations`, all
`sqlite_*`) are excluded. The upload root is walked recursively and the
`.staging` directory is skipped. Files are base64-encoded inline so the
artifact is a single self-contained JSON file.
The response uses
`Content-Disposition: attachment; filename="hipctf-backup-<ISO>.json"`.
## POST /restore/validate
Body:
```jsonc
{ "text": "<raw backup JSON string>" } // or
{ "archive": { /* already-parsed object */ } }
```
The body is parsed with a strict zod schema (`RestoreArchiveSchema`).
Validation enforces:
* `format === 'hipctf-system-backup'`, `version === 1`
* every upload entry has a safe, non-`..` path, decodes from
base64 to exactly `sizeBytes`, and (when provided) matches `sha256`
* total restored bytes ≤ `BODY_SIZE_LIMIT`
Successful validation returns a `stagingId` plus a summary (sorted
table list, per-table counts, file count, total bytes, ISO expiresAt):
```jsonc
{
"stagingId": "f0c9...",
"expiresAt": "2026-07-23T12:15:00.000Z",
"summary": {
"tables": ["category", "challenge", "user", /* ... */],
"tableCounts": { "user": 12, "challenge": 4 },
"files": 3,
"totalBytes": 8192
}
}
```
The stage lives in-memory for `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default
15 min) and inside `<DATA_DIR>/.system-staging/restore-<id>/`.
Errors:
* `SYSTEM_RESTORE_VALIDATION_FAILED` (400) — JSON parse, schema, safe
path, duplicate path, base64, size mismatch, or checksum mismatch.
* `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` (400) — total bytes over
`BODY_SIZE_LIMIT`.
## POST /restore/commit
Body:
```jsonc
{
"operation": "restore-backup",
"token": "<confirmation token>",
"stagingId": "<stage returned from validate>"
}
```
Flow:
1. Verifies the admin role and that `operation === 'restore-backup'`.
2. `ConfirmationTokenService.consume()` verifies the token belongs to
this admin, is for `restore-backup`, is unexpired, and is unused.
3. `RestoreService.commitRestore(stagingId)` builds a new SQLite file
by cloning the live DB, then clearing every application table and
inserting the archived rows in FK-safe order. Uploads are copied
aside as `<UPLOAD_DIR>.restore-stage-<stamp>` and the live
database + uploads are swapped atomically via
`FilesystemTransactionService.stageSwap()` (with a `restore-backup`
rollback handle).
The offline clone rebuild begins by capturing the exact SQL of the
deployment-wide `trg_user_last_admin_update` and
`trg_user_last_admin_delete` triggers from `sqlite_master`,
dropping them in the candidate database, then clearing the
application tables and re-inserting the archived rows. SQLite
triggers fire regardless of `PRAGMA foreign_keys = OFF`, so
clearing the sole admin row before the archive's admin is inserted
would otherwise abort the rebuild with `LAST_ADMIN`. The suspension
is confined to the offline candidate file. Before the candidate is
eligible to swap, the rebuild:
- asserts at least one `user.role = 'admin'` row exists in the
rebuilt data (zero-admin archives fail with
`SYSTEM_RESTORE_ROLLED_BACK` so the live system never lands in a
no-admin state, even momentarily),
- re-creates both captured triggers via `db.exec(sql)` so the
`LAST_ADMIN` invariant is restored on the candidate, and
- re-enables `PRAGMA foreign_keys` and runs `PRAGMA foreign_key_check`.
On any rebuild, integrity, swap, or verification error the capture
loop is wrapped in a `try/catch` that best-effort re-creates any
missing trigger before rethrowing, so a rolled-back candidate never
leaks to disk missing the `LAST_ADMIN` safety net. The original
failure is then rethrown unchanged.
4. On any failure the swap is rolled back, the staged tree is removed,
and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500).
5. On success, every refresh token belonging to the current admin is
revoked via `AuthService.revokeAllRefreshSessions()` so the SPA is
forced back to `/login`.
Returns `{ ok: true, operation: 'restore-backup' }`.
## POST /confirmations
Body:
```jsonc
{
"operation": "restore-backup" | "reset-scores" | "wipe-challenges",
"password": "<current admin password>",
"stagingId": "<optional, only when restoring>"
}
```
Flow:
1. `AuthService.reauthenticateAdmin(userId, password)` verifies the
user is still enabled + admin and the Argon2id password matches.
2. `ConfirmationTokenService.issue()` stores a SHA-256 hash of the
generated token and returns the raw token plus its `expiresAt`.
Returns:
```jsonc
{ "operation": "restore-backup", "token": "<raw>", "expiresAt": "2026-07-23T12:05:00.000Z" }
```
Errors:
* `SYSTEM_INVALID_CREDENTIALS` (401) — wrong password.
* `SYSTEM_REAUTH_REQUIRED` (401) — user is no longer admin/disabled.
## POST /scores/reset
Body:
```jsonc
{ "operation": "reset-scores", "token": "<confirmation token>" }
```
Consumes a `reset-scores` token, then `DangerZoneService.resetScores()`
runs a single `DELETE FROM solve` inside a transaction. Returns
`{ ok: true, solvesRemoved: N }`. Failure maps to `SYSTEM_DANGER_FAILED`
(500).
## POST /challenges/wipe
Body:
```jsonc
{ "operation": "wipe-challenges", "token": "<confirmation token>" }
```
Consumes a `wipe-challenges` token, then
`DangerZoneService.wipeChallenges()`:
1. Snapshots `<UPLOAD_DIR>/challenges` to `<challengesDir>.wipe-stage-<ts>`.
2. Runs a SQLite transaction that deletes every `challenge` row
(cascading via FK into `challenge_file` and `solve`).
3. After the DB commits, physically removes the live
`<UPLOAD_DIR>/challenges` directory. If the rm fails, the snapshot
is copied back into place.
4. Removes the snapshot only once the live tree is gone.
Returns
`{ ok: true, challengesRemoved: N, challengeFilesRemoved: M, solvesRemoved: K }`.
Failure maps to `SYSTEM_DANGER_ROLLED_BACK` (500).
# Error codes
| Code | HTTP | Source |
|-----------------------------------|------|-------------------------------------------------|
| `SYSTEM_BACKUP_FAILED` | 500 | `BackupService` table/file read failure |
| `SYSTEM_FILE_READ_FAILED` | 500 | `BackupService` upload walk failure |
| `SYSTEM_RESTORE_VALIDATION_FAILED`| 400 | `RestoreService` archive schema/size/sha mismatch |
| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`| 400 | Restored bytes exceed `BODY_SIZE_LIMIT` |
| `SYSTEM_RESTORE_STAGE_EXPIRED` | 400 | `stagingId` missing or expired |
| `SYSTEM_RESTORE_FAILED` | 500 | Live DB not readable after swap |
| `SYSTEM_RESTORE_ROLLED_BACK` | 500 | Restore aborted; original state restored |
| `SYSTEM_DANGER_FAILED` | 500 | Reset-scores internal error |
| `SYSTEM_DANGER_ROLLED_BACK` | 500 | Reset/wipe failed; data unchanged |
| `SYSTEM_PAYLOAD_INVALID` | 400 | `restore/validate` got neither `text` nor `archive` |
| `SYSTEM_REAUTH_REQUIRED` | 401 | `reauthenticateAdmin` user no longer admin/enabled |
| `SYSTEM_INVALID_CREDENTIALS` | 401 | Wrong password |
| `SYSTEM_TOKEN_INVALID` | 400 | Unknown token hash |
| `SYSTEM_TOKEN_EXPIRED` | 400 | Token `expiresAt` < now |
| `SYSTEM_TOKEN_REUSED` | 400 | Token already consumed |
| `SYSTEM_TOKEN_MISMATCH` | 400 | Token issued for a different operation/user |
# Examples
## Backup → restore on a fresh install
```bash
# 1. Download a backup
curl -sS -b cookies.txt -c cookies.txt \
-X GET 'http://localhost:3000/api/v1/admin/system/backup' \
-o hipctf-backup.json
# 2. Validate (and stage) it
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/validate' \
-d "{\"text\": \"$(cat hipctf-backup.json | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')\"}"
# => {"stagingId":"<id>", "expiresAt":"...", "summary":{...}}
# 3. Re-authenticate to get a confirmation token
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/confirmations' \
-d '{"operation":"restore-backup","password":"<currentPassword>","stagingId":"<id>"}'
# => {"operation":"restore-backup","token":"<raw>","expiresAt":"..."}
# 4. Commit
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/commit' \
-d '{"operation":"restore-backup","token":"<raw>","stagingId":"<id>"}'
```
## Reset scores
```bash
# 1. Re-auth
curl -sS ... -d '{"operation":"reset-scores","password":"..."}' # → token
# 2. Commit
curl -sS ... -d '{"operation":"reset-scores","token":"<raw>"}'
# → {"ok":true,"solvesRemoved":42}
```
# Wiring
| Concern | File |
|------------------------------|---------------------------------------------------------------------------------|
| Controller | `backend/src/modules/admin/system/admin-system.controller.ts` |
| Module | `backend/src/modules/admin/system/admin-system.module.ts` |
| Backup | `backend/src/modules/admin/system/backup.service.ts` |
| Restore | `backend/src/modules/admin/system/restore.service.ts` |
| Confirmation tokens | `backend/src/modules/admin/system/confirmation-token.service.ts` |
| Danger zone | `backend/src/modules/admin/system/danger-zone.service.ts` |
| Filesystem swap | `backend/src/modules/admin/system/filesystem-transaction.service.ts` |
| Staging dir helper | `backend/src/common/utils/upload.ts` (`resolveSystemStagingDir`) |
| Password re-auth / revoke | `backend/src/modules/auth/auth.service.ts` (`reauthenticateAdmin`, `revokeAllRefreshSessions`) |
| Error codes | `backend/src/common/errors/error-codes.ts` |
| Front-end page | `frontend/src/app/features/admin/system/system.component.ts` |
| Front-end modal | `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` |
| Front-end service | `frontend/src/app/features/admin/system/system.service.ts` |
| Front-end pure helpers | `frontend/src/app/features/admin/system/system.pure.ts` |
| Front-end nav entry | `frontend/src/app/features/admin/admin-shell.component.ts` |
| Front-end route | `frontend/src/app/app.routes.ts` (admin child `system`) |
| Cross-store invalidation | `frontend/src/app/core/services/system-data-change.service.ts` |
| Forced logout helper | `frontend/src/app/core/services/auth.service.ts` (`forceServerInvalidation`) |
| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`, `admin-system-restore-commit.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` |