Web-layer auth via Cloudflare Access JWT (MVP, #353)#355
Open
woltspace-jerpint[bot] wants to merge 8 commits into
Open
Web-layer auth via Cloudflare Access JWT (MVP, #353)#355woltspace-jerpint[bot] wants to merge 8 commits into
woltspace-jerpint[bot] wants to merge 8 commits into
Conversation
Adds opt-in per-user permissions to the FastAPI server. Validates the
Cloudflare Access JWT that already arrives with every authenticated request,
looks up the caller's email in users.json, and gates wolt/app/session routes.
Two modes via WOLTSPACE_AUTH env var:
- none (default): no-op, identical to today
- cloudflare: JWT validated, routes filter + 403 by per-user allow-list
users.json lives at wolts/.space/auth/users.json. wolts=["*"] is admin.
Admin entry auto-bootstrapped from WOLTSPACE_ADMIN_EMAIL on first boot.
Apps inherit access from their keeper wolt — no separate apps list.
Routes gated:
- /wolts, /sessions, /sites, /apps (filtered to visible)
- /sessions/new/{create,lodge,telegram,slack}
- /apps/{name}/{detail,start,stop,share,unshare}
- /sites/{wolt}/{detail,start,stop}
- /wolt/{wolt}/site/*
- /sessions/{name}/{resume,stop}
Known gap (intentional, tracked separately as #354): sessions still run as
one OS user, so a running session can read other wolts' files via the
shell. This issue is web-layer only.
No admin UI yet — users.json is hand-edited. Docs in HUMANS.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
A small Python CLI at container/bin/access wraps the JSON edits cleanly: list, add, grant, revoke, promote, demote, remove, check. Plus a skill that teaches any wolt how to invoke it from a session. Explicitly documented as a CONVENIENCE LAYER, not a security boundary. Any wolt can write users.json directly via the shell. Real enforcement requires #354 (filesystem isolation). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
) After review, the admin role wasn't actually gating any operation in this PR — it was forward-looking scaffolding for /admin/users endpoints we decided to defer to #354 (which will have proper OS-level enforcement). Changes: - Remove is_admin(); allow-list with '*' wildcard is the whole model. - Remove WOLTSPACE_ADMIN_EMAIL bootstrap — the woltspace-access skill walks operators through 'access add yourself *' as the first-time setup step. - Drop promote/demote from the access CLI. - /sessions/new/create now auto-appends the new wolt to the creator's allow-list (in cloudflare mode). Self-onboarding: a user with empty wolts can create their first wolt and immediately use it. - Rewrite woltspace-access skill with explicit 'First-time auth setup' and 'Day-to-day user management' sections. - HUMANS.md updated to match. - 28 auth tests pass; full suite green (one pre-existing flake in test_wolt_sites unrelated to this change). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
uvicorn's --reload-dir reloads code but doesn't refresh the process env. Reading WOLTSPACE_AUTH / WOLTSPACE_CF_* directly from os.environ meant operators had to fully restart the container to flip the toggle. Now _env() falls back to parsing wolts/.env so settings can be edited and picked up via a normal reload (touch server/app.py). Matches where the rest of woltspace's runtime config (CLOUDFLARE_API_TOKEN, etc) lives. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The root cause of jeremy@onixai.ai (and jerpint) seeing zero wolts in the live test: PyJWT is not in server/.venv. extract_email() swallowed the ImportError silently and returned None for every request, so visible_wolts() filtered everything to []. Unit tests passed because they ran in a separate venv that did have PyJWT. Fixes: 1. PyJWT[crypto] added to server/pyproject.toml dependencies. 2. ImportError now logs loudly via _fail() + flushes to stderr. 3. All JWT validation failure paths now record a last_error string, exposed via /auth/debug. 4. New /auth/debug endpoint (loopback-only) returns auth state, last error, whether PyJWT is available, etc. Catches misconfigs in seconds. 5. New 'is_loopback' safety net: when auth=cloudflare and a request hits 127.0.0.1 with no JWT (in-container caller, desktop app via localhost, debug session), it's treated as a synthetic '__local__' user with wildcard access. Operator can never lock themselves out via local paths. Threat-model-equivalent to today since no FS perms yet. 6. New TestJWTRoundtrip class: self-signs a real RS256 JWT and verifies the full decode path end-to-end. Catches PyJWT API drift and other decode-level bugs that 'garbage in, None out' tests miss. 35 auth tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The @app.middleware("http") hook doesn't run for websocket connections, so
request.state.user_email was never set for them — the /tui terminal-attach
and site-livereload WS routes were ungated. A user could attach to a
terminal in a wolt they couldn't see.
Fix: WS-specific auth helpers (ws_email, ws_can_access_wolt) that re-derive
identity from the upgrade request's headers/client directly (CF injects the
JWT on the WS upgrade GET). Same loopback safety net as http.
- /tui: gated by the session's wolt (parsed from the slug). "main" shell is
loopback-only.
- /wolt/{wolt}/site/livereload: gated by wolt.
- GET /app/{name} serve route: gated by keeper wolt (was only the
control endpoints before).
apps were already gated at the REST layer (start/stop/share/unshare/detail)
via keeper wolt; this extends it to the static-serve path too.
41 auth tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
With auth on, localhost:7777 browser access showed no wolts: Docker port-mapping (-p 7777:7777) presents host traffic as the bridge gateway (e.g. 172.17.0.1), not 127.0.0.1, so it failed the loopback check and carried no JWT. New opt-in flag WOLTSPACE_AUTH_TRUST_LOCAL (default OFF): when set, is_loopback also trusts RFC1918 private addresses, restoring localhost convenience. Genuine loopback (127.0.0.1/::1) is always trusted so in-container CLIs keep working regardless. Documented the tradeoff prominently (HUMANS.md + skill): because the port binds 0.0.0.0, enabling the flag grants unauthenticated full access to any LAN device that can reach the port — Docker can't distinguish the operator's browser from another host. Only for trusted networks. 45 auth tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ot guide (#353) Captures everything learned during the live rollout: - Two-layer model (CF Access front door + users.json interior) stated up front - Concrete CF lookup commands for team domain + AUD tag via API - /auth/debug verification step + full troubleshooting section keyed on its output - PyJWT-missing as the #1 "I see no wolts" cause, with the fix - CF policy add via API + the create-only token scope gotcha (10405) - WOLTSPACE_AUTH_TRUST_LOCAL localhost flag with the LAN tradeoff - Rollback instructions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes part of #353 (phase-1 MVP — no admin UI yet, users.json is hand-edited).
Summary
Opt-in per-user permissions on the FastAPI server. Validates the Cloudflare Access JWT that already arrives with every authenticated request, looks up the caller's email in
users.json, and gates wolt/app/session routes.Two modes via
WOLTSPACE_AUTHenv var:none(default): no-op, identical to today's behavior. All existing setups keep working unchanged.cloudflare: JWT validated, routes filter + 403 by per-user allow-list.users.jsonatwolts/.space/auth/users.json:{ "users": [ {"email": "admin@example.com", "wolts": ["*"]}, {"email": "collaborator@example.com", "wolts": ["bloggo"]} ] }wolts: ["*"]is admin. Admin entry auto-bootstrapped fromWOLTSPACE_ADMIN_EMAILon first boot. Apps inherit access from their keeper wolt (no separate apps list).Routes gated
GET /wolts,/sessions,/sites,/apps— filtered to visiblePOST /sessions/new/{create,lodge,telegram,slack}— 403 if target wolt not allowedGET/POST /apps/{name}/{detail,start,stop,share,unshare}— 403 if app's keeper wolt not allowedGET/POST /sites/{wolt}/{detail,start,stop}— 403 if wolt not allowedGET /wolt/{wolt}/site/*— 403 if wolt not allowedPOST /sessions/{name}/{resume,stop}— 403 if session's wolt not allowedOut of scope (intentional)
node). A running session can stillcd ../other-woltand read files via the shell. Tracked in Phase 2: filesystem-level wolt isolation (follow-up to #353) #354 as a much bigger follow-up.Implementation
server/auth.py(new, ~280 lines): JWT validation via PyJWT,users.jsonCRUD, permission helpers, FastAPI route-guard helpers (require_wolt,require_app).server/app.py: middleware to extract email intorequest.state.user_email, route guards inline.test/test_auth.py(new, 26 tests covering both modes).HUMANS.md: new "Multi-user permissions" section with the env vars, schema, and a clear scope statement.Test plan
uv run --extra test pytest test/test_auth.py -v→ 26/26 pass-k "not haiku and not agent_loop and not telegram_loop and not closed_loop and not vulture") → 330/330 passWOLTSPACE_AUTH=cloudflaremode against a tunnel'd container — set the 3 CF env vars, log in as admin email, confirm/woltsshows everything; log in as a second user, confirm filtered view + 403s on cross-wolt routesWOLTSPACE_AUTH=none(or unset) — confirm zero behavior change🤖 Generated with Claude Code