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

32 KiB
Raw Blame History

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:

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:

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

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

// 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:

_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(...):

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

// 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

// 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

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.

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.