554 lines
32 KiB
Markdown
554 lines
32 KiB
Markdown
# Cyclone Auth (admin / user / viewer) — Design
|
||
|
||
**Date:** 2026-06-22
|
||
**Status:** Draft (pending user review)
|
||
**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP.
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors).
|
||
|
||
This sub-project adds the minimum viable role-based access control:
|
||
- A `/login` page that posts username + password to the backend.
|
||
- Server-side sessions in SQLite, keyed by an HttpOnly cookie.
|
||
- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix.
|
||
- An admin-only user-management surface so the first admin can create other accounts.
|
||
- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate.
|
||
|
||
After this, an operator can:
|
||
1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot.
|
||
2. Open `http://localhost:8081/`, get redirected to `/login`, sign in.
|
||
3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`.
|
||
4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
|
||
|
||
## 2. Goals
|
||
|
||
1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself.
|
||
2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes.
|
||
3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`.
|
||
4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message.
|
||
5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.".
|
||
6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety.
|
||
7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event.
|
||
8. **CLI command for user management.** `python -m cyclone users create <username> --role admin --password ...` works as an alternative to the admin UI for ops.
|
||
|
||
## 3. Non-goals (this sub-project)
|
||
|
||
- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table.
|
||
- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow.
|
||
- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor.
|
||
- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X".
|
||
- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account.
|
||
- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions.
|
||
- **Branding / SSO.** Out of scope.
|
||
|
||
## 4. Stack
|
||
|
||
**Backend additions:**
|
||
- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`).
|
||
- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose).
|
||
- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy).
|
||
- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`.
|
||
- Migration `0015_users_and_sessions.py` creates both tables.
|
||
- FastAPI dependency injection for `get_current_user` and `require_role`.
|
||
|
||
**Frontend additions:**
|
||
- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401.
|
||
- New `/login` page (`src/pages/Login.tsx`).
|
||
- No new build tools; the existing Vite + React + react-query stack stays.
|
||
|
||
**Infrastructure:**
|
||
- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy.
|
||
- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service.
|
||
- Backend image installs `passlib[bcrypt]`.
|
||
|
||
## 5. Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ Browser │
|
||
│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │
|
||
│ │ React SPA │ │ Cookie jar │ │
|
||
│ │ ┌────────────────────────────────────┐ │ │ cyclone_session=<id> │ │
|
||
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
|
||
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
|
||
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
|
||
│ │ │ </AuthProvider> │ │ │
|
||
│ │ │ ┌─────────────────────────────┐ │ │ │
|
||
│ │ │ │ <RoleGate allow=...> │ │ │ │
|
||
│ │ │ │ disables write buttons │ │ │ │
|
||
│ │ │ │ for users without role │ │ │ │
|
||
│ │ │ └─────────────────────────────┘ │ │ │
|
||
│ │ └────────────────────────────────────┘ │ │
|
||
│ └──────────────────────────────────────────┘ │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│ HTTPS (or HTTP behind LAN)
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ nginx (frontend container) │
|
||
│ - serves SPA static bundle │
|
||
│ - location /api/* → proxy_pass http://cyclone-backend:8000 │
|
||
│ - proxy_cookie_path /api/ /; (rewrites cookie path) │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ FastAPI (backend container) │
|
||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||
│ │ cyclone.auth.routes POST /api/auth/login, logout │ │
|
||
│ │ GET /api/auth/me │ │
|
||
│ │ cyclone.auth.admin CRUD /api/admin/users │ │
|
||
│ │ cyclone.api (existing) every endpoint gains │ │
|
||
│ │ Depends(get_current_user) │ │
|
||
│ │ sensitive endpoints gain │ │
|
||
│ │ Depends(require_role("admin")) │ │
|
||
│ ├──────────────────────────────────────────────────────────────────┤ │
|
||
│ │ cyclone.auth.deps get_current_user, require_role │ │
|
||
│ │ cyclone.auth.sessions create/validate/expire │ │
|
||
│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │
|
||
│ │ cyclone.auth.permissions Role enum + matrix │ │
|
||
│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │
|
||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||
│ │ cyclone.db (SQLAlchemy) │ │
|
||
│ │ users(id, username UNIQUE, password_hash, role, │ │
|
||
│ │ created_at, disabled_at) │ │
|
||
│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │
|
||
│ │ audit_log (existing — gets user_id column via 0016) │ │
|
||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Data flow on login:**
|
||
1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401).
|
||
2. User submits `{username, password}` → `POST /api/auth/login`.
|
||
3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`.
|
||
4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists.
|
||
5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`.
|
||
6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`.
|
||
7. Set `Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var).
|
||
8. Return `200 {id, username, role, createdAt}`.
|
||
|
||
**Data flow on a normal request:**
|
||
1. Browser sends request with `Cookie: cyclone_session=<id>`.
|
||
2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`.
|
||
3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`.
|
||
4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it.
|
||
5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`.
|
||
|
||
**Data flow on logout:**
|
||
1. SPA sends `POST /api/auth/logout`.
|
||
2. Backend deletes the `sessions` row matching the cookie's id.
|
||
3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie.
|
||
4. SPA's `AuthProvider` clears its state and navigates to `/login`.
|
||
|
||
**Sliding expiry:**
|
||
Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
|
||
|
||
## 6. Backend changes
|
||
|
||
### 6.1 New module `backend/src/cyclone/auth/`
|
||
|
||
```
|
||
auth/
|
||
├── __init__.py # re-exports the public API
|
||
├── users.py # User model, CRUD, bcrypt hashing
|
||
├── sessions.py # Session model, create/validate/expire
|
||
├── permissions.py # Role enum, permission matrix
|
||
├── deps.py # get_current_user, require_role
|
||
├── routes.py # /api/auth/login, logout, me
|
||
├── admin.py # /api/admin/users/* (admin-only)
|
||
└── rate_limit.py # per-username failed-login counter
|
||
```
|
||
|
||
### 6.2 Data model
|
||
|
||
New SQLAlchemy models in `cyclone.db`:
|
||
|
||
```python
|
||
class User(Base):
|
||
__tablename__ = "users"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||
password_hash: Mapped[str] = mapped_column(String(255))
|
||
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
|
||
class Session(Base):
|
||
__tablename__ = "sessions"
|
||
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
```
|
||
|
||
Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`.
|
||
|
||
Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log).
|
||
|
||
### 6.3 Permissions matrix
|
||
|
||
`cyclone/auth/permissions.py`:
|
||
|
||
```python
|
||
from enum import Enum
|
||
|
||
class Role(str, Enum):
|
||
ADMIN = "admin"
|
||
USER = "user"
|
||
VIEWER = "viewer"
|
||
|
||
|
||
# Endpoint path prefix → set of roles allowed.
|
||
PERMISSIONS: dict[str, set[Role]] = {
|
||
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
|
||
"GET /api/healthz": set(),
|
||
"POST /api/auth/login": set(),
|
||
|
||
# Auth surface (authenticated, all roles).
|
||
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
|
||
# Admin-only user management.
|
||
"GET /api/admin/users": {Role.ADMIN},
|
||
"POST /api/admin/users": {Role.ADMIN},
|
||
"PATCH /api/admin/users": {Role.ADMIN},
|
||
"DELETE /api/admin/users": {Role.ADMIN},
|
||
|
||
# Read endpoints (everyone authenticated can read).
|
||
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
|
||
|
||
# Write endpoints (admin + user, no viewer).
|
||
"POST /api/parse-837": {Role.ADMIN, Role.USER},
|
||
"POST /api/parse-835": {Role.ADMIN, Role.USER},
|
||
"POST /api/inbox": {Role.ADMIN, Role.USER},
|
||
"POST /api/reconcile": {Role.ADMIN, Role.USER},
|
||
"POST /api/resubmit": {Role.ADMIN, Role.USER},
|
||
"POST /api/acks": {Role.ADMIN, Role.USER},
|
||
# ...every other POST/PATCH/DELETE goes here too.
|
||
|
||
# CSV export — read-only, so all roles.
|
||
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||
}
|
||
```
|
||
|
||
The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed.
|
||
|
||
### 6.4 Dependencies
|
||
|
||
```python
|
||
# deps.py
|
||
from fastapi import Depends, HTTPException, Request, status
|
||
|
||
async def get_current_user(request: Request) -> User:
|
||
sid = request.cookies.get("cyclone_session")
|
||
if not sid:
|
||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||
session = await sessions.get_valid(sid)
|
||
if not session:
|
||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||
user = await users.get(session.user_id)
|
||
if not user or user.disabled_at is not None:
|
||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
|
||
# Sliding expiry refresh.
|
||
await sessions.touch(sid)
|
||
request.state.user = user
|
||
return user
|
||
|
||
|
||
def require_role(*allowed: Role):
|
||
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
|
||
method = request.method
|
||
path = request.url.path
|
||
key = f"{method} {path}"
|
||
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
|
||
allowed_roles = _lookup_permissions(key)
|
||
if user.role not in allowed_roles:
|
||
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
|
||
return user
|
||
return _dep
|
||
```
|
||
|
||
### 6.5 Endpoints
|
||
|
||
**Auth (`cyclone/auth/routes.py`):**
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie |
|
||
| POST | `/api/auth/logout` | session | — | `204` + cookie cleared |
|
||
| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` |
|
||
|
||
**Admin (`cyclone/auth/admin.py`):**
|
||
|
||
| Method | Path | Auth | Body | Response |
|
||
|---|---|---|---|---|
|
||
| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` |
|
||
| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` |
|
||
| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` |
|
||
| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) |
|
||
|
||
**Error shapes:**
|
||
|
||
```json
|
||
// 401
|
||
{ "error": "session_expired", "detail": "Session is missing or expired." }
|
||
// 403
|
||
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
|
||
// 429
|
||
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
|
||
// Login 401 (generic — never leak username existence)
|
||
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
|
||
```
|
||
|
||
### 6.6 Rate limit
|
||
|
||
In-memory dict in `rate_limit.py`:
|
||
|
||
```python
|
||
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
|
||
WINDOW_SECONDS = 300
|
||
MAX_FAILS = 5
|
||
|
||
def check(username: str) -> int:
|
||
"""Return Retry-After seconds, or 0 if allowed."""
|
||
now = time.monotonic()
|
||
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||
if len(fails) >= MAX_FAILS:
|
||
return int(WINDOW_SECONDS - (now - fails[0]))
|
||
return 0
|
||
|
||
def record_failure(username: str) -> None:
|
||
_FAILS.setdefault(username, []).append(time.monotonic())
|
||
|
||
def reset(username: str) -> None:
|
||
_FAILS.pop(username, None)
|
||
```
|
||
|
||
Per-process. Resets on backend restart. Acceptable for v1.
|
||
|
||
### 6.7 Bootstrap (first admin)
|
||
|
||
In `cyclone/__main__.py`, before `uvicorn.run(...)`:
|
||
|
||
```python
|
||
async def bootstrap_admin():
|
||
async with SessionLocal() as db:
|
||
if await db.scalar(select(func.count()).select_from(User)) > 0:
|
||
return
|
||
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
||
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
||
if not username or not password:
|
||
raise RuntimeError(
|
||
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||
"CYCLONE_ADMIN_PASSWORD env vars, or run "
|
||
"`python -m cyclone users create <username> --role admin` first."
|
||
)
|
||
if len(password) < 12:
|
||
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
|
||
await users.create(db, username=username, password=password, role=Role.ADMIN)
|
||
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||
```
|
||
|
||
### 6.8 CLI command
|
||
|
||
`python -m cyclone users ...`:
|
||
|
||
```
|
||
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
|
||
python -m cyclone users list
|
||
python -m cyclone users disable <username>
|
||
python -m cyclone users reset-password <username> [--password <pw>]
|
||
python -m cyclone users set-role <username> --role {admin|user|viewer}
|
||
```
|
||
|
||
If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts.
|
||
|
||
## 7. Frontend changes
|
||
|
||
### 7.1 New module `src/auth/`
|
||
|
||
```
|
||
auth/
|
||
├── AuthProvider.tsx # React context + reducer
|
||
├── AuthProvider.test.tsx
|
||
├── useAuth.ts # hook: {user, login, logout, status}
|
||
├── api.ts # fetch wrapper that handles 401
|
||
├── RoleGate.tsx # disables children when role not allowed
|
||
└── RoleGate.test.tsx
|
||
```
|
||
|
||
### 7.2 AuthProvider
|
||
|
||
```typescript
|
||
// AuthProvider.tsx (sketch)
|
||
type Status = "loading" | "authenticated" | "unauthenticated";
|
||
|
||
interface AuthState {
|
||
status: Status;
|
||
user: User | null;
|
||
}
|
||
|
||
const AuthContext = createContext<AuthState & { login, logout }>(...);
|
||
|
||
export function AuthProvider({ children }) {
|
||
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
|
||
|
||
useEffect(() => {
|
||
api.getAuthMe()
|
||
.then(user => setState({ status: "authenticated", user }))
|
||
.catch(err => {
|
||
if (err.status === 401) setState({ status: "unauthenticated", user: null });
|
||
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
|
||
});
|
||
}, []);
|
||
|
||
// Expose login() that POSTs /api/auth/login + sets state on success.
|
||
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
|
||
}
|
||
```
|
||
|
||
### 7.3 Login page
|
||
|
||
`src/pages/Login.tsx`:
|
||
- Centered card on the existing dark background, ~400px wide.
|
||
- Username + password fields, "Sign in" button, error message slot.
|
||
- On submit: POST `/api/auth/login`, on success → `useNavigate()/<prev>` or `/`.
|
||
- On `401 invalid_credentials` → "Username or password is incorrect."
|
||
- On `403 account_disabled` → "Account is disabled. Contact your administrator."
|
||
- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`).
|
||
- The page renders *without* the sidebar — it lives outside the protected `<Layout>`.
|
||
|
||
### 7.4 API wrapper
|
||
|
||
`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response:
|
||
1. Calls `POST /api/auth/logout` (best-effort, ignore failure).
|
||
2. Clears the auth context.
|
||
3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`.
|
||
|
||
Use a hard navigation (not `<Navigate>`) so the SPA's in-memory state is wiped.
|
||
|
||
### 7.5 RoleGate
|
||
|
||
```tsx
|
||
// RoleGate.tsx
|
||
interface Props {
|
||
allow: Role[];
|
||
children: ReactNode;
|
||
fallback?: ReactNode; // optional explicit "no permission" UI
|
||
}
|
||
|
||
export function RoleGate({ allow, children, fallback }: Props) {
|
||
const { user } = useAuth();
|
||
if (!user) return null;
|
||
if (allow.includes(user.role)) return <>{children}</>;
|
||
if (fallback) return <>{fallback}</>;
|
||
return (
|
||
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
|
||
<span className="pointer-events-none opacity-50">{children}</span>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
```
|
||
|
||
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
|
||
|
||
### 7.6 Sidebar
|
||
|
||
`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
|
||
|
||
### 7.7 Routes
|
||
|
||
`src/main.tsx`:
|
||
- `QueryClientProvider`
|
||
- `AuthProvider`
|
||
- `<BrowserRouter>` with two route groups:
|
||
- **Public:** `/login` (no `<Layout>` wrap).
|
||
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
|
||
|
||
### 7.8 react-query keys
|
||
|
||
`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state.
|
||
|
||
## 8. Infrastructure
|
||
|
||
### 8.1 nginx (`frontend/nginx.conf`)
|
||
|
||
The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed.
|
||
|
||
We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag.
|
||
|
||
### 8.2 docker-compose.yml
|
||
|
||
```yaml
|
||
services:
|
||
backend:
|
||
environment:
|
||
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
|
||
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
|
||
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
|
||
```
|
||
|
||
A `.env.example` documents the required vars.
|
||
|
||
### 8.3 Cookie `Secure` flag
|
||
|
||
Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx).
|
||
|
||
## 9. Testing
|
||
|
||
### 9.1 Backend (`backend/tests/`)
|
||
|
||
| File | Coverage |
|
||
|---|---|
|
||
| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum |
|
||
| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response |
|
||
| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 |
|
||
| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 |
|
||
| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) |
|
||
| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored |
|
||
| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter |
|
||
| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id |
|
||
| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie |
|
||
|
||
### 9.2 Frontend (`src/**/__tests__/`)
|
||
|
||
| File | Coverage |
|
||
|---|---|
|
||
| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state |
|
||
| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided |
|
||
| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 |
|
||
| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses |
|
||
|
||
### 9.3 End-to-end
|
||
|
||
`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip.
|
||
|
||
## 10. Risk + open questions
|
||
|
||
- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README.
|
||
- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it.
|
||
- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms.
|
||
- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent.
|
||
|
||
## 11. Rollout
|
||
|
||
- All code lands in one PR (sub-project is small enough).
|
||
- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive).
|
||
- The Docker image rebuilds pick up `passlib[bcrypt]` automatically.
|
||
- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix.
|
||
- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset. |