3355 lines
96 KiB
Markdown
3355 lines
96 KiB
Markdown
# 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 <username> --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<T = unknown>(
|
|
path: string,
|
|
init?: RequestInit
|
|
): Promise<T> {
|
|
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) => <AuthProvider>{children}</AuthProvider>;
|
|
|
|
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<void>;
|
|
logout: () => Promise<void>;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
|
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<AuthStatus>("loading");
|
|
const [user, setUser] = useState<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
```
|
|
|
|
- [ ] **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(<RoleGate allow={["admin", "user"]}><button>Do thing</button></RoleGate>);
|
|
expect(screen.getByRole("button", { name: "Do thing" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders disabled with tooltip when role is NOT allowed", () => {
|
|
mockUser("viewer");
|
|
render(<RoleGate allow={["admin", "user"]}><button>Do thing</button></RoleGate>);
|
|
const btn = screen.getByRole("button", { name: "Do thing" });
|
|
expect(btn).toBeDisabled();
|
|
});
|
|
|
|
it("renders fallback when provided and role not allowed", () => {
|
|
mockUser("viewer");
|
|
render(
|
|
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
|
|
<button>Do thing</button>
|
|
</RoleGate>
|
|
);
|
|
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 (
|
|
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
|
|
<span className="pointer-events-none opacity-50 cursor-not-allowed inline-flex">
|
|
{/* wrap children in a disabled clone of the first interactive child if possible */}
|
|
<DisabledClone>{children}</DisabledClone>
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// 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(
|
|
<MemoryRouter initialEntries={["/login"]}>
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/" element={<div>HOME</div>} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/login"]}>
|
|
<Routes><Route path="/login" element={<Login />} /></Routes>
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/login?next=/claims"]}>
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/claims" element={<div>CLAIMS</div>} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
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<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
if (user) return <Navigate to={next} replace />;
|
|
|
|
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 (
|
|
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">
|
|
<form
|
|
onSubmit={onSubmit}
|
|
className="w-full max-w-sm p-8 rounded-lg border border-border bg-card shadow"
|
|
>
|
|
<h1 className="text-2xl font-semibold mb-6">Cyclone</h1>
|
|
<label className="block mb-3">
|
|
<span className="text-sm">Username</span>
|
|
<input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
required
|
|
className="mt-1 w-full px-3 py-2 rounded bg-background border border-border"
|
|
autoComplete="username"
|
|
/>
|
|
</label>
|
|
<label className="block mb-5">
|
|
<span className="text-sm">Password</span>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
className="mt-1 w-full py-2 px-3 rounded bg-background border border-border"
|
|
autoComplete="current-password"
|
|
/>
|
|
</label>
|
|
{error && (
|
|
<div className="mb-4 text-sm text-red-400" role="alert">{error}</div>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="w-full py-2 rounded bg-primary text-primary-foreground font-medium disabled:opacity-50"
|
|
>
|
|
{submitting ? "Signing in…" : "Sign in"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **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 (
|
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
|
|
Loading…
|
|
</div>
|
|
);
|
|
}
|
|
if (status === "unauthenticated") {
|
|
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
|
}
|
|
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 (
|
|
<QueryClientProvider client={queryClient}>
|
|
<BrowserRouter>
|
|
<AuthProvider>
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
<Route path="/dashboard" element={<Dashboard />} />
|
|
<Route path="/claims" element={<Claims />} />
|
|
{/* ... other protected routes */}
|
|
</Route>
|
|
</Routes>
|
|
</AuthProvider>
|
|
</BrowserRouter>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
Adjust to match the existing route definitions — preserve every existing route, just wrap them in `<RequireAuth>`.
|
|
|
|
- [ ] **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<T>(params: ListClaimsParams): Promise<PaginatedResponse<T>> {
|
|
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 (
|
|
<div className="px-3 py-2 text-xs">
|
|
<div className="font-medium">{user.username}</div>
|
|
<div className="text-muted-foreground uppercase">{user.role}</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />`. 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:
|
|
<RoleGate allow={["admin", "user"]}>
|
|
<Dropzone ... />
|
|
</RoleGate>
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap Inbox resubmit + acknowledge buttons**
|
|
|
|
In `src/pages/Inbox.tsx`, find the "Resubmit" and "Acknowledge" buttons. Wrap each in:
|
|
|
|
```tsx
|
|
<RoleGate allow={["admin", "user"]}>
|
|
<Button onClick={...}>Resubmit</Button>
|
|
</RoleGate>
|
|
```
|
|
|
|
- [ ] **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. |