# 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.