Authentication, Users & RBAC¶
aaiclick authenticates its HTTP surfaces (REST + MCP) with username/password
users and two-role RBAC. The browser SPA and any programmatic HTTP / MCP client
share one login flow; the CLI runs internal_api in-process and never crosses
the HTTP auth layer.
Scope¶
- Users: username + password, stored in the orchestration SQL database.
- Roles: exactly two —
admin(full access) andviewer(read-only). No teams, per-resource ACLs, or custom roles. - Sessions: password login → short-lived access JWT + rotating refresh
token. One credential header everywhere:
Authorization: Bearer <access-jwt>. - Mode-derived enforcement: auth is a hardcoded convention, not a flag —
disabled in local mode (single-process chdb + SQLite; the server is open,
zero-config) and enforced in distributed mode. There are no long-lived API
tokens / PATs, no SSO/OIDC/MFA, no user-management UI, and the
/mcpsurface is all-or-nothing (admin only).
Configuration¶
Whether auth is enforced follows the backend mode (is_local()), not an env
var. These variables tune the enforced (distributed) case:
| Variable | Purpose | Default |
|---|---|---|
AAICLICK_JWT_SECRET |
HS256 signing secret. Required in distributed mode. | unset |
AAICLICK_JWT_ACCESS_TTL |
Access-JWT lifetime, seconds. | 1800 (30 min) |
AAICLICK_JWT_REFRESH_TTL |
Refresh-token lifetime, seconds. | 1209600 (14 d) |
AAICLICK_ADMIN_USERNAME |
Seed-admin username (inserted on startup when no users exist). | unset |
AAICLICK_ADMIN_PASSWORD |
Seed-admin password. | unset |
Distributed without a secret is a hard error
In distributed mode with AAICLICK_JWT_SECRET unset, the server refuses to
start. In local mode auth is disabled, every request is allowed, and the
server logs a single startup WARNING.
Data Model¶
Two SQLModel tables in aaiclick/auth/models.py. IDs are snowflake
BigInteger PKs; role uses the project's _enum_check String + CHECK
pattern.
users¶
| Column | Type | Notes |
|---|---|---|
id |
BigInteger PK (snowflake) |
|
username |
String, unique, indexed |
Login identifier |
password_hash |
String |
bcrypt |
role |
String + CHECK IN ('admin','viewer') |
Role literal |
disabled |
Boolean, default false |
Disabled → cannot log in |
created_at |
datetime (utc_now) |
refresh_tokens¶
| Column | Type | Notes |
|---|---|---|
id |
BigInteger PK (snowflake) |
|
user_id |
BigInteger FK → users.id, indexed |
|
token_hash |
String, unique, indexed |
sha256(secret) |
expires_at |
datetime |
|
rotated_at |
datetime \| None |
Set when consumed by /auth/refresh |
revoked_at |
datetime \| None |
Set on logout |
Role = Literal["admin", "viewer"] lives in aaiclick/auth/models.py with
module constants ROLE_ADMIN / ROLE_VIEWER.
Module Layout¶
aaiclick/
auth/
models.py users / refresh_tokens; Role literal + constants
security.py bcrypt hash/verify; secret gen + sha256; JWT encode/decode
(pure functions, no DB, no contextvars)
config.py env getters (enabled, secret, TTLs, admin seed)
store.py raw DB CRUD over users / refresh_tokens
view_models.py LoginRequest, RefreshRequest, LogoutRequest, TokenPair,
MeView, UserView, CreateUserRequest, ...
internal_api/
auth.py login(), refresh(), logout() → view models
users.py create_user, list_users, get_user, set_role,
disable_user, set_password
server/
auth.py principal resolution + RBAC dependencies + /mcp middleware
routers/
auth.py /auth/login, /auth/refresh, /auth/logout, /auth/me
users.py /users (admin-only)
__main__.py aaiclick user create|list|set-role|disable|passwd
Business logic is transport-agnostic in internal_api / auth, running inside
orch_context and reading the SQL session via the contextvar getter. server/
owns JWT/transport.
Auth Mechanics¶
Passwords are hashed with bcrypt. Access JWTs are signed HS256 with
AAICLICK_JWT_SECRET.
Login → token pair¶
POST /api/v0/auth/login {username, password} → 200 TokenPair:
{ "access_token": "<jwt>", "refresh_token": "<opaque>", "token_type": "bearer",
"expires_in": 1800 }
- The user must exist, be enabled, and the password must match. Otherwise
401(code="unauthorized") — no user-enumeration distinction. - Access JWT claims:
sub=<user_id>,role,exp,type="access". - Refresh token: a random opaque secret; only its
sha256is stored inrefresh_tokens.
Refresh (rotation)¶
POST /api/v0/auth/refresh {refresh_token} → new TokenPair. The row is
looked up by hash and rejected if missing / expired / rotated / revoked. On
success the old row is stamped rotated_at and a fresh refresh token is issued,
re-reading the owner's current role and disabled. Reusing a rotated token
returns 401.
Logout¶
POST /api/v0/auth/logout {refresh_token} revokes that refresh row. Access
JWTs are stateless and expire on their own (≤ 30 min).
Me¶
GET /api/v0/auth/me → MeView {id, username, role} for the current principal.
Principal Resolution & RBAC¶
require_principal extracts the credential with FastAPI's
HTTPBearer(auto_error=False) (which also registers the /docs Authorize
box; auto_error=False so a missing credential yields the Problem envelope
rather than FastAPI's bare HTTPException), then resolves a
Principal {user_id, username, role}:
- Auth disabled → a synthetic admin principal; all routes open.
- Valid access JWT (
type="access", valid signature +exp) → claims are trusted for the token's ≤30-min lifetime (sub,role). Disabling or demoting a user takes full effect within one access-TTL — the next/auth/refreshre-reads the DB and fails / downgrades. - Otherwise →
401withWWW-Authenticate: Bearer.
require_admin depends on require_principal and raises Forbidden (403,
code="forbidden") when role != "admin".
Role matrix¶
| Capability | viewer | admin |
|---|---|---|
GET reads (jobs, tasks, workers, objects, lineage) |
✅ | ✅ |
/auth/* (login, refresh, logout, me) |
✅ | ✅ |
| Run / cancel jobs, register / enable / disable jobs | ❌ | ✅ |
| Delete / purge objects | ❌ | ✅ |
| Start / stop workers | ❌ | ✅ |
User management (/users) |
❌ | ✅ |
MCP surface (/mcp) |
❌ | ✅ |
require_principal guards every /api/v0 router via
include_router(dependencies=...); require_admin is added to each mutating
route. Reads need only a valid principal. Per-router scope deps (orch_scope)
run alongside.
MCP Surface¶
The /mcp mount is admin-only and all-or-nothing — no per-tool RBAC.
Because FastAPI's Depends does not reach mounted sub-apps, the mount is
wrapped in an ASGI middleware that runs the same principal resolution and
additionally requires role == "admin"; non-admin or unauthenticated requests
get a 401/403 Problem.
CLI & Admin Bootstrap¶
- CLI:
aaiclick user create --username --password --role {admin,viewer},list,set-role,disable,passwd— thin renderers overinternal_api.users, running in-process. - Startup seed: when auth is enabled and
AAICLICK_ADMIN_USERNAME/AAICLICK_ADMIN_PASSWORDare set, that admin is inserted during server lifespan startup if theuserstable is empty. The seed and the CLI both bootstrap the first admin.
SPA¶
src/api/client.tsattachesAuthorization: Bearer <access>from the auth store; on401it attempts/auth/refreshonce and retries, else clears the session and drops back to Login. Single chokepoint.src/lib/auth.ts: in-memory access token +localStoragerefresh token;login/logout/tryRefresh/fetchMehelpers.src/components/Auth.tsx:AuthProvider/useAuth, bootstrapped from/auth/me.src/views/Login.tsx: username + password form.App.tsxgates rendering on the session. When auth is disabled/auth/mereturns the synthetic admin, so no login wall appears.
Migration¶
The users and refresh_tokens tables are created by an Alembic revision
(aaiclick/auth/models.py is imported in migrations/env.py so autogenerate
sees them). Local/dev (aaiclick setup) builds the tables from
SQLModel.metadata, so the revision is only required for Postgres-backed
deployments.