Identity & permissions — Design
Draft

Identity & permissions — Design

2026-05-11Danielwiki-browsersub-project #7

Problem

Sub-project #7 of the collaborative-annotations initiative adds real identity and permission checks before wiki-browser is exposed on the public internet. The deployment is a Raspberry Pi behind Nginx Proxy Manager; Nginx terminates HTTPS, while wiki-browser owns Google OAuth/OIDC and its own application sessions.

The domain model originally placed identity late in the sequence, but the project now needs it earlier: public read-only document browsing is allowed, yet Topics, messages, proposals, Perspective management, and Agent-triggering actions must be available only to two allowlisted collaborators. This spec defines that boundary without redesigning Topic core, Incorporation, Perspectives, or real-time collaboration.

Goals

Non-goals

Approach

Use app-owned OAuth plus app-owned sessions. Google proves the user's email address; wiki-browser decides whether that identity is allowed, creates its own server-side session, and attaches a request principal to protected handlers.

Browser public docs + login Nginx Proxy TLS only wiki-browser OAuth callback, sessions, permission checks, attribution Google OAuth/OIDC openid email profile callback
Nginx owns HTTPS; wiki-browser owns identity, sessions, and permissions.

Anonymous users can keep using wiki-browser as a read-only public site. Signing in changes the server-side request principal; the server then renders collaborator affordances and allows protected JSON endpoints. The client never receives hidden collaborator data for anonymous users.

Decision — implement auth in wiki-browser

Proxy-owned auth was rejected. It would reduce Go code, but correctness would depend on header-stripping and Nginx configuration. App-owned OAuth keeps local tests representative and keeps authorization close to the collaboration handlers that need it.

Design

Configuration

Authentication is configured explicitly in wiki-browser.yaml. Secrets live outside the repo and are read from files so deployment can mount or rotate them without editing committed config.

yamlauth:
  public_base_url: "https://wiki.example.com"
  google_client_id: "123.apps.googleusercontent.com"
  google_client_secret_file: "/srv/wiki-browser/secrets/google-client-secret"
  session_secret_file: "/srv/wiki-browser/secrets/session-secret"
  allowed_emails:
    - "daniel@getorcha.com"
    - "max@getorcha.com"

Auth fully replaces the earlier single-operator bootstrap. Once #7 lands, operator is removed from required config, startup no longer inserts a bootstrap operator row, and collaborative mutators receive the authenticated request principal. There is no local single-operator fallback when auth: is absent. If auth config is incomplete, startup fails.

Users and sessions

The existing users table becomes the durable identity table. For this deployment, email is the local user ID:

Google's stable sub claim is useful during callback verification, but the local audit trail should remain readable. The allowlist is email-based, so email-as-ID is the right local key unless this deployment later needs domain-wide users or account migration.

Add server-side sessions to the collab DB:

sqlCREATE TABLE auth_sessions (
  id_hash       TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL,
  csrf_hash     TEXT NOT NULL,
  created_at    INTEGER NOT NULL,
  last_seen_at  INTEGER NOT NULL,
  expires_at    INTEGER NOT NULL,
  revoked_at    INTEGER,
  FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX auth_sessions_user ON auth_sessions(user_id);
CREATE INDEX auth_sessions_expires ON auth_sessions(expires_at);

The browser cookie carries only an opaque random session token. The DB stores a hash of that token, not the raw token. Sessions use a bounded sliding lifetime: for example, 30 days from last activity, with last_seen_at and expires_at updated through the existing single write funnel. Logout sets revoked_at or deletes the row.

Each session also owns one opaque CSRF token. The raw CSRF value is returned only to signed-in same-origin JavaScript via GET /auth/me; SQLite stores csrf_hash. Sliding session expiry does not rotate the CSRF token. Logout, revocation, and session expiry invalidate it together with the session. Every mutating endpoint, including POST /auth/logout, requires the session cookie plus X-CSRF-Token: <raw csrf token>. Missing or invalid CSRF returns 403 Forbidden.

Short-lived OAuth transactions also live in SQLite, separate from durable sessions:

sqlCREATE TABLE auth_oauth_states (
  state_hash    TEXT PRIMARY KEY,
  pkce_verifier TEXT NOT NULL,
  return_path   TEXT NOT NULL,
  created_at    INTEGER NOT NULL,
  expires_at    INTEGER NOT NULL,
  consumed_at   INTEGER
);
CREATE INDEX auth_oauth_states_expires ON auth_oauth_states(expires_at);

/auth/login creates a high-entropy state, stores only state_hash plus the PKCE verifier and safe local return path, and redirects to Google with the raw state. /auth/callback hashes the received state, finds an unexpired unconsumed row, marks it consumed, then uses the stored verifier for token exchange. Rows expire after 10 minutes and may be deleted opportunistically during login or startup. A process restart does not break pending logins because the transaction state is in SQLite.

Routes

RouteAuthPurpose
GET /auth/loginpublicCreate short-lived OAuth state and PKCE verifier, then redirect to Google.
GET /auth/callbackpublicVerify Google callback, allowlist email, create session, and redirect to the original safe local path.
POST /auth/logoutsession + CSRFRevoke the current session and return to the same public document view.
GET /auth/meoptional sessionReturn anonymous state, or the current principal plus raw CSRF token for same-origin JS bootstrapping.

Public read routes remain unauthenticated: /, /doc/..., /content/..., /search, /static/..., and /healthz. Collaboration APIs require a valid session even for reads, because Topic and proposal data can contain private discussion about otherwise public Sources.

OAuth callback validation

The callback handler follows the standard server-side OAuth/OIDC code flow:

  1. Verify the single-use state value and consume the stored PKCE verifier.
  2. Exchange the authorization code for tokens with Google.
  3. Verify the ID token's signature, issuer, audience, and expiry.
  4. Require email_verified == true.
  5. Normalize email to lowercase and require membership in allowed_emails.
  6. Upsert users(id, display_name).
  7. Create an auth_sessions row, including a hashed CSRF token, and set the session cookie.
  8. Redirect to the originally requested local path, or / if the path is missing or unsafe.

The OAuth scopes are minimal: openid email profile. The Google Workspace hosted-domain claim may be checked as a defense-in-depth hint if present, but the allowlist remains the authority; never accept a domain claim as a replacement for exact email matching.

Development mode

Running the full Google OAuth dance against a local http://localhost:8080 dev server adds friction without exercising any real security property: the allowlist gate that decides authorization runs after Google's redirect, and locally there is no domain or session-store difference between an OAuth-issued session and a hand-issued one. Dev mode short-circuits the dance with a one-click "Continue as <email>" picker drawn from the same allowed_emails list, producing a real session through the same code path OAuth uses.

Dev mode is gated by an explicit auth.dev_mode: true in the YAML and is mutually exclusive with HTTPS:

yamlauth:
  dev_mode: true
  public_base_url: "http://localhost:8080"
  session_secret_file: "./secrets/session-secret"
  allowed_emails:
    - "daniel@getorcha.com"
    - "max@getorcha.com"
  # google_client_id / google_client_secret_file: optional when dev_mode is true

The anonymous UI is identical to production: the chrome shows a single "Sign in" button pointing at /auth/login. The server branches on dev_mode behind that one URL:

The resulting session is indistinguishable from an OAuth-issued one: same wb_session + wb_csrf cookie pair, same auth_sessions row, same sliding 30-day expiry, same CSRF behavior, same logout flow. Middleware, permission helpers, and protected handlers cannot tell which login path produced a session. Anonymous chrome renders exactly the same markup in dev and prod; the only difference is what the server returns from /auth/login.

Warning — never enable dev_mode on the public deployment

Dev mode trusts client-supplied identity. A misconfigured Pi with dev_mode: true would let anyone pick "I'm Daniel" and gain collaborator access. The HTTPS-mutually-exclusive startup check is the load-bearing guard; allowed_emails in dev mode is a UI list, not a security boundary.

Authorization model

There is one v1 role: authenticated allowlisted user = collaborator. Daniel and Max have identical permissions.

PolicyRequired principalOwned by
document:readanonymous allowedexisting wiki-browser
topic:createcollaborator#2
topic:messagecollaborator#2
topic:discardcollaborator#4
proposal:createcollaborator#4
proposal:approvecollaborator#4
perspective:managecollaborator#5 / #8
agent:invokecollaborator#3 / #4 / #5

The implementation should expose a small server helper: public handlers can read an optional principal; protected handlers call something equivalent to RequireCollaborator. Current and future mutators should take the request principal's UserID, not a process-wide OperatorUserID.

UI contract

Anonymous users see wiki-browser almost exactly as it exists today. The only new visible control is a sign-in affordance in the chrome.

Cache policy

HTML can vary by session because the chrome, content iframe, and collaboration bootstrap differ for anonymous and authenticated users. Use conservative headers so collaborator affordances or bootstrapping data cannot be cached into a public response.

Decision — collaborator data is never public

Documents are public read-only, but Topics, messages, proposals, pending diffs, Perspective definitions, and Agent job metadata are collaborator-only. Public document viewing is not permission to read collaboration state.

Error handling

Security controls

Testing

References