Goal
Bolt on application-layer per-user permissions to the FastAPI server, driven by the Cloudflare Access JWT that already arrives with every authenticated request. Scope: gate which wolts and apps a user can see and interact with via the web/REST surface.
Out of scope for this issue (see follow-up): OS-level filesystem isolation. A user inside a session can still cd ../other-wolt and read files. That's a known, acknowledged gap — phase 2 territory.
Why now
CF Access already does email OTP at the tunnel edge. Every request arrives with Cf-Access-Jwt-Assertion. The server reads zero of it today. Once it's parsed, we have a trusted email per request — enough to build a user model and gate routes.
Design
Two modes (one env var toggle)
WOLTSPACE_AUTH=cloudflare or WOLTSPACE_AUTH=none (default: none).
none: middleware not registered. Permission checks return allow-all. Behaves identically to today. Local dev, single-user containers, all existing setups keep working unchanged.
cloudflare: middleware validates JWT, extracts email, attaches to request. Permission checks enforce ownership.
The two paths must not leak into each other. Permission check is a FastAPI Depends(...) that resolves to allow-all in none mode — not branchy if auth_enabled sprinkled through route handlers.
JWT validation
- Fetch CF public certs from
https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certs once at boot, cache (rotate periodically).
- Validate signature,
aud claim, expiry on each request.
- Extract
email claim.
- Use
PyJWT[crypto] (already required in the container).
- ~30–50 lines of middleware.
Users data model
wolts/.space/auth/users.json:
{
"users": [
{
"email": "jerpint@gmail.com",
"role": "admin",
"wolts": ["*"],
"apps": ["*"],
"added_at": "2026-06-17T00:00:00Z",
"added_by": "bootstrap"
},
{
"email": "collaborator@example.com",
"role": "user",
"wolts": ["bloggo", "shared-wolt"],
"apps": ["corework"],
"added_at": "2026-06-17T00:00:00Z",
"added_by": "jerpint@gmail.com"
}
]
}
Roles: admin (sees and controls everything, can edit users.json) and user (allow-list). Keep it minimal — no RBAC yet.
Bootstrap
WOLTSPACE_ADMIN_EMAIL=jerpint@gmail.com env var. On first JWT match with that email, auto-promote to admin and write to users.json if missing. Solves the chicken-and-egg of "who configures the configurator".
Wolt/app ownership
- Wolts gain
owner_email in their wolt.json (set on create in auth=cloudflare mode).
- Apps inherit from their keeper wolt.
- Migration: existing wolts get
owner_email = WOLTSPACE_ADMIN_EMAIL on first boot.
Routes that need guards
All in server/app.py. Each gets a Depends(require_access(...)) or a permission lookup inline:
GET /wolts — filter to user's allow-list
GET /sessions — filter to sessions whose wolt is in user's allow-list
GET /apps — filter to user's apps
POST /sessions/new/{create,lodge,telegram,slack} — 403 if target wolt not allowed
GET /wolt/{name}/site/* — 403 if wolt not allowed
GET /sites, POST /sites/{name}/start|stop — 403 if wolt not allowed
POST /apps/{name}/start|stop|share|unshare — 403 if app not allowed
GET /current/meta, POST /current — viewport setters, 403 if session's wolt not allowed
Admin UI
New page in the lodge, visible only to admin:
- Table of users (email, role, allowed wolts, allowed apps, added_at)
- Add user (email + role + allow-lists)
- Edit user (modify allow-lists, change role)
- Remove user
- Backed by
GET/POST/DELETE /admin/users routes
- Lodge sidebar gains "admin" entry, conditionally rendered
Pending-approval state
A user who's authenticated via CF Access but not in users.json reaches the tunnel and gets a 403 page (or a friendly "pending approval — ask jerpint" landing). CF Access controls "who reaches the box", users.json controls "what they see once on it." Two sources of truth, but clean separation.
Telegram/Slack bots
Out of scope for this issue. Bots bypass the web tunnel and have their own allow-list mechanisms (TELEGRAM_ALLOWED_USERS, slack thread ownership). They stay as-is for phase 1. If/when needed, a follow-up adds per-user-id allow-lists for bots — but the web layer is the priority.
Acceptance criteria
Known gap (intentional)
Sessions run as node (single OS user). A user inside a session can read other wolts' files via the shell. This issue does not address that — it's the FS-level permissions follow-up.
Files touched (estimate)
server/app.py — middleware registration, route guards (~150 lines)
server/auth.py (new) — JWT validation, users.json CRUD, permission checks (~150 lines)
container/lib/wolts.py — owner_email on create, migration helper
templates/admin.html (new) + sidebar.html
public/static/admin.js (new)
- Tests in
test/test_auth.py (new)
Total: ~400–500 lines + admin UI.
Goal
Bolt on application-layer per-user permissions to the FastAPI server, driven by the Cloudflare Access JWT that already arrives with every authenticated request. Scope: gate which wolts and apps a user can see and interact with via the web/REST surface.
Out of scope for this issue (see follow-up): OS-level filesystem isolation. A user inside a session can still
cd ../other-woltand read files. That's a known, acknowledged gap — phase 2 territory.Why now
CF Access already does email OTP at the tunnel edge. Every request arrives with
Cf-Access-Jwt-Assertion. The server reads zero of it today. Once it's parsed, we have a trusted email per request — enough to build a user model and gate routes.Design
Two modes (one env var toggle)
WOLTSPACE_AUTH=cloudflareorWOLTSPACE_AUTH=none(default:none).none: middleware not registered. Permission checks return allow-all. Behaves identically to today. Local dev, single-user containers, all existing setups keep working unchanged.cloudflare: middleware validates JWT, extracts email, attaches to request. Permission checks enforce ownership.The two paths must not leak into each other. Permission check is a FastAPI
Depends(...)that resolves to allow-all innonemode — not branchyif auth_enabledsprinkled through route handlers.JWT validation
https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certsonce at boot, cache (rotate periodically).audclaim, expiry on each request.emailclaim.PyJWT[crypto](already required in the container).Users data model
wolts/.space/auth/users.json:{ "users": [ { "email": "jerpint@gmail.com", "role": "admin", "wolts": ["*"], "apps": ["*"], "added_at": "2026-06-17T00:00:00Z", "added_by": "bootstrap" }, { "email": "collaborator@example.com", "role": "user", "wolts": ["bloggo", "shared-wolt"], "apps": ["corework"], "added_at": "2026-06-17T00:00:00Z", "added_by": "jerpint@gmail.com" } ] }Roles:
admin(sees and controls everything, can editusers.json) anduser(allow-list). Keep it minimal — no RBAC yet.Bootstrap
WOLTSPACE_ADMIN_EMAIL=jerpint@gmail.comenv var. On first JWT match with that email, auto-promote toadminand write tousers.jsonif missing. Solves the chicken-and-egg of "who configures the configurator".Wolt/app ownership
owner_emailin theirwolt.json(set on create inauth=cloudflaremode).owner_email = WOLTSPACE_ADMIN_EMAILon first boot.Routes that need guards
All in
server/app.py. Each gets aDepends(require_access(...))or a permission lookup inline:GET /wolts— filter to user's allow-listGET /sessions— filter to sessions whose wolt is in user's allow-listGET /apps— filter to user's appsPOST /sessions/new/{create,lodge,telegram,slack}— 403 if target wolt not allowedGET /wolt/{name}/site/*— 403 if wolt not allowedGET /sites,POST /sites/{name}/start|stop— 403 if wolt not allowedPOST /apps/{name}/start|stop|share|unshare— 403 if app not allowedGET /current/meta,POST /current— viewport setters, 403 if session's wolt not allowedAdmin UI
New page in the lodge, visible only to
admin:GET/POST/DELETE /admin/usersroutesPending-approval state
A user who's authenticated via CF Access but not in
users.jsonreaches the tunnel and gets a 403 page (or a friendly "pending approval — ask jerpint" landing). CF Access controls "who reaches the box",users.jsoncontrols "what they see once on it." Two sources of truth, but clean separation.Telegram/Slack bots
Out of scope for this issue. Bots bypass the web tunnel and have their own allow-list mechanisms (TELEGRAM_ALLOWED_USERS, slack thread ownership). They stay as-is for phase 1. If/when needed, a follow-up adds per-user-id allow-lists for bots — but the web layer is the priority.
Acceptance criteria
WOLTSPACE_AUTH=none(default): platform behaves identically to today, all existing tests passWOLTSPACE_AUTH=cloudflare: JWT extracted + validated on every requestusers.jsondata model + load/save helpersWOLTSPACE_ADMIN_EMAILKnown gap (intentional)
Sessions run as
node(single OS user). A user inside a session can read other wolts' files via the shell. This issue does not address that — it's the FS-level permissions follow-up.Files touched (estimate)
server/app.py— middleware registration, route guards (~150 lines)server/auth.py(new) — JWT validation, users.json CRUD, permission checks (~150 lines)container/lib/wolts.py—owner_emailon create, migration helpertemplates/admin.html(new) + sidebar.htmlpublic/static/admin.js(new)test/test_auth.py(new)Total: ~400–500 lines + admin UI.