Skip to content

Web-layer auth via Cloudflare Access JWT (MVP, #353)#355

Open
woltspace-jerpint[bot] wants to merge 8 commits into
mainfrom
uxw/web-auth-mvp
Open

Web-layer auth via Cloudflare Access JWT (MVP, #353)#355
woltspace-jerpint[bot] wants to merge 8 commits into
mainfrom
uxw/web-auth-mvp

Conversation

@woltspace-jerpint

Copy link
Copy Markdown
Contributor

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_AUTH env 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.json at wolts/.space/auth/users.json:

{
  "users": [
    {"email": "admin@example.com",        "wolts": ["*"]},
    {"email": "collaborator@example.com", "wolts": ["bloggo"]}
  ]
}

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

  • GET /wolts, /sessions, /sites, /apps — filtered to visible
  • POST /sessions/new/{create,lodge,telegram,slack} — 403 if target wolt not allowed
  • GET/POST /apps/{name}/{detail,start,stop,share,unshare} — 403 if app's keeper wolt not allowed
  • GET/POST /sites/{wolt}/{detail,start,stop} — 403 if wolt not allowed
  • GET /wolt/{wolt}/site/* — 403 if wolt not allowed
  • POST /sessions/{name}/{resume,stop} — 403 if session's wolt not allowed

Out of scope (intentional)

  • Admin UI: users.json is hand-edited for the MVP. The lodge UI for user CRUD is the natural next iteration.
  • Bot adapters: Telegram/Slack bypass the web tunnel — they keep their existing allow-list mechanisms.
  • OS-level isolation: sessions still run as one OS user (node). A running session can still cd ../other-wolt and 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.json CRUD, permission helpers, FastAPI route-guard helpers (require_wolt, require_app).
  • server/app.py: middleware to extract email into request.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
  • Full unit suite (-k "not haiku and not agent_loop and not telegram_loop and not closed_loop and not vulture") → 330/330 pass
  • Live test in WOLTSPACE_AUTH=cloudflare mode against a tunnel'd container — set the 3 CF env vars, log in as admin email, confirm /wolts shows everything; log in as a second user, confirm filtered view + 403s on cross-wolt routes
  • Live test fallback path with WOLTSPACE_AUTH=none (or unset) — confirm zero behavior change

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
woltspace Ready Ready Preview, Comment Jun 21, 2026 1:15am

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants