3ba5ca0849
After the 8-commit SP23 implementation landed, kicking the tires on
`docker compose build && docker compose up -d` (the gated DOCKER_TESTS=1
live test) surfaced five real bugs that don't show up in unit tests:
1. Backend wheel was built from the stub `__init__.py`, not the real
source. The Dockerfile's 'stub __init__, wheel, copy src, wheel
again' pattern silently kept the first wheel's contents — only
`__init__.py` got re-stubbed. The installed package had an empty
`__init__.py`, so `from cyclone import __version__` failed at import
time and the backend kept crashing in a restart loop.
Fix: single `COPY src/` + single `pip wheel`. Comment explains
why the stub trick is gone for good.
2. Backend binds to 127.0.0.1 (intentional — local-only by design,
see CLAUDE.md). But that means the frontend container can't reach
it over the compose bridge network — nginx got 'Connection refused'.
Fix: `CYCLONE_HOST` env var, defaults to 127.0.0.1 (preserves local
posture for non-Docker runs), set to 0.0.0.0 by the docker-compose
backend service. Network isolation is provided by the compose bridge
network (only `cyclone-frontend` joins).
3. Healthcheck probed `/api/healthz` (404 — the route is `/api/health`).
Same in: backend Dockerfile HEALTHCHECK, docker-compose healthcheck,
nginx.conf doesn't have one (frontend proxies through), RUNBOOK.md,
scripts/post-deploy.sh, scripts/smoke.sh.
Fix: `/api/healthz` → `/api/health` everywhere SP23 owns.
4. The auth matrix in `cyclone.auth.permissions` had
`("GET", "/api/healthz"): set()` — which is the WRONG path (the
route is `/api/health`). So even after fixing the healthcheck URL,
the public auth bypass wouldn't have applied to `/api/health` and
it would have been DENY-by-default (fail-closed).
Fix: matrix entry updated to `/api/health`.
5. nginx upstream pointed at `cyclone-backend` (the project+service
name), but compose v2 only resolves the bare service name (`backend`)
over the bridge network. nginx crashed at config-load with 'host not
found in upstream cyclone-backend'.
Fix: `cyclone-backend:8000` → `backend:8000` in nginx.conf + spec
+ plan.
6. Frontend HEALTHCHECK used `http://localhost:8080/`. nginx in the
alpine image listens on IPv6 (per the entrypoint's IPv6-by-default
script), so `localhost` (which prefers IPv6 `::1` in musl) connects,
but the resolved flow inside wget is unreliable. `127.0.0.1` works.
Fix: HEALTHCHECK uses `http://127.0.0.1:8080/`.
Also moves `frontend/Dockerfile` → `Dockerfile.frontend` and
`frontend/nginx.conf` → `nginx.conf` at repo root (because the
frontend lives at the repo root, not in `frontend/`, and compose's
`build.context: .` needs them at the same root as compose.yml).
The frontend's pre-existing `.dockerignore` was empty/unused, so it's
dropped — the root `.dockerignore` covers it.
Adds `docker-compose.override.yml` for local bring-up testing on a
host without sudo. Production uses `/etc/cyclone/secrets/` directly.
Verified end-to-end on this dev host with `DOCKER_TESTS=1`:
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200 with valid JSON
- Login as admin → 200, /api/auth/me → 200
- `POST /api/parse-837` with docs/goodclaim.x12 → 200, batch created
- Full backend test suite: 1014 passed, 9 skipped (prodfiles gitignored)
832 lines
38 KiB
Markdown
832 lines
38 KiB
Markdown
# Cyclone Ubuntu Docker Deployment — Design
|
|
|
|
**Date:** 2026-06-22 (spec drafted); 2026-06-23 (reconciled with auth work that landed in main on 2026-06-23)
|
|
**Status:** Approved (2026-06-23)
|
|
**Branch:** `sp23-ubuntu-docker-deployment`
|
|
**Aesthetic direction:** No new UI surface in v1 — the nginx + reverse-proxy posture is invisible to the operator; auth + dashboard styling is unchanged from what's already on main.
|
|
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only); the auth work that merged into `main` on 2026-06-23 (`a25504b`..`39ae988`, the `feat(auth):` series).
|
|
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
|
|
|
|
---
|
|
|
|
## 1.1 Delta vs. `main` at 2026-06-23
|
|
|
|
The auth work that landed in main before SP23 began means sections 6 (auth modules + routes + matrix) and 7 (frontend login + AuthProvider + RoleGate) describe code that **already exists on main**. SP23's delta is therefore narrower than the original draft:
|
|
|
|
- **§6 (auth) — already on main.** The `cyclone.auth` package, `users` / `sessions` SQLAlchemy models (migrations `0013` + `0014`), bcrypt password hashing, the `PERMISSIONS` matrix + `matrix_gate` app-wide dependency, `Login.tsx` + `AuthProvider` + `RequireAuth` + `RoleGate`, the `cyclone admin` CLI, and `CYCLONE_AUTH_DISABLED=1` escape hatch are all merged. SP23 **reuses** them; it does not re-implement. §6 is kept in this spec as the contract that Docker + secrets + compose env vars must satisfy, not as work to do.
|
|
- **§7 (login UI) — already on main.** Reused.
|
|
- **§6.6 (new migration) — N/A.** No new SQL migration is needed for SP23; auth tables already exist.
|
|
- **What SP23 adds (the actual work):** §6.8 backend Dockerfile, §7.8 frontend Dockerfile + nginx.conf, §8.2 Docker secrets wiring, §9.4 image tag strategy + `:stable` aliases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2 `tests/test_docker.py`, §9.5 bootstrap script, §9.6 RUNBOOK.md, post-deploy + smoke scripts.
|
|
- **Role name reconciliation:** the original draft used `admin / operator / viewer`. Main uses `admin / user / viewer`. All references to `operator` in this spec should be read as `user` against the live codebase.
|
|
- **Hashing algorithm reconciliation:** the original draft specified `argon2id`. Main uses `bcrypt`. The Docker secret posture is unaffected — both algorithms need only a key file or a salt, neither of which SP23 introduces.
|
|
- **Lockout timing:** original draft was 5 fails / 15 min. Auth on main is 5 fails / 5 min (`cyclone.auth.rate_limit`). SP23 inherits the main behavior.
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
|
|
|
|
This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server:
|
|
|
|
- **Two-container Docker Compose** (backend + frontend) replacing the dev-server split
|
|
- **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer)
|
|
- **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret
|
|
- **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention
|
|
- **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access
|
|
- **Manual `docker compose pull` updates** with semver tags; one-env-var rollback
|
|
- **Operator runbook** + bootstrap scripts so the deploy is reproducible
|
|
|
|
Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2.
|
|
|
|
After this ships, the operator can:
|
|
1. `git clone <repo> /opt/cyclone && cd /opt/cyclone`
|
|
2. `docker compose build`
|
|
3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once
|
|
4. `docker compose up -d`
|
|
5. Open `http://<lan-ip>:8080/login` → log in as `admin`, change password, create operators
|
|
6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
|
|
7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
|
|
|
|
## 2. Goals
|
|
|
|
1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`.
|
|
2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
|
|
3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
|
|
4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention).
|
|
5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge.
|
|
6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective.
|
|
|
|
## 3. Non-goals (this sub-project)
|
|
|
|
- **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2.
|
|
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2.
|
|
- **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2.
|
|
- **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2.
|
|
- **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated.
|
|
- **2FA / SSO / OAuth** — v2.
|
|
- **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI.
|
|
- **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
|
|
- **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it).
|
|
- **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2.
|
|
- **New X12 transaction types or validation rules** — not this sub-project.
|
|
|
|
## 4. Stack
|
|
|
|
**Backend additions:**
|
|
- `argon2-cffi` (new dep) for password hashing
|
|
- `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic
|
|
- `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}`
|
|
- `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD)
|
|
- `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}`
|
|
- Migration `0012_users_sessions.sql` for `users` + `sessions` tables
|
|
- Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged
|
|
|
|
**Backend Dockerfile** (`backend/Dockerfile`):
|
|
- Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source
|
|
- Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`)
|
|
- Non-root user `cyclone` (uid 1000)
|
|
- `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1`
|
|
- Entrypoint: `python -m cyclone serve`
|
|
|
|
**Frontend additions:**
|
|
- `nginx.conf` for SPA routing + reverse proxy to backend
|
|
- `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`)
|
|
- `useCurrentUser()` hook (react-query)
|
|
- `Login.tsx` page
|
|
- `<AuthGate>` wrapper in `App.tsx`
|
|
- `<NavBar>` shows username + role badge + logout button
|
|
- `/admin/users` page (admin-only)
|
|
|
|
**Infrastructure:**
|
|
- `docker-compose.yml` at repo root
|
|
- `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32`
|
|
- `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host
|
|
- `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test
|
|
- `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures
|
|
- `.dockerignore` files for both build contexts
|
|
|
|
## 5. Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────┐
|
|
│ Ubuntu Linux server (your machine) │
|
|
│ │
|
|
│ cyclone_network (bridge) │
|
|
│ ┌─────────────────────┐ ┌────────────────────┐ │
|
|
│ │ cyclone-backend │ │ cyclone-frontend │ │
|
|
│ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │
|
|
│ │ :8000 (internal) │ │ :8080 (host) │ │
|
|
│ └──────┬──────────────┘ └─────────┬──────────┘ │
|
|
│ │ │ │
|
|
└─────────┼─────────────────────────────┼────────────┘
|
|
│ │
|
|
┌──────▼──────────────────┐ ┌──────▼──────────┐
|
|
│ Named volumes │ │ Bind mounts │
|
|
│ cyclone_db │ │ ./dist (built) │
|
|
│ cyclone_backups │ │ ./nginx.conf │
|
|
│ cyclone_prodfiles │ └─────────────────┘
|
|
│ cyclone_sftp_staging │
|
|
└─────────────────────────┘
|
|
|
|
/etc/cyclone/secrets/ (host, chmod 600, root:root)
|
|
├─ db.key → /run/secrets/cyclone_db_key
|
|
├─ secret.key → /run/secrets/cyclone_secret_key
|
|
└─ admin_pw → /run/secrets/cyclone_admin_password
|
|
```
|
|
|
|
**Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header).
|
|
|
|
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
|
|
|
|
**Container-to-container networking:** the frontend container reaches the backend at `http://backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
|
|
|
|
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
|
|
|
|
**Reverse proxy in nginx:**
|
|
|
|
```nginx
|
|
server {
|
|
listen 8080;
|
|
server_name _;
|
|
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
# API + auth: proxy to backend
|
|
location /api/ {
|
|
proxy_pass http://backend:8000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_read_timeout 300s; # long enough for big parse streams
|
|
client_max_body_size 50m; # claims files can be large
|
|
}
|
|
|
|
# SPA: serve index.html for all non-/api routes
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
```
|
|
|
|
## 6. Backend changes
|
|
|
|
### 6.1 New module `backend/src/cyclone/auth/__init__.py`
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class Role:
|
|
VIEWER = "viewer"
|
|
OPERATOR = "operator"
|
|
ADMIN = "admin"
|
|
|
|
ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2}
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
id: Mapped[str] # uuid4 hex
|
|
username: Mapped[str] # UNIQUE
|
|
password_hash: Mapped[str] # argon2id
|
|
role: Mapped[str] # viewer | operator | admin
|
|
created_at: Mapped[datetime]
|
|
last_login_at: Mapped[datetime | None]
|
|
failed_count: Mapped[int] = 0
|
|
locked_until: Mapped[datetime | None] = None
|
|
|
|
|
|
class Session(Base):
|
|
__tablename__ = "sessions"
|
|
id: Mapped[str] # 32-byte hex token
|
|
user_id: Mapped[str]
|
|
created_at: Mapped[datetime]
|
|
expires_at: Mapped[datetime]
|
|
ip: Mapped[str | None]
|
|
user_agent: Mapped[str | None]
|
|
```
|
|
|
|
Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`).
|
|
|
|
### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`)
|
|
|
|
| Method | Path | Behavior | Auth |
|
|
|---|---|---|---|
|
|
| POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none |
|
|
| POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in |
|
|
| GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in |
|
|
| POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in |
|
|
| POST | `/api/admin/users` | `{username, password, role}` → create user | admin |
|
|
| GET | `/api/admin/users` | list users | admin |
|
|
| PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin |
|
|
| DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin |
|
|
|
|
### 6.3 The `require_role` dependency
|
|
|
|
Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation:
|
|
|
|
```python
|
|
def require_role(min_role: str):
|
|
"""FastAPI dependency factory: read cookie, load user, enforce role."""
|
|
def dep(request: Request) -> User:
|
|
sid = request.cookies.get("cyclone_session")
|
|
if not sid:
|
|
raise HTTPException(401, "not authenticated")
|
|
with db.SessionLocal()() as s:
|
|
sess = s.get(Session, sid)
|
|
if sess is None or sess.expires_at < utcnow():
|
|
raise HTTPException(401, "session expired")
|
|
user = s.get(User, sess.user_id)
|
|
if user is None:
|
|
raise HTTPException(401, "user gone")
|
|
if user.locked_until and user.locked_until > utcnow():
|
|
raise HTTPException(423, "account locked")
|
|
if ROLE_RANK[user.role] < ROLE_RANK[min_role]:
|
|
raise HTTPException(403, f"requires {min_role}")
|
|
request.state.user = user
|
|
request.state.actor = user.username # existing audit-log contract
|
|
return user
|
|
return dep
|
|
```
|
|
|
|
**Role → action matrix:**
|
|
|
|
| Action | viewer | operator | admin |
|
|
|---|:-:|:-:|:-:|
|
|
| View claims, batches, acks | ✓ | ✓ | ✓ |
|
|
| `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ |
|
|
| Edit claim fields, resubmit rejected | — | ✓ | ✓ |
|
|
| `POST /api/batches/{id}/export-837` | — | ✓ | ✓ |
|
|
| `POST /api/clearhouse/submit` | — | ✓ | ✓ |
|
|
| `/api/admin/users` CRUD | — | — | ✓ |
|
|
| `/api/admin/backup/*` | — | — | ✓ |
|
|
| `/api/config/*` (clearhouse, payers) | — | — | ✓ |
|
|
| `/api/admin/audit-log` | — | — | ✓ |
|
|
| Backup scheduler on/off | — | — | ✓ |
|
|
|
|
**Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top:
|
|
- `failed_count >= 5` within 15min → `locked_until = now + 15min`
|
|
- Locked accounts return 423; the lock clears after the cooldown
|
|
|
|
### 6.4 Audit log wiring
|
|
|
|
The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change.
|
|
|
|
### 6.5 CLI additions
|
|
|
|
```bash
|
|
cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
|
|
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
|
|
cyclone admin list-users
|
|
```
|
|
|
|
Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery.
|
|
|
|
### 6.6 Migration `0012_users_sessions.sql`
|
|
|
|
```sql
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
|
|
created_at TIMESTAMP NOT NULL,
|
|
last_login_at TIMESTAMP,
|
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
locked_until TIMESTAMP,
|
|
disabled_at TIMESTAMP
|
|
);
|
|
CREATE INDEX idx_users_username ON users(username);
|
|
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMP NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
ip TEXT,
|
|
user_agent TEXT
|
|
);
|
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
|
```
|
|
|
|
### 6.7 Session timeout config
|
|
|
|
| Setting | Default | Env var |
|
|
|---|---|---|
|
|
| Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` |
|
|
| Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` |
|
|
| Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` |
|
|
| Cookie `Secure` flag | true | always true in prod |
|
|
|
|
### 6.8 Backend Dockerfile
|
|
|
|
```dockerfile
|
|
# Builder stage
|
|
FROM python:3.11-slim-bookworm AS builder
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
build-essential libsqlcipher-dev libffi-dev \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
WORKDIR /build
|
|
COPY pyproject.toml .
|
|
COPY src/ ./src/
|
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
|
|
|
# Runtime stage
|
|
FROM python:3.11-slim-bookworm
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
libsqlcipher-dev curl tini \
|
|
&& rm -rf /var/lib/apt/lists/* \
|
|
&& useradd --create-home --uid 1000 cyclone
|
|
WORKDIR /app
|
|
COPY --from=builder /wheels /wheels
|
|
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone
|
|
COPY src/ /app/src/
|
|
USER cyclone
|
|
ENV PYTHONUNBUFFERED=1
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
CMD curl -fs http://localhost:8000/api/healthz || exit 1
|
|
ENTRYPOINT ["tini", "--"]
|
|
CMD ["python", "-m", "cyclone", "serve"]
|
|
```
|
|
|
|
`sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod.
|
|
|
|
## 7. Frontend changes
|
|
|
|
### 7.1 New login page `src/pages/Login.tsx`
|
|
|
|
A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication.
|
|
|
|
```tsx
|
|
export default function Login() {
|
|
const login = useMutation({
|
|
mutationFn: ({ username, password }) => api.login(username, password),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
|
|
});
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 7.2 `useCurrentUser` hook
|
|
|
|
```ts
|
|
export function useCurrentUser() {
|
|
return useQuery({
|
|
queryKey: ['me'],
|
|
queryFn: () => api.me(),
|
|
retry: false,
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
```
|
|
|
|
### 7.3 AuthGate in `App.tsx`
|
|
|
|
```tsx
|
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
|
const { data: user, isLoading } = useCurrentUser();
|
|
const location = useLocation();
|
|
if (isLoading) return <FullPageSkeleton />;
|
|
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
|
return <>{children}</>;
|
|
}
|
|
```
|
|
|
|
Wrap every route except `/login` in `<AuthGate>`. `/login` itself does *not* require auth.
|
|
|
|
### 7.4 Role-based UI gating
|
|
|
|
```ts
|
|
const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 };
|
|
export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean {
|
|
const { data: user } = useCurrentUser();
|
|
if (!user) return false;
|
|
return ROLE_RANK[user.role] >= ROLE_RANK[minRole];
|
|
}
|
|
```
|
|
|
|
Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return <Forbidden />`).
|
|
|
|
### 7.5 NavBar changes
|
|
|
|
- Username + role badge (admin/operator/viewer) shown right-aligned
|
|
- Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`)
|
|
- "Admin" dropdown appears only when `useCan('admin')`
|
|
|
|
### 7.6 `/admin/users` page (admin only)
|
|
|
|
Table of users (username, role, last login, status). Actions:
|
|
- **Create user** (modal with username + password + role)
|
|
- **Edit role** (inline select)
|
|
- **Reset password** (modal)
|
|
- **Disable / enable** (toggle; sets `disabled_at`)
|
|
|
|
### 7.7 api.ts additions
|
|
|
|
```ts
|
|
api.login(username: string, password: string): Promise<{ user: User }>
|
|
api.logout(): Promise<void>
|
|
api.me(): Promise<{ user: User }> // throws 401 if not logged in
|
|
api.changePassword(oldPw: string, newPw: string): Promise<void>
|
|
api.listUsers(): Promise<User[]>
|
|
api.createUser(...): Promise<User>
|
|
api.updateUser(id: string, ...): Promise<User>
|
|
api.deleteUser(id: string): Promise<void>
|
|
```
|
|
|
|
All `api.*` calls send `credentials: 'include'` so the session cookie flows.
|
|
|
|
### 7.8 Frontend Dockerfile + nginx.conf
|
|
|
|
`frontend/Dockerfile`:
|
|
```dockerfile
|
|
# Build stage
|
|
FROM node:20-alpine AS builder
|
|
WORKDIR /build
|
|
COPY package.json package-lock.json ./
|
|
RUN npm ci
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
# Runtime stage
|
|
FROM nginx:1.27-alpine
|
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
COPY --from=builder /build/dist /usr/share/nginx/html
|
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
|
|
```
|
|
|
|
The nginx config shown in §5 lives at `frontend/nginx.conf`.
|
|
|
|
## 8. Data, secrets, backups
|
|
|
|
### 8.1 Named volumes
|
|
|
|
| Volume | Mount path in container | Contents |
|
|
|---|---|---|
|
|
| `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite |
|
|
| `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` |
|
|
| `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 |
|
|
| `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound |
|
|
|
|
These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host.
|
|
|
|
### 8.2 Docker secrets (host-managed)
|
|
|
|
| File | Mounted as | Purpose |
|
|
|---|---|---|
|
|
| `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars |
|
|
| `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars |
|
|
| `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password |
|
|
|
|
`scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once.
|
|
|
|
### 8.3 Backup strategy (≤24h loss is acceptable)
|
|
|
|
- The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume
|
|
- `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start
|
|
- `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default)
|
|
- 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`)
|
|
- Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired)
|
|
- Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user)
|
|
|
|
**Off-box backup copy** (operator's responsibility, documented in runbook):
|
|
|
|
The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step.
|
|
|
|
### 8.4 Data loss scenarios
|
|
|
|
| Scenario | Outcome |
|
|
|---|---|
|
|
| Container crashes | Docker restarts; SQLite WAL recovers; no loss |
|
|
| Disk corruption | Restore from latest local backup (≤24h old) |
|
|
| Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) |
|
|
| DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle |
|
|
| Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` |
|
|
|
|
### 8.5 Encryption-at-rest coverage
|
|
|
|
| Asset | Encrypted at rest (v1) | Notes |
|
|
|---|---|---|
|
|
| Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret |
|
|
| Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase |
|
|
| Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB |
|
|
| sftp_staging | — plain on disk | v2: per-file encryption |
|
|
| Log files | — plain on disk | rotated, contained |
|
|
|
|
v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level.
|
|
|
|
## 9. Logging, monitoring, updates, ops
|
|
|
|
### 9.1 Logging
|
|
|
|
- JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs`
|
|
- Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`)
|
|
- Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG`
|
|
- Every login event (success + failure) logged with username, ip, user-agent, role
|
|
- Every state-changing API call carries `actor=<user_id>` (existing audit-log machinery; we wire the auth user into `request.state.actor`)
|
|
- New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled`
|
|
|
|
### 9.2 Healthcheck
|
|
|
|
- Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile)
|
|
- Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff)
|
|
- `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged)
|
|
|
|
### 9.3 Monitoring (v1 minimal)
|
|
|
|
- No Prometheus/Grafana in v1
|
|
- Host-level cron: `curl -fs http://127.0.0.1:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
|
|
- Set up by `scripts/post-deploy.sh` during initial bootstrap
|
|
|
|
### 9.4 Updates
|
|
|
|
- Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0`
|
|
- Two tag aliases per image:
|
|
- `latest` — set automatically by `docker compose build` (most recent build)
|
|
- `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out.
|
|
- Update procedure (to pick up a new `:stable`):
|
|
```bash
|
|
cd /opt/cyclone
|
|
docker compose pull # pulls newer :stable tags
|
|
docker compose up -d # recreates containers, preserves volumes + secrets
|
|
```
|
|
- DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern)
|
|
- Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag)
|
|
- Previous image stays in Docker's local cache for one cycle so rollback is offline
|
|
|
|
### 9.5 Initial bootstrap
|
|
|
|
```bash
|
|
git clone <repo> /opt/cyclone && cd /opt/cyclone
|
|
docker compose build # or: docker compose pull
|
|
bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password
|
|
docker compose up -d # starts both containers
|
|
# wait ~10s for migrations
|
|
docker compose exec backend cyclone admin create-user \
|
|
--username admin --role admin --password-file /run/secrets/cyclone_admin_password
|
|
# open browser to http://<lan-ip>:8080/login
|
|
# log in as admin, change password, create operators
|
|
bash scripts/post-deploy.sh # logrotate + healthcheck cron
|
|
```
|
|
|
|
### 9.6 Daily ops (operator runbook)
|
|
|
|
- **Daily:** check `GET /api/healthz` (or trust the healthcheck email)
|
|
- **Weekly:** review `/admin/audit-log` page for unusual activity
|
|
- **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key`
|
|
- **As needed:** create operators via `/admin/users`; restore from backup via the restore UI
|
|
- **Annual:** rotate cookie signing key (re-login all users)
|
|
|
|
Full procedures live in `RUNBOOK.md` shipped in the repo.
|
|
|
|
## 10. docker-compose.yml
|
|
|
|
```yaml
|
|
name: cyclone
|
|
|
|
services:
|
|
backend:
|
|
image: cyclone-backend:${TAG:-stable}
|
|
build: ./backend
|
|
restart: unless-stopped
|
|
environment:
|
|
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
|
CYCLONE_BACKUP_AUTOSTART: "1"
|
|
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
|
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
|
CYCLONE_LOG_LEVEL: "INFO"
|
|
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
|
CYCLONE_LOG_JSON: "1"
|
|
CYCLONE_SESSION_ABSOLUTE_DAYS: "30"
|
|
CYCLONE_SESSION_SLIDING_HOURS: "8"
|
|
CYCLONE_COOKIE_SECURE: "1"
|
|
secrets:
|
|
- cyclone_db_key
|
|
- cyclone_secret_key
|
|
volumes:
|
|
- cyclone_db:/var/lib/cyclone/db
|
|
- cyclone_backups:/var/lib/cyclone/backups
|
|
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
|
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
|
- cyclone_logs:/var/log/cyclone
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 30s
|
|
networks: [cyclone_network]
|
|
|
|
frontend:
|
|
image: cyclone-frontend:${TAG:-stable}
|
|
build: ./frontend
|
|
restart: unless-stopped
|
|
ports:
|
|
- "8080:8080"
|
|
depends_on:
|
|
backend:
|
|
condition: service_healthy
|
|
networks: [cyclone_network]
|
|
|
|
secrets:
|
|
cyclone_db_key:
|
|
file: /etc/cyclone/secrets/db.key
|
|
cyclone_secret_key:
|
|
file: /etc/cyclone/secrets/secret.key
|
|
cyclone_admin_password:
|
|
file: /etc/cyclone/secrets/admin_pw
|
|
|
|
volumes:
|
|
cyclone_db:
|
|
cyclone_backups:
|
|
cyclone_prodfiles:
|
|
cyclone_sftp_staging:
|
|
cyclone_logs:
|
|
|
|
networks:
|
|
cyclone_network:
|
|
driver: bridge
|
|
```
|
|
|
|
The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar).
|
|
|
|
## 11. Error handling
|
|
|
|
- **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts.
|
|
- **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved.
|
|
- **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render.
|
|
- **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes.
|
|
- **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning.
|
|
- **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`.
|
|
- **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`.
|
|
|
|
## 12. Testing
|
|
|
|
### 12.1 New unit tests
|
|
|
|
- `tests/test_auth.py` (~12 tests)
|
|
- `test_create_user_with_argon2_hash`
|
|
- `test_login_with_correct_password_returns_session`
|
|
- `test_login_with_wrong_password_increments_failed_count`
|
|
- `test_login_locks_account_after_5_failures`
|
|
- `test_login_lock_clears_after_15min`
|
|
- `test_session_cookie_is_httponly_secure_samesite_lax`
|
|
- `test_session_expires_after_sliding_window`
|
|
- `test_session_absolute_lifetime_enforced`
|
|
- `test_logout_deletes_session_and_clears_cookie`
|
|
- `test_change_password_invalidates_other_sessions`
|
|
- `test_require_role_rejects_below_minimum`
|
|
- `test_require_role_returns_user_object_to_dependency`
|
|
- `tests/test_users.py` (~6 tests)
|
|
- `test_admin_can_create_user`
|
|
- `test_operator_cannot_create_user`
|
|
- `test_admin_can_change_user_role`
|
|
- `test_admin_can_disable_user`
|
|
- `test_disabled_user_cannot_login`
|
|
- `test_audit_event_for_user_lifecycle`
|
|
|
|
Total new backend tests: **~18**.
|
|
|
|
### 12.2 Docker smoke tests
|
|
|
|
- `tests/test_docker.py` (~4 tests)
|
|
- `test_compose_config_validates`
|
|
- `test_dockerfile_backend_builds`
|
|
- `test_dockerfile_frontend_builds`
|
|
- `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var)
|
|
|
|
### 12.3 Smoke script
|
|
|
|
`scripts/smoke.sh`:
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# 1. Bring up stack
|
|
docker compose up -d --build
|
|
|
|
# 2. Wait for healthcheck
|
|
for i in {1..30}; do
|
|
if curl -fs http://127.0.0.1:8080/api/healthz > /dev/null; then break; fi
|
|
sleep 2
|
|
done
|
|
|
|
# 3. Login
|
|
COOKIE_JAR=$(mktemp)
|
|
curl -fs -c "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/auth/login \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
|
|
|
|
# 4. Parse a sample 837
|
|
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/parse-837 \
|
|
-F "file=@docs/goodclaim.x12"
|
|
|
|
# 5. List batches
|
|
curl -fs -b "$COOKIE_JAR" http://127.0.0.1:8080/api/batches
|
|
|
|
# 6. Export
|
|
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/batches/<id>/export-837 \
|
|
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
|
|
-o /tmp/export.zip
|
|
|
|
unzip -l /tmp/export.zip | grep -E '\.x12$'
|
|
|
|
echo "smoke: OK"
|
|
```
|
|
|
|
### 12.4 Existing tests
|
|
|
|
- All existing backend tests must continue to pass
|
|
- All existing frontend tests must continue to pass
|
|
- Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope
|
|
|
|
## 13. Migration / rollout
|
|
|
|
### 13.1 First-time deploy
|
|
|
|
For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment.
|
|
|
|
### 13.2 Upgrading an existing dev install
|
|
|
|
If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is:
|
|
|
|
1. **Stop the existing server.**
|
|
2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration).
|
|
3. **Run `scripts/cyclone-init.sh`** to generate secrets.
|
|
4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run.
|
|
5. **Bootstrap admin user** via the CLI in the container.
|
|
6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK).
|
|
|
|
The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed.
|
|
|
|
### 13.3 Updating the running stack
|
|
|
|
```bash
|
|
cd /opt/cyclone
|
|
docker compose pull # or: docker compose build
|
|
docker compose up -d # recreates containers
|
|
docker compose logs -f backend | head -200 # verify migrations + healthcheck
|
|
```
|
|
|
|
DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`.
|
|
|
|
## 14. Out of scope (explicit)
|
|
|
|
- Real SFTP wire (paramiko)
|
|
- Public TLS / domain / Caddy reverse proxy in compose
|
|
- Multi-host HA / DB replication / load balancing
|
|
- Prometheus / Grafana / alerting beyond the simple email cron
|
|
- Off-box automated backup shipping (operator's cron)
|
|
- 2FA / SSO / OAuth
|
|
- Self-service password reset
|
|
- Per-file encryption of prodfiles / sftp_staging
|
|
- LUKS full-disk encryption
|
|
- Watchtower / automatic updates
|
|
- Linting/pre-commit/dev tooling polish
|
|
- Component / E2E browser tests (Playwright)
|
|
|
|
## 15. Acceptance checklist
|
|
|
|
### 15.1 Backend
|
|
|
|
- [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes
|
|
- [ ] All previously-passing tests still pass; new total ≥ previous + 18
|
|
- [ ] `docker compose config` validates with no errors
|
|
- [ ] `docker compose build` succeeds for both services
|
|
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
|
|
- [ ] `curl http://127.0.0.1:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
|
|
|
|
### 15.2 Auth + RBAC
|
|
|
|
- [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}`
|
|
- [ ] 5 wrong logins within 15min locks the account; 423 returned
|
|
- [ ] `GET /api/batches` without session cookie returns 401
|
|
- [ ] `GET /api/admin/users` as operator returns 403; as admin returns list
|
|
- [ ] Logout clears the cookie and invalidates the session in the DB
|
|
- [ ] Password change invalidates other sessions for that user
|
|
|
|
### 15.3 Encryption + backups
|
|
|
|
- [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container
|
|
- [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext
|
|
- [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/`
|
|
- [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`)
|
|
- [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present
|
|
|
|
### 15.4 Frontend
|
|
|
|
- [ ] Unauthenticated browser request to `/` redirects to `/login`
|
|
- [ ] Login as admin → land on `/`; NavBar shows username + role
|
|
- [ ] Operator does not see "Admin" link in NavBar
|
|
- [ ] `/admin/users` as operator shows "Forbidden" page
|
|
- [ ] Logout button clears session and returns to `/login`
|
|
- [ ] `npm run build` produces a `dist/` that serves correctly from nginx
|
|
|
|
### 15.5 Smoke
|
|
|
|
- [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack
|
|
- [ ] `RUNBOOK.md` exists and contains the §9.6 procedures
|
|
- [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors |