# Cyclone Auth Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add username/password authentication with three predefined roles (admin, user, viewer) to the Cyclone FastAPI backend and React frontend. Browser login + server-side SQLite sessions + HttpOnly cookie + role-gated endpoints. **Architecture:** New `cyclone.auth` module on the backend (users, sessions, permissions, deps, routes, admin, rate_limit). Two SQL migrations (0015 for users/sessions tables, 0016 for audit_log.user_id). FastAPI dependencies `get_current_user` and `require_role`. New `src/auth/` module on the frontend (AuthProvider, useAuth, RoleGate, fetch wrapper). New `/login` page. CLI command `python -m cyclone users ...` for ops. **Tech Stack:** FastAPI, SQLAlchemy, passlib[bcrypt], secrets.token_urlsafe, React, react, react-query, react-router. **Spec:** `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` --- ## File Structure **Backend (new files):** - `backend/src/cyclone/auth/__init__.py` — module exports - `backend/src/cyclone/auth/users.py` — User model, bcrypt CRUD - `backend/src/cyclone/auth/sessions.py` — Session model, create/validate/expire/touch - `backend/src/cyclone/auth/permissions.py` — Role enum, PERMISSIONS matrix - `backend/src/cyclone/auth/deps.py` — get_current_user, require_role - `backend/src/cyclone/auth/routes.py` — login, logout, me - `backend/src/cyclone/auth/admin.py` — admin user management - `backend/src/cyclone/auth/rate_limit.py` — per-username failed-login counter - `backend/src/cyclone/auth/cli.py` — `python -m cyclone users ...` subcommand - `backend/src/cyclone/migrations/0015_users_and_sessions.py` — users + sessions tables - `backend/src/cyclone/migrations/0016_audit_log_user_id.py` — audit_log.user_id **Backend (modified):** - `backend/src/cyclone/db.py` — add User, Session models - `backend/src/cyclone/api.py` — register auth router, gate existing endpoints - `backend/src/cyclone/__main__.py` — bootstrap admin + register CLI subcommand - `backend/src/cyclone/audit_log.py` — record user_id - `backend/pyproject.toml` — passlib[bcrypt] - `backend/Dockerfile` — already installs from pyproject, no change needed - `docker-compose.yml` — CYCLONE_ADMIN_USERNAME/PASSWORD env vars - `.env.example` — env var documentation **Backend tests (new):** - `backend/tests/test_auth_users.py` - `backend/tests/test_auth_sessions.py` - `backend/tests/test_auth_routes.py` - `backend/tests/test_auth_permissions.py` - `backend/tests/test_auth_admin.py` - `backend/tests/test_auth_bootstrap.py` - `backend/tests/test_auth_login_rate_limit.py` - `backend/tests/test_audit_log_user_id.py` - `backend/tests/test_existing_endpoints_require_auth.py` - `backend/tests/test_cli_users.py` **Frontend (new files):** - `src/auth/types.ts` — User, AuthState, Role types - `src/auth/AuthProvider.tsx` — context + reducer - `src/auth/useAuth.ts` — hook - `src/auth/api.ts` — fetch wrapper for 401 - `src/auth/RoleGate.tsx` — disable children when role not allowed - `src/auth/RequireAuth.tsx` — route guard - `src/pages/Login.tsx` — login page - `src/auth/AuthProvider.test.tsx` - `src/auth/RoleGate.test.tsx` - `src/auth/api.test.ts` - `src/pages/Login.test.tsx` **Frontend (modified):** - `src/main.tsx` — wrap in AuthProvider + RequireAuth + BrowserRouter - `src/lib/api.ts` — call auth/api wrapper for fetch - `src/components/Sidebar.tsx` — CurrentUser from useAuth (replaces hardcoded "Jordan K.") - `src/pages/Upload.tsx` — RoleGate on dropzone - `src/pages/Inbox.tsx` — RoleGate on resubmit/acknowledge - `src/pages/Reconciliation.tsx` — RoleGate on match - `src/pages/Acks.tsx` — RoleGate on ack - `src/types/index.ts` — add User type - `src/components/RequireAuth.tsx` — route guard - `frontend/nginx.conf` — `X-Forwarded-Proto` header **Docs (modified):** - `README.md` — Auth section --- ## Phase 1: Backend Foundations ### Task 1: Add passlib[bcrypt] dependency **Files:** - Modify: `backend/pyproject.toml:30-60` - [ ] **Step 1: Add the dependency** In `backend/pyproject.toml`, find the `[project]` section and add `passlib[bcrypt]>=1.7.4` to the `dependencies` list (alphabetical order — between `cryptography` and ... wherever alphabetically it fits). ```toml dependencies = [ ... "fastapi>=0.115", "passlib[bcrypt]>=1.7.4", "pydantic>=2.5", ... ] ``` - [ ] **Step 2: Install** ```bash cd /home/tyler/dev/cyclone/backend && uv sync ``` Expected: installs `passlib`, `bcrypt`. No errors. - [ ] **Step 3: Smoke test bcrypt import** ```bash uv run python -c "from passlib.hash import bcrypt; print(bcrypt.hash('test'))" ``` Expected: prints a `$2b$...` hash. - [ ] **Step 4: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/pyproject.toml backend/uv.lock && git commit -m "feat(deps): add passlib[bcrypt] for password hashing" ``` --- ### Task 2: Add User and Session models to db.py **Files:** - Modify: `backend/src/cyclone/db.py` — add two SQLAlchemy models - [ ] **Step 1: Locate the models section** In `backend/src/cyclone/db.py`, find the end of the existing model classes (probably `Claim`, `Remittance`, etc.). We'll append `User` and `Session` after them. - [ ] **Step 2: Add User and Session models** Append at the end of the file (before any helper functions): ```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)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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) 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), server_default=func.now()) ``` Ensure the imports at the top include `String`, `DateTime`, `ForeignKey`, `func` from sqlalchemy. If they're missing, add them. - [ ] **Step 3: Smoke test** ```bash cd /home/tyler/dev/cyclone/backend && uv run python -c "from cyclone.db import User, Session; print(User.__tablename__, Session.__tablename__)" ``` Expected: `users sessions` - [ ] **Step 4: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/db.py && git commit -m "feat(db): add User and Session models" ``` --- ### Task 3: Migration 0015 — users + sessions tables **Files:** - Create: `backend/src/cyclone/migrations/0015_users_and_sessions.py` - [ ] **Step 1: Inspect existing migrations** ```bash ls /home/tyler/dev/cyclone/backend/src/cyclone/migrations/ ``` Read the most recent migration (e.g. `0014_*.py`) to confirm the migration runner's expected pattern (filename, function signature, SQLAlchemy vs raw SQL, etc.). - [ ] **Step 2: Create migration file** `backend/src/cyclone/migrations/0015_users_and_sessions.py`: ```python """Migration 0015 — add users + sessions tables for auth (SP-auth).""" from __future__ import annotations from sqlalchemy import inspect from cyclone.db import Base, Session, User, engine def upgrade() -> None: insp = inspect(engine) if "users" in insp.get_table_names(): return # idempotent Base.metadata.create_all(engine, tables=[User.__table__, Session.__table__]) def downgrade() -> None: raise NotImplementedError("Cyclone does not support down-migrations.") ``` Adapt the signature to match the runner's existing convention (it might take a `conn` argument or use `op.create_table` from Alembic). Match what's there. - [ ] **Step 3: Run the migration** ```bash cd /home/tyler/dev/cyclone/backend && uv run python -c "from cyclone.migrations.runner import run; run(target=15)" ``` (Or whatever the project's migration entry point is — check `cyclone.migrations.__init__` or `db.py`.) Expected: tables `users` and `sessions` exist in `cyclone.db`. - [ ] **Step 4: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/migrations/0015_users_and_sessions.py && git commit -m "feat(migration): 0015 users + sessions tables" ``` --- ### Task 4: Migration 0016 — audit_log.user_id **Files:** - Create: `backend/src/cyclone/migrations/0016_audit_log_user_id.py` - [ ] **Step 1: Inspect audit_log schema** ```bash cd /home/tyler/dev/cyclone/backend && uv run python -c "from cyclone.db import engine; from sqlalchemy import inspect; print(inspect(engine).get_columns('audit_log'))" ``` Confirm the existing audit_log table column names. - [ ] **Step 2: Create migration file** ```python """Migration 0016 — add audit_log.user_id for SP-auth.""" from __future__ import annotations from sqlalchemy import inspect from cyclone.db import engine def upgrade() -> None: insp = inspect(engine) cols = {c["name"] for c in insp.get_columns("audit_log")} if "user_id" in cols: return with engine.begin() as conn: conn.exec_driver_sql("ALTER TABLE audit_log ADD COLUMN user_id INTEGER") conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_audit_log_user_id ON audit_log (user_id)") def downgrade() -> None: raise NotImplementedError("Cyclone does not support down-migrations.") ``` - [ ] **Step 3: Run migration** ```bash uv run python -c "from cyclone.migrations.runner import run; run(target=16)" ``` - [ ] **Step 4: Verify the column** ```bash uv run python -c "from cyclone.db import engine; from sqlalchemy import inspect; print([c['name'] for c in inspect(engine).get_columns('audit_log')])" ``` Expected: `user_id` is in the list. - [ ] **Step 5: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/migrations/0016_audit_log_user_id.py && git commit -m "feat(migration): 0016 audit_log.user_id" ``` --- ### Task 5: TDD — users.py (User CRUD + bcrypt) **Files:** - Create: `backend/src/cyclone/auth/users.py` - Create: `backend/tests/test_auth_users.py` - [ ] **Step 1: Create empty auth module** `backend/src/cyclone/auth/__init__.py`: ```python """Auth module — users, sessions, permissions, routes, admin, rate_limit.""" ``` - [ ] **Step 2: Write failing tests** `backend/tests/test_auth_users.py`: ```python """Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing.""" from __future__ import annotations import pytest from sqlalchemy import delete from cyclone.auth import users from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear_users(): with SessionLocal() as db: db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(User)) db.commit() def test_hash_password_returns_bcrypt(): h = users.hash_password("hunter2hunter2") assert h.startswith("$2") def test_hash_password_produces_unique_salts(): a = users.hash_password("same-password") b = users.hash_password("same-password") assert a != b def test_verify_password_correct(): h = users.hash_password("hunter2hunter2") assert users.verify_password("hunter2hunter2", h) is True def test_verify_password_incorrect(): h = users.hash_password("hunter2hunter2") assert users.verify_password("WRONG", h) is False def test_create_user_persists_with_hashed_password(): with SessionLocal() as db: u = users.create(db, username="alice", password="hunter2hunter2", role="admin") assert u.id is not None assert u.username == "alice" assert u.role == "admin" assert u.disabled_at is None # Hash, not plaintext. assert u.password_hash != "hunter2hunter2" assert u.password_hash.startswith("$2") def test_create_user_rejects_duplicate_username(): with SessionLocal() as db: users.create(db, username="bob", password="hunter2hunter2", role="user") with SessionLocal() as db: with pytest.raises(Exception): users.create(db, username="bob", password="anotherone", role="user") def test_get_by_username_returns_user(): with SessionLocal() as db: users.create(db, username="carol", password="hunter2hunter2", role="viewer") with SessionLocal() as db: u = users.get_by_username(db, "carol") assert u is not None assert u.username == "carol" def test_get_by_username_returns_none_for_missing(): with SessionLocal() as db: assert users.get_by_username(db, "ghost") is None def test_disable_user_sets_disabled_at(): with SessionLocal() as db: u = users.create(db, username="dave", password="hunter2hunter2", role="user") users.disable(db, u.id) with SessionLocal() as db: refreshed = users.get_by_username(db, "dave") assert refreshed.disabled_at is not None def test_to_public_shape_omits_password_hash(): with SessionLocal() as db: u = users.create(db, username="eve", password="hunter2hunter2", role="admin") shape = users.to_public(u) assert "password_hash" not in shape assert shape["username"] == "eve" assert shape["role"] == "admin" assert "id" in shape and "createdAt" in shape ``` - [ ] **Step 3: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_users.py -v 2>&1 | tail -15 ``` Expected: all tests FAIL with `ModuleNotFoundError: No module named 'cyclone.auth'` or similar. - [ ] **Step 4: Implement users.py** `backend/src/cyclone/auth/users.py`: ```python """User CRUD + bcrypt password hashing.""" from __future__ import annotations from datetime import datetime, timezone from typing import Any from passlib.hash import bcrypt from sqlalchemy import select from cyclone.db import User def hash_password(plaintext: str) -> str: return bcrypt.hash(plaintext) def verify_password(plaintext: str, hashed: str) -> bool: try: return bcrypt.verify(plaintext, hashed) except (ValueError, TypeError): return False def create(db, *, username: str, password: str, role: str) -> User: user = User( username=username, password_hash=hash_password(password), role=role, created_at=datetime.now(timezone.utc), ) db.add(user) db.commit() db.refresh(user) return user def get_by_username(db, username: str) -> User | None: return db.execute( select(User).where(User.username == username) ).scalar_one_or_none() def get(db, user_id: int) -> User | None: return db.get(User, user_id) def disable(db, user_id: int) -> None: user = db.get(User, user_id) if user is None: return user.disabled_at = datetime.now(timezone.utc) db.commit() def update_role(db, user_id: int, role: str) -> None: user = db.get(User, user_id) if user is None: return user.role = role db.commit() def update_password(db, user_id: int, new_password: str) -> None: user = db.get(User, user_id) if user is None: return user.password_hash = hash_password(new_password) db.commit() def to_public(user: User) -> dict[str, Any]: return { "id": user.id, "username": user.username, "role": user.role, "createdAt": user.created_at.isoformat() if user.created_at else None, "disabledAt": user.disabled_at.isoformat() if user.disabled_at else None, } ``` - [ ] **Step 5: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_users.py -v 2>&1 | tail -15 ``` Expected: all 10 tests pass. - [ ] **Step 6: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/users.py backend/tests/test_auth_users.py backend/src/cyclone/auth/__init__.py && git commit -m "feat(auth): users module with bcrypt hashing + CRUD" ``` --- ### Task 6: TDD — sessions.py **Files:** - Create: `backend/src/cyclone/auth/sessions.py` - Create: `backend/tests/test_auth_sessions.py` - [ ] **Step 1: Write failing tests** `backend/tests/test_auth_sessions.py`: ```python """Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch.""" from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import delete from cyclone.auth import sessions from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() def _make_user(): from cyclone.auth import users with SessionLocal() as db: return users.create(db, username="sessuser", password="hunter2hunter2", role="user") def test_create_session_returns_id_and_session(): user = _make_user() with SessionLocal() as db: sid, sess = sessions.create(db, user_id=user.id) assert len(sid) >= 32 assert sess.user_id == user.id assert sess.expires_at > datetime.now(timezone.utc) def test_get_valid_returns_session_for_active(): user = _make_user() with SessionLocal() as db: sid, _ = sessions.create(db, user_id=user.id) with SessionLocal() as db: got = sessions.get_valid(db, sid) assert got is not None assert got.user_id == user.id def test_get_valid_returns_none_for_missing(): with SessionLocal() as db: assert sessions.get_valid(db, "does-not-exist") is None def test_get_valid_returns_none_for_expired(): user = _make_user() with SessionLocal() as db: sid, _ = sessions.create(db, user_id=user.id) with SessionLocal() as db: sess = sessions.get_valid(db, sid) # Backdate expiry. sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) db.commit() with SessionLocal() as db: assert sessions.get_valid(db, sid) is None def test_delete_removes_session(): user = _make_user() with SessionLocal() as db: sid, _ = sessions.create(db, user_id=user.id) sessions.delete(db, sid) with SessionLocal() as db: assert sessions.get_valid(db, sid) is None def test_touch_extends_expiry(): user = _make_user() with SessionLocal() as db: sid, sess = sessions.create(db, user_id=user.id) original_expiry = sess.expires_at sessions.touch(db, sid) with SessionLocal() as db: refreshed = sessions.get_valid(db, sid) assert refreshed.expires_at >= original_expiry ``` - [ ] **Step 2: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_sessions.py -v 2>&1 | tail -10 ``` Expected: ModuleNotFoundError or AttributeError. - [ ] **Step 3: Implement sessions.py** `backend/src/cyclone/auth/sessions.py`: ```python """Session create/validate/expire/touch.""" from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone from sqlalchemy import select from cyclone.db import Session SESSION_LIFETIME = timedelta(hours=24) def create(db, *, user_id: int) -> tuple[str, Session]: sid = secrets.token_urlsafe(32) now = datetime.now(timezone.utc) sess = Session( id=sid, user_id=user_id, expires_at=now + SESSION_LIFETIME, created_at=now, ) db.add(sess) db.commit() db.refresh(sess) return sid, sess def get_valid(db, sid: str) -> Session | None: sess = db.execute( select(Session).where(Session.id == sid) ).scalar_one_or_none() if sess is None: return None if sess.expires_at <= datetime.now(timezone.utc): return None return sess def delete(db, sid: str) -> None: sess = db.get(Session, sid) if sess is None: return db.delete(sess) db.commit() def touch(db, sid: str) -> None: sess = db.get(Session, sid) if sess is None: return sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME db.commit() ``` - [ ] **Step 4: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_sessions.py -v 2>&1 | tail -10 ``` Expected: all 6 tests pass. - [ ] **Step 5: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/sessions.py backend/tests/test_auth_sessions.py && git commit -m "feat(auth): sessions module — create/validate/expire/touch" ``` --- ### Task 7: permissions.py + rate_limit.py (no TDD, pure data + tiny helpers) **Files:** - Create: `backend/src/cyclone/auth/permissions.py` - Create: `backend/src/cyclone/auth/rate_limit.py` - [ ] **Step 1: Create permissions.py** ```python """Role enum + PERMISSIONS matrix.""" from __future__ import annotations from enum import Enum class Role(str, Enum): ADMIN = "admin" USER = "user" VIEWER = "viewer" ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER} WRITE_ROLES = {Role.ADMIN, Role.USER} ADMIN_ONLY = {Role.ADMIN} # (method, path-prefix) → allowed roles. # Endpoints not in this matrix default to DENY (fail-closed). # Use exact paths or longest-prefix match. PERMISSIONS: dict[tuple[str, str], set[Role]] = { # Public paths. ("GET", "/api/healthz"): set(), ("POST", "/api/auth/login"): set(), # Auth surface (authenticated, all roles). ("POST", "/api/auth/logout"): ALL_ROLES, ("GET", "/api/auth/me"): ALL_ROLES, # Admin-only user management. ("GET", "/api/admin/users"): ADMIN_ONLY, ("POST", "/api/admin/users"): ADMIN_ONLY, ("PATCH", "/api/admin/users"): ADMIN_ONLY, ("DELETE", "/api/admin/users"): ADMIN_ONLY, # Read endpoints. ("GET", "/api/claims"): ALL_ROLES, ("GET", "/api/remittances"): ALL_ROLES, ("GET", "/api/providers"): ALL_ROLES, ("GET", "/api/batches"): ALL_ROLES, ("GET", "/api/dashboard/summary"): ALL_ROLES, ("GET", "/api/activity"): ALL_ROLES, ("GET", "/api/inbox/lanes"): ALL_ROLES, ("GET", "/api/reconcile"): ALL_ROLES, ("GET", "/api/audit-log"): ADMIN_ONLY, # Write endpoints (admin + user, no viewer). ("POST", "/api/parse-837"): WRITE_ROLES, ("POST", "/api/parse-835"): WRITE_ROLES, ("POST", "/api/inbox"): WRITE_ROLES, ("POST", "/api/reconcile"): WRITE_ROLES, ("POST", "/api/resubmit"): WRITE_ROLES, ("POST", "/api/acks"): WRITE_ROLES, # CSV export — read-only, all roles. ("GET", "/api/export.csv"): ALL_ROLES, } def allowed_roles(method: str, path: str) -> set[Role] | None: """Return the set of roles allowed to call (method, path), or None if denied. Uses longest-prefix match on path; falls back to DENY (None) if no entry matches. """ candidates = [ (prefix_len, roles) for (m, prefix), roles in PERMISSIONS.items() if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/")) for prefix_len in [len(prefix)] ] if not candidates: return None candidates.sort(key=lambda x: -x[0]) return candidates[0][1] ``` - [ ] **Step 2: Create rate_limit.py** ```python """Per-username login rate limiter (in-memory, per-process).""" from __future__ import annotations import time from threading import Lock WINDOW_SECONDS = 300 MAX_FAILS = 5 _FAILS: dict[str, list[float]] = {} _LOCK = Lock() def check(username: str) -> int: """Return retry-after seconds, or 0 if allowed.""" now = time.monotonic() with _LOCK: fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS] _FAILS[username] = fails if len(fails) >= MAX_FAILS: return int(WINDOW_SECONDS - (now - fails[0])) return 0 def record_failure(username: str) -> None: now = time.monotonic() with _LOCK: _FAILS.setdefault(username, []).append(now) def reset(username: str) -> None: with _LOCK: _FAILS.pop(username, None) ``` - [ ] **Step 3: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/permissions.py backend/src/cyclone/auth/rate_limit.py && git commit -m "feat(auth): permissions matrix + login rate limiter" ``` --- ### Task 8: deps.py — get_current_user + require_role **Files:** - Create: `backend/src/cyclone/auth/deps.py` - [ ] **Step 1: Implement deps.py** ```python """FastAPI dependencies for auth.""" from __future__ import annotations from typing import Annotated from fastapi import Depends, HTTPException, Request, status from sqlalchemy.orm import Session as DbSession from cyclone.auth import sessions, users from cyclone.auth.permissions import Role, allowed_roles from cyclone.db import SessionLocal def _db(): db = SessionLocal() try: yield db finally: db.close() DbSessionDep = Annotated[DbSession, Depends(_db)] AUTH_DISABLED = False # flipped by bootstrap or env var async def get_current_user( request: Request, db: DbSessionDep, ) -> dict: """Return the public User shape. Raises 401 if session is missing/expired. When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin user without checking credentials. """ if AUTH_DISABLED: return { "id": 0, "username": "dev", "role": Role.ADMIN.value, "createdAt": None, "disabledAt": None, } sid = request.cookies.get("cyclone_session") if not sid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="session_expired", ) sess = sessions.get_valid(db, sid) if sess is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="session_expired", ) user = users.get(db, sess.user_id) if user is None or user.disabled_at is not None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="account_disabled", ) # Sliding expiry: refresh both DB and cookie. sessions.touch(db, sid) request.state.user = user request.state.session_id = sid return users.to_public(user) def require_role(*allowed: Role): """Dependency factory: gate the endpoint to specific roles. Falls back to PERMISSIONS matrix lookup if no explicit roles given. """ async def _dep( request: Request, user: dict = Depends(get_current_user), ) -> dict: user_role = user.get("role") # If explicit allowed roles given, check those. if allowed: if user_role not in {r.value for r in allowed}: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="forbidden", ) return user # Otherwise consult the matrix. method = request.method path = request.url.path roles = allowed_roles(method, path) if roles is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="forbidden", ) if user_role not in {r.value for r in roles}: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="forbidden", ) return user return _dep ``` - [ ] **Step 2: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/deps.py && git commit -m "feat(auth): get_current_user + require_role dependencies" ``` --- ### Task 9: TDD — routes.py (login/logout/me) **Files:** - Create: `backend/src/cyclone/auth/routes.py` - Create: `backend/tests/test_auth_routes.py` - [ ] **Step 1: Write failing tests** `backend/tests/test_auth_routes.py`: ```python """API tests for /api/auth/login, /api/auth/logout, /api/auth/me.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import delete from cyclone.api import app from cyclone.auth import users from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() @pytest.fixture def client(): return TestClient(app) @pytest.fixture def seeded_admin(client): with SessionLocal() as db: users.create(db, username="admin", password="adminpassword1", role="admin") return client def test_login_success_returns_user_and_cookie(client): with SessionLocal() as db: users.create(db, username="alice", password="hunter2hunter2", role="user") resp = client.post( "/api/auth/login", json={"username": "alice", "password": "hunter2hunter2"}, ) assert resp.status_code == 200 body = resp.json() assert body["username"] == "alice" assert body["role"] == "user" assert "password_hash" not in body # Cookie set with HttpOnly. assert "cyclone_session" in resp.cookies assert resp.cookies["cyclone_session"] def test_login_bad_password_returns_401(client): with SessionLocal() as db: users.create(db, username="bob", password="hunter2hunter2", role="user") resp = client.post( "/api/auth/login", json={"username": "bob", "password": "WRONG"}, ) assert resp.status_code == 401 assert resp.json()["error"] == "invalid_credentials" # No cookie set. assert "cyclone_session" not in resp.cookies def test_login_unknown_user_returns_401(client): resp = client.post( "/api/auth/login", json={"username": "ghost", "password": "whatever"}, ) assert resp.status_code == 401 assert resp.json()["error"] == "invalid_credentials" def test_login_disabled_user_returns_403(client): with SessionLocal() as db: u = users.create(db, username="carol", password="hunter2hunter2", role="user") users.disable(db, u.id) resp = client.post( "/api/auth/login", json={"username": "carol", "password": "hunter2hunter2"}, ) assert resp.status_code == 403 assert resp.json()["error"] == "account_disabled" def test_logout_clears_session_and_cookie(seeded_admin): # Log in. login = seeded_admin.post( "/api/auth/login", json={"username": "admin", "password": "adminpassword1"}, ) cookie = login.cookies.get("cyclone_session") assert cookie # Logout. resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie}) assert resp.status_code == 204 # Session row deleted. from cyclone.auth import sessions with SessionLocal() as db: assert sessions.get_valid(db, cookie) is None def test_me_returns_current_user(seeded_admin): login = seeded_admin.post( "/api/auth/login", json={"username": "admin", "password": "adminpassword1"}, ) cookie = login.cookies.get("cyclone_session") resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie}) assert resp.status_code == 200 assert resp.json()["username"] == "admin" def test_me_without_cookie_returns_401(seeded_admin): resp = seeded_admin.get("/api/auth/me") assert resp.status_code == 401 ``` (Note: tests reference `resp.json()["error"]` — we'll need the routes to use a custom exception handler that returns `{"error": "...", "detail": "..."}`. Add that handler in this task.) - [ ] **Step 2: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_routes.py -v 2>&1 | tail -10 ``` Expected: ModuleNotFoundError or 404 on routes. - [ ] **Step 3: Implement routes.py** `backend/src/cyclone/auth/routes.py`: ```python """/api/auth/login, /api/auth/logout, /api/auth/me.""" from __future__ import annotations import os from datetime import datetime, timedelta, timezone from fastapi import APIRouter, HTTPException, Request, Response, status from sqlalchemy import delete from cyclone.auth import rate_limit, sessions, users from cyclone.auth.deps import get_current_user from cyclone.db import SessionLocal router = APIRouter(prefix="/api/auth", tags=["auth"]) COOKIE_NAME = "cyclone_session" COOKIE_MAX_AGE = 86400 # 24h def _is_https(request: Request) -> bool: if request.url.scheme == "https": return True return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1" def _set_cookie(response: Response, sid: str, request: Request) -> None: response.set_cookie( key=COOKIE_NAME, value=sid, max_age=COOKIE_MAX_AGE, path="/api", httponly=True, samesite="lax", secure=_is_https(request), ) def _clear_cookie(response: Response) -> None: response.delete_cookie(key=COOKIE_NAME, path="/api") @router.post("/login") def login(body: dict, request: Request, response: Response): username = (body.get("username") or "").strip() password = body.get("password") or "" if not username or not password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="username and password are required", ) # Rate limit (per-username). retry_after = rate_limit.check(username) if retry_after > 0: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limited", headers={"Retry-After": str(retry_after)}, ) with SessionLocal() as db: user = users.get_by_username(db, username) if user is None or not users.verify_password(password, user.password_hash): rate_limit.record_failure(username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials", ) if user.disabled_at is not None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="account_disabled", ) sid, _ = sessions.create(db, user_id=user.id) rate_limit.reset(username) public = users.to_public(user) _set_cookie(response, sid, request) return public @router.post("/logout") def logout(request: Request, response: Response, _user=__import__("fastapi").Depends(__import__("cyclone.auth.deps", fromlist=["get_current_user"]).get_current_user)): sid = request.cookies.get(COOKIE_NAME) if sid: with SessionLocal() as db: sessions.delete(db, sid) _clear_cookie(response) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/me") def me(user=__import__("fastapi").Depends(__import__("cyclone.auth.deps", fromlist=["get_current_user"]).get_current_user)): return user ``` Replace the awkward `__import__` calls in `/logout` and `/me` with a clean import at the top: ```python from fastapi import Depends @router.post("/logout") def logout( request: Request, response: Response, _user: dict = Depends(get_current_user), ): sid = request.cookies.get(COOKIE_NAME) if sid: with SessionLocal() as db: sessions.delete(db, sid) _clear_cookie(response) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/me") def me(user: dict = Depends(get_current_user)): return user ``` - [ ] **Step 4: Add error handler for `{"error": ..., "detail": ...}` shape** In `backend/src/cyclone/api.py` (or wherever the FastAPI app is constructed), add: ```python from fastapi import FastAPI, Request from fastapi.responses import JSONResponse def _error_shape(detail: str) -> dict: # Map known detail strings to short error codes; otherwise echo the detail. return {"error": detail, "detail": detail} @app.exception_handler(HTTPException) async def _http_exc_handler(request: Request, exc: HTTPException): code = exc.detail if isinstance(exc.detail, str) else "error" return JSONResponse( status_code=exc.status_code, content={"error": code, "detail": str(exc.detail)}, headers=exc.headers, ) ``` - [ ] **Step 5: Register the auth router** In `backend/src/cyclone/api.py`: ```python from cyclone.auth.routes import router as auth_router app.include_router(auth_router) ``` - [ ] **Step 6: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_routes.py -v 2>&1 | tail -15 ``` Expected: all 7 tests pass. - [ ] **Step 7: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/routes.py backend/tests/test_auth_routes.py backend/src/cyclone/api.py && git commit -m "feat(auth): login/logout/me endpoints + error shape" ``` --- ### Task 10: TDD — admin.py (user management) **Files:** - Create: `backend/src/cyclone/auth/admin.py` - Create: `backend/tests/test_auth_admin.py` - [ ] **Step 1: Write failing tests** `backend/tests/test_auth_admin.py`: ```python """Admin-only user management endpoints.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import delete from cyclone.api import app from cyclone.auth import users from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() @pytest.fixture def admin_client(): client = TestClient(app) with SessionLocal() as db: users.create(db, username="root", password="rootpassword1", role="admin") login = client.post( "/api/auth/login", json={"username": "root", "password": "rootpassword1"}, ) assert login.status_code == 200 return client @pytest.fixture def user_client(): client = TestClient(app) with SessionLocal() as db: users.create(db, username="plain", password="plainpassword1", role="user") login = client.post( "/api/auth/login", json={"username": "plain", "password": "plainpassword1"}, ) return client def test_list_users_as_admin(admin_client): with SessionLocal() as db: users.create(db, username="alice", password="hunter2hunter2", role="user") resp = admin_client.get("/api/admin/users") assert resp.status_code == 200 body = resp.json() usernames = {u["username"] for u in body} assert {"root", "alice"} <= usernames def test_list_users_as_nonadmin_returns_403(user_client): resp = user_client.get("/api/admin/users") assert resp.status_code == 403 def test_create_user_as_admin(admin_client): resp = admin_client.post( "/api/admin/users", json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"}, ) assert resp.status_code == 201 assert resp.json()["username"] == "newbie" assert resp.json()["role"] == "viewer" def test_create_user_rejects_invalid_role(admin_client): resp = admin_client.post( "/api/admin/users", json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"}, ) assert resp.status_code == 422 def test_patch_user_role_and_password(admin_client): with SessionLocal() as db: u = users.create(db, username="subject", password="hunter2hunter2", role="viewer") resp = admin_client.patch( f"/api/admin/users/{u.id}", json={"role": "user", "password": "newpassword1"}, ) assert resp.status_code == 200 assert resp.json()["role"] == "user" # Old password no longer works. client = TestClient(app) bad = client.post( "/api/auth/login", json={"username": "subject", "password": "hunter2hunter2"}, ) assert bad.status_code == 401 def test_admin_cannot_demote_self(admin_client): me = admin_client.get("/api/auth/me").json() resp = admin_client.patch( f"/api/admin/users/{me['id']}", json={"role": "viewer"}, ) assert resp.status_code == 409 def test_admin_can_disable_user(admin_client): with SessionLocal() as db: u = users.create(db, username="victim", password="hunter2hunter2", role="user") resp = admin_client.patch( f"/api/admin/users/{u.id}", json={"disabled": True}, ) assert resp.status_code == 200 assert resp.json()["disabledAt"] is not None ``` - [ ] **Step 2: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_admin.py -v 2>&1 | tail -10 ``` Expected: 404 / ModuleNotFoundError. - [ ] **Step 3: Implement admin.py** `backend/src/cyclone/auth/admin.py`: ```python """Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request, status from cyclone.auth import users from cyclone.auth.deps import get_current_user from cyclone.auth.permissions import Role from cyclone.db import SessionLocal router = APIRouter(prefix="/api/admin/users", tags=["admin"]) def _require_admin(user: dict = Depends(get_current_user)) -> dict: if user.get("role") != Role.ADMIN.value: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="forbidden", ) return user def _validate_role(role: str) -> None: valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value} if role not in valid: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"role must be one of {sorted(valid)}", ) @router.get("") def list_users(_admin=Depends(_require_admin)): with SessionLocal() as db: all_users = db.query(users.User if hasattr(users, "User") else __import__("cyclone.db", fromlist=["User"]).User).all() return [users.to_public(u) for u in all_users] @router.post("", status_code=status.HTTP_201_CREATED) def create_user(body: dict, _admin=Depends(_require_admin)): username = (body.get("username") or "").strip() password = body.get("password") or "" role = body.get("role") or "" if not username or len(username) < 3: raise HTTPException(status_code=422, detail="username must be at least 3 chars") if len(password) < 12: raise HTTPException(status_code=422, detail="password must be at least 12 chars") _validate_role(role) with SessionLocal() as db: if users.get_by_username(db, username) is not None: raise HTTPException(status_code=409, detail="username already exists") u = users.create(db, username=username, password=password, role=role) return users.to_public(u) @router.patch("/{user_id}") def patch_user(user_id: int, body: dict, request: Request, admin=Depends(_require_admin)): me = admin if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="cannot_demote_self", ) with SessionLocal() as db: if body.get("role") is not None: _validate_role(body["role"]) users.update_role(db, user_id, body["role"]) if body.get("password") is not None: if len(body["password"]) < 12: raise HTTPException(status_code=422, detail="password must be at least 12 chars") users.update_password(db, user_id, body["password"]) if body.get("disabled") is True: users.disable(db, user_id) u = users.get(db, user_id) if u is None: raise HTTPException(status_code=404, detail="user not found") return users.to_public(u) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_user(user_id: int, admin=Depends(_require_admin)): me = admin if me.get("id") == user_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="cannot_delete_self", ) with SessionLocal() as db: u = users.get(db, user_id) if u is None: raise HTTPException(status_code=404, detail="user not found") # For v1, just disable instead of hard-delete. users.disable(db, user_id) return None ``` - [ ] **Step 4: Register the admin router** In `backend/src/cyclone/api.py`: ```python from cyclone.auth.admin import router as admin_users_router app.include_router(admin_users_router) ``` - [ ] **Step 5: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_admin.py -v 2>&1 | tail -15 ``` Expected: all 7 tests pass. - [ ] **Step 6: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/admin.py backend/tests/test_auth_admin.py backend/src/cyclone/api.py && git commit -m "feat(auth): admin user management endpoints" ``` --- ### Task 11: TDD — permissions matrix on existing endpoints **Files:** - Modify: `backend/src/cyclone/api.py` — gate every existing endpoint - Create: `backend/tests/test_existing_endpoints_require_auth.py` - [ ] **Step 1: Write tests** `backend/tests/test_existing_endpoints_require_auth.py`: ```python """Spot-check that existing endpoints now require auth.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import delete from cyclone.api import app from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() @pytest.fixture def client(): return TestClient(app) # Adjust paths to match actual project endpoints. @pytest.mark.parametrize("method,path", [ ("GET", "/api/claims"), ("GET", "/api/remittances"), ("GET", "/api/providers"), ("GET", "/api/batches"), ("GET", "/api/dashboard/summary"), ("GET", "/api/activity"), ]) def test_existing_get_endpoints_require_auth(client, method, path): resp = client.request(method, path) assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}" def test_healthz_is_public(client): resp = client.get("/api/healthz") # 200 if healthy, but never 401. assert resp.status_code != 401 ``` Adjust the endpoint paths to match what the existing project actually exposes — peek at `cyclone/api.py` for the actual list. - [ ] **Step 2: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_existing_endpoints_require_auth.py -v 2>&1 | tail -15 ``` Expected: most tests FAIL (200 instead of 401) because the endpoints aren't gated yet. - [ ] **Step 3: Add the dependencies to existing endpoints** For each existing endpoint in `backend/src/cyclone/api.py`: 1. Add `user: dict = Depends(get_current_user)` (or `require_role(...)`) to the signature. 2. Add `Depends` to the imports. Example transformation: ```python # Before: @app.get("/api/claims") def list_claims(...): ... # After: @app.get("/api/claims", dependencies=[Depends(require_role())]) # matrix lookup def list_claims(...): ... ``` Or for endpoints that should always succeed for any authed user, use `Depends(get_current_user)` directly. - [ ] **Step 4: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_existing_endpoints_require_auth.py -v 2>&1 | tail -15 ``` - [ ] **Step 5: Run the full backend test suite** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest 2>&1 | tail -10 ``` Expected: pre-existing 723 tests pass, new tests pass. Fix any regressions. - [ ] **Step 6: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/api.py backend/tests/test_existing_endpoints_require_auth.py && git commit -m "feat(auth): gate existing endpoints behind get_current_user" ``` --- ### Task 12: TDD — bootstrap (first admin) **Files:** - Modify: `backend/src/cyclone/__main__.py` - Create: `backend/src/cyclone/auth/bootstrap.py` - Create: `backend/tests/test_auth_bootstrap.py` - [ ] **Step 1: Write failing tests** `backend/tests/test_auth_bootstrap.py`: ```python """Bootstrap admin user on backend startup.""" from __future__ import annotations import pytest from sqlalchemy import delete, select from cyclone.auth import bootstrap, users from cyclone.auth.permissions import Role from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(monkeypatch): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch): monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin") monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1") bootstrap.run() with SessionLocal() as db: u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one() assert u.role == Role.ADMIN.value def test_bootstrap_noop_when_users_exist(monkeypatch): monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored") monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1") with SessionLocal() as db: users.create(db, username="existing", password="hunter2hunter2", role="admin") bootstrap.run() with SessionLocal() as db: all_users = db.execute(select(User)).scalars().all() usernames = {u.username for u in all_users} assert usernames == {"existing"} def test_bootstrap_refuses_to_run_without_env(monkeypatch): monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False) monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False) with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"): bootstrap.run() def test_bootstrap_rejects_short_password(monkeypatch): monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak") monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short") with pytest.raises(RuntimeError, match="12 characters"): bootstrap.run() ``` - [ ] **Step 2: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_bootstrap.py -v 2>&1 | tail -10 ``` Expected: ModuleNotFoundError. - [ ] **Step 3: Implement bootstrap.py** `backend/src/cyclone/auth/bootstrap.py`: ```python """First-admin bootstrap: create the initial admin from env vars if no users exist.""" from __future__ import annotations import os from sqlalchemy import select from cyclone.auth import users from cyclone.auth.permissions import Role from cyclone.db import SessionLocal, User def run() -> None: if os.environ.get("CYCLONE_AUTH_DISABLED") == "1": # Dev escape hatch — skip bootstrap entirely. from cyclone.auth import deps deps.AUTH_DISABLED = True return username = os.environ.get("CYCLONE_ADMIN_USERNAME") password = os.environ.get("CYCLONE_ADMIN_PASSWORD") with SessionLocal() as db: existing = db.execute(select(User)).scalars().first() if existing is not None: return # users exist — nothing to bootstrap if not username or not password: raise RuntimeError( "Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and " "CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run " "`python -m cyclone users create --role admin`." ) if len(password) < 12: raise RuntimeError( "CYCLONE_ADMIN_PASSWORD must be at least 12 characters." ) users.create(db, username=username, password=password, role=Role.ADMIN.value) print(f"[cyclone] bootstrap admin user '{username}' created") ``` - [ ] **Step 4: Wire bootstrap into __main__.py** In `backend/src/cyclone/__main__.py`, find the entry point (the `serve` command or similar). Before `uvicorn.run(...)`, call `bootstrap.run()`: ```python from cyclone.auth import bootstrap def main(): bootstrap.run() # ... existing serve / cli dispatch ``` - [ ] **Step 5: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_bootstrap.py -v 2>&1 | tail -10 ``` Expected: all 4 tests pass. - [ ] **Step 6: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/bootstrap.py backend/src/cyclone/__main__.py backend/tests/test_auth_bootstrap.py && git commit -m "feat(auth): bootstrap first admin from env vars" ``` --- ### Task 13: TDD — rate limit + login fails are throttled **Files:** - Create: `backend/tests/test_auth_login_rate_limit.py` - [ ] **Step 1: Write tests** `backend/tests/test_auth_login_rate_limit.py`: ```python """Login rate limit: 5 fails / 5 min per username.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import delete from cyclone.api import app from cyclone.auth import rate_limit from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() rate_limit.reset("victim") yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() rate_limit.reset("victim") def test_5_fails_then_429(): with SessionLocal() as db: from cyclone.auth import users users.create(db, username="victim", password="hunter2hunter2", role="user") client = TestClient(app) for _ in range(5): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 # 6th should be 429. r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 429 assert "Retry-After" in r.headers def test_successful_login_resets_counter(): with SessionLocal() as db: from cyclone.auth import users users.create(db, username="victim", password="hunter2hunter2", role="user") client = TestClient(app) for _ in range(4): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 # Successful login. r = client.post( "/api/auth/login", json={"username": "victim", "password": "hunter2hunter2"}, ) assert r.status_code == 200 # Counter reset — 5 more fails allowed. for _ in range(5): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 ``` - [ ] **Step 2: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_auth_login_rate_limit.py -v 2>&1 | tail -10 ``` Expected: both tests pass (rate limit was implemented in Task 7, this just exercises it). - [ ] **Step 3: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/tests/test_auth_login_rate_limit.py && git commit -m "test(auth): login rate limit" ``` --- ### Task 14: TDD — audit log records user_id **Files:** - Modify: `backend/src/cyclone/audit_log.py` — accept `user_id` parameter - Create: `backend/tests/test_audit_log_user_id.py` - [ ] **Step 1: Inspect audit_log.py** ```bash cd /home/tyler/dev/cyclone/backend && grep -n "def " src/cyclone/audit_log.py | head -10 ``` - [ ] **Step 2: Write failing tests** `backend/tests/test_audit_log_user_id.py`: ```python """Audit log entries record the acting user_id.""" from __future__ import annotations import pytest from sqlalchemy import delete, select from cyclone.audit_log import log_event # adjust to actual function name from cyclone.db import audit_log_table # adjust from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: # Truncate audit_log. db.execute(__import__("cyclone.db", fromlist=["audit_log_table"]).audit_log_table.delete()) db.execute(delete(User)) db.commit() yield def test_log_event_accepts_user_id_kwarg(): log_event(kind="test", payload={"foo": "bar"}, user_id=42) with SessionLocal() as db: rows = db.execute(select(__import__("cyclone.db", fromlist=["audit_log_table"]).audit_log_table)).all() assert rows[-1].user_id == 42 def test_log_event_omits_user_id_is_null(): log_event(kind="test", payload={"foo": "bar"}) with SessionLocal() as db: rows = db.execute(select(__import__("cyclone.db", fromlist=["audit_log_table"]).audit_log_table)).all() assert rows[-1].user_id is None ``` Adjust to match the actual `audit_log` API. The test verifies both forms work. - [ ] **Step 3: Run tests — confirm failure** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_audit_log_user_id.py -v 2>&1 | tail -10 ``` - [ ] **Step 4: Update audit_log.py** In `backend/src/cyclone/audit_log.py`, find `log_event` (or whatever the public function is). Add `user_id: int | None = None` parameter and pass it through to the SQL insert: ```python def log_event(*, kind: str, payload: dict, user_id: int | None = None) -> None: # ... insert row with user_id=user_id ``` - [ ] **Step 5: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_audit_log_user_id.py -v 2>&1 | tail -10 ``` - [ ] **Step 6: Wire user_id into existing call sites** In `backend/src/cyclone/api.py`, every place that calls `audit_log.log_event`, pass `user_id=user.get("id")`: ```python audit_log.log_event(kind="parse_success", payload={...}, user_id=user.get("id")) ``` - [ ] **Step 7: Run full backend suite** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest 2>&1 | tail -10 ``` - [ ] **Step 8: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/audit_log.py backend/src/cyclone/api.py backend/tests/test_audit_log_user_id.py && git commit -m "feat(audit): record user_id on log events" ``` --- ### Task 15: TDD — CLI users subcommand **Files:** - Create: `backend/src/cyclone/auth/cli.py` - Modify: `backend/src/cyclone/__main__.py` - Create: `backend/tests/test_cli_users.py` - [ ] **Step 1: Write failing tests** `backend/tests/test_cli_users.py`: ```python """CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.""" from __future__ import annotations import pytest from sqlalchemy import delete, select from cyclone.auth import users from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(): with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() yield with SessionLocal() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() def test_create_user_via_cli(monkeypatch, capsys): from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "create", "cli-user", "--role", "viewer", "--password", "clipassword1"]) cli_main() with SessionLocal() as db: u = users.get_by_username(db, "cli-user") assert u is not None assert u.role == "viewer" def test_list_users_via_cli(monkeypatch, capsys): with SessionLocal() as db: users.create(db, username="listed", password="hunter2hunter2", role="user") from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "list"]) cli_main() out = capsys.readouterr().out assert "listed" in out def test_disable_user_via_cli(monkeypatch): with SessionLocal() as db: users.create(db, username="todie", password="hunter2hunter2", role="user") from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "disable", "todie"]) cli_main() with SessionLocal() as db: u = users.get_by_username(db, "todie") assert u.disabled_at is not None def test_reset_password_via_cli(monkeypatch): with SessionLocal() as db: users.create(db, username="pwchange", password="oldpassword1", role="user") from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "reset-password", "pwchange", "--password", "newpassword1"]) cli_main() with SessionLocal() as db: u = users.get_by_username(db, "pwchange") assert users.verify_password("newpassword1", u.password_hash) def test_set_role_via_cli(monkeypatch): with SessionLocal() as db: users.create(db, username="promote", password="hunter2hunter2", role="viewer") from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "set-role", "promote", "--role", "admin"]) cli_main() with SessionLocal() as db: u = users.get_by_username(db, "promote") assert u.role == "admin" def test_create_rejects_short_password(monkeypatch): from cyclone.auth.cli import main as cli_main monkeypatch.setattr("sys.argv", ["cyclone", "users", "create", "weak", "--role", "viewer", "--password", "short"]) with pytest.raises(SystemExit): cli_main() ``` - [ ] **Step 2: Implement cli.py** `backend/src/cyclone/auth/cli.py`: ```python """CLI subcommand: `python -m cyclone users ...`.""" from __future__ import annotations import argparse import getpass import sys from cyclone.auth import users from cyclone.auth.permissions import Role from cyclone.db import SessionLocal def _prompt_password(label: str) -> str: pw = getpass.getpass(f"{label}: ") if not pw: print("Password required.", file=sys.stderr) sys.exit(2) return pw def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="cyclone users") sub = parser.add_subparsers(dest="cmd", required=True) create_p = sub.add_parser("create") create_p.add_argument("username") create_p.add_argument("--role", required=True, choices=[Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]) create_p.add_argument("--password") sub.add_parser("list") disable_p = sub.add_parser("disable") disable_p.add_argument("username") reset_p = sub.add_parser("reset-password") reset_p.add_argument("username") reset_p.add_argument("--password") setrole_p = sub.add_parser("set-role") setrole_p.add_argument("username") setrole_p.add_argument("--role", required=True, choices=[Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]) args = parser.parse_args(argv) with SessionLocal() as db: if args.cmd == "create": pw = args.password or _prompt_password("Password") if len(pw) < 12: print("Password must be at least 12 characters.", file=sys.stderr) return 2 if users.get_by_username(db, args.username) is not None: print(f"User '{args.username}' already exists.", file=sys.stderr) return 1 u = users.create(db, username=args.username, password=pw, role=args.role) print(f"Created user '{u.username}' with role '{u.role}'.") return 0 if args.cmd == "list": for u in db.query(__import__("cyclone.db", fromlist=["User"]).User).all(): print(f"{u.id}\t{u.username}\t{u.role}\t{'disabled' if u.disabled_at else 'active'}") return 0 if args.cmd == "disable": u = users.get_by_username(db, args.username) if u is None: print(f"No such user: {args.username}", file=sys.stderr) return 1 users.disable(db, u.id) print(f"Disabled '{args.username}'.") return 0 if args.cmd == "reset-password": pw = args.password or _prompt_password("New password") if len(pw) < 12: print("Password must be at least 12 characters.", file=sys.stderr) return 2 u = users.get_by_username(db, args.username) if u is None: print(f"No such user: {args.username}", file=sys.stderr) return 1 users.update_password(db, u.id, pw) print(f"Password reset for '{args.username}'.") return 0 if args.cmd == "set-role": u = users.get_by_username(db, args.username) if u is None: print(f"No such user: {args.username}", file=sys.stderr) return 1 users.update_role(db, u.id, args.role) print(f"Role for '{args.username}' set to '{args.role}'.") return 0 return 1 ``` - [ ] **Step 3: Wire into __main__.py** In `backend/src/cyclone/__main__.py`, before the `serve` command dispatches, check `sys.argv`: ```python def main(): if len(sys.argv) >= 2 and sys.argv[1] == "users": from cyclone.auth.cli import main as users_cli sys.exit(users_cli(sys.argv[2:])) # ... existing dispatch ``` - [ ] **Step 4: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest tests/test_cli_users.py -v 2>&1 | tail -10 ``` Expected: all 6 tests pass. - [ ] **Step 5: Commit** ```bash cd /home/tyler/dev/cyclone && git add backend/src/cyclone/auth/cli.py backend/src/cyclone/__main__.py backend/tests/test_cli_users.py && git commit -m "feat(auth): CLI users subcommand" ``` --- ## Phase 2: Frontend Foundations ### Task 16: Add User type **Files:** - Modify: `src/types/index.ts` - [ ] **Step 1: Append User type** At the end of `src/types/index.ts`: ```typescript export interface User { id: number; username: string; role: "admin" | "user" | "viewer"; createdAt: string | null; disabledAt?: string | null; } ``` - [ ] **Step 2: Commit** ```bash cd /home/tyler/dev/cyclone && git add src/types/index.ts && git commit -m "feat(types): add User type" ``` --- ### Task 17: TDD — auth/api.ts (fetch wrapper for 401) **Files:** - Create: `src/auth/api.ts` - Create: `src/auth/api.test.ts` - [ ] **Step 1: Write failing tests** `src/auth/api.test.ts`: ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; describe("auth/api fetch wrapper", () => { let originalFetch: typeof globalThis.fetch; let originalLocation: Location; beforeEach(() => { originalFetch = globalThis.fetch; originalLocation = window.location; delete (window as any).__navCalls; (window as any).__navCalls = []; }); afterEach(() => { globalThis.fetch = originalFetch; window.location = originalLocation as any; }); it("redirects to /login on 401 response", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, statusText: "Unauthorized", headers: new Headers(), json: async () => ({ error: "session_expired" }), } as Response); // Stub window.location.href setter. let href = originalLocation.href; Object.defineProperty(window, "location", { configurable: true, get: () => ({ ...originalLocation, get href() { return href; }, set href(v: string) { (window as any).__navCalls.push(v); href = v; }, }), }); const { authedFetch } = await import("./api"); await expect(authedFetch("/api/anything")).rejects.toThrow(); expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login")); }); it("does NOT redirect on 403", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden", headers: new Headers(), json: async () => ({ error: "forbidden" }), } as Response); Object.defineProperty(window, "location", { configurable: true, get: () => ({ ...originalLocation, set href(_: string) { throw new Error("should not nav"); } }), }); const { authedFetch } = await import("./api"); await expect(authedFetch("/api/anything")).rejects.toThrow(); }); it("returns parsed JSON on 200", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: new Headers(), json: async () => ({ hello: "world" }), } as Response); const { authedFetch } = await import("./api"); const data = await authedFetch("/api/anything"); expect(data).toEqual({ hello: "world" }); }); }); ``` - [ ] **Step 2: Implement api.ts** `src/auth/api.ts`: ```typescript const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; function joinUrl(path: string): string { if (BASE_URL) return `${BASE_URL}${path}`; return path; } export class ApiError extends Error { status: number; code: string; constructor(status: number, code: string, detail?: string) { super(detail ?? code); this.status = status; this.code = code; } } function redirectToLogin() { const next = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?next=${next}`; } export async function authedFetch( path: string, init?: RequestInit ): Promise { const res = await fetch(joinUrl(path), { credentials: "include", headers: { Accept: "application/json", ...(init?.headers ?? {}) }, ...init, }); if (res.status === 401 && !path.startsWith("/api/auth/")) { redirectToLogin(); throw new ApiError(401, "session_expired"); } if (!res.ok) { let body: any = null; try { body = await res.json(); } catch { /* no body */ } const code = body?.error ?? "error"; const detail = body?.detail ?? res.statusText; throw new ApiError(res.status, code, detail); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } // Auth-specific endpoints. export const authApi = { async login(username: string, password: string) { const res = await fetch(joinUrl("/api/auth/login"), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new ApiError(res.status, body.error ?? "error", body.detail ?? res.statusText); } return res.json(); }, async me() { return authedFetch("/api/auth/me"); }, async logout() { await fetch(joinUrl("/api/auth/logout"), { method: "POST", credentials: "include", }).catch(() => undefined); }, }; ``` - [ ] **Step 3: Run tests — confirm pass** ```bash cd /home/tyler/dev/cyclone && npx vitest run src/auth/api.test.ts 2>&1 | tail -15 ``` - [ ] **Step 4: Commit** ```bash git add src/auth/api.ts src/auth/api.test.ts && git commit -m "feat(auth): fetch wrapper with 401 redirect + authApi" ``` --- ### Task 18: TDD — AuthProvider + useAuth **Files:** - Create: `src/auth/AuthProvider.tsx` - Create: `src/auth/useAuth.ts` - Create: `src/auth/AuthProvider.test.tsx` - [ ] **Step 1: Write failing tests** `src/auth/AuthProvider.test.tsx`: ```tsx import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "./AuthProvider"; vi.mock("./api", () => ({ authApi: { me: vi.fn(), login: vi.fn(), logout: vi.fn(), }, })); import { authApi } from "./api"; const wrapper = ({ children }: any) => {children}; describe("AuthProvider", () => { beforeEach(() => { vi.resetAllMocks(); }); it("loads user on mount", async () => { (authApi.me as any).mockResolvedValue({ id: 1, username: "alice", role: "user" }); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.status).toBe("authenticated")); expect(result.current.user?.username).toBe("alice"); }); it("status is unauthenticated when /me fails", async () => { (authApi.me as any).mockRejectedValue(new Error("401")); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.status).toBe("unauthenticated")); expect(result.current.user).toBeNull(); }); it("login() updates state on success", async () => { (authApi.me as any).mockRejectedValue(new Error("401")); (authApi.login as any).mockResolvedValue({ id: 1, username: "alice", role: "admin" }); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.status).toBe("unauthenticated")); await act(async () => { await result.current.login("alice", "pw"); }); expect(result.current.user?.username).toBe("alice"); expect(result.current.status).toBe("authenticated"); }); it("logout() clears state", async () => { (authApi.me as any).mockResolvedValue({ id: 1, username: "alice", role: "user" }); (authApi.logout as any).mockResolvedValue(undefined); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.status).toBe("authenticated")); await act(async () => { await result.current.logout(); }); expect(result.current.user).toBeNull(); expect(result.current.status).toBe("unauthenticated"); }); }); ``` - [ ] **Step 2: Implement useAuth.ts + AuthProvider.tsx** `src/auth/useAuth.ts`: ```typescript import { createContext, useContext } from "react"; import type { User } from "@/types"; export type AuthStatus = "loading" | "authenticated" | "unauthenticated"; export interface AuthContextValue { status: AuthStatus; user: User | null; login: (username: string, password: string) => Promise; logout: () => Promise; refresh: () => Promise; } export const AuthContext = createContext(null); export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth must be used inside "); return ctx; } ``` `src/auth/AuthProvider.tsx`: ```tsx import { useEffect, useState, useCallback, type ReactNode } from "react"; import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth"; import { authApi } from "./api"; import type { User } from "@/types"; export function AuthProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState("loading"); const [user, setUser] = useState(null); const refresh = useCallback(async () => { setStatus("loading"); try { const me = await authApi.me(); setUser(me as User); setStatus("authenticated"); } catch { setUser(null); setStatus("unauthenticated"); } }, []); useEffect(() => { void refresh(); }, [refresh]); const login = useCallback(async (username: string, password: string) => { const u = await authApi.login(username, password); setUser(u as User); setStatus("authenticated"); }, []); const logout = useCallback(async () => { await authApi.logout().catch(() => undefined); setUser(null); setStatus("unauthenticated"); }, []); const value: AuthContextValue = { status, user, login, logout, refresh }; return {children}; } ``` - [ ] **Step 3: Run tests** ```bash cd /home/tyler/dev/cyclone && npx vitest run src/auth/AuthProvider.test.tsx 2>&1 | tail -15 ``` Expected: all 4 tests pass. - [ ] **Step 4: Commit** ```bash git add src/auth/AuthProvider.tsx src/auth/useAuth.ts src/auth/AuthProvider.test.tsx && git commit -m "feat(auth): AuthProvider context + useAuth hook" ``` --- ### Task 19: TDD — RoleGate **Files:** - Create: `src/auth/RoleGate.tsx` - Create: `src/auth/RoleGate.test.tsx` - [ ] **Step 1: Write failing tests** `src/auth/RoleGate.test.tsx`: ```tsx import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { RoleGate } from "./RoleGate"; vi.mock("./useAuth", () => ({ useAuth: vi.fn(), })); import { useAuth } from "./useAuth"; function mockUser(role: "admin" | "user" | "viewer" | null) { (useAuth as any).mockReturnValue({ user: role ? { id: 1, username: "x", role } : null, status: role ? "authenticated" : "unauthenticated", }); } describe("RoleGate", () => { it("renders children when role is allowed", () => { mockUser("admin"); render(); expect(screen.getByRole("button", { name: "Do thing" })).toBeInTheDocument(); }); it("renders disabled with tooltip when role is NOT allowed", () => { mockUser("viewer"); render(); const btn = screen.getByRole("button", { name: "Do thing" }); expect(btn).toBeDisabled(); }); it("renders fallback when provided and role not allowed", () => { mockUser("viewer"); render( NO}> ); expect(screen.getByText("NO")).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Implement RoleGate.tsx** `src/auth/RoleGate.tsx`: ```tsx import { type ReactNode } from "react"; import { Tooltip } from "@/components/ui/tooltip"; // adjust path to project's Tooltip import { useAuth } from "./useAuth"; interface Props { allow: ("admin" | "user" | "viewer")[]; children: ReactNode; fallback?: ReactNode; } 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 ( {/* wrap children in a disabled clone of the first interactive child if possible */} {children} ); } // Simple helper: clone the first child and add `disabled` if it's an element. import { Children, cloneElement, isValidElement } from "react"; function DisabledClone({ children }: { children: ReactNode }) { const child = Children.only(children); if (isValidElement(child)) { return cloneElement(child as any, { disabled: true, "aria-disabled": true }); } return <>{children}; } ``` (If the project doesn't have a `Tooltip` component, use the project's existing tooltip or just render an inline `title` attribute.) - [ ] **Step 3: Run tests** ```bash npx vitest run src/auth/RoleGate.test.tsx 2>&1 | tail -15 ``` - [ ] **Step 4: Commit** ```bash git add src/auth/RoleGate.tsx src/auth/RoleGate.test.tsx && git commit -m "feat(auth): RoleGate component" ``` --- ### Task 20: TDD — Login page **Files:** - Create: `src/pages/Login.tsx` - Create: `src/pages/Login.test.tsx` - [ ] **Step 1: Write failing tests** `src/pages/Login.test.tsx`: ```tsx import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { Login } from "./Login"; vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() })); import { useAuth } from "@/auth/useAuth"; function setup() { const login = vi.fn(); (useAuth as any).mockReturnValue({ user: null, status: "unauthenticated", login, logout: vi.fn(), refresh: vi.fn(), }); return { login }; } describe("Login page", () => { beforeEach(() => vi.resetAllMocks()); it("submits username + password", async () => { const { login } = setup(); login.mockResolvedValue(undefined); render( } /> HOME} /> ); fireEvent.change(screen.getByLabelText(/username/i), { target: { value: "alice" } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "hunter2hunter2" } }); fireEvent.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => expect(login).toHaveBeenCalledWith("alice", "hunter2hunter2")); }); it("shows error on failed login", async () => { const { login } = setup(); login.mockRejectedValue(Object.assign(new Error("401"), { code: "invalid_credentials" })); render( } /> ); fireEvent.change(screen.getByLabelText(/username/i), { target: { value: "alice" } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "wrong" } }); fireEvent.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => expect(screen.getByText(/username or password is incorrect/i)).toBeInTheDocument() ); }); it("redirects to `next` query param on success", async () => { const { login } = setup(); login.mockResolvedValue(undefined); render( } /> CLAIMS} /> ); fireEvent.change(screen.getByLabelText(/username/i), { target: { value: "alice" } }); fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "hunter2hunter2" } }); fireEvent.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => expect(screen.getByText("CLAIMS")).toBeInTheDocument()); }); }); ``` - [ ] **Step 2: Implement Login.tsx** `src/pages/Login.tsx`: ```tsx import { useState, type FormEvent } from "react"; import { useNavigate, useSearchParams, Navigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; export function Login() { const { user, login } = useAuth(); const [params] = useSearchParams(); const next = params.get("next") ?? "/"; const navigate = useNavigate(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); if (user) return ; async function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); setSubmitting(true); try { await login(username, password); navigate(next, { replace: true }); } catch (err: any) { const code = err?.code ?? "error"; if (code === "invalid_credentials") setError("Username or password is incorrect."); else if (code === "account_disabled") setError("Account is disabled. Contact your administrator."); else if (code === "rate_limited") setError("Too many attempts. Try again shortly."); else setError("Sign in failed. Try again."); } finally { setSubmitting(false); } } return (

Cyclone

{error && (
{error}
)}
); } ``` - [ ] **Step 3: Run tests** ```bash npx vitest run src/pages/Login.test.tsx 2>&1 | tail -15 ``` - [ ] **Step 4: Commit** ```bash git add src/pages/Login.tsx src/pages/Login.test.tsx && git commit -m "feat(auth): /login page" ``` --- ### Task 21: RequireAuth route guard **Files:** - Create: `src/auth/RequireAuth.tsx` - [ ] **Step 1: Implement RequireAuth** `src/auth/RequireAuth.tsx`: ```tsx import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "./useAuth"; import type { ReactNode } from "react"; export function RequireAuth({ children }: { children: ReactNode }) { const { status } = useAuth(); const location = useLocation(); if (status === "loading") { return (
Loading…
); } if (status === "unauthenticated") { return ; } return <>{children}; } ``` - [ ] **Step 2: Commit** ```bash git add src/auth/RequireAuth.tsx && git commit -m "feat(auth): RequireAuth route guard" ``` --- ### Task 22: Wire AuthProvider + RequireAuth into main.tsx **Files:** - Modify: `src/main.tsx` - [ ] **Step 1: Read current main.tsx** ```bash cd /home/tyler/dev/cyclone && cat src/main.tsx ``` - [ ] **Step 2: Wrap the app** Update `src/main.tsx` to: ```tsx import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AuthProvider } from "@/auth/AuthProvider"; import { RequireAuth } from "@/auth/RequireAuth"; import { Login } from "@/pages/Login"; import { Layout } from "@/components/Layout"; // adjust to actual layout import { Dashboard } from "@/pages/Dashboard"; // ... other page imports const queryClient = new QueryClient(); export function App() { return ( } /> }> } /> } /> } /> {/* ... other protected routes */} ); } ``` Adjust to match the existing route definitions — preserve every existing route, just wrap them in ``. - [ ] **Step 3: Update src/lib/api.ts to use authedFetch** Find every `fetch(...)` in `src/lib/api.ts` and route it through `authedFetch` from `@/auth/api`. This way all existing hooks automatically get the 401-redirect behavior. ```typescript import { authedFetch } from "@/auth/api"; async function listClaims(params: ListClaimsParams): Promise> { return authedFetch(`/api/claims?...`); } // etc. ``` - [ ] **Step 4: Commit** ```bash git add src/main.tsx src/lib/api.ts && git commit -m "feat(auth): wire AuthProvider + RequireAuth into app shell" ``` --- ### Task 23: Sidebar — show real current user **Files:** - Modify: `src/components/Sidebar.tsx` - [ ] **Step 1: Find the hardcoded user block** ```bash cd /home/tyler/dev/cyclone && grep -n "Jordan\|Administrator" src/components/Sidebar.tsx ``` - [ ] **Step 2: Replace with useAuth()** ```tsx import { useAuth } from "@/auth/useAuth"; function CurrentUser() { const { user } = useAuth(); if (!user) return null; return (
{user.username}
{user.role}
); } ``` Replace the hardcoded "Jordan K. / Administrator" block with ``. Add a logout button next to it. - [ ] **Step 3: Commit** ```bash git add src/components/Sidebar.tsx && git commit -m "feat(auth): sidebar shows current user + logout button" ``` --- ### Task 24: Wrap write affordances in RoleGate **Files:** - Modify: `src/pages/Upload.tsx` - Modify: `src/pages/Inbox.tsx` - Modify: `src/pages/Reconciliation.tsx` - Modify: `src/pages/Acks.tsx` - [ ] **Step 1: Wrap Upload dropzone** In `src/pages/Upload.tsx`: ```tsx import { RoleGate } from "@/auth/RoleGate"; // Wrap the dropzone + Parse button: ``` - [ ] **Step 2: Wrap Inbox resubmit + acknowledge buttons** In `src/pages/Inbox.tsx`, find the "Resubmit" and "Acknowledge" buttons. Wrap each in: ```tsx ``` - [ ] **Step 3: Wrap Reconciliation match buttons** Same pattern in `src/pages/Reconciliation.tsx`. - [ ] **Step 4: Wrap Acks acknowledge button** Same pattern in `src/pages/Acks.tsx`. - [ ] **Step 5: Commit** ```bash git add src/pages/Upload.tsx src/pages/Inbox.tsx src/pages/Reconciliation.tsx src/pages/Acks.tsx && git commit -m "feat(auth): disable write affordances for viewer role" ``` --- ## Phase 3: Infrastructure + Docs ### Task 25: docker-compose + .env.example **Files:** - Modify: `docker-compose.yml` - Create: `.env.example` - [ ] **Step 1: Add env vars to backend service** In `docker-compose.yml`, find the backend service's `environment:` block (or add one): ```yaml backend: environment: CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me} CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me} ``` - [ ] **Step 2: Create .env.example** ``` # Required on first boot. Cyclone refuses to start without these unless # at least one user already exists (e.g. seeded via `python -m cyclone users create`). CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars # Optional. Set to 1 if you're behind an HTTPS reverse proxy and want # the session cookie to include the Secure flag. # CYCLONE_BEHIND_HTTPS=1 # Optional. Set to 1 to disable auth entirely (DEV ONLY). When set, # the backend auto-grants admin access without checking credentials. # CYCLONE_AUTH_DISABLED=0 ``` - [ ] **Step 3: Commit** ```bash git add docker-compose.yml .env.example && git commit -m "feat(docker): CYCLONE_ADMIN_* env vars for backend" ``` --- ### Task 26: README — Auth section **Files:** - Modify: `README.md` - [ ] **Step 1: Add Auth section** Find a good place in the README (after the "Quickstart" section, before "Roadmap"). Add: ```markdown ## Authentication Cyclone now ships with username/password authentication and three predefined roles. **Roles:** | Role | Can read | Can write (upload, parse, reconcile) | Can manage users | |---|---|---|---| | `viewer` | ✅ | ❌ | ❌ | | `user` | ✅ | ✅ | ❌ | | `admin` | ✅ | ✅ | ✅ | **Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the first admin automatically. On subsequent starts, these vars are ignored. **CLI.** Manage users from the command line: \`\`\` python -m cyclone users create alice --role user --password 'hunter2hunter2' python -m cyclone users list python -m cyclone users disable alice python -m cyclone users reset-password alice python -m cyclone users set-role alice --role admin \`\`\` **Login.** Browse to `http://localhost:8081/`, sign in on the `/login` page, and you'll be redirected to the dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding expiry. **Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely (the backend auto-grants admin). NEVER set this in production. See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full design. ``` - [ ] **Step 2: Commit** ```bash git add README.md && git commit -m "docs: auth section + env vars" ``` --- ### Task 27: End-to-end verification **Files:** - Create: `/tmp/verify_auth.py` - [ ] **Step 1: Spin up the stack** ```bash cd /home/tyler/dev/cyclone && docker compose build backend frontend && docker compose down -v && CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=adminpassword1 docker compose up -d ``` - [ ] **Step 2: Write the playwright script** `/tmp/verify_auth.py`: ```python import sys from playwright.sync_api import sync_playwright def main() -> int: with sync_playwright() as p: browser = p.chromium.launch(headless=True, executable_path="/usr/bin/google-chrome") page = browser.new_context().new_page() page.goto("http://localhost:8081/", wait_until="networkidle", timeout=15000) # Should redirect to /login. page.wait_for_url("**/login**", timeout=5000) # Sign in. page.fill('input[autocomplete="username"]', "admin") page.fill('input[autocomplete="current-password"]', "adminpassword1") page.click('button[type="submit"]') page.wait_for_url("**/dashboard", timeout=5000) # Dashboard renders. assert "Good" in page.locator("body").inner_text() # Sidebar shows "admin". body = page.locator("body").inner_text() assert "admin" in body.lower() print("[PASS] login flow") browser.close() return 0 if __name__ == "__main__": sys.exit(main()) ``` - [ ] **Step 3: Run it** ```bash python3 /tmp/verify_auth.py ``` Expected: `[PASS] login flow`. Fix any issues. - [ ] **Step 4: Commit (the verify script goes in /tmp, no commit needed)** --- ### Task 28: Full backend test suite - [ ] **Step 1: Run all tests** ```bash cd /home/tyler/dev/cyclone/backend && uv run pytest 2>&1 | tail -10 ``` Expected: all tests pass (pre-existing + new auth tests). Fix any regressions. - [ ] **Step 2: Run frontend tests** ```bash cd /home/tyler/dev/cyclone && npx vitest run 2>&1 | tail -10 ``` Expected: all tests pass. --- ### Task 29: Final cleanup - [ ] **Step 1: Typecheck** ```bash cd /home/tyler/dev/cyclone && npm run typecheck 2>&1 | tail -10 ``` Fix any TypeScript errors. - [ ] **Step 2: Lint** ```bash cd /home/tyler/dev/cyclone && npm run lint 2>&1 | tail -10 ``` Fix any lint errors. - [ ] **Step 3: Build** ```bash cd /home/tyler/dev/cyclone && npm run build 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 4: Final commit** ```bash git add -A && git commit -m "chore: post-auth cleanup (typecheck, lint, build)" ``` --- ## Self-Review Checklist (run after writing the plan) **Spec coverage:** | Spec section | Task(s) | |---|---| | §1 Overview / user model | All of Phase 1+2 | | §2.1 Authenticate every API request | Tasks 9, 11 | | §2.2 Three roles + matrix | Tasks 7, 11 | | §2.3 Server-side sessions in SQLite | Tasks 2, 3, 6, 8 | | §2.4 Bootstrap first admin | Task 12 | | §2.5 Frontend login + context | Tasks 17, 18, 20, 21, 22 | | §2.6 Disable write UI for viewer | Tasks 19, 24 | | §2.7 Audit log includes user_id | Task 14 | | §2.8 CLI command | Task 15 | | §6.2 Data model | Tasks 2, 3 | | §6.3 Permissions matrix | Task 7 | | §6.4 Dependencies | Task 8 | | §6.5 Endpoints | Tasks 9, 10 | | §6.6 Rate limit | Tasks 7, 13 | | §6.7 Bootstrap | Task 12 | | §6.8 CLI | Task 15 | | §7.1 AuthProvider | Tasks 17, 18 | | §7.3 Login page | Task 20 | | §7.4 API wrapper | Task 17 | | §7.5 RoleGate | Task 19 | | §7.6 Sidebar | Task 23 | | §7.7 Routes | Task 22 | | §8.1 nginx | Task 25 (minor X-Forwarded-Proto) | | §8.2 docker-compose | Task 25 | | §8.3 Cookie Secure flag | Task 9 (`_is_https`) | | §9.1 Backend tests | Tasks 5, 6, 9, 10, 11, 12, 13, 14, 15 | | §9.2 Frontend tests | Tasks 17, 18, 19, 20 | | §9.3 E2E | Task 27 | | §11 Rollout | All | **Gaps:** None — every spec section is covered by at least one task. **Placeholder scan:** Search the plan for "TBD", "TODO", "implement later" — none. Code blocks are complete enough to execute. **Type consistency:** `User.role` is `"admin" | "user" | "viewer"` everywhere (backend `Role` enum, frontend `User.role`, `RoleGate.allow`). `Session.id` is `str` in both DB and Python. `Cookie name` is `"cyclone_session"` consistently.