Skip to content

feat(gate): activate, mint and revoke the preview access gate (switchboard#26 Part B) - #31

Merged
luthermonson merged 1 commit into
mainfrom
feat/preview-access-gate
Sep 9, 2026
Merged

luthermonson merged 1 commit into
mainfrom
feat/preview-access-gate

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

What

Switchboard's control-plane half of the GitHub-OAuth preview access gate. ePHPm ships the enforcement and verification (ephpm/ephpm #487/#491, per-site preview-gate middleware + Hs256Policy + KV revocation); this PR turns the gate on per preview, posts access info, mints share links, and drives revocation. Security-critical: a private repo's preview must never deploy world-readable — every path where gating could silently not happen fails the deploy instead.

Do not merge — for review. Please pay attention to the fail-closed gating of private previews and that the session secret never leaks.

Visibility detection + gating policy

  • Repo visibility comes from repository.private, plumbed through both producers: JobRepository::private (queue path) and webhook Repository::private, into PreviewRequest::private. Absent ⇒ private (fail closed) in both.
  • Policy (preview_auth::should_gate): private repo → always gated; public repo → ungated unless --gate-public-previews.

The override written

For a gated preview, [preview_auth] is written into the same <site-key>.toml that already carries document_root/auto_prepend_file:

[preview_auth]
session_secret = "env:EPHPM_PREVIEW_SESSION_SECRET"   # a REFERENCE, never the key
login_url      = "/_ephpm/auth/github/login"

session_secret is written as a reference (env:/file:/literal), matching the contract — the file is tenant-adjacent, so the key never lands in it or the served tree. Both strings are TOML-injection-validated.

Temp-URL mint + revocation

  • Mint (preview_auth::mint_share_token): wire-compatible with ephpm_middleware_builtins::preview_gate::mint_share_token{site, via:"share", jti, iat, exp}, header {"alg":"HS256","typ":"JWT"}, base64url-unpadded, HMAC-SHA256 over h.p. site = canonical site key; jti from the OS CSPRNG. Switchboard mints directly (it resolves the shared secret); only the token travels in the URL, never the secret. Opt-in via --share-link, short default TTL (1 day).
  • Revocation (kv.rs + teardown): on teardown, bump preview:share:epoch = now in the preview's own KV keyspace (RESP AUTH <site> <HMAC-SHA256([kv] secret, site)>, then SET), killing all outstanding share links cluster-wide. Best-effort — an unreachable KV warns, never fails the teardown; skipped without --kv-secret-file. Wired into the teardown path (env: never reaches PHP for docroot: "." — the generated prepend is not auto-loaded #4/fix(teardown): keep the applied/ marker that records our own teardown #24 reconcile).

Fail-closed (the whole point) + tests

  • Gated preview whose session_secret won't resolve to ≥32 bytes → deploy fails (resolve_preview_gate).
  • Gated preview with no --site-overrides-dir (nowhere to write the gate) → deploy fails (apply_site_override); an ungated preview only warns.
  • Tests: private job → [preview_auth] written + deploy proceeds; private job with no secret → fails; public ungated by default, gated under --gate-public-previews; PR comment for a gated preview has login guidance; a minted token is a valid via:"share" token for the site (shape + signature verified) and the secret never appears; teardown bumps the epoch (mock RESP server); unreachable KV doesn't fail teardown. All existing deployer/teardown/comment/config tests stay green. 265 tests pass; clippy -D warnings clean; cargo fmt clean; MSRV 1.85 cargo check clean.

PR-comment format

A gated preview's sticky comment gains an Access block ("Sign in with GitHub — you'll be authorised automatically if you have read access"); with --share-link it also shows the shareable URL and states plainly it is a bearer capability (anyone with it is in until expiry/revocation). Ungated previews are unchanged.

Operator config: one-time fleet vs per-preview

One-time, per fleet (ePHPm node config — NOT switchboard code): register one GitHub OAuth App, mount github-auth globally in ephpm.toml (client id/secret, per-repo access target, session_secret = "env:EPHPM_PREVIEW_SESSION_SECRET", and for a wildcard fleet the apex redirect_uri + cookie_domain), and set EPHPM_PREVIEW_SESSION_SECRET (≥32 bytes) in both the ePHPm and switchboard process environments (both must resolve the reference to identical bytes for a switchboard-minted share token to verify).

Per preview (switchboard, this PR): decide gating from visibility, write [preview_auth], mint/post the optional share link, bump the revocation epoch on teardown. New flags: --gate-public-previews, --preview-session-secret-ref, --share-link, --share-link-ttl-secs, --kv-secret-file, --kv-addr.

Rollout ordering: only write [preview_auth] once an ePHPm that enforces it is deployed (an older ePHPm ignores the unknown section and would serve ungated) — same discipline document_root and the ephpm exec gate follow.

Docs (docs/preview-access-gate.md, README) rewritten to match the shipped OAuth + share-token design, superseding the earlier Basic-auth plan.

🤖 Generated with Claude Code

…board#26 Part B)

ePHPm ships the enforcement and verification for the GitHub-OAuth preview
access gate (ephpm#487/#491); this is switchboard's control-plane half.

- Gating policy (preview_auth.rs): a private repo's preview is always gated,
  a public one only with --gate-public-previews. Repo visibility comes from
  repository.private, plumbed through the job and webhook paths and defaulting
  to private when absent (fail closed).
- Activation (site_override.rs): a gated preview gets a [preview_auth] section
  written into the same per-site override file, carrying a session_secret
  REFERENCE (env:/file:, never the key) and the issuer's login_url.
- Fail closed (deployer.rs): resolve_preview_gate resolves the session secret
  (>=32 bytes) before serving; a gated preview whose secret does not resolve,
  or that has nowhere to write the gate (no --site-overrides-dir), FAILS the
  deploy rather than coming up world-readable.
- Share links (preview_auth.rs + github.rs): opt-in --share-link mints a
  wire-compatible via:"share" HS256 capability token bound to the preview's
  site key and posts ...?ephpm_share=<token> in the PR comment with the
  bearer-capability warning. Only the token travels; the secret never does.
- Revocation (kv.rs + teardown.rs): teardown bumps the per-site epoch
  (preview:share:epoch) in the preview's KV keyspace, killing every
  outstanding share link cluster-wide. Best-effort — an unreachable KV warns,
  never fails the teardown; skipped without --kv-secret-file.
- Config: --gate-public-previews, --preview-session-secret-ref, --share-link,
  --share-link-ttl-secs, --kv-secret-file, --kv-addr.
- Docs: rewrote docs/preview-access-gate.md and the README to match the
  shipped OAuth + share-token design (superseding the old Basic-auth plan).
@ephpm

ephpm Bot commented Sep 9, 2026

Copy link
Copy Markdown

ePHPm Preview — removed

Preview deployment has been torn down.

@ephpm
ephpm Bot temporarily deployed to preview-pr-31 September 9, 2026 04:41 Inactive
@ephpm
ephpm Bot temporarily deployed to preview-pr-31 September 9, 2026 04:41 Inactive
@luthermonson
luthermonson merged commit 9f6acb5 into main Sep 9, 2026
4 checks passed
@luthermonson
luthermonson deleted the feat/preview-access-gate branch September 9, 2026 04:42
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.

1 participant