diff --git a/docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md b/docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md new file mode 100644 index 0000000..a58c80e --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md @@ -0,0 +1,816 @@ +# Cyclone Ubuntu Docker Deployment — Design + +**Date:** 2026-06-22 +**Status:** Approved (pending user review of this doc) +**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) +**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. 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 /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://: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 +- `` wrapper in `App.tsx` +- `` 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://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://: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://cyclone-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 +cyclone admin reset-password --username X --new-password +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 ; + if (!user) return ; + return <>{children}; +} +``` + +Wrap every route except `/login` in ``. `/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 `). + +### 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 +api.me(): Promise<{ user: User }> // throws 401 if not logged in +api.changePassword(oldPw: string, newPw: string): Promise +api.listUsers(): Promise +api.createUser(...): Promise +api.updateUser(id: string, ...): Promise +api.deleteUser(id: string): Promise +``` + +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://localhost: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=` (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://localhost: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 /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://: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://localhost:8080/api/healthz > /dev/null; then break; fi + sleep 2 +done + +# 3. Login +COOKIE_JAR=$(mktemp) +curl -fs -c "$COOKIE_JAR" -X POST http://localhost: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://localhost:8080/api/parse-837 \ + -F "file=@docs/goodclaim.x12" + +# 5. List batches +curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches + +# 6. Export +curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches//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://localhost: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 \ No newline at end of file