Files
cyclone/docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md
T
Nora 3ba5ca0849 feat(sp23): live-verification fixes from end-to-end bring-up
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)
2026-06-23 17:53:16 -06:00

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