AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61)
This commit was merged in pull request #61.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
---
|
||||
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-23T12:04:50Z
|
||||
---
|
||||
|
||||
# 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).
|
||||
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`; `tests/frontend/admin-system.pure.spec.ts` |
|
||||
Reference in New Issue
Block a user