From 32b0b041901c58add81682ec903ad0d211137b9a Mon Sep 17 00:00:00 2001 From: Atharva Sehgal Date: Thu, 16 Apr 2026 22:54:06 +0000 Subject: [PATCH 1/2] Revoke default anon SELECT; grant only on four public tables --- .../migrations/00015_revoke_anon_select.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 supabase/migrations/00015_revoke_anon_select.sql diff --git a/supabase/migrations/00015_revoke_anon_select.sql b/supabase/migrations/00015_revoke_anon_select.sql new file mode 100644 index 00000000..85a6b677 --- /dev/null +++ b/supabase/migrations/00015_revoke_anon_select.sql @@ -0,0 +1,17 @@ +-- Supabase's default grants give the anon role SELECT on every table in the +-- public schema. Migration 00012 only enabled RLS (and added a public_read +-- policy) on four tables, leaving the rest unprotected: with RLS disabled, +-- the default grant wins and anon can read them via PostgREST. +-- +-- Lock this down: revoke the broad anon SELECT, re-grant only the four +-- intentionally-public tables, and change the default privilege so any +-- table added later is anon-invisible unless explicitly granted. +-- +-- The service-role key bypasses grants (superuser-equivalent), so the +-- pipeline is unaffected. The grafana_ro role is a separate principal. + +REVOKE SELECT ON ALL TABLES IN SCHEMA public FROM anon; + +GRANT SELECT ON repositories, pull_requests, candidate_containers, harbor_runs TO anon; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT ON TABLES FROM anon; From 2a2cb1e3ad2575008ffdbda299fd43f3ab101f0a Mon Sep 17 00:00:00 2001 From: Atharva Sehgal Date: Thu, 16 Apr 2026 22:54:10 +0000 Subject: [PATCH 2/2] Document api.formulacode.org ungated anon-read hostname --- CLAUDE.md | 2 +- docs/guide/remote-access.md | 91 +++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef6a2e7e..4a7c6831 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,7 @@ When both `DATASMITH_CF_ACCESS_*` vars are set, `get_client()` and `get_async_cl ### Public read-only access (RLS) -Four tables have Row Level Security enabled with `public_read` SELECT-only policies for the `anon` role: `repositories`, `pull_requests`, `candidate_containers`, `harbor_runs`. The service-role key bypasses RLS, so pipeline processes are unaffected. See `supabase/migrations/00012_public_read_rls.sql`. +Four tables are readable by the `anon` role: `repositories`, `pull_requests`, `candidate_containers`, `harbor_runs`. Migration `00012_public_read_rls.sql` enables RLS with a `public_read` SELECT policy on those tables; migration `00015_revoke_anon_select.sql` revokes Supabase's default broad `anon` `SELECT` grant and re-grants it only on the four, so every other table returns `permission denied`. The service-role key bypasses both layers, so pipeline processes are unaffected. Public anon access is served on `https://api.formulacode.org` (no Cloudflare Access gate); pipeline operators continue to use `https://db.formulacode.org` with CF Access + service-role key. ### Key tables diff --git a/docs/guide/remote-access.md b/docs/guide/remote-access.md index 891b3437..bed266a0 100644 --- a/docs/guide/remote-access.md +++ b/docs/guide/remote-access.md @@ -1,37 +1,41 @@ # Remote Access -The fc-data Supabase instance is not exposed on the public internet. Two paths -reach it from outside the host: +The fc-data Supabase instance is not exposed on the public internet. Two +hostnames front the same tunnel: -1. **Full read/write access** over a Cloudflare Tunnel, gated by Cloudflare - Access service tokens. Intended for pipeline operators running `fc-data` - against the shared database. -2. **Public read-only access** via the Supabase anon key. Intended for - client-side websites that display dataset statistics. +| Hostname | CF Access gate | Credential | Scope | +|---|---|---|---| +| `db.formulacode.org` | yes, service-token policy | service-role key | full read/write | +| `api.formulacode.org` | no | anon (publishable) key | `SELECT` on four tables | + +Pipeline operators use `db.*`; public websites use `api.*`. Two independent +credentials (service token + service-role key) must both leak before full +write access is at risk. ## Architecture ```mermaid flowchart LR - User["Remote fc-data
(service token)"] + User["Pipeline operator
(service-role key + CF token)"] Web["Public website
(anon key)"] - CFE["Cloudflare Edge
db.formulacode.org"] - CFA["Cloudflare Access"] + CFE1["db.formulacode.org"] + CFE2["api.formulacode.org"] + CFA["Cloudflare Access
(service-token policy)"] subgraph Host["Host machine"] - CFD["cloudflared"] + CFD["cloudflared
(datasmith-db tunnel)"] SB["Supabase :54321
PostgREST + RLS"] end - User -->|CF-Access headers + service-role key| CFE - Web -->|anon key| CFE - CFE --> CFA - CFA --> CFD + User --> CFE1 --> CFA --> CFD + Web --> CFE2 --> CFD CFD --> SB ``` -Cloudflare Access blocks every request at the edge unless it carries a valid -service token. The anon-key path works because the RLS policies in -`supabase/migrations/00012_public_read_rls.sql` allow `SELECT` for the `anon` -role on four tables; writes and all other tables are rejected by RLS. +On `db.*`, Cloudflare Access rejects any request without a valid service +token before it reaches Supabase. On `api.*`, there is no Access application; +Supabase's `anon` role has `SELECT` grants on four tables only (see +`supabase/migrations/00012_public_read_rls.sql` and +`supabase/migrations/00015_revoke_anon_select.sql`), so anything else returns +`permission denied`. --- @@ -62,7 +66,9 @@ fc-data --preflight ### Public read-only access Use the Supabase anon key (shown as the "Publishable" key in -`supabase status`). No tunnel credentials are required; the host is the same. +`supabase status`) against `https://api.formulacode.org`. No Cloudflare +Access credentials are required for this hostname; the anon key is the only +auth layer. | Table | Exposed | |-------|---------| @@ -74,7 +80,7 @@ Use the Supabase anon key (shown as the "Publishable" key in Example: ```js -const SUPABASE_URL = "https://db.formulacode.org"; +const SUPABASE_URL = "https://api.formulacode.org"; const ANON_KEY = "sb_publishable_..."; const res = await fetch( @@ -88,7 +94,9 @@ const res = await fetch( ); ``` -Writes return `HTTP 403: new row violates row-level security policy`. +Writes are rejected by RLS (`HTTP 401`, `new row violates row-level security +policy`). Reads of any table outside the four listed above return +`permission denied for table `. --- @@ -127,15 +135,27 @@ credentials-file: /home//.cloudflared/.json ingress: - hostname: db.formulacode.org service: http://localhost:54321 + - hostname: api.formulacode.org + service: http://localhost:54321 - service: http_status:404 ``` -### 4. DNS record +Both hostnames proxy to the same Supabase port. `db.*` is the gated +read/write path; `api.*` is the ungated public-read path. The distinction +lives in Cloudflare Access (step 6), not in the tunnel config. + +### 4. DNS records ```bash cloudflared tunnel route dns datasmith-db db.formulacode.org +cloudflared --config ~/.cloudflared/config-db.yml tunnel route dns \ + --overwrite-dns datasmith-db api.formulacode.org ``` +The `--config` + `--overwrite-dns` flags on the second command are necessary +because `cloudflared` resolves the tunnel name against the default config; +without them it will route the CNAME to the wrong tunnel. + ### 5. Run the tunnel ```bash @@ -147,7 +167,7 @@ sudo systemctl enable --now cloudflared At this point `https://db.formulacode.org` proxies to local Supabase but Cloudflare Access blocks everything until the policy is in place. -### 6. Cloudflare Access policy +### 6. Cloudflare Access policy — for `db.*` only In [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → Access → Applications, add a self-hosted app: @@ -162,16 +182,29 @@ Then under Access → Service Auth → Service Tokens, create a token (e.g. is only shown once. Hand these to the requesting user along with the service-role key. -### 7. Apply the RLS migration +**Do not create an Access application for `api.formulacode.org`.** That +hostname must remain ungated so anon-key clients can reach PostgREST. The +anon key plus RLS grants are the only auth layer there. + +### 7. Apply the Supabase migrations -The anon-key path requires `supabase/migrations/00012_public_read_rls.sql` to -be applied: +The anon-key path depends on two migrations being applied against the host +Supabase instance: ```bash -docker exec supabase_db_ psql -U postgres -d postgres \ - -c "$(cat supabase/migrations/00012_public_read_rls.sql)" +docker exec -i supabase_db_ psql -U postgres -d postgres \ + < supabase/migrations/00012_public_read_rls.sql +docker exec -i supabase_db_ psql -U postgres -d postgres \ + < supabase/migrations/00015_revoke_anon_select.sql ``` +`00012` enables RLS and adds the `public_read` SELECT policy on the four +exposed tables. `00015` revokes the default `anon` `SELECT ON ALL TABLES` +grant that Supabase ships with, then re-grants it only on those four tables +and sets the default for future tables to anon-invisible. Without `00015`, +anon can read every table because Supabase's default grants override the +absence of RLS. + ### How the client picks up the headers When both `DATASMITH_CF_ACCESS_CLIENT_ID` and