32 KiB
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
/loginpage 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_userdependency; write-affording endpoints get arequire_rolegate.
After this, an operator can:
- 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. - Open
http://localhost:8081/, get redirected to/login, sign in. - Browse the Dashboard, Claims, Remittances, etc. as a
userorviewer. - As
admin, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
2. Goals
- 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 incyclone.api) andPOST /api/auth/loginitself. - 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. - Server-side sessions in SQLite. New
usersandsessionstables. 24-hour sliding expiry. HttpOnly cookie namedcyclone_session. - Bootstrap the first admin. If
userstable is empty on startup, readCYCLONE_ADMIN_USERNAME/CYCLONE_ADMIN_PASSWORDenv vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message. - Frontend
/loginroute + auth context.AuthProviderloads/api/auth/meon mount, redirects to/loginon 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.". - 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. - Audit log includes the acting user. The existing
audit_logtable (SP11) gets auser_idcolumn on a new migration; entries record the user that triggered each event. - 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
userstable. - 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
UserandSessionmodels on the samecyclone.db.Base. - Migration
0015_users_and_sessions.pycreates both tables. - FastAPI dependency injection for
get_current_userandrequire_role.
Frontend additions:
- New
src/auth/module:AuthProvidercontext,useAuthhook,RoleGatecomponent, fetch wrapper that handles 401. - New
/loginpage (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.ymladdsCYCLONE_ADMIN_USERNAMEandCYCLONE_ADMIN_PASSWORDenv 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:
- User opens
/login(the SPA redirects there on first visit because/api/auth/mereturned 401). - User submits
{username, password}→POST /api/auth/login. - Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with
Retry-After. - 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. - If user.disabled_at is not None, return 403
{error: "account_disabled"}. - Create a
sessionsrow:id = secrets.token_urlsafe(32),user_id,expires_at = now() + 24h. - Set
Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400(alsoSecurewhen behind HTTPS — detected viarequest.url.schemeor aBEHIND_HTTPS=1env var). - Return
200 {id, username, role, createdAt}.
Data flow on a normal request:
- Browser sends request with
Cookie: cyclone_session=<id>. - FastAPI middleware / dependency reads the cookie, looks up
sessionsrow byid. - If missing, expired (
expires_at < now), or user is disabled → raiseHTTPException(401, "session_expired"). - Otherwise, attach
Usertorequest.state.user. Theget_current_userdependency returns it. - For endpoints with
Depends(require_role("admin")), checkuser.role == "admin"; else raiseHTTPException(403, "forbidden").
Data flow on logout:
- SPA sends
POST /api/auth/logout. - Backend deletes the
sessionsrow matching the cookie's id. - Backend sets
Set-Cookie: cyclone_session=; Max-Age=0to clear the cookie. - SPA's
AuthProviderclears its state and navigates to/login.
Sliding expiry:
Every successful authenticated request refreshes sessions.expires_at = now() + 24h (cheap UPDATE) and re-emits the Set-Cookie header with a fresh Max-Age=86400. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
6. Backend changes
6.1 New module backend/src/cyclone/auth/
auth/
├── __init__.py # re-exports the public API
├── users.py # User model, CRUD, bcrypt hashing
├── sessions.py # Session model, create/validate/expire
├── permissions.py # Role enum, permission matrix
├── deps.py # get_current_user, require_role
├── routes.py # /api/auth/login, logout, me
├── admin.py # /api/admin/users/* (admin-only)
└── rate_limit.py # per-username failed-login counter
6.2 Data model
New SQLAlchemy models in cyclone.db:
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
Migration 0015_users_and_sessions.py creates both tables and adds indexes on users.username, sessions.expires_at, sessions.user_id.
Migration 0016_audit_log_user_id.py adds user_id INT NULL to the existing audit_log table (SP11's hash-chained audit log).
6.3 Permissions matrix
cyclone/auth/permissions.py:
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
# Endpoint path prefix → set of roles allowed.
PERMISSIONS: dict[str, set[Role]] = {
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
"GET /api/healthz": set(),
"POST /api/auth/login": set(),
# Auth surface (authenticated, all roles).
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
# Admin-only user management.
"GET /api/admin/users": {Role.ADMIN},
"POST /api/admin/users": {Role.ADMIN},
"PATCH /api/admin/users": {Role.ADMIN},
"DELETE /api/admin/users": {Role.ADMIN},
# Read endpoints (everyone authenticated can read).
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
# Write endpoints (admin + user, no viewer).
"POST /api/parse-837": {Role.ADMIN, Role.USER},
"POST /api/parse-835": {Role.ADMIN, Role.USER},
"POST /api/inbox": {Role.ADMIN, Role.USER},
"POST /api/reconcile": {Role.ADMIN, Role.USER},
"POST /api/resubmit": {Role.ADMIN, Role.USER},
"POST /api/acks": {Role.ADMIN, Role.USER},
# ...every other POST/PATCH/DELETE goes here too.
# CSV export — read-only, so all roles.
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
}
The require_role dependency reads (method, path) from the request and looks up the allowed roles. Endpoints not in the matrix default to deny — fail-closed.
6.4 Dependencies
# deps.py
from fastapi import Depends, HTTPException, Request, status
async def get_current_user(request: Request) -> User:
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
session = await sessions.get_valid(sid)
if not session:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
user = await users.get(session.user_id)
if not user or user.disabled_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
# Sliding expiry refresh.
await sessions.touch(sid)
request.state.user = user
return user
def require_role(*allowed: Role):
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
method = request.method
path = request.url.path
key = f"{method} {path}"
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
allowed_roles = _lookup_permissions(key)
if user.role not in allowed_roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
return user
return _dep
6.5 Endpoints
Auth (cyclone/auth/routes.py):
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/auth/login |
public | {username, password} |
200 {id, username, role, createdAt} + Set-Cookie |
| POST | /api/auth/logout |
session | — | 204 + cookie cleared |
| GET | /api/auth/me |
session | — | 200 {id, username, role, createdAt} |
Admin (cyclone/auth/admin.py):
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /api/admin/users |
admin | — | 200 [{id, username, role, createdAt, disabledAt}] |
| POST | /api/admin/users |
admin | {username, password, role} |
201 {id, username, role, createdAt} |
| PATCH | /api/admin/users/{id} |
admin | {role?, password?, disabled?} |
200 {id, username, role, createdAt, disabledAt} |
| DELETE | /api/admin/users/{id} |
admin | — | 204 (only if user has no authored data; otherwise 409) |
Error shapes:
// 401
{ "error": "session_expired", "detail": "Session is missing or expired." }
// 403
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
// 429
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
// Login 401 (generic — never leak username existence)
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
6.6 Rate limit
In-memory dict in rate_limit.py:
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
WINDOW_SECONDS = 300
MAX_FAILS = 5
def check(username: str) -> int:
"""Return Retry-After seconds, or 0 if allowed."""
now = time.monotonic()
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
_FAILS.setdefault(username, []).append(time.monotonic())
def reset(username: str) -> None:
_FAILS.pop(username, None)
Per-process. Resets on backend restart. Acceptable for v1.
6.7 Bootstrap (first admin)
In cyclone/__main__.py, before uvicorn.run(...):
async def bootstrap_admin():
async with SessionLocal() as db:
if await db.scalar(select(func.count()).select_from(User)) > 0:
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars, or run "
"`python -m cyclone users create <username> --role admin` first."
)
if len(password) < 12:
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
await users.create(db, username=username, password=password, role=Role.ADMIN)
print(f"[cyclone] bootstrap admin user '{username}' created")
6.8 CLI command
python -m cyclone users ...:
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
python -m cyclone users list
python -m cyclone users disable <username>
python -m cyclone users reset-password <username> [--password <pw>]
python -m cyclone users set-role <username> --role {admin|user|viewer}
If --password is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require --password from env to avoid accidental empty-password accounts.
7. Frontend changes
7.1 New module src/auth/
auth/
├── AuthProvider.tsx # React context + reducer
├── AuthProvider.test.tsx
├── useAuth.ts # hook: {user, login, logout, status}
├── api.ts # fetch wrapper that handles 401
├── RoleGate.tsx # disables children when role not allowed
└── RoleGate.test.tsx
7.2 AuthProvider
// AuthProvider.tsx (sketch)
type Status = "loading" | "authenticated" | "unauthenticated";
interface AuthState {
status: Status;
user: User | null;
}
const AuthContext = createContext<AuthState & { login, logout }>(...);
export function AuthProvider({ children }) {
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
useEffect(() => {
api.getAuthMe()
.then(user => setState({ status: "authenticated", user }))
.catch(err => {
if (err.status === 401) setState({ status: "unauthenticated", user: null });
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
});
}, []);
// Expose login() that POSTs /api/auth/login + sets state on success.
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
}
7.3 Login page
src/pages/Login.tsx:
- Centered card on the existing dark background, ~400px wide.
- Username + password fields, "Sign in" button, error message slot.
- On submit: POST
/api/auth/login, on success →useNavigate()/<prev>or/. - On
401 invalid_credentials→ "Username or password is incorrect." - On
403 account_disabled→ "Account is disabled. Contact your administrator." - On
429 rate_limited→ "Too many attempts. Try again in N seconds." (N fromRetry-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:
- Calls
POST /api/auth/logout(best-effort, ignore failure). - Clears the auth context.
window.location.href = "/login?next=" + encodeURIComponent(currentPath).
Use a hard navigation (not <Navigate>) so the SPA's in-memory state is wiped.
7.5 RoleGate
// RoleGate.tsx
interface Props {
allow: Role[];
children: ReactNode;
fallback?: ReactNode; // optional explicit "no permission" UI
}
export function RoleGate({ allow, children, fallback }: Props) {
const { user } = useAuth();
if (!user) return null;
if (allow.includes(user.role)) return <>{children}</>;
if (fallback) return <>{fallback}</>;
return (
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
<span className="pointer-events-none opacity-50">{children}</span>
</Tooltip>
);
}
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
7.6 Sidebar
src/components/Sidebar.tsx — replace the hardcoded "Jordan K. / Administrator" block with <CurrentUser /> which reads useAuth(). Shows the user's actual username + role.
7.7 Routes
src/main.tsx:
QueryClientProviderAuthProvider<BrowserRouter>with two route groups:- Public:
/login(no<Layout>wrap). - Protected: everything else, wrapped in a
<RequireAuth>guard that waits forAuthProvider.status === "loading"to resolve, then renders<Outlet />or<Navigate to="/login" />.
- Public:
7.8 react-query keys
useAuth() itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (['claims', ...], etc.) are unchanged. On logout, the app does a hard reload to /login which wipes all react-query state.
8. Infrastructure
8.1 nginx (frontend/nginx.conf)
The existing nginx config already proxies /api/* to the backend. With cookie-based auth, the only requirement is that the cookie's Path matches a prefix of the request URL the browser sends. We set Path=/api on the cookie (matching the proxied path), and the browser sends it automatically on every /api/* request to the frontend nginx — no proxy_cookie_path rewrite needed.
We do add proxy_set_header X-Forwarded-Proto $scheme; so the backend can detect when it's behind HTTPS and emit the Secure cookie flag.
8.2 docker-compose.yml
services:
backend:
environment:
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
A .env.example documents the required vars.
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/, theproxy_cookie_pathwill 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
--passwordfrom 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
sessionstable directly. Acceptable for v1; admin canDELETE FROM sessions WHERE user_id = ?fromsqlite3 /data/cyclone.dbif 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.mdgets 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 setCYCLONE_AUTH_DISABLED=1env var; the bootstrap function is a no-op when this is set, and theget_current_userdependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset.