From 2ce68cb0984d6f33cd7a2bbae1454f0f8538ebee Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Wed, 8 Jul 2026 22:33:50 +0200 Subject: [PATCH] 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 --- .../0001-manual-supabase-cli-migrations.md | 51 +++++ .../0002-role-passwords-via-oneshot-unit.md | 61 +++++ .../0003-vendored-supabase-compose-subset.md | 66 ++++++ docs/adr/0004-legacy-jwt-api-keys.md | 103 +++++++++ docs/adr/README.md | 12 + nix/hosts/db-host.nix | 5 + nix/modules/gebos-postgres.nix | 92 +++++++- nix/modules/gebos-supabase.nix | 18 +- nix/secrets/secrets.yaml | 8 +- nix/supabase/README.md | 74 ++++-- nix/supabase/docker-compose.yml | 214 +++++++++++++++--- nix/supabase/init.sql | 33 +-- nix/supabase/kong-entrypoint.sh | 49 ++++ nix/supabase/kong.yml | 194 ++++++++++++++-- supabase/README.md | 55 +++++ supabase/config.toml | 4 + .../migrations/20260708100000_schema_v1.sql | 46 +++- 17 files changed, 967 insertions(+), 118 deletions(-) create mode 100644 docs/adr/0001-manual-supabase-cli-migrations.md create mode 100644 docs/adr/0002-role-passwords-via-oneshot-unit.md create mode 100644 docs/adr/0003-vendored-supabase-compose-subset.md create mode 100644 docs/adr/0004-legacy-jwt-api-keys.md create mode 100644 docs/adr/README.md create mode 100644 nix/supabase/kong-entrypoint.sh create mode 100644 supabase/README.md create mode 100644 supabase/config.toml rename db/schema.sql => supabase/migrations/20260708100000_schema_v1.sql (93%) diff --git a/docs/adr/0001-manual-supabase-cli-migrations.md b/docs/adr/0001-manual-supabase-cli-migrations.md new file mode 100644 index 0000000..9eb6885 --- /dev/null +++ b/docs/adr/0001-manual-supabase-cli-migrations.md @@ -0,0 +1,51 @@ +# ADR-0001: Database schema changes are manual Supabase CLI migrations + +Date: 2026-07-08 +Status: Accepted + +## Context + +Schema v1 (`db/schema.sql`, ~600 lines) was committed but wired to nothing. +The only SQL that ran automatically was `nix/supabase/init.sql` via +`services.postgresql.initialScript` — which executes **only at first initdb**, +so on the already-initialized db-host cluster neither it nor any schema change +would ever apply again. A delivery mechanism was needed. + +Options considered: + +1. **Automated runner** — a systemd oneshot on db-host applying migrations on + every deploy (dbmate/atlas or plain psql with a tracking table). +2. **Manual Supabase CLI migrations** — plain-SQL files in + `supabase/migrations/`, applied by a human with `supabase db push`. +3. **Extending initialScript** — rejected outright: never re-runs on an + existing cluster. + +## Decision + +Option 2. Schema changes are rare, deliberate, admin-level operations; a +human applies them on purpose, and no automation exists to break or to apply +a half-reviewed migration as a side effect of a deploy. + +* Migrations live in `supabase/migrations/_.sql`; schema v1 + moved there as the first one. `supabase/config.toml` is the minimal CLI + project marker. +* Applied via SSH tunnel to db-host as the `postgres` superuser + (see `supabase/README.md` for the exact commands). The CLI records applied + files in `supabase_migrations.schema_migrations`, so pushes are incremental + and re-running is safe. Individual migrations need **not** be idempotent. +* Layering rule: `nix/supabase/init.sql` holds only cluster bootstrap + (Supabase schemas, admin/API roles, extensions needing + `shared_preload_libraries`); everything else — including the `gebos_ingest` + role and its grants, which are coupled to application tables and must reach + existing clusters — lives in migrations. Passwords live in neither + (see ADR-0002). + +## Consequences + +* Deploys (`deploy-rs`) never touch the schema; a deploy and a migration are + two separate, independently reversible acts. +* A human must remember to push after merging a migration. Accepted for a + pilot with one operator. +* The local `nix run .#dev` Postgres has no TimescaleDB yet, so the v1 + migration's `create_hypertable` fails there until the dev stack gains the + extension. diff --git a/docs/adr/0002-role-passwords-via-oneshot-unit.md b/docs/adr/0002-role-passwords-via-oneshot-unit.md new file mode 100644 index 0000000..40871ba --- /dev/null +++ b/docs/adr/0002-role-passwords-via-oneshot-unit.md @@ -0,0 +1,61 @@ +# ADR-0002: Postgres role passwords are synced by a one-shot systemd unit + +Date: 2026-07-08 +Status: Accepted + +## Context + +The Supabase services on app-host and the ingester on mqtt-ingest connect to +db-host over TCP and need password auth, but every login role +(`supabase_admin`, `supabase_auth_admin`, `authenticator`, `gebos_ingest`) +was created passwordless. + +NixOS deliberately offers **no declarative option** for Postgres passwords: +anything the configuration references lands world-readable in `/nix/store`, +and `initialScript` — the only built-in hook that could set one — runs solely +at first initdb. Passwords are data, not configuration. + +Two supporting gaps surfaced at the same time: + +* NixOS's default `pg_hba.conf` covers only local sockets and loopback, so + connections from 10.0.0.0/8 were rejected before password auth even + started, firewall rule notwithstanding. +* The upstream `supabase/postgres` image ships role *attributes* our vanilla + Postgres lacked: `supabase_admin` is a SUPERUSER there (Studio/postgres-meta + connect as it), and `supabase_auth_admin` owns the `auth` schema (GoTrue + runs its own migrations in it at startup). + +## Decision + +A oneshot unit, `gebos-postgres-passwords` (`nix/modules/gebos-postgres.nix`), +runs on db-host after `postgresql.service` on every boot/deploy: + +* Runs as the `postgres` OS user (local peer auth); reads the sops-rendered + env file via systemd `LoadCredential`, so the root-owned 0400 file needs no + permission widening. +* Idempotently syncs: `postgres` ← `postgres_admin_password`; + `supabase_admin` / `supabase_auth_admin` / `authenticator` ← the shared + `supabase_postgres_password` (mirroring upstream's single + `POSTGRES_PASSWORD` convention); `gebos_ingest` ← + `ingester_postgres_password`. +* Enforces the two upstream attributes: `supabase_admin SUPERUSER` and + `ALTER SCHEMA auth OWNER TO supabase_auth_admin`. +* Guards every role for existence (tolerates a cluster the schema-v1 + migration hasn't reached) and discards query output + (`--output=/dev/null`) so passwords never reach the journal. +* db-host enables the `supabase` and `ingester` secret toggles solely to get + those keys into its env file; `.sops.yaml`'s single key group already + permits this. +* `pg_hba` gains `host all all 10.0.0.0/8 scram-sha-256`. + +## Consequences + +* No password ever exists in git, the nix store, or SQL files; rotation is + `sops nix/secrets/secrets.yaml` + deploy + `systemctl restart + gebos-postgres-passwords` (the unit does not auto-rerun on secret-content + changes alone). +* The three Supabase roles share one password by design — per-role passwords + would diverge from the vendored compose, which interpolates a single + `${POSTGRES_PASSWORD}` everywhere. +* db-host can decrypt the Supabase JWT/API-key secrets it doesn't strictly + need; acceptable until `.sops.yaml` moves to per-prefix rules (noted there). diff --git a/docs/adr/0003-vendored-supabase-compose-subset.md b/docs/adr/0003-vendored-supabase-compose-subset.md new file mode 100644 index 0000000..fdf8364 --- /dev/null +++ b/docs/adr/0003-vendored-supabase-compose-subset.md @@ -0,0 +1,66 @@ +# ADR-0003: Vendor a trimmed Supabase compose stack against external Postgres + +Date: 2026-07-08 +Status: Accepted + +## Context + +The Supabase stack on app-host was a placeholder skeleton. The official +self-hosting bundle assumes its own bundled `db` container and ships nine+ +services (realtime, storage, imgproxy, edge functions, supavisor pooler, +Logflare analytics, …), most of which the UVI pilot does not use. Meanwhile +our Postgres lives on a separate NixOS host (db-host) with TimescaleDB. + +Kong deserves a note, since "why is it even there" came up: Kong is the +single front door for `api.gebos.online` (Caddy → Kong on loopback :8000). +It routes paths to internal services (`/auth/v1` → GoTrue, `/rest/v1` → +PostgREST) and enforces the perimeter (key-auth + ACLs), so unauthenticated +internet traffic never reaches the backends. Replacing it would mean +re-implementing routing/auth/CORS in Caddy by hand instead of inheriting +Supabase's tested config. + +## Decision + +Vendor the upstream `docker/docker-compose.yml` (and `kong.yml` + +`kong-entrypoint.sh`) into `nix/supabase/`, keeping only +**studio, kong, auth (GoTrue), rest (PostgREST), meta (postgres-meta)**, with +these deviations — each also documented in the files' header comments: + +1. **No `db` service.** Everything connects to + `${POSTGRES_HOST}:${POSTGRES_PORT}` (db-host); `depends_on: db` dropped. +2. **No realtime / storage / imgproxy / functions / supavisor / analytics.** + Re-vendor from upstream when actually needed. +3. **Studio binds to loopback only, and Kong's catch-all dashboard route is + removed.** Upstream fronts Studio with Kong basic-auth on `/`; we expose + no route to it at all. Access is `ssh -L 3000:127.0.0.1:3000` — possession + of the SSH key is the credential. `supabase_dashboard_password` is + therefore currently unused. The public API surface is exactly + `/auth/v1`, `/rest/v1`, `/graphql/v1` and two `.well-known` endpoints. +4. **meta connects as `supabase_admin`, not `postgres`.** Upstream's + `postgres` role *is* its cluster superuser; on db-host `postgres` keeps a + separate admin password, so `supabase_admin` (made SUPERUSER by ADR-0002) + fills that role with the shared service password. +5. **`kong-entrypoint.sh` is vendored byte-identical.** Kong's declarative + YAML cannot read env vars, so the entrypoint substitutes `$VARS` (the API + keys) into the config at container start; keeping it unmodified makes + re-vendoring a plain copy. + +Variable layering (the answer to "where does `POSTGRES_HOST` come from"): + +* non-secret deployment config (`POSTGRES_HOST/PORT/DB`, `STUDIO_BIND`) — + systemd unit `environment` in `gebos-supabase.nix`, fed by module options; +* secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, + `SERVICE_ROLE_KEY`) — sops-rendered `EnvironmentFile`; +* gebos constants (public URLs, org name) — hardcoded in the vendored + compose. + +## Consequences + +* Deliberately small attack surface and dependency set; Studio reachable only + through SSH. +* Upstream updates are a re-copy plus re-applying the five listed deviations; + all drift is concentrated in `docker-compose.yml` and `kong.yml` headers. +* GoTrue has **no SMTP configured yet** — magic-link login cannot send email + until the `SMTP_*` secrets are added (TODO marked in the compose file). +* `anon`/`authenticated` still hold zero grants; PostgREST serves nothing + until the RLS/grants migration lands (future ADR). diff --git a/docs/adr/0004-legacy-jwt-api-keys.md b/docs/adr/0004-legacy-jwt-api-keys.md new file mode 100644 index 0000000..c58beb8 --- /dev/null +++ b/docs/adr/0004-legacy-jwt-api-keys.md @@ -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":"",…}` | +| 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 — 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 — 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. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..3418125 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,12 @@ +# Architecture Decision Records + +Numbered, immutable-once-accepted records of the significant decisions in +this repo. Supersede by adding a new ADR and flipping the old one's status to +`Superseded by ADR-NNNN`, not by editing history. + +| # | Title | Status | +|---|-------|--------| +| [0001](0001-manual-supabase-cli-migrations.md) | Database schema changes are manual Supabase CLI migrations | Accepted | +| [0002](0002-role-passwords-via-oneshot-unit.md) | Postgres role passwords are synced by a one-shot systemd unit | Accepted | +| [0003](0003-vendored-supabase-compose-subset.md) | Vendor a trimmed Supabase compose stack against external Postgres | Accepted | +| [0004](0004-legacy-jwt-api-keys.md) | Legacy JWT-based API keys, one signing secret | Accepted | diff --git a/nix/hosts/db-host.nix b/nix/hosts/db-host.nix index 2795476..1a88264 100644 --- a/nix/hosts/db-host.nix +++ b/nix/hosts/db-host.nix @@ -8,6 +8,11 @@ networking.hostName = "db-host"; services.gebos.secrets.postgresAdmin = true; + # The gebos-postgres-passwords oneshot (gebos-postgres.nix) also syncs the + # shared Supabase service-role password and the ingester's password, so + # db-host needs those keys rendered into its env file too. + services.gebos.secrets.supabase = true; + services.gebos.secrets.ingester = true; services.gebos.postgres = { enable = true; diff --git a/nix/modules/gebos-postgres.nix b/nix/modules/gebos-postgres.nix index b8d0dac..5b87923 100644 --- a/nix/modules/gebos-postgres.nix +++ b/nix/modules/gebos-postgres.nix @@ -37,8 +37,98 @@ in # supabase_vault — TODO: package or vendor ]; # Init script creates Supabase's `auth`, `storage`, `_analytics`, `_supavisor` - # schemas, the gebos_ingest role (BYPASSRLS), and telemetry hypertables. + # schemas and the Supabase admin/API roles. It only runs at first initdb; + # everything else arrives via manual supabase migrations (see + # supabase/README.md) and the password oneshot below. initialScript = ../supabase/init.sql; + + # The firewall limits 5432 to 10.0.0.0/8, but NixOS's default pg_hba + # only covers local sockets and loopback — without this line every + # connection from app-host / mqtt-ingest is rejected before password + # auth even starts. + authentication = '' + host all all 10.0.0.0/8 scram-sha-256 + ''; }; + + # Role passwords are data, not configuration: NixOS has no declarative + # option for them (anything reachable from the config lands world-readable + # in /nix/store, and initialScript only runs at first initdb). This + # oneshot re-applies them from the sops-rendered env file on every boot / + # deploy, idempotently. It also enforces two attributes the Supabase + # services expect because the upstream supabase/postgres image ships them: + # supabase_admin is SUPERUSER (Studio/postgres-meta connect as it) and + # supabase_auth_admin owns the auth schema (GoTrue runs its own migrations + # there on startup). + # + # After rotating a password in secrets.yaml, re-run with + # `systemctl restart gebos-postgres-passwords` (deploys re-run it only + # when the unit definition itself changed). + systemd.services.gebos-postgres-passwords = + lib.mkIf config.services.gebos.secrets.postgresAdmin { + description = "Gebos — sync Postgres role passwords from sops secrets"; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ config.services.postgresql.package ]; + serviceConfig = { + Type = "oneshot"; + User = "postgres"; + # LoadCredential hands the root-owned 0400 env file to the postgres + # user without widening its permissions. + LoadCredential = "gebos-env:${config.services.gebos.secrets.envFile}"; + }; + script = '' + set -euo pipefail + set -a; . "$CREDENTIALS_DIRECTORY/gebos-env"; set +a + + # All three come from the gebos-env template; the toggles in + # db-host.nix (postgresAdmin + supabase + ingester) must be on. + # --output=/dev/null: the set_config() SELECTs would otherwise echo + # the passwords into the journal. + psql -v ON_ERROR_STOP=1 --output=/dev/null \ + -v admin_pw="''${POSTGRES_ADMIN_PASSWORD:?}" \ + -v supabase_pw="''${POSTGRES_PASSWORD:?}" \ + -v ingest_pw="''${GEBOS_POSTGRES_PASSWORD:?}" \ + --dbname postgres <<'SQL' + -- psql :'var' interpolation does not reach inside DO bodies, so + -- stash the values in session GUCs first. + SELECT set_config('gebos.supabase_pw', :'supabase_pw', false); + SELECT set_config('gebos.ingest_pw', :'ingest_pw', false); + + ALTER ROLE postgres PASSWORD :'admin_pw'; + + DO $do$ + DECLARE r text; + BEGIN + -- One shared password for the roles the compose services log in + -- as, mirroring upstream's POSTGRES_PASSWORD convention. + FOREACH r IN ARRAY ARRAY['supabase_admin','supabase_auth_admin','authenticator'] LOOP + IF EXISTS (SELECT FROM pg_roles WHERE rolname = r) THEN + EXECUTE format('ALTER ROLE %I PASSWORD %L', + r, current_setting('gebos.supabase_pw')); + END IF; + END LOOP; + + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN + EXECUTE 'ALTER ROLE supabase_admin SUPERUSER'; + END IF; + + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_auth_admin') + AND EXISTS (SELECT FROM pg_namespace WHERE nspname = 'auth') THEN + EXECUTE 'ALTER SCHEMA auth OWNER TO supabase_auth_admin'; + END IF; + + -- Created by the schema-v1 migration, so tolerate its absence on + -- a cluster the migration has not reached yet. + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gebos_ingest') THEN + EXECUTE format('ALTER ROLE gebos_ingest PASSWORD %L', + current_setting('gebos.ingest_pw')); + END IF; + END + $do$; + SQL + ''; + }; }; } diff --git a/nix/modules/gebos-supabase.nix b/nix/modules/gebos-supabase.nix index 248c52e..47dab33 100644 --- a/nix/modules/gebos-supabase.nix +++ b/nix/modules/gebos-supabase.nix @@ -30,10 +30,16 @@ in "d /var/lib/gebos-supabase 0750 root root - -" ]; - # Copy the vendored compose file into place at activation time so changes - # to nix/supabase/docker-compose.yml are deployed atomically. + # Copy the vendored compose bundle into place at activation time so + # changes under nix/supabase/ are deployed atomically. Kong's declarative + # config is mounted from here by the compose file (relative ./kong.yml + # resolves against WorkingDirectory). environment.etc."gebos/supabase/docker-compose.yml".source = "${composeDir}/docker-compose.yml"; + environment.etc."gebos/supabase/kong.yml".source = + "${composeDir}/kong.yml"; + environment.etc."gebos/supabase/kong-entrypoint.sh".source = + "${composeDir}/kong-entrypoint.sh"; systemd.services.gebos-supabase = { description = "Gebos — Supabase compose stack"; @@ -41,6 +47,14 @@ in wants = [ "docker.service" "network-online.target" ]; wantedBy = [ "multi-user.target" ]; + # `docker compose up -d` only recreates containers whose definition + # changed, but the unit must re-run for it to notice at all. + restartTriggers = [ + "${composeDir}/docker-compose.yml" + "${composeDir}/kong.yml" + "${composeDir}/kong-entrypoint.sh" + ]; + environment = { POSTGRES_HOST = cfg.postgresHost; POSTGRES_PORT = toString cfg.postgresPort; diff --git a/nix/secrets/secrets.yaml b/nix/secrets/secrets.yaml index a595294..e9a8748 100644 --- a/nix/secrets/secrets.yaml +++ b/nix/secrets/secrets.yaml @@ -17,8 +17,8 @@ # Supabase compose stack (consumed on app-host by gebos-supabase.service) supabase_postgres_password: ENC[AES256_GCM,data:OrjkfiumbB3uMD0tuDkgzaiLDgnt20wYOP9tBAyVwVUXQJxLiDypJoUb4wTY,iv:b4/Lep0Jzb6UtAhyOCpcetXgppul+V5O43zokMtpM2M=,tag:3fvi5xpverfSG+xc/4/HHg==,type:str] supabase_jwt_secret: ENC[AES256_GCM,data:csu0Rj/DM07DCtYFieIe6eEca+eUPR6KGfQBqa6cqRe7bX4=,iv:OKYFa7Y1bbEAKh1ueM7K8JDLldrTJL/r83AGTtMx/IU=,tag:y6OGVofm4cUFPc0BBmkY+A==,type:str] -supabase_anon_key: ENC[AES256_GCM,data:ReE4g5pNUHqi9M9ejnR88XUjwgaibcgZRM5GCMl+Ds7pzKUY442nqEtc2xVO2vQ=,iv:+lp/a/JshLZfdkxm4uQrgOKxBFmIduLtu3aUAhWrKlE=,tag:ft9swD2SeFJ/jMyL07kmGQ==,type:str] -supabase_service_role_key: ENC[AES256_GCM,data:QZQbvJ6rkyoHIpxIvjVDFwxeZ4oIb7Bt2h2vgtW8/sp6swSxtW/oAeVfLkdeduir+2BC5mFAhA==,iv:/F5F3MRC5Yebn68g45F1sj4d18+60JZupneHa3ipExs=,tag:fuzDuSj2npxKQxP2Pz+cjQ==,type:str] +supabase_anon_key: ENC[AES256_GCM,data:piGlqQFvF2pVeL/IedL+/PS+5WO2k90zQooNTk9Q9RPUWtkyIYkZxe5kU8OqmharPO6RJbm6Ez5ly2Gk5NyzodQiEUW58wyCK+iXkduUThvtihQS1fh63ijuIPLwzZAdAjRDTY6vYrWWBnUpRELo5A0QCY4SF7KZUkZhCFN0KUHie2PHHzOPVb0koaacqc+K7A2L6Q/Ay0miLG7MXKNDkHsXdXDh9fhP6A==,iv:kq9wti3d1tugHj2p9BPKG42ZIRvvtvU6eGZhh9Ywq1w=,tag:qBMJbFJ1v95uUDWQ9kLb7g==,type:str] +supabase_service_role_key: ENC[AES256_GCM,data:jGIHQVni8Cc7trwl89mQzN2p2g3ry3mNHxYtXHBcmSkpHp7AjsuypxS3W4yEdq96nNLCkbFeFdw+0X4vC8JIhHhl0NHbVb1eKOM/k9fL7q/nEWGqJGdMxoOytF/2L/NjQgzIFfK2L+YDZYUPaf6Ve0FKJ/awnRi8Csrof73PY5rtBrgrndbobYHEBlmzjv25l28kT3lwD4kaKW6ThnxUFmSAUFMwUd9maAdWm+47Vb1G4rTN,iv:N+XbGLnWqiDUfRl53KrFkY37kyyLCuU5sSU9aNHBYXI=,tag:qu9koOkum0m0F83/OgGC2g==,type:str] supabase_dashboard_password: ENC[AES256_GCM,data:6KHJCPJ66dW9n/uzb3mpGIYqGZABHeEew1H5kx3I8kqgABqXcnHi6w==,iv:87NBfRoNOwtEKgGC0CacY/0xyVE5DQJpuN0X+7j8dfQ=,tag:TcMmIS2GgUlBXlH0elC7Rg==,type:str] # Ingester (consumed on mqtt-ingest by gebos-ingester.service) ingester_postgres_password: ENC[AES256_GCM,data:6jy1G6zG4r0z3c9B6s7jPUtvn/Nr/J2HQN23ch6AGFigiVbefY3hCw==,iv:X/PWQjTW/++MVGsi+hcxbkOJZQMOUH0a4mRH5CbOw4A=,tag:TBKFIdbXZ2YzDLbjdnsyxg==,type:str] @@ -71,6 +71,6 @@ sops: -----END AGE ENCRYPTED FILE----- recipient: age190gu75rf3ra89mhk27xe3tv87tad087altqhugjlhkerqwe2jfqsnu738d encrypted_regex: ^(supabase_|ingester_|postgres_admin_|emqx_|porkbun_) - lastmodified: "2026-07-02T13:11:42Z" - mac: ENC[AES256_GCM,data:eJRwD6eGsmca+Gv3TP7JLSoCXlH34boilkxxEqRkbz8vNsCpEANZufet4Oes8vpNubU8yx509nrGo3SoC+MihvRaw/hfWqYOlXhlB0j6RsQuuG3LRQFVLJ1m1Q4zw5PYEcB/JHc4ORSIDrMb6nlemBc1dsDItIqyxlOqFnesUec=,iv:EBmK7XZ9ZDQT68FiItivu3QglhX0LfvMgw6n6qUhXUM=,tag:IqvQ4CVOrnWpQ3cE/mC/2w==,type:str] + lastmodified: "2026-07-08T20:32:18Z" + mac: ENC[AES256_GCM,data:56m8mOIai7eRKBXwcvWa0SLH6pH2LkL0zrRQ3nbfqe2qqn2qiji5dh4fzVVHmAKnXyob9HaRgRGBTnoi0L357MX+rzefSb0RTDXItsHkYSURYDZnRegEtszPpE5tlff/AlxtLXjs43P6nErkE263gzoFD2EFBwVlH6aufcX3fRo=,iv:eeGYKLRJqDq/ODHeyb6Ucn/K/drFNEJzKZGuAMEGGAU=,tag:qNo4uY4PV7C4MsYThvol3w==,type:str] version: 3.13.1 diff --git a/nix/supabase/README.md b/nix/supabase/README.md index e94d471..9b12a6d 100644 --- a/nix/supabase/README.md +++ b/nix/supabase/README.md @@ -1,40 +1,66 @@ # Supabase compose stack -Vendored from the official Supabase self-hosting bundle, with two deliberate -changes: +Vendored from the official self-hosting bundle +(`docker/docker-compose.yml` in supabase/supabase), with deliberate changes: -1. The bundled `db` service is removed. Every service that talks to Postgres - reads `POSTGRES_HOST` / `POSTGRES_PORT` from the environment and connects to - the external `db-host`. The systemd unit (`nix/modules/gebos-supabase.nix`) - injects these from `/etc/gebos/secrets.env`. +1. **No `db` service.** Every service reads `POSTGRES_HOST` / `POSTGRES_PORT` + from the environment and connects to the external `db-host`. The systemd + unit (`nix/modules/gebos-supabase.nix`) injects these; secrets come from + the sops-rendered env file (see `gebos-secrets.nix`). -2. Studio is bound to `127.0.0.1` only. There is **no public route** to it. - To use it from your laptop: +2. **Only auth + rest + studio (+ kong, meta).** Realtime, storage, imgproxy, + edge functions, supavisor and the Logflare analytics stack are not + vendored — re-add from upstream when actually needed. + +3. **Studio is bound to `127.0.0.1` only, with no Kong dashboard route.** + Upstream protects Studio with Kong basic-auth on a catch-all `/` route; we + drop that route entirely (`kong.yml` here), so there is **no public route** + to Studio and the api.gebos.online surface is only `/auth/v1`, `/rest/v1`, + `/graphql/v1` and the two `.well-known` endpoints. To use Studio: ``` - ssh -L 3000:127.0.0.1:3000 app-host + ssh -L 3000:127.0.0.1:3000 deploy@app.gebos.online open http://localhost:3000 ``` - This is intentional — Studio runs with the `service_role` JWT and bypasses - RLS. Putting it on the public internet behind only HTTP basic auth (the - default) is too thin. + Studio itself has no login in this topology — possession of the SSH key is + the credential. (`supabase_dashboard_password` in secrets.yaml is currently + unused; it becomes relevant only if Studio ever gets fronted by basic + auth again.) + +4. **meta connects as `supabase_admin`, not `postgres`.** In the upstream + image `postgres`/`supabase_admin` is the superuser; on db-host the + `postgres` superuser keeps its own password (`postgres_admin_password`), + and the `gebos-postgres-passwords` oneshot makes `supabase_admin` + SUPERUSER with the shared `POSTGRES_PASSWORD` to match upstream semantics. ## What's in here - `docker-compose.yml` — the stack itself -- `init.sql` — schemas, roles, and extensions Postgres needs before the - compose services come up. Loaded by `nix/modules/gebos-postgres.nix`. -- `kong.yml` — Kong's declarative routing (to be vendored alongside the compose - file when we copy it in). +- `init.sql` — Supabase schemas, roles, and extensions Postgres needs before + the compose services come up. Runs once at first initdb via + `nix/modules/gebos-postgres.nix`. Application schema lives in + `supabase/migrations/` (applied manually — see `supabase/README.md`). +- `kong.yml` — Kong's declarative routing, trimmed to the services we run +- `kong-entrypoint.sh` — upstream helper, verbatim: substitutes `$VARS` into + kong.yml (Kong has no native env interpolation) and builds the + request-transformer Lua expressions + +## Passwords + +No service role has a password in any SQL file. The +`gebos-postgres-passwords` oneshot on db-host (see `gebos-postgres.nix`) +syncs them from sops on every boot/deploy: + +| role | secret | +|-----------------------------------------------------|--------| +| `postgres` | `postgres_admin_password` | +| `supabase_admin`, `supabase_auth_admin`, `authenticator` | `supabase_postgres_password` (= `POSTGRES_PASSWORD` in the compose env) | +| `gebos_ingest` | `ingester_postgres_password` | ## Updating the vendored compose -When upstream Supabase ships a new compose layout, re-vendor by: - -1. Copy upstream `docker/docker-compose.yml` over `docker-compose.yml`. -2. Remove the `db:` service block. -3. Replace every `db:5432` / `postgres:5432` reference with - `${POSTGRES_HOST}:${POSTGRES_PORT}`. -4. Bind Studio to `${STUDIO_BIND}:3000` instead of `0.0.0.0:3000`. -5. Commit, deploy, smoke-test through the SSH tunnel. +When upstream ships a new layout, re-vendor by copying the upstream files and +re-applying the deviations listed at the top of `docker-compose.yml` and +`kong.yml` (both carry the list in their header comments). Then commit, +deploy, and smoke-test Studio through the SSH tunnel. diff --git a/nix/supabase/docker-compose.yml b/nix/supabase/docker-compose.yml index c5311e9..ffe3535 100644 --- a/nix/supabase/docker-compose.yml +++ b/nix/supabase/docker-compose.yml @@ -1,65 +1,209 @@ # Gebos — vendored Supabase compose stack. # -# TODO: copy the full official compose from +# Vendored from the official self-hosting bundle # https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml -# and apply the three modifications described in ./README.md: -# - drop the `db` service -# - replace `db:5432` / `postgres:5432` with ${POSTGRES_HOST}:${POSTGRES_PORT} -# - bind Studio to ${STUDIO_BIND}:3000 (default 127.0.0.1) +# with the deliberate deviations documented in ./README.md: # -# The skeleton below names the services we'll keep and the env vars each one -# reads, so reviewers can sanity-check the topology before the real compose -# lands. +# * no `db` service — Postgres runs on db-host; every service connects to +# ${POSTGRES_HOST}:${POSTGRES_PORT} and the `depends_on: db` blocks are +# dropped +# * no realtime / storage / imgproxy / functions / supavisor / analytics — +# the pilot needs auth + rest + studio only; re-vendor from upstream when +# one of them becomes needed +# * Studio binds to ${STUDIO_BIND}:3000 (loopback; reach it via SSH tunnel) +# * meta connects as supabase_admin, not `postgres`: upstream's `postgres` +# is its cluster superuser, but on db-host that role carries a different +# password. gebos-postgres-passwords makes supabase_admin SUPERUSER to +# match the upstream image's semantics. +# +# Where variables come from (see nix/modules/gebos-supabase.nix): +# * POSTGRES_HOST / POSTGRES_PORT / POSTGRES_DB / STUDIO_BIND — systemd +# unit `environment` (non-secret deployment config, nix-managed) +# * POSTGRES_PASSWORD / JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY — +# sops-rendered EnvironmentFile (secrets) +# * gebos constants (public URLs, org name) — hardcoded right here + +name: gebos-supabase services: - kong: - image: kong:2.8.1 + + studio: + container_name: supabase-studio + image: supabase/studio:2026.07.07-sha-a6a04f2 restart: unless-stopped ports: - - "127.0.0.1:8000:8000/tcp" # Caddy proxies api.gebos.online here + # Never 0.0.0.0 — Studio performs no authentication of its own in this + # topology (the upstream basic-auth lives on Kong's dashboard route, + # which we do not expose). Access = SSH tunnel to app-host. + - ${STUDIO_BIND}:3000:3000/tcp + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\"" + ] + timeout: 10s + interval: 5s + retries: 3 + start_period: 20s + environment: + HOSTNAME: "0.0.0.0" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_USER_READ_WRITE: supabase_admin + + PGRST_DB_SCHEMAS: public + + DEFAULT_ORGANIZATION_NAME: Gebos + DEFAULT_PROJECT_NAME: Gebos + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: https://api.gebos.online + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + # No Logflare/analytics stack in this deployment. + ENABLED_FEATURES_LOGS_ALL: "false" + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + ports: + - 127.0.0.1:8000:8000/tcp # Caddy proxies api.gebos.online here + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + # The entrypoint substitutes $ENV_VARS in temp.yml and writes the result + # to KONG_DECLARATIVE_CONFIG (Kong has no native env interpolation). + - ./kong.yml:/home/kong/temp.yml:ro + - ./kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro environment: KONG_DATABASE: "off" - KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml - volumes: - - ./kong.yml:/home/kong/kong.yml:ro + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + KONG_ROUTER_FLAVOR: expressions + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + # Opaque sb_* API keys — not used yet. Empty values make the entrypoint + # fall back to legacy apikey pass-through and strip the empty + # credentials from the declarative config. + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + entrypoint: ["/bin/sh", "/home/kong/kong-entrypoint.sh"] auth: - image: supabase/gotrue:v2.158.1 + container_name: supabase-auth + image: supabase/gotrue:v2.189.0 restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 environment: - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - GOTRUE_SITE_URL: https://app.gebos.online - GOTRUE_JWT_SECRET: ${JWT_SECRET} - GOTRUE_JWT_EXP: "3600" + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: https://api.gebos.online + GOTRUE_DB_DRIVER: postgres + # GoTrue owns the auth schema and runs its own migrations in it on + # startup — supabase_auth_admin's ownership of that schema is enforced + # by the gebos-postgres-passwords oneshot on db-host. + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: https://app.gebos.online + GOTRUE_URI_ALLOW_LIST: "" + # Tenants are invited by an admin (magic link), never self-signed-up. + GOTRUE_DISABLE_SIGNUP: "true" + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: "3600" + GOTRUE_JWT_SECRET: ${JWT_SECRET} + GOTRUE_JWT_ISSUER: https://api.gebos.online + + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false" + GOTRUE_MAILER_AUTOCONFIRM: "false" + + # TODO: no SMTP provider configured yet. Magic links (UVI-NFR-08, + # passwordless-only) cannot send until these are filled in — add the + # SMTP secrets to nix/secrets/secrets.yaml and wire them through + # gebos-secrets.nix like the other supabase keys. + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-} + GOTRUE_SMTP_HOST: ${SMTP_HOST:-} + GOTRUE_SMTP_PORT: ${SMTP_PORT:-587} + GOTRUE_SMTP_USER: ${SMTP_USER:-} + GOTRUE_SMTP_PASS: ${SMTP_PASS:-} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Gebos} + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + GOTRUE_EXTERNAL_PHONE_ENABLED: "false" + GOTRUE_SMS_AUTOCONFIRM: "false" + rest: - image: postgrest/postgrest:v12.0.2 + container_name: supabase-rest + image: postgrest/postgrest:v14.12 restart: unless-stopped + healthcheck: + test: [ "CMD", "postgrest", "--ready" ] + interval: 5s + timeout: 5s + retries: 3 environment: PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} PGRST_DB_SCHEMAS: public - PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_MAX_ROWS: 1000 + PGRST_DB_EXTRA_SEARCH_PATH: public PGRST_DB_ANON_ROLE: anon - studio: - image: supabase/studio:20240326-5e5586d - restart: unless-stopped - ports: - - "${STUDIO_BIND}:3000:3000/tcp" # never bind to 0.0.0.0 - environment: - STUDIO_PG_META_URL: http://meta:8080 - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - DEFAULT_ORGANIZATION_NAME: Gebos - SUPABASE_URL: https://api.gebos.online - DASHBOARD_USERNAME: gebos - DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + PGRST_ADMIN_SERVER_PORT: 3001 + PGRST_ADMIN_SERVER_HOST: localhost + + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: "3600" + command: + [ + "postgrest" + ] meta: - image: supabase/postgres-meta:v0.83.2 + container_name: supabase-meta + image: supabase/postgres-meta:v0.96.6 restart: unless-stopped environment: + PG_META_PORT: 8080 PG_META_DB_HOST: ${POSTGRES_HOST} PG_META_DB_PORT: ${POSTGRES_PORT} PG_META_DB_NAME: ${POSTGRES_DB} diff --git a/nix/supabase/init.sql b/nix/supabase/init.sql index 339ba21..85be465 100644 --- a/nix/supabase/init.sql +++ b/nix/supabase/init.sql @@ -47,35 +47,4 @@ exception when duplicate_object then null; end $$; do $$ begin create role supabase_auth_admin login createrole; grant usage on schema auth to supabase_auth_admin; -exception when duplicate_object then null; end $$; - --- Ingester writes telemetry; bypasses RLS because at write time no user is --- authenticated. Trust boundary is the broker ACL ("who can publish to what --- tenant's topic"), not the database. -do $$ begin - create role gebos_ingest login bypassrls; -exception when duplicate_object then null; end $$; - --- Telemetry hypertable. Tenant lives on the row so standard RLS works. -create table if not exists public.telemetry ( - ts timestamptz not null, - tenant_id uuid not null, - device_id uuid not null, - metric text not null, - value double precision, - payload jsonb -); - -select create_hypertable('public.telemetry', 'ts', if_not_exists => true); - -create index if not exists telemetry_tenant_device_ts_idx - on public.telemetry (tenant_id, device_id, ts desc); - -alter table public.telemetry enable row level security; - -create policy telemetry_tenant_isolation on public.telemetry - for select - using (tenant_id = current_setting('app.tenant_id', true)::uuid); - -grant select on public.telemetry to authenticated; -grant insert on public.telemetry to gebos_ingest; +exception when duplicate_object then null; end $$; \ No newline at end of file diff --git a/nix/supabase/kong-entrypoint.sh b/nix/supabase/kong-entrypoint.sh new file mode 100644 index 0000000..daf1d5e --- /dev/null +++ b/nix/supabase/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Custom entrypoint for Kong that builds Lua expressions for request-transformer +# and performs environment variable substitution in the declarative config. + +# Build Lua expressions for translating opaque API keys to asymmetric JWTs. +# When opaque keys are not configured (empty env vars), expressions fall through +# to legacy-only behavior - just passing apikey as-is. +# +# Full expression logic (when opaque keys are configured): +# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT) +# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key" +# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key" +# 4. Fallback: pass apikey as-is (legacy HS256 JWT) + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # Opaque keys configured -> full translation expressions + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" + + # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey + # via query string), outputs to x-api-key header which Realtime checks first. + export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" +else + # Legacy API keys, not sb_ API keys -> pass apikey through unchanged + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" + export LUA_RT_WS_EXPR="\$(query_params.apikey)" +fi + +# Substitute environment variables in the Kong declarative config. +# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double +# quotes, breaking "Header: value" patterns that YAML parses as mappings). +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +# Remove empty key-auth credentials (unconfigured opaque keys) +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/nix/supabase/kong.yml b/nix/supabase/kong.yml index 188b58e..327e1d8 100644 --- a/nix/supabase/kong.yml +++ b/nix/supabase/kong.yml @@ -1,48 +1,206 @@ -_format_version: "2.1" +_format_version: '2.1' +_transform: true -# Declarative Kong config — vanilla Supabase routing, no surprises. -# Lives at /home/kong/kong.yml inside the container. +# Vendored from the official bundle (docker/volumes/api/kong.yml), with the +# routes for services we don't run removed: realtime, storage, functions, +# analytics, pg-meta (/pg/), MCP, and — deliberately — the catch-all +# dashboard route + DASHBOARD basic-auth consumer. Studio is loopback-only +# behind an SSH tunnel and must never be reachable via api.gebos.online. +# +# $VARS are substituted by kong-entrypoint.sh at container start; empty +# credential lines (unconfigured opaque keys) are stripped there too. +### +### Consumers / Users +### consumers: - username: anon keyauth_credentials: - - key: ${ANON_KEY} + - key: $SUPABASE_ANON_KEY + - key: $SUPABASE_PUBLISHABLE_KEY - username: service_role keyauth_credentials: - - key: ${SERVICE_ROLE_KEY} + - key: $SUPABASE_SERVICE_KEY + - key: $SUPABASE_SECRET_KEY +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### API Routes +### services: + ## Open Auth routes + - name: auth-v1-open + _comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*' + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + _comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*' + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + _comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*' + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + - name: auth-v1-open-jwks + _comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json' + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + ## Secure Auth routes - name: auth-v1 + _comment: 'Auth: /auth/v1/* -> http://auth:9999/*' url: http://auth:9999/ routes: - - name: auth-v1-route + - name: auth-v1-all strip_path: true - paths: [ /auth/v1/ ] + paths: + - /auth/v1/ plugins: - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon - - name: rest-v1 + ## OpenAPI root - admin only + - name: rest-v1-openapi + _comment: 'PostgREST OpenAPI root: /rest/v1/ -> (admin only). See ' url: http://rest:3000/ routes: - - name: rest-v1-route + - name: rest-v1-openapi-root strip_path: true - paths: [ /rest/v1/ ] + expression: 'http.path == "/rest/v1/"' plugins: - name: cors - name: key-auth config: - hide_credentials: true - key_names: [ apikey ] + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin - - name: rpc - url: http://rest:3000/rpc/ + ## Secure PostgREST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ routes: - - name: rpc-route + - name: rest-v1-all strip_path: true - paths: [ /rpc/ ] + paths: + - /rest/v1/ plugins: - name: cors - name: key-auth config: - hide_credentials: true - key_names: [ apikey ] + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Content-Profile: graphql_public" + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## OAuth 2.0 Authorization Server Metadata (RFC 8414) + - name: well-known-oauth + _comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server' + url: http://auth:9999/.well-known/oauth-authorization-server + routes: + - name: well-known-oauth + strip_path: true + paths: + - /.well-known/oauth-authorization-server + plugins: + - name: cors diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..6c7c461 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,55 @@ +# Database migrations + +Plain-SQL migrations in `migrations/`, applied **manually** with the supabase +CLI (available in the dev shell). There is deliberately no automated runner: +schema changes are rare, deliberate, admin-level operations. + +The CLI records what has been applied in +`supabase_migrations.schema_migrations` on the target database, so pushing is +incremental and re-running it is safe. + +## Applying to prod (db-host) + +Postgres on db-host only accepts connections from the private network, so go +through an SSH tunnel: + +```sh +# terminal 1 — tunnel to db-host's loopback +ssh -L 15432:127.0.0.1:5432 deploy@db.gebos.online + +# terminal 2 — from the repo root, inside `nix develop` +PGPASS=$(sops -d --extract '["postgres_admin_password"]' nix/secrets/secrets.yaml) +supabase db push --db-url "postgres://postgres:${PGPASS}@127.0.0.1:15432/postgres" +``` + +The `postgres` superuser password is set from the same sops secret by the +`gebos-postgres-passwords` oneshot on db-host (see +`nix/modules/gebos-postgres.nix`), so it only works after the first deploy of +that unit. + +## Adding a migration + +```sh +supabase migration new # creates migrations/_.sql +``` + +Write plain SQL. Conventions: + +* Migrations are ordered and applied exactly once — they do NOT need to be + idempotent (the schema-v1 file's `CREATE TYPE`s aren't). +* Cluster bootstrap (Supabase schemas/roles, extensions that need + `shared_preload_libraries`) belongs in `nix/supabase/init.sql`, not here. +* Role passwords never go in migrations — they are synced from sops by the + `gebos-postgres-passwords` unit. + +## Local dev + +`nix run .#dev` starts a plain Postgres via process-compose; apply migrations +to it the same way: + +```sh +supabase db push --db-url "postgres://gebos_ingest:dev@127.0.0.1:5432/gebos" +``` + +(Note: the dev database has no TimescaleDB unless the process-compose postgres +gains the extension — `create_hypertable` calls will fail there until then.) diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..a8affd8 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,4 @@ +# Minimal supabase CLI project marker. Only `supabase db push --db-url ...` +# is used here (manual migrations against db-host); local `supabase start` +# is not — the local dev stack is `nix run .#dev` (process-compose). +project_id = "gebos" diff --git a/db/schema.sql b/supabase/migrations/20260708100000_schema_v1.sql similarity index 93% rename from db/schema.sql rename to supabase/migrations/20260708100000_schema_v1.sql index 6a696f1..b0058ea 100644 --- a/db/schema.sql +++ b/supabase/migrations/20260708100000_schema_v1.sql @@ -1,7 +1,15 @@ -- ============================================================================ --- GebOS – Schema V1 +-- GebOS – Schema V1 (initial Supabase migration) -- Target: Postgres 17 + TimescaleDB -- +-- Applied MANUALLY via `supabase db push` — see supabase/README.md. There is +-- deliberately no automated migration runner. The CLI records applied +-- migrations in supabase_migrations.schema_migrations, so re-running push is +-- safe; the file itself is NOT idempotent (CREATE TYPE has no IF NOT EXISTS). +-- +-- Cluster-level bootstrap (Supabase schemas/roles, extensions requiring +-- shared_preload_libraries) lives in nix/supabase/init.sql, not here. +-- -- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden -- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate -- epic and deliberately not modeled here. @@ -597,4 +605,38 @@ CREATE TABLE saving_tip ( -- query, driven by count(apartment) per building at query time. -- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display -- logic; the data model is season-agnostic. --- =========================================================================== \ No newline at end of file +-- =========================================================================== + + +-- =========================================================================== +-- 6. INGEST ROLE & GRANTS +-- +-- gebos_ingest is the MQTT ingester's login role — gebos-specific, NOT a +-- Supabase role. It bypasses RLS because at write time no end user is +-- authenticated; the trust boundary is the broker ACL ("who may publish to +-- which tenant's topic"), not the database. Created here rather than in +-- init.sql because its grants are coupled to the tables above, and because +-- init.sql only runs at first initdb while migrations always apply. +-- +-- Its password is set out-of-band by the gebos-postgres-passwords oneshot +-- on db-host (sops secret: ingester_postgres_password). +-- =========================================================================== + +DO $$ BEGIN + CREATE ROLE gebos_ingest LOGIN BYPASSRLS; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +GRANT USAGE ON SCHEMA public TO gebos_ingest; + +-- Lookups the ingest/parse pipeline performs: gateway by IMEI, sensor by +-- oms_id, parser configuration by device type. +GRANT SELECT ON gateway, sensor, device_type, value_parser TO gebos_ingest; + +-- Frame log: INSERT on arrival; SELECT + UPDATE for the parse worker's +-- queue query and its parse_status transitions. +GRANT SELECT, INSERT, UPDATE ON received_payload TO gebos_ingest; + +-- Measurements are append-only from the ingester's point of view — dedup is +-- INSERT .. ON CONFLICT DO NOTHING against the composite PK, which needs no +-- UPDATE privilege. +GRANT SELECT, INSERT ON measurement TO gebos_ingest; \ No newline at end of file