Skip to content

Phase 1: per-user web auth via Cloudflare Access JWT #353

@woltspace-jerpint

Description

@woltspace-jerpint

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

  • WOLTSPACE_AUTH=none (default): platform behaves identically to today, all existing tests pass
  • WOLTSPACE_AUTH=cloudflare: JWT extracted + validated on every request
  • users.json data model + load/save helpers
  • Bootstrap admin via WOLTSPACE_ADMIN_EMAIL
  • All listed routes gated
  • Admin UI for user CRUD
  • Pending-approval state for unknown emails
  • Tests for both modes
  • Docs (HUMANS.md + ARCHITECTURE.md) updated

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.pyowner_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions