diff --git a/docs/superpowers/specs/2026-06-22-cyclone-auth-design.md b/docs/superpowers/specs/2026-06-22-cyclone-auth-design.md new file mode 100644 index 0000000..351b944 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-cyclone-auth-design.md @@ -0,0 +1,554 @@ +# Cyclone Auth (admin / user / viewer) — Design + +**Date:** 2026-06-22 +**Status:** Draft (pending user review) +**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP. + +--- + +## 1. Overview + +Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors). + +This sub-project adds the minimum viable role-based access control: +- A `/login` page that posts username + password to the backend. +- Server-side sessions in SQLite, keyed by an HttpOnly cookie. +- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix. +- An admin-only user-management surface so the first admin can create other accounts. +- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate. + +After this, an operator can: +1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot. +2. Open `http://localhost:8081/`, get redirected to `/login`, sign in. +3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`. +4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords. + +## 2. Goals + +1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself. +2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes. +3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`. +4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message. +5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.". +6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety. +7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event. +8. **CLI command for user management.** `python -m cyclone users create --role admin --password ...` works as an alternative to the admin UI for ops. + +## 3. Non-goals (this sub-project) + +- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table. +- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow. +- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor. +- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X". +- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account. +- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions. +- **Branding / SSO.** Out of scope. + +## 4. Stack + +**Backend additions:** +- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`). +- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose). +- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy). +- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`. +- Migration `0015_users_and_sessions.py` creates both tables. +- FastAPI dependency injection for `get_current_user` and `require_role`. + +**Frontend additions:** +- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401. +- New `/login` page (`src/pages/Login.tsx`). +- No new build tools; the existing Vite + React + react-query stack stays. + +**Infrastructure:** +- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy. +- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service. +- Backend image installs `passlib[bcrypt]`. + +## 5. Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │ +│ │ React SPA │ │ Cookie jar │ │ +│ │ ┌────────────────────────────────────┐ │ │ cyclone_session= │ │ +│ │ │ │ │ │ HttpOnly, Lax, │ │ +│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │ +│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │ +│ │ │ │ │ │ +│ │ │ ┌─────────────────────────────┐ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ disables write buttons │ │ │ │ +│ │ │ │ for users without role │ │ │ │ +│ │ │ └─────────────────────────────┘ │ │ │ +│ │ └────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────┘ │ +└────────────────────────────────┬─────────────────────────────────────────┘ + │ HTTPS (or HTTP behind LAN) + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ nginx (frontend container) │ +│ - serves SPA static bundle │ +│ - location /api/* → proxy_pass http://cyclone-backend:8000 │ +│ - proxy_cookie_path /api/ /; (rewrites cookie path) │ +└────────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ FastAPI (backend container) │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ cyclone.auth.routes POST /api/auth/login, logout │ │ +│ │ GET /api/auth/me │ │ +│ │ cyclone.auth.admin CRUD /api/admin/users │ │ +│ │ cyclone.api (existing) every endpoint gains │ │ +│ │ Depends(get_current_user) │ │ +│ │ sensitive endpoints gain │ │ +│ │ Depends(require_role("admin")) │ │ +│ ├──────────────────────────────────────────────────────────────────┤ │ +│ │ cyclone.auth.deps get_current_user, require_role │ │ +│ │ cyclone.auth.sessions create/validate/expire │ │ +│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │ +│ │ cyclone.auth.permissions Role enum + matrix │ │ +│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ cyclone.db (SQLAlchemy) │ │ +│ │ users(id, username UNIQUE, password_hash, role, │ │ +│ │ created_at, disabled_at) │ │ +│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │ +│ │ audit_log (existing — gets user_id column via 0016) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**Data flow on login:** +1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401). +2. User submits `{username, password}` → `POST /api/auth/login`. +3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`. +4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists. +5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`. +6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`. +7. Set `Set-Cookie: cyclone_session=; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var). +8. Return `200 {id, username, role, createdAt}`. + +**Data flow on a normal request:** +1. Browser sends request with `Cookie: cyclone_session=`. +2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`. +3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`. +4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it. +5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`. + +**Data flow on logout:** +1. SPA sends `POST /api/auth/logout`. +2. Backend deletes the `sessions` row matching the cookie's id. +3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie. +4. SPA's `AuthProvider` clears its state and navigates to `/login`. + +**Sliding expiry:** +Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request. + +## 6. Backend changes + +### 6.1 New module `backend/src/cyclone/auth/` + +``` +auth/ +├── __init__.py # re-exports the public API +├── users.py # User model, CRUD, bcrypt hashing +├── sessions.py # Session model, create/validate/expire +├── permissions.py # Role enum, permission matrix +├── deps.py # get_current_user, require_role +├── routes.py # /api/auth/login, logout, me +├── admin.py # /api/admin/users/* (admin-only) +└── rate_limit.py # per-username failed-login counter +``` + +### 6.2 Data model + +New SQLAlchemy models in `cyclone.db`: + +```python +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer" + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class Session(Base): + __tablename__ = "sessions" + id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) +``` + +Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`. + +Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log). + +### 6.3 Permissions matrix + +`cyclone/auth/permissions.py`: + +```python +from enum import Enum + +class Role(str, Enum): + ADMIN = "admin" + USER = "user" + VIEWER = "viewer" + + +# Endpoint path prefix → set of roles allowed. +PERMISSIONS: dict[str, set[Role]] = { + # Public paths (no auth required). Empty set = anyone, including unauthenticated. + "GET /api/healthz": set(), + "POST /api/auth/login": set(), + + # Auth surface (authenticated, all roles). + "POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER}, + + # Admin-only user management. + "GET /api/admin/users": {Role.ADMIN}, + "POST /api/admin/users": {Role.ADMIN}, + "PATCH /api/admin/users": {Role.ADMIN}, + "DELETE /api/admin/users": {Role.ADMIN}, + + # Read endpoints (everyone authenticated can read). + "GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER}, + "GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only + + # Write endpoints (admin + user, no viewer). + "POST /api/parse-837": {Role.ADMIN, Role.USER}, + "POST /api/parse-835": {Role.ADMIN, Role.USER}, + "POST /api/inbox": {Role.ADMIN, Role.USER}, + "POST /api/reconcile": {Role.ADMIN, Role.USER}, + "POST /api/resubmit": {Role.ADMIN, Role.USER}, + "POST /api/acks": {Role.ADMIN, Role.USER}, + # ...every other POST/PATCH/DELETE goes here too. + + # CSV export — read-only, so all roles. + "GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER}, +} +``` + +The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed. + +### 6.4 Dependencies + +```python +# deps.py +from fastapi import Depends, HTTPException, Request, status + +async def get_current_user(request: Request) -> User: + sid = request.cookies.get("cyclone_session") + if not sid: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired") + session = await sessions.get_valid(sid) + if not session: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired") + user = await users.get(session.user_id) + if not user or user.disabled_at is not None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled") + # Sliding expiry refresh. + await sessions.touch(sid) + request.state.user = user + return user + + +def require_role(*allowed: Role): + async def _dep(request: Request, user: User = Depends(get_current_user)) -> User: + method = request.method + path = request.url.path + key = f"{method} {path}" + # Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims". + allowed_roles = _lookup_permissions(key) + if user.role not in allowed_roles: + raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden") + return user + return _dep +``` + +### 6.5 Endpoints + +**Auth (`cyclone/auth/routes.py`):** + +| Method | Path | Auth | Body | Response | +|---|---|---|---|---| +| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie | +| POST | `/api/auth/logout` | session | — | `204` + cookie cleared | +| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` | + +**Admin (`cyclone/auth/admin.py`):** + +| Method | Path | Auth | Body | Response | +|---|---|---|---|---| +| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` | +| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` | +| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` | +| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) | + +**Error shapes:** + +```json +// 401 +{ "error": "session_expired", "detail": "Session is missing or expired." } +// 403 +{ "error": "forbidden", "detail": "Your role lacks permission for this action." } +// 429 +{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." } +// Login 401 (generic — never leak username existence) +{ "error": "invalid_credentials", "detail": "Username or password is incorrect." } +``` + +### 6.6 Rate limit + +In-memory dict in `rate_limit.py`: + +```python +_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails +WINDOW_SECONDS = 300 +MAX_FAILS = 5 + +def check(username: str) -> int: + """Return Retry-After seconds, or 0 if allowed.""" + now = time.monotonic() + fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS] + if len(fails) >= MAX_FAILS: + return int(WINDOW_SECONDS - (now - fails[0])) + return 0 + +def record_failure(username: str) -> None: + _FAILS.setdefault(username, []).append(time.monotonic()) + +def reset(username: str) -> None: + _FAILS.pop(username, None) +``` + +Per-process. Resets on backend restart. Acceptable for v1. + +### 6.7 Bootstrap (first admin) + +In `cyclone/__main__.py`, before `uvicorn.run(...)`: + +```python +async def bootstrap_admin(): + async with SessionLocal() as db: + if await db.scalar(select(func.count()).select_from(User)) > 0: + return + username = os.environ.get("CYCLONE_ADMIN_USERNAME") + password = os.environ.get("CYCLONE_ADMIN_PASSWORD") + if not username or not password: + raise RuntimeError( + "Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and " + "CYCLONE_ADMIN_PASSWORD env vars, or run " + "`python -m cyclone users create --role admin` first." + ) + if len(password) < 12: + raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.") + await users.create(db, username=username, password=password, role=Role.ADMIN) + print(f"[cyclone] bootstrap admin user '{username}' created") +``` + +### 6.8 CLI command + +`python -m cyclone users ...`: + +``` +python -m cyclone users create --role {admin|user|viewer} [--password ] +python -m cyclone users list +python -m cyclone users disable +python -m cyclone users reset-password [--password ] +python -m cyclone users set-role --role {admin|user|viewer} +``` + +If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts. + +## 7. Frontend changes + +### 7.1 New module `src/auth/` + +``` +auth/ +├── AuthProvider.tsx # React context + reducer +├── AuthProvider.test.tsx +├── useAuth.ts # hook: {user, login, logout, status} +├── api.ts # fetch wrapper that handles 401 +├── RoleGate.tsx # disables children when role not allowed +└── RoleGate.test.tsx +``` + +### 7.2 AuthProvider + +```typescript +// AuthProvider.tsx (sketch) +type Status = "loading" | "authenticated" | "unauthenticated"; + +interface AuthState { + status: Status; + user: User | null; +} + +const AuthContext = createContext(...); + +export function AuthProvider({ children }) { + const [state, setState] = useState({ status: "loading", user: null }); + + useEffect(() => { + api.getAuthMe() + .then(user => setState({ status: "authenticated", user })) + .catch(err => { + if (err.status === 401) setState({ status: "unauthenticated", user: null }); + else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out + }); + }, []); + + // Expose login() that POSTs /api/auth/login + sets state on success. + // Expose logout() that POSTs /api/auth/logout + clears state + nav to /login. +} +``` + +### 7.3 Login page + +`src/pages/Login.tsx`: +- Centered card on the existing dark background, ~400px wide. +- Username + password fields, "Sign in" button, error message slot. +- On submit: POST `/api/auth/login`, on success → `useNavigate()/` or `/`. +- On `401 invalid_credentials` → "Username or password is incorrect." +- On `403 account_disabled` → "Account is disabled. Contact your administrator." +- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`). +- The page renders *without* the sidebar — it lives outside the protected ``. + +### 7.4 API wrapper + +`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response: +1. Calls `POST /api/auth/logout` (best-effort, ignore failure). +2. Clears the auth context. +3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`. + +Use a hard navigation (not ``) so the SPA's in-memory state is wiped. + +### 7.5 RoleGate + +```tsx +// RoleGate.tsx +interface Props { + allow: Role[]; + children: ReactNode; + fallback?: ReactNode; // optional explicit "no permission" UI +} + +export function RoleGate({ allow, children, fallback }: Props) { + const { user } = useAuth(); + if (!user) return null; + if (allow.includes(user.role)) return <>{children}; + if (fallback) return <>{fallback}; + return ( + + {children} + + ); +} +``` + +Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button. + +### 7.6 Sidebar + +`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `` which reads `useAuth()`. Shows the user's actual username + role. + +### 7.7 Routes + +`src/main.tsx`: +- `QueryClientProvider` +- `AuthProvider` +- `` with two route groups: + - **Public:** `/login` (no `` wrap). + - **Protected:** everything else, wrapped in a `` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `` or ``. + +### 7.8 react-query keys + +`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state. + +## 8. Infrastructure + +### 8.1 nginx (`frontend/nginx.conf`) + +The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed. + +We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag. + +### 8.2 docker-compose.yml + +```yaml +services: + backend: + environment: + CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me} + CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me} + # CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy +``` + +A `.env.example` documents the required vars. + +### 8.3 Cookie `Secure` flag + +Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx). + +## 9. Testing + +### 9.1 Backend (`backend/tests/`) + +| File | Coverage | +|---|---| +| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum | +| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response | +| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 | +| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 | +| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) | +| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored | +| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter | +| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id | +| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie | + +### 9.2 Frontend (`src/**/__tests__/`) + +| File | Coverage | +|---|---| +| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state | +| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided | +| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 | +| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses | + +### 9.3 End-to-end + +`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip. + +## 10. Risk + open questions + +- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README. +- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it. +- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms. +- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent. + +## 11. Rollout + +- All code lands in one PR (sub-project is small enough). +- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive). +- The Docker image rebuilds pick up `passlib[bcrypt]` automatically. +- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix. +- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset. \ No newline at end of file