From 71d4c1817c0c8228e975abe932851a71842ba892 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 23:41:53 +0000 Subject: [PATCH] Cloud container: pb-dev as the team's shared PocketBase, signed in through the team OIDC provider Laptops reach pb-dev's API without the site sign-in; PocketBase signs people in itself through the same OIDC provider (its own client), members of OIDC_ALLOWED_GROUPS only, password sign-in off. The group check is ONE hook baked into the image and pinned by SHA-256; hooks from the pulled repo still never run. The dashboard, _superusers and system collections stay behind the site sign-in for admins. It fails closed: without the baked gate or a working OIDC setup, /pb/ stays behind the site sign-in as before. --- deployment/cloud/Dockerfile | 18 ++++++++++++++++ deployment/cloud/README.md | 3 ++- deployment/cloud/admin.mjs | 6 +++++- deployment/cloud/entry.sh | 28 +++++++++++++++++++++--- deployment/cloud/pb-oidc.mjs | 41 ++++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 deployment/cloud/pb-oidc.mjs diff --git a/deployment/cloud/Dockerfile b/deployment/cloud/Dockerfile index 39ff6e22f..ae048a1ce 100644 --- a/deployment/cloud/Dockerfile +++ b/deployment/cloud/Dockerfile @@ -36,6 +36,24 @@ ARG PB_SHA256=9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa RUN curl -fsSL -o /tmp/pb.zip "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" \ && echo "${PB_SHA256} /tmp/pb.zip" | sha256sum -c - \ && unzip -q /tmp/pb.zip pocketbase -d /usr/local/bin && rm /tmp/pb.zip && chmod 755 /usr/local/bin/pocketbase +# PocketBase team sign-in gate (a JS hook), baked here and pinned: the ONLY server-side hook that runs, in pb-dev. +# It lives in the image, never in the pulled repo, so a plugin writer's push can't change what it lets in. +# Changing it = edit these lines AND the SHA-256 below, then redeploy the image (an admin act). +RUN mkdir -p /app/pb_hooks_gate && printf '%s\n' \ + '// Team sign-in gate: only members of OIDC_ALLOWED_GROUPS (the groups claim from the identity provider) may sign in here.' \ + '// Baked into the image by deployment/cloud/Dockerfile and pinned by SHA-256: never read from the pulled repo.' \ + 'onRecordAuthWithOAuth2Request((e) => {' \ + ' const allow = ($os.getenv("OIDC_ALLOWED_GROUPS") || "").split(",").map((s) => s.trim()).filter((s) => s)' \ + ' const raw = (e.oAuth2User && e.oAuth2User.rawUser) || {}' \ + ' const groups = []' \ + ' if (raw.groups) for (let i = 0; i < raw.groups.length; i++) groups.push(String(raw.groups[i]))' \ + ' if (!allow.length || !groups.some((g) => allow.indexOf(g) >= 0)) {' \ + ' throw new ForbiddenError("This account is not in a group allowed to use this team backend.")' \ + ' }' \ + ' e.next()' \ + '}, "users")' \ + > /app/pb_hooks_gate/team-gate.pb.js \ + && echo "2987055ba6067fc30d8162608da21c6c2b691fb6897a46d10b7075271a38f13d /app/pb_hooks_gate/team-gate.pb.js" | sha256sum -c - RUN printf '%s\n' \ '#!/bin/bash' \ 'set -u; export GIT_TERMINAL_PROMPT=0' \ diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index e70e43fdb..d65300d30 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -51,7 +51,7 @@ With the sign-in on, the container also runs PocketBase and can publish a produc | Route | Who | What | |---|---|---| | `/` | signed-in | the dev server (Plugin Studio, hot reload) | -| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only) | PocketBase **dev** | +| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only). With `PB_OIDC_CLIENT_SECRET`, its `/api/` is public and PocketBase signs people in itself (see below); `_superusers` and system collections stay admin-only | PocketBase **dev**, the team's shared backend | | `/api-prod/api/` | **public** | PocketBase **prod**'s API; its dashboard is never exposed | | `/cdn/dx-maps/` | **public** | the last published build: `manifest.json` at a stable URL, CORS open for embeds | | `POST /admin/build` · `GET /admin/status` | `OIDC_ADMIN_GROUPS` only | build and publish the pulled commit | @@ -65,6 +65,7 @@ there. Only then does it swap the new build in. |---|---| | `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard | | `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances | +| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https:///pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https:///pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` | | `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https:///api-prod`) | | `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) | | `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index a870d495a..137970c6d 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -1,7 +1,8 @@ // admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy. // /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here) // /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard -// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS +// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET, +// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections // /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET // Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only. import { spawn } from 'node:child_process' @@ -42,6 +43,9 @@ Bun.serve({ } if (p.startsWith('/pb/')) { if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403) + // The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these + // paths to the public (its skip routes exclude names starting with _), so the groups header is its own. + if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403) return proxy(req, url, '/pb/', 8090) } if (p === '/admin/status') return Response.json(last) diff --git a/deployment/cloud/entry.sh b/deployment/cloud/entry.sh index fb415af3a..c1845bbe6 100755 --- a/deployment/cloud/entry.sh +++ b/deployment/cloud/entry.sh @@ -102,13 +102,34 @@ for inst in dev prod; do && echo "[pb-$inst] superuser set" || echo "[pb-$inst] superuser upsert failed" fi done -# Server-side JS hooks OFF: point --hooksDir at a path that never exists, so a pb_hooks/ folder -# added to the repo can't run code in either instance (v1 has no per-plugin isolation for hooks). +# Server-side JS hooks from the REPO are OFF: --hooksDir never points into the pulled tree, so a pb_hooks/ +# folder pushed by a plugin writer can't run code (v1 has no per-plugin isolation for hooks). prod runs +# none; dev runs only the team sign-in gate BAKED INTO THE IMAGE (Dockerfile, SHA-256 pinned). NOHOOKS=/var/empty/pb_hooks_disabled -pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$NOHOOKS" & +GATE=/app/pb_hooks_gate +DEV_HOOKS=$NOHOOKS; [ -f "$GATE/team-gate.pb.js" ] && DEV_HOOKS=$GATE +pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$DEV_HOOKS" & pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" --hooksDir "$NOHOOKS" & bun /app/repo/deployment/cloud/admin.mjs & +# TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https:///pb), and PocketBase +# itself signs people in through the same OIDC provider, members of OIDC_ALLOWED_GROUPS only (the baked +# gate). Fails CLOSED: /pb/api/ is opened only when the gate is in place AND the OIDC setup succeeded; +# otherwise pb-dev stays behind the site sign-in, as before. /pb/_/ (dashboard) and _superusers never open. +PB_PUBLIC=() +if [ -n "${PB_OIDC_CLIENT_SECRET:-}" ]; then + for _ in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:8090/api/health && break; sleep 0.5; done + PB_APP_URL="${PB_APP_URL:-$(printf '%s' "$OIDC_REDIRECT_URL" | sed -E 's#^([a-z]+://[^/]+).*#\1#')/pb}" + if [ "$DEV_HOOKS" = "$GATE" ] && PB_APP_URL="$PB_APP_URL" bun /app/repo/deployment/cloud/pb-oidc.mjs; then + PB_PUBLIC=(--skip-auth-route='^/pb/api/(health|realtime|oauth2-redirect|batch)$' + --skip-auth-route='^/pb/api/files/' + --skip-auth-route='^/pb/api/collections/[^_/][^/]*/') + echo "[pb-dev] team API open at /pb/api/ (sign-in through PocketBase; dashboard stays gated)" + else + echo "[pb-dev] team sign-in NOT configured: /pb/ stays behind the site sign-in" >&2 + fi +fi + VITE_HOST=127.0.0.1 VITE_PORT=5174 bun x vite --config "$CONFIG" & oauth2-proxy \ --provider=oidc \ @@ -128,6 +149,7 @@ oauth2-proxy \ --upstream=http://127.0.0.1:5175/admin/ \ --skip-auth-route='^/cdn/' \ --skip-auth-route='^/api-prod/api/' \ + "${PB_PUBLIC[@]}" \ --http-address="0.0.0.0:$PORT" \ --reverse-proxy=true \ --cookie-secure=true \ diff --git a/deployment/cloud/pb-oidc.mjs b/deployment/cloud/pb-oidc.mjs new file mode 100644 index 000000000..043efb6a4 --- /dev/null +++ b/deployment/cloud/pb-oidc.mjs @@ -0,0 +1,41 @@ +// pb-oidc.mjs — make the team PocketBase (pb-dev) sign people in through the team's OIDC provider. +// Run by entry.sh at every start, against PocketBase on localhost, as the superuser (env, never printed). +// users: sign-in ONLY through OIDC (password sign-in off); the group check is the image's baked hook. +// The client secret comes from the environment; nothing here, or in PocketBase's data, is in git. +const env = process.env +const PB = env.PB_LOCAL_URL || 'http://127.0.0.1:8090' +const need = (k) => env[k] || (console.error(`[pb-oidc] ${k} is not set: team sign-in NOT configured`), process.exit(1)) + +const call = async (method, path, body, token) => { + const r = await fetch(PB + path, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) }, body: body && JSON.stringify(body) }) + const j = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${j.message || ''}`) + return j +} + +const issuer = need('OIDC_ISSUER').replace(/\/$/, '') +const disc = env.PB_OIDC_DISCOVERY_JSON ? JSON.parse(env.PB_OIDC_DISCOVERY_JSON) + : await (await fetch(issuer + '/.well-known/openid-configuration')).json() +const { token } = await call('POST', '/api/collections/_superusers/auth-with-password', + { identity: need('PB_SUPERUSER_EMAIL'), password: need('PB_SUPERUSER_PASSWORD') }) + +await call('PATCH', '/api/collections/users', { + passwordAuth: { enabled: false, identityFields: ['email'] }, + oauth2: { + enabled: true, + mappedFields: { id: '', name: 'name', username: '', avatarURL: '' }, + providers: [{ + name: 'oidc', + displayName: env.OIDC_DISPLAY_NAME || 'Team sign-in', + clientId: env.PB_OIDC_CLIENT_ID || 'dx-maps-pb-dev', + clientSecret: need('PB_OIDC_CLIENT_SECRET'), + authURL: disc.authorization_endpoint, + tokenURL: disc.token_endpoint, + userInfoURL: disc.userinfo_endpoint, + pkce: true, + extra: {}, + }], + }, +}, token) +if (env.PB_APP_URL) await call('PATCH', '/api/settings', { meta: { appURL: env.PB_APP_URL } }, token) +console.log(`[pb-oidc] users sign in through ${issuer} only (password sign-in off)`)