Cloud container: pb-dev as the team's shared PocketBase, signed in through the team OIDC provider
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

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.
This commit is contained in:
Doxa Maps 2026-09-27 23:41:53 +00:00
parent 1d4ce570e4
commit 71d4c1817c
5 changed files with 91 additions and 5 deletions

View file

@ -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' \

View file

@ -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://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/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://<host>/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 |

View file

@ -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)

View file

@ -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://<host>/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 \

View file

@ -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)`)