feat(supabase): the scoped role never stopped a superuser from logging in - #2702
Conversation
…g in
The juicefs cross-node lane orders its steps around one stated property:
"the scoped role below is what keeps this from being a superuser surface."
It does not. A scoped role changes which credential JuiceFS USES. It does
nothing to stop a superuser credential from being ACCEPTED.
What actually governs that is pg_hba, which the handoff never mentions:
host all all 0.0.0.0/0 scram-sha-256
Every role, from anywhere. Once :5432 is tailnet-published that admits
`pmoves` and `supabase_admin` -- both rolsuper AND rolbypassrls -- as
password logins over the tailnet. `pmoves` is the initdb superuser and
carries 35 of the 37 live connections; `supabase_admin` carries one. So
rotating only `supabase_admin`, as the lane specifies, would have left the
busier superuser untouched and the surface open.
Adds four rules above the catch-alls (first match wins):
host all juicefs_meta 100.64.0.0/10 scram-sha-256
host all all 100.64.0.0/10 reject
host all juicefs_meta fd7a:115c:a1e0::/48 scram-sha-256
host all all fd7a:115c:a1e0::/48 reject
Only juicefs_meta may authenticate from the tailnet; every other role is
rejected there. In-cluster traffic is untouched -- it arrives on 172.30.x
and still matches the existing 172.16.0.0/12 rule, below these.
An IP-scoped rule only works if the source IP survives, so that was checked
rather than assumed. Docker's only MASQUERADE rules are `-s <bridge> ! -o
<bridge>` -- container to outside. Inbound published-port traffic is pure
DNAT with no source rewrite, so a remote peer arrives as its real 100.x.
Repo-managed, not edited in place, because the image ships pg_hba.conf at
/etc/postgresql/ -- the container's WRITABLE LAYER, not the data volume.
An in-container edit survives a restart and is destroyed by a recreate,
and Step 4 of this same lane REQUIRES a recreate (multi-homing onto
pmoves_external to publish the port). A live edit would have been silently
reverted by the very step it exists to protect.
Verified: file diffs against the live one as additions only, zero removals;
supabase/postgres:17.6.1.108 loads it, starts, and matches rules from it,
with no parse errors -- postgres refuses to start on a malformed hba.
Takes effect on the next supabase-db recreate. Step 4 supplies that, so it
costs no extra disruption.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Docker Hardening ValidationHardening Validation ReportValidated: Sun Aug 23 22:59:09 UTC 2026Services CheckedPMOVES.AI Docker Hardening Validation[INFO] Checking: pmoves/docker-compose.hardened.yml [INFO] Validating: hi-rag-gateway-v2 [INFO] Validating: extract-worker [INFO] Validating: langextract [INFO] Validating: presign [INFO] Validating: render-webhook [INFO] Validating: retrieval-eval [INFO] Validating: pdf-ingest [INFO] Validating: jellyfin-bridge [INFO] Validating: invidious-companion-proxy [INFO] Validating: ffmpeg-whisper [INFO] Validating: media-video [INFO] Validating: media-audio [INFO] Validating: hi-rag-gateway-v2-gpu [INFO] Validating: hi-rag-gateway-gpu [INFO] Validating: deepresearch [INFO] Validating: supaserch [INFO] Validating: publisher-discord [INFO] Validating: mesh-agent [INFO] Validating: nats-echo-req [INFO] Validating: nats-echo-res [INFO] Validating: comfy-watcher [INFO] Validating: grayjay-plugin-host [INFO] Validating: agent-zero [INFO] Validating: p7-room-orchestrator [INFO] Validating: archon [INFO] Validating: channel-monitor [INFO] Validating: pmoves-yt [INFO] Validating: notebook-sync [INFO] Validating: supabase_service_role_key [INFO] Validating: supabase_jwt_secret [INFO] Validating: p7_control_token ====================================== |
docker-compose.core.yml is generated from docker-compose.yml by scripts/split_compose.py; editing the source alone left the two out of sync and `Validate Compose Files` failed as designed. Regenerated with the pinned toolchain the Makefile specifies (uv run --with ruamel.yaml==0.19.1) so the rendering is byte-stable. The only delta is the supabase-db pg_hba mount from the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 869f16ca84
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Docker Hardening ValidationHardening Validation ReportValidated: Sun Aug 23 23:02:35 UTC 2026Services CheckedPMOVES.AI Docker Hardening Validation[INFO] Checking: pmoves/docker-compose.hardened.yml [INFO] Validating: hi-rag-gateway-v2 [INFO] Validating: extract-worker [INFO] Validating: langextract [INFO] Validating: presign [INFO] Validating: render-webhook [INFO] Validating: retrieval-eval [INFO] Validating: pdf-ingest [INFO] Validating: jellyfin-bridge [INFO] Validating: invidious-companion-proxy [INFO] Validating: ffmpeg-whisper [INFO] Validating: media-video [INFO] Validating: media-audio [INFO] Validating: hi-rag-gateway-v2-gpu [INFO] Validating: hi-rag-gateway-gpu [INFO] Validating: deepresearch [INFO] Validating: supaserch [INFO] Validating: publisher-discord [INFO] Validating: mesh-agent [INFO] Validating: nats-echo-req [INFO] Validating: nats-echo-res [INFO] Validating: comfy-watcher [INFO] Validating: grayjay-plugin-host [INFO] Validating: agent-zero [INFO] Validating: p7-room-orchestrator [INFO] Validating: archon [INFO] Validating: channel-monitor [INFO] Validating: pmoves-yt [INFO] Validating: notebook-sync [INFO] Validating: supabase_service_role_key [INFO] Validating: supabase_jwt_secret [INFO] Validating: p7_control_token ====================================== |
|
This one is stale — it reviewed Current branch head: Regenerated with the Makefile's pinned toolchain ( Your underlying point was right, though — |
Docker Hardening ValidationHardening Validation ReportValidated: Mon Aug 24 00:09:42 UTC 2026Services CheckedPMOVES.AI Docker Hardening Validation[INFO] Checking: pmoves/docker-compose.hardened.yml [INFO] Validating: hi-rag-gateway-v2 [INFO] Validating: extract-worker [INFO] Validating: langextract [INFO] Validating: presign [INFO] Validating: render-webhook [INFO] Validating: retrieval-eval [INFO] Validating: pdf-ingest [INFO] Validating: jellyfin-bridge [INFO] Validating: invidious-companion-proxy [INFO] Validating: ffmpeg-whisper [INFO] Validating: media-video [INFO] Validating: media-audio [INFO] Validating: hi-rag-gateway-v2-gpu [INFO] Validating: hi-rag-gateway-gpu [INFO] Validating: deepresearch [INFO] Validating: supaserch [INFO] Validating: publisher-discord [INFO] Validating: mesh-agent [INFO] Validating: nats-echo-req [INFO] Validating: nats-echo-res [INFO] Validating: comfy-watcher [INFO] Validating: grayjay-plugin-host [INFO] Validating: agent-zero [INFO] Validating: p7-room-orchestrator [INFO] Validating: archon [INFO] Validating: channel-monitor [INFO] Validating: pmoves-yt [INFO] Validating: notebook-sync [INFO] Validating: supabase_service_role_key [INFO] Validating: supabase_jwt_secret [INFO] Validating: p7_control_token ====================================== |
…#2705) * feat(secrets): juicefs_meta's password had no route to any other node B850 mounts pmoves-media as the scoped `juicefs_meta` role. The role exists, the cutover happened, the mount is healthy. The credential, however, never entered the pipeline: /home/pmoves/.pmoves-secrets/juicefs_meta_pw -> bind -> /run/secrets/jfs_meta_pw A hand-placed file, under a different user's home, referenced NOWHERE in this repo and holding no CHIT registry entry. So B850 works and no second node can mount without someone hand-copying a secret -- the exact failure the funnel exists to prevent, and the blocker the 5090 is currently parked on. This is load-bearing now rather than tidy. PR #2702 made pg_hba admit ONLY juicefs_meta from the tailnet and reject every other role there, so a remote mount has no fallback credential: it authenticates as this role or not at all. Registered through the sanctioned path only -- REGISTRY entry, then chit-manifest-register, then chit-manifest-sync. No manifest was hand-edited. "JUICEFS_META_PASSWORD": {"tier": "data", "required": False} tier=data because juicefs-mount and juicefs-gateway both read env.tier-data. required=False unlike its neighbours: only nodes that actually mount pmoves-media need it, and under `--merge` (strict) a required slot fails the whole funnel on every node that legitimately lacks it. The usual argument against required=False -- that it buys silence -- does not apply here: an absent value fails loudly at mount time with an auth error. v2 also emits `docker_secret: pmoves_juicefs_meta_password`, which is the shape B850's hand-bound secret already has, so the pipeline can materialize what is currently placed by hand. Necessary but not sufficient, in #2700's sense: this routes the label. The value still has to be put into CHIT, and the compose defaults for juicefs-format/juicefs-gateway still name supabase_admin -- both tracked separately. Verified: chit-manifest-check reports in sync; chit-manifest-register --check reports complete with no constraint drift; re-running sync is byte-stable; both manifests changed by additions only (+23, 0 deletions). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(secrets): the route terminated one hop short of the node that needs it Codex, correctly: registering the manifest slot does not deliver the value. sync-secrets-local.yml builds its CGP bundle SOLELY from the workflow's explicit env map, and the v1 sync discards github_secret targets -- so a registered label with no row in that map funnels nothing on any node hydrated from the bundle. #2700 hit this exact trap and wrote the warning inline five lines above where this row now goes. I read that PR and still shipped the half that does not reach anyone. It matters more here than it did there, because the nodes hydrated from the bundle are precisely the ones this slot exists for: the 5090 has no Actions runner and hydrates via `secrets-funnel-from-prod`. Without this row the operator could create the Prod secret, the funnel would report success, and env.tier-data would still have no password -- an optional entry skipped in silence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it targets The META_ROLE forwarding in this PR is the important half and is correct — the script has supported META_ROLE since #2683 but the target never passed it, so the canonical make path silently used supabase_admin, which pg_hba now REJECTS from the tailnet (#2702). Step 5 would have failed with an auth error that reads like a bad secret rather than a rejected role. The password fallback, though, could not fire. `$(JUICEFS_META_PASSWORD)` is a MAKE variable reference, and make populates its variables only from the environment and from Makefiles — nothing includes the generated tier files. The funnel writes this key to env.tier-data and .env.generated, not to env.shared, so it is never exported into an operator's session either. Net effect: the operator hits "DB_PASS or JUICEFS_META_PASSWORD required" on precisely the node the funnel just delivered to, and the documented remedy is to read the value out of the tier file by hand — which the script's own error text tells them not to do. Switched to the recipe-time shell read this file already uses ~line 229: DB_PASS="$(or $(DB_PASS),$$(grep -m1 '^JUICEFS_META_PASSWORD=' env.tier-data ...))" `$$(...)` not `$(...)`: shell at recipe time, which can read generated files. `cut -d= -f2-` not `-f2`, so a value containing '=' is not truncated at the first one (the neighbouring line has that bug; not inheriting it). Dropped the $(error): the script already fails with a better message that names both DB_PASS and the funnel path, and a make-level abort pre-empts it. Verified all three paths: funnel-delivered key in env.tier-data, no DB_PASS -> script receives META_ROLE=juicefs_meta with a non-empty DB_PASS explicit DB_PASS=... on the command line wins neither script's own "no metadata password" error, no make abort and separately that -f2- preserves `abc=def==` where -f2 yields `abc`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
…re that (#2707) * fix(db): anon could execute as a superuser, and a rebuild would restore that Three SECURITY DEFINER functions in `public` -- claim_/complete_/fail_ studio_board_publish -- are owned by supabase_admin (rolsuper), so they run as a superuser and bypass RLS. PUBLIC and anon both held EXECUTE. Nobody granted that. All three defining migrations (20260325110000_publisher_publish_state, 20260326000400, 20260326001000) contain ZERO GRANT statements; it is Supabase's stock default privilege on `public`. Which is exactly why this has to be a migration: the fix was applied by hand on B850 and a volume reset would silently undo it. The schema already states the intent, and it is the opposite: publisher_audit_svc service_role USING true WITH CHECK true publisher_audit_auth_read authenticated read-only publisher_audit_anon_deny anon USING false WITH CHECK false and detections / segments / emotions / studio_board all have RLS enabled with NO anon policy at all. So anon is denied everywhere it is named -- while holding EXECUTE on three functions that write studio_board as a superuser. No consumer breaks. The publisher (services/publisher + services/common/ supabase.py) does direct inserts into those RLS-protected tables, which an anon-key client cannot do at all; it has to be service_role, and service_role keeps its explicit grant. authenticated keeps its own. That is what settled the key-class question the review was blocked on: it was answerable from RLS, not from the env. PUBLIC is revoked for the same reason one level up. Every role inherits PUBLIC, which is how juicefs_meta -- a LOGIN role that pg_hba now admits from the tailnet (#2702) -- ended up holding superuser-execution rights it was created specifically not to have. Idempotent, and asserts its own end state in both directions: nothing reachable by anon or juicefs_meta, and nothing lost by service_role or authenticated. Verified by re-applying against the already-hardened B850 DB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(db): the migration named roles that do not exist yet on a fresh database Codex, correctly: supabase-bootstrap runs `apply_dir migration supabase/migrations` and only THEN `apply_dir seed supabase/initdb`, and 00_3_juicefs_meta_role.sql -- which creates juicefs_meta -- is a seed. So on a fresh database this migration ran before the role existed. has_function_privilege() raises `role "x" does not exist`. The apply loop catches a failing file, prints "FAILED (skipped)" and moves on WITHOUT recording it in pmoves_bootstrap_history -- so the bootstrap looks like it succeeded and the hardening silently never applied, on precisely the rebuild path this file exists to secure. REVOKE has the same problem, so both halves are guarded now: - the REVOKE only names anon when anon exists (it is created by scripts/supabase/bootstrap_db.sh, a different entrypoint that is not ordered against supabase-bootstrap) - the assertion CONTINUEs past any role that does not exist - the PUBLIC half stays unconditional, which is what preserves the guarantee: PUBLIC always exists, and revoking from PUBLIC removes what juicefs_meta would otherwise inherit whenever it is created Also replaced the CROSS JOIN LATERAL with a plain FOREACH -- its `r` alias collided with the outer unnest and raised "column reference r is ambiguous". Verified on a throwaway postgres:17 with NEITHER role present: - migration exits 0 and revokes PUBLIC (acl goes to `postgres=X/postgres`, the `=X/` PUBLIC entry gone) - negative test: create anon, grant it EXECUTE, and the assertion raises "revoke incomplete: anon still holds EXECUTE on 1 secdef function(s)" - re-running the full migration then re-revokes and passes and against the live already-hardened B850 DB, where it is a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… JUICEFS_META_PASSWORD fallback) (#2708) * fix(juicefs): forward META_ROLE through the make target + JUICEFS_META_PASSWORD fallback #2683 added META_ROLE to juicefs-cross-node-setup.sh but the make target never forwarded it, so the canonical path (make juicefs-cross-node-setup META_ROLE=juicefs_meta) silently defaulted to supabase_admin — the scoped-role cutover was unreachable on remote nodes (the 5090 step-5 mount). Forward META_ROLE (default supabase_admin, back-compat). Also: DB_PASS now falls back to the funnel-delivered JUICEFS_META_PASSWORD (registered in chit_manifest_register.py, #2705), so a node that received the secret via the pipeline runs with just META_ROLE=juicefs_meta. Both pass as sub-process env (not argv) -> handed to JuiceFS via META_PASSWORD, never in ps. Same 'wired end-to-end?' class as the node-local fixes B850 surfaced tonight. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017g8jC7dupS2ubafo6zPQY6 * fix(juicefs): $(JUICEFS_META_PASSWORD) is empty on exactly the nodes it targets The META_ROLE forwarding in this PR is the important half and is correct — the script has supported META_ROLE since #2683 but the target never passed it, so the canonical make path silently used supabase_admin, which pg_hba now REJECTS from the tailnet (#2702). Step 5 would have failed with an auth error that reads like a bad secret rather than a rejected role. The password fallback, though, could not fire. `$(JUICEFS_META_PASSWORD)` is a MAKE variable reference, and make populates its variables only from the environment and from Makefiles — nothing includes the generated tier files. The funnel writes this key to env.tier-data and .env.generated, not to env.shared, so it is never exported into an operator's session either. Net effect: the operator hits "DB_PASS or JUICEFS_META_PASSWORD required" on precisely the node the funnel just delivered to, and the documented remedy is to read the value out of the tier file by hand — which the script's own error text tells them not to do. Switched to the recipe-time shell read this file already uses ~line 229: DB_PASS="$(or $(DB_PASS),$$(grep -m1 '^JUICEFS_META_PASSWORD=' env.tier-data ...))" `$$(...)` not `$(...)`: shell at recipe time, which can read generated files. `cut -d= -f2-` not `-f2`, so a value containing '=' is not truncated at the first one (the neighbouring line has that bug; not inheriting it). Dropped the $(error): the script already fails with a better message that names both DB_PASS and the funnel path, and a make-level abort pre-empts it. Verified all three paths: funnel-delivered key in env.tier-data, no DB_PASS -> script receives META_ROLE=juicefs_meta with a non-empty DB_PASS explicit DB_PASS=... on the command line wins neither script's own "no metadata password" error, no make abort and separately that -f2- preserves `abc=def==` where -f2 yields `abc`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(juicefs): resolve the funnel password through with-env.sh, the canonical loader The previous commit read env.tier-data with an inline grep. That works, but it is not the house pattern and it only reads one file. scripts/with-env.sh is the canonical loader — "env.shared* -> tier env files -> .env* overlays", mirroring compose layering — so it honours precedence instead of hard-coding which tier file the value happens to land in today. Same idiom as mk/yt-cookies.mk:18, and infra.mk:603 already carries a comment recording this exact lesson as a prior Codex P1 ("uses with-env.sh to load tier files"). Using it also drops the `cut -d= -f2-` parsing entirely, so there is no longer a quoting or embedded-'=' edge case to get right. Verified all three paths with a stubbed script: tier-delivered key in env.tier-data, no DB_PASS -> DB_PASS_len=27 explicit DB_PASS=explicit -> DB_PASS_len=8 neither script's own "no metadata password" error, no make abort Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz --------- Co-authored-by: Mavis <Mavis@pmoves.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…procedure (#2709) * docs(ops): the metadata credential has an owner and a vendor-sourced procedure "Someone creates the GitHub Prod secret" is not an assignment. Provisioning the JuiceFS metadata credential is fleet infrastructure — it gates every node's ability to mount pmoves-media — so it belongs to Z890 (Infrastructure Coordinator), not to whichever node happens to notice it is missing. B850 holds the metadata DB and should not become the ambient owner of a secret it merely consumes. Written against JuiceFS community documentation rather than our own habits, and the comparison found four things: ALIGNED Dedicated user + network-scoped pg_hba is the vendor's own recommended shape. PR #2702 landed it scoped to the tailnet. Worth recording that this was vendor practice and not local invention -- and that the doc pairs the role WITH the hba rule, which is the pairing the original lane handoff was missing. PARTIAL META_PASSWORD is documented and we use it. META_PASSWORD_FILE is also documented, and B850 already bind-mounts the secret as a file before reading it back through a shell. Adopt on next recreate. DIVERGENT sslmode=disable. Unremarkable container-to-container; not unremarkable once :5432 is on the tailnet. WireGuard covers the transport, but the session has no TLS of its own. Flagged as a decision to make AT Step 4 rather than inherit. GAP The vendor asks for restore TESTS, not just backups. B850 dumps metadata hourly to MinIO and has never restored one. An untested backup is a hypothesis. Z890 task, tracked separately. Plus a constraint worth writing down before someone proposes the obvious fix: PostgreSQL metadata must stay single-server per vendor guidance, so B850 is an SPOF by design and availability work belongs in restore, not replication. Also corrects the mount runbook, which still told operators DB_PASS was "the Supabase DB password". That predates the scoped-role cutover and is now actively wrong: pg_hba REJECTS supabase_admin from the tailnet, so following it produces an auth error that reads like a bad secret rather than a rejected role. It now passes META_ROLE explicitly, since the script still defaults to supabase_admin for back-compat. The vendor's pg_hba example is paraphrased rather than quoted -- it carries a private LAN CIDR, and the no-LAN-IPs guard is right to block that in a public repo even when the address is someone else's documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * docs(ops): the runbook told operators to leak the password it was protecting Two Codex P1s, both correct. 1. ROTATION CONTRADICTED ITSELF. Section 2.1 says never paste the value on a CLI, then 2.2-alt handed over a `psql -c "ALTER ROLE … PASSWORD '<new>'"` that puts it in shell history AND in the container's argv. `psql -v` is no better — same argv. Replaced with a stdin form: `read -rs` (no echo, no history) piped through a shell builtin, so the value never becomes another process's argv. Validated against the live DB on a throwaway role: ALTER ROLE applied and the role authenticated with the piped value over a real scram path. Also noted where it belongs long-term — behind a Make target reading stdin, next to secrets-rotate. 2. DELIVERY ORDER WAS SILENTLY WRONG FOR RUNNERLESS NODES. `secrets-funnel-from-prod` calls pull_chit_bundle.sh, which takes the newest ALREADY-successful run: gh run list --workflow "$WORKFLOW" --status success --limit 1 That run can predate the Prod secret you just created. The pull succeeds, the funnel reports success, and JUICEFS_META_PASSWORD is absent or stale — on the 5090, which is exactly the runnerless node this slot exists for. The runbook now requires a producer run FIRST, an explicit wait, and a createdAt comparison against when the secret was set. That timestamp check is the whole guarantee. The third finding is a merge-order dependency, not a doc defect: the mount snippet's `META_ROLE=juicefs_meta` with no DB_PASS needs the target change in #2708. Merge #2708 first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e lane needs
The compose has published this port since March:
ports: - ${SUPABASE_DB_BIND:-127.0.0.1}:${SUPABASE_DB_PORT:-54322}:5432
but it has never been reachable, and the reason is not the bind. supabase-db sits
on pmoves_data and pmoves_api, both `internal: true`. A published port on a
container attached only to internal networks maps and then answers nothing.
Multi-homing onto pmoves_external is what actually plumbs it — the pattern the
NATS bus already uses, and the one the lane handoff specified.
This does not put a database on the open internet:
- SUPABASE_DB_BIND is set per node to that node's TAILNET address, never
0.0.0.0, so the listener exists only on the tailnet interface. The default
stays 127.0.0.1, so any node that does not set it publishes nothing new and
this change is inert there.
- pg_hba (supabase/config/pg_hba.conf, #2702) admits ONLY juicefs_meta from
100.64.0.0/10 and REJECTS every other role there. That is the control that
keeps this from being a superuser surface. The scoped role alone never was —
it changes which credential JuiceFS uses, not which one is accepted.
sslmode is a recorded decision rather than an inherited default, which is what
JUICEFS_META_CREDENTIAL_RUNBOOK.md §1.3 asked for at this step: the cross-node
DSN carries sslmode=disable, JuiceFS's own PostgreSQL best-practices advise
against it, and the justification for keeping it is that WireGuard encrypts the
tailnet transport and the metadata role is non-superuser and pg_hba-scoped.
Revisit if the DB is ever reachable off-tailnet.
Operators should publish on 5432, not the 54322 default: PORT_REGISTRY already
assigns 5432 to this service, and juicefs-cross-node-setup.sh:28 defaults
DB_PORT=5432, so the canonical port means the remote node needs no override.
Registry row updated from "internal only" to reflect that.
Verified: compose parses; supabase-db resolves to
[pmoves_data, pmoves_api, pmoves_external]; split overlays regenerated with the
pinned toolchain; both 5432 and 54322 are free on this host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
…e lane needs (#2728) * feat(juicefs): Step 4 — plumb the tailnet-bound DB port the cross-node lane needs The compose has published this port since March: ports: - ${SUPABASE_DB_BIND:-127.0.0.1}:${SUPABASE_DB_PORT:-54322}:5432 but it has never been reachable, and the reason is not the bind. supabase-db sits on pmoves_data and pmoves_api, both `internal: true`. A published port on a container attached only to internal networks maps and then answers nothing. Multi-homing onto pmoves_external is what actually plumbs it — the pattern the NATS bus already uses, and the one the lane handoff specified. This does not put a database on the open internet: - SUPABASE_DB_BIND is set per node to that node's TAILNET address, never 0.0.0.0, so the listener exists only on the tailnet interface. The default stays 127.0.0.1, so any node that does not set it publishes nothing new and this change is inert there. - pg_hba (supabase/config/pg_hba.conf, #2702) admits ONLY juicefs_meta from 100.64.0.0/10 and REJECTS every other role there. That is the control that keeps this from being a superuser surface. The scoped role alone never was — it changes which credential JuiceFS uses, not which one is accepted. sslmode is a recorded decision rather than an inherited default, which is what JUICEFS_META_CREDENTIAL_RUNBOOK.md §1.3 asked for at this step: the cross-node DSN carries sslmode=disable, JuiceFS's own PostgreSQL best-practices advise against it, and the justification for keeping it is that WireGuard encrypts the tailnet transport and the metadata role is non-superuser and pg_hba-scoped. Revisit if the DB is ever reachable off-tailnet. Operators should publish on 5432, not the 54322 default: PORT_REGISTRY already assigns 5432 to this service, and juicefs-cross-node-setup.sh:28 defaults DB_PORT=5432, so the canonical port means the remote node needs no override. Registry row updated from "internal only" to reflect that. Verified: compose parses; supabase-db resolves to [pmoves_data, pmoves_api, pmoves_external]; split overlays regenerated with the pinned toolchain; both 5432 and 54322 are free on this host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(compose): regenerate PMOVES_NETWORKS mirror for the external network Step 4 hand-edited the networks: lists but not the generated PMOVES_NETWORKS env that topology.TopologyContext.from_env() reads — the injector drift gate failed. Re-ran the injector and compose-split; one mirrored env line per file, no other changes. * fix(juicefs): dedicated DB-egress bridge instead of the shared network Review P1: joining supabase-db to pmoves_external handed every internet-facing container on that bridge a direct route to supabase-db:5432, and pg_hba's 172.16.0.0/12 catch-all accepts every role from any docker bridge -- the tailnet scoping never sees those sources. Replaced with pmoves_db_egress (172.30.8.0/24, internal: false, external network carrying ONLY the database): - pg_hba scopes the new subnet above the catch-all: juicefs_meta only, everything else rejected -- identical treatment to the tailnet rules. Legitimate inbound still DNATs in with its real 100.x source and hits the 100.64.0.0/10 block; only a container that joined this bridge sources from 172.30.8.x, and that bridge is supposed to hold the database alone. - Subnet is .8, not .7: pmoves_public is compose-declared at 172.30.7.0/24 (docker-compose.yml) -- .7 would collide. - Network ensured alongside the others in ensure-networks and ensure-overlay-networks; overlays regenerated via injector + split. The 5090's reachability Monitor is unaffected: SUPABASE_DB_BIND still pins the listener to the node's tailnet address, and the dedicated bridge is what makes the published port answer at all. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 3.5 of the juicefs cross-node lane — a step the handoff doesn't have, and Step 4 shouldn't ship without.
The gap
The lane orders its steps around one stated property:
It doesn't. A scoped role changes which credential JuiceFS uses. It does nothing to stop a superuser credential from being accepted. What governs that is
pg_hba.conf, which the handoff never mentions:Once
:5432is tailnet-published, that admits both superusers as password logins over the tailnet:pmovessupabase_adminjuicefs_metapmovesis the initdb superuser (POSTGRES_USER=pmoves) and carries nearly all traffic. Rotating onlysupabase_admin, as the lane specifies, would have left the busier superuser untouched and the surface open.The change
Four rules above the catch-alls — first match wins:
In-cluster traffic is untouched: it arrives on
172.30.xand still matches the existing172.16.0.0/12rule, which sits below these.Why an IP-scoped rule is sound here
Checked rather than assumed. Docker's only MASQUERADE rules are
-s <bridge-subnet> ! -o <bridge>— container→outside. TheDOCKERchain does pureDNAT --to-destinationwith no inbound source rewrite, so a remote peer arrives as its real100.x. Had it been SNAT'd to the bridge gateway, tailnet traffic would be indistinguishable from in-cluster traffic and pg_hba could not have been the control at all.Why repo-managed instead of edited in place
The image ships
pg_hba.confat/etc/postgresql/— the container's writable layer, not the data volume. An in-container edit survivesrestartand is destroyed byrecreate. Step 4 of this same lane requires a recreate (multi-homing ontopmoves_externalto publish the port).A live edit would have been silently reverted by the very step it exists to protect.
Verified
supabase/postgres:17.6.1.108loads the file, starts, and matches rules from it (Connection matched file "/tmp/hba/pg_hba.conf" line 83) with no parse errors — Postgres refuses to start on a malformed hbasupabase-dbgains exactly one volume entryActivation
Takes effect on the next
supabase-dbrecreate. Step 4 supplies that, so it costs no extra disruption — and the cross-node confirmation (5090 reaching:5432asjuicefs_meta, and not as a superuser) lands naturally as Step 4's acceptance test.🤖 Generated with Claude Code
https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz