Wire schema v1 into a manual Supabase migration and vendor the real stack
- Move db/schema.sql to supabase/migrations/ as the first supabase CLI migration (manual `db push` only, no automated runner); re-add the gebos_ingest role + grants there since init.sql never re-runs - Add gebos-postgres-passwords oneshot on db-host: syncs role passwords from sops (LoadCredential, journal-safe), makes supabase_admin SUPERUSER and hands the auth schema to supabase_auth_admin to match the upstream supabase/postgres image; add pg_hba rule for 10.0.0.0/8 - Vendor the official docker-compose (studio/kong/auth/rest/meta only, external Postgres, loopback Studio with no Kong dashboard route) plus kong.yml (trimmed) and kong-entrypoint.sh (verbatim); tested: compose config, Kong config parse, migration applied on TimescaleDB pg17 - Document decisions as ADRs 0001-0004 (migrations, passwords, vendored stack, JWT API keys incl. verify/mint procedure) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# ADR-0004: Legacy JWT-based API keys, one signing secret
|
||||
|
||||
Date: 2026-07-08
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Supabase's API tier knows two credentials, and they are easy to confuse
|
||||
because in the legacy scheme **both are JWTs signed with the same
|
||||
`JWT_SECRET`**:
|
||||
|
||||
| | `apikey` header | `Authorization: Bearer …` header |
|
||||
|---|---|---|
|
||||
| identifies | the application | a logged-in person |
|
||||
| minted by | a human, once, at project setup | GoTrue, at every login |
|
||||
| payload | `{"role":"anon"}` / `{"role":"service_role"}` | `{"role":"authenticated","sub":"<user uuid>",…}` |
|
||||
| lifetime | ~10 years | 1 h (`GOTRUE_JWT_EXP`), refreshed |
|
||||
| checked by | Kong: exact string match against its consumer list | PostgREST: signature verification, then `SET ROLE` on the claim |
|
||||
|
||||
The `role` claim is what binds tokens to the database: PostgREST executes
|
||||
each request as the Postgres role the (verified) `Authorization` JWT names —
|
||||
`anon` for pre-login traffic, `authenticated` for sessions (with `auth.uid()`
|
||||
= the `sub` claim, feeding RLS), `service_role` for server-side admin work.
|
||||
`service_role` exists because some work legitimately spans all tenants
|
||||
(admin invites, cross-apartment aggregation jobs, backfills); it carries
|
||||
`BYPASSRLS` and its key must never leave the server side, while the anon key
|
||||
is public by design (it ships in the frontend bundle).
|
||||
|
||||
Supabase is migrating to opaque `sb_publishable_*`/`sb_secret_*` keys that
|
||||
separate the two concepts; the vendored `kong-entrypoint.sh` contains the
|
||||
translation shim for that scheme.
|
||||
|
||||
## Decision
|
||||
|
||||
Stay on the **legacy scheme** for the pilot: `supabase_anon_key` and
|
||||
`supabase_service_role_key` are long-lived HS256 JWTs signed with
|
||||
`supabase_jwt_secret`. The opaque-key shim stays dormant (its env vars are
|
||||
empty, which switches the Kong entrypoint to plain apikey pass-through).
|
||||
|
||||
This has a non-obvious integrity requirement: **the three secrets form one
|
||||
cryptographic family.** The API keys are only valid if they were signed with
|
||||
the exact `JWT_SECRET` stored alongside them. They are minted offline by us —
|
||||
no service issues them — and rotating `JWT_SECRET` silently invalidates both
|
||||
API keys *and* every active user session; all three must always be rotated
|
||||
together.
|
||||
|
||||
## Verifying / minting the keys
|
||||
|
||||
The keys currently in `nix/secrets/secrets.yaml` must be checked once against
|
||||
the stored secret (they predate this ADR). From the repo root, with sops
|
||||
access — `openssl` is not in the dev shell, so wrap in
|
||||
`nix shell nixpkgs#openssl` if needed:
|
||||
|
||||
```sh
|
||||
SECRET=$(sops -d --extract '["supabase_jwt_secret"]' nix/secrets/secrets.yaml)
|
||||
|
||||
verify() { # verify <jwt> — checks HS256 signature against $SECRET
|
||||
local hp=${1%.*} sig=${1##*.}
|
||||
local expect=$(printf '%s' "$hp" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" -binary \
|
||||
| basenc --base64url -w0 | tr -d '=')
|
||||
if [ -n "$expect" ] && [ "$sig" = "$expect" ]; then
|
||||
echo "valid ($(printf '%s' "${hp#*.}" | tr '_-' '/+' | base64 -d 2>/dev/null))"
|
||||
else
|
||||
echo "INVALID SIGNATURE"
|
||||
fi
|
||||
}
|
||||
|
||||
verify "$(sops -d --extract '["supabase_anon_key"]' nix/secrets/secrets.yaml)"
|
||||
verify "$(sops -d --extract '["supabase_service_role_key"]' nix/secrets/secrets.yaml)"
|
||||
```
|
||||
|
||||
Each should print `valid` with a payload naming the right role. If either
|
||||
prints `INVALID SIGNATURE`, re-mint and store both:
|
||||
|
||||
```sh
|
||||
mint() { # mint <role> — 10-year HS256 JWT signed with $SECRET
|
||||
local iat=$(date +%s) b64='basenc --base64url -w0'
|
||||
local h=$(printf '{"alg":"HS256","typ":"JWT"}' | $b64 | tr -d '=')
|
||||
local p=$(printf '{"role":"%s","iss":"supabase","iat":%s,"exp":%s}' \
|
||||
"$1" "$iat" $((iat + 315360000)) | $b64 | tr -d '=')
|
||||
local s=$(printf '%s.%s' "$h" "$p" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" -binary | $b64 | tr -d '=')
|
||||
printf '%s.%s.%s\n' "$h" "$p" "$s"
|
||||
}
|
||||
|
||||
mint anon # → sops set as supabase_anon_key
|
||||
mint service_role # → sops set as supabase_service_role_key
|
||||
```
|
||||
|
||||
(Equivalently: the generator on Supabase's self-hosting docs page produces
|
||||
the same thing — paste in the stored `JWT_SECRET`, don't let it invent a new
|
||||
one.)
|
||||
|
||||
## Consequences
|
||||
|
||||
* Matches what supabase-js sends by default; no client-side configuration
|
||||
beyond URL + anon key.
|
||||
* One secret to protect (`JWT_SECRET`) — and one blast radius: leak it and an
|
||||
attacker can mint `service_role` tokens; rotate it and keys + sessions die
|
||||
together.
|
||||
* Moving to opaque keys later is config-only: fill the four `sb_*`/asymmetric
|
||||
env vars and the already-vendored Kong entrypoint starts translating.
|
||||
Reference in New Issue
Block a user