Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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