Files
cyclone/docs/superpowers/specs/2026-06-22-cyclone-auth-design.md
T

554 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <username> --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=<id> │ │
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
│ │ │ </AuthProvider> │ │ │
│ │ │ ┌─────────────────────────────┐ │ │ │
│ │ │ │ <RoleGate allow=...> │ │ │ │
│ │ │ │ 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=<id>; 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=<id>`.
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 <username> --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 <username> --role {admin|user|viewer} [--password <pw>]
python -m cyclone users list
python -m cyclone users disable <username>
python -m cyclone users reset-password <username> [--password <pw>]
python -m cyclone users set-role <username> --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<AuthState & { login, logout }>(...);
export function AuthProvider({ children }) {
const [state, setState] = useState<AuthState>({ 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()/<prev>` 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 `<Layout>`.
### 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 `<Navigate>`) 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 (
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
<span className="pointer-events-none opacity-50">{children}</span>
</Tooltip>
);
}
```
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 `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
### 7.7 Routes
`src/main.tsx`:
- `QueryClientProvider`
- `AuthProvider`
- `<BrowserRouter>` with two route groups:
- **Public:** `/login` (no `<Layout>` wrap).
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
### 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.