From 65c3678f37c9975cb5da0790f9af8407c486612b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:21:35 +0000 Subject: [PATCH 01/10] Initial plan From 16a3fa5320c0ff6b6bf01736838e2d601d617baa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:28:55 +0000 Subject: [PATCH 02/10] test: add postgrest-db bats CI coverage --- .github/workflows/ci-test-postgrest-db.yml | 70 ++++++++++++++++++ .github/workflows/publish-postgrest-db.yml | 23 ++++++ postgrest-tandem/tests/test_api.bats | 34 +++++++++ postgrest-tandem/tests/test_auth.bats | 77 ++++++++++++++++++++ postgrest-tandem/tests/test_helper.bash | 80 +++++++++++++++++++++ postgrest-tandem/tests/test_init.bats | 37 ++++++++++ postgrest-tandem/tests/test_jwt_secret.bats | 54 ++++++++++++++ postgrest-tandem/tests/test_security.bats | 61 ++++++++++++++++ 8 files changed, 436 insertions(+) create mode 100644 .github/workflows/ci-test-postgrest-db.yml create mode 100644 postgrest-tandem/tests/test_api.bats create mode 100644 postgrest-tandem/tests/test_auth.bats create mode 100644 postgrest-tandem/tests/test_helper.bash create mode 100644 postgrest-tandem/tests/test_init.bats create mode 100644 postgrest-tandem/tests/test_jwt_secret.bats create mode 100644 postgrest-tandem/tests/test_security.bats diff --git a/.github/workflows/ci-test-postgrest-db.yml b/.github/workflows/ci-test-postgrest-db.yml new file mode 100644 index 0000000..702da9a --- /dev/null +++ b/.github/workflows/ci-test-postgrest-db.yml @@ -0,0 +1,70 @@ +name: CI test postgrest-db + +on: + pull_request: + paths: + - 'postgrest-tandem/**' + push: + branches: + - main + paths: + - 'postgrest-tandem/**' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image tarball + uses: docker/build-push-action@v6 + with: + context: ./postgrest-tandem/image + file: ./postgrest-tandem/image/Containerfile + push: false + tags: local/postgrest-db:ci + outputs: type=docker,dest=/tmp/postgrest-db-ci.tar + build-args: | + IMAGE_VERSION=ci + VCS_REF=${{ github.sha }} + SOURCE_URL=${{ github.server_url }}/${{ github.repository }} + POSTGRES_BASE_TAG=16 + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: postgrest-db-image + path: /tmp/postgrest-db-ci.tar + if-no-files-found: error + + integration-test: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: postgrest-db-image + path: /tmp + + - name: Load image + run: docker load -i /tmp/postgrest-db-ci.tar + + - name: Install test dependencies + run: | + sudo apt-get update + sudo apt-get install -y bats bats-support bats-assert postgresql-client jq + + - name: Run BATS integration tests + env: + TEST_IMAGE: local/postgrest-db:ci + run: bats postgrest-tandem/tests/ diff --git a/.github/workflows/publish-postgrest-db.yml b/.github/workflows/publish-postgrest-db.yml index 9e84c62..12ef734 100644 --- a/.github/workflows/publish-postgrest-db.yml +++ b/.github/workflows/publish-postgrest-db.yml @@ -54,6 +54,29 @@ jobs: type=raw,value=${{ steps.vars.outputs.version }} type=raw,value=latest + - name: Build image for vulnerability scan + uses: docker/build-push-action@v6 + with: + context: ./postgrest-tandem/image + file: ./postgrest-tandem/image/Containerfile + push: false + load: true + tags: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.version }} + build-args: | + IMAGE_VERSION=${{ steps.vars.outputs.version }} + VCS_REF=${{ github.sha }} + SOURCE_URL=${{ github.server_url }}/${{ github.repository }} + POSTGRES_BASE_TAG=16 + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@0.25.0 + with: + image-ref: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.version }} + format: table + exit-code: '1' + severity: CRITICAL,HIGH + ignore-unfixed: true + - name: Build and push image uses: docker/build-push-action@v6 with: diff --git a/postgrest-tandem/tests/test_api.bats b/postgrest-tandem/tests/test_api.bats new file mode 100644 index 0000000..6c56ccf --- /dev/null +++ b/postgrest-tandem/tests/test_api.bats @@ -0,0 +1,34 @@ +#!/usr/bin/env bats + +load ./test_helper.bash + +setup_file() { + start_postgrest_db + ensure_alice_user +} + +teardown_file() { + stop_postgrest_db +} + +@test "api.login called as anon returns valid JWT" { + run psql_authenticator "SET ROLE anon; SELECT (api.login('alice', 'correct-password')).token;" + [ "$status" -eq 0 ] + token="$(printf '%s\n' "$output" | tail -n1)" + [ -n "$token" ] + + part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')" + [ "$part_count" -eq 3 ] +} + +@test "api.login called as anon with wrong password raises invalid_password" { + run psql_authenticator "SET ROLE anon; DO \ + \\$\\$ BEGIN \ + PERFORM api.login('alice', 'wrong-password'); \ + RAISE EXCEPTION 'expected invalid password'; \ + EXCEPTION WHEN invalid_password THEN \ + RAISE NOTICE 'caught:%', SQLSTATE; \ + END \\$\\$;" + [ "$status" -eq 0 ] + [[ "$output" == *"caught:28P01"* ]] +} diff --git a/postgrest-tandem/tests/test_auth.bats b/postgrest-tandem/tests/test_auth.bats new file mode 100644 index 0000000..7d6773e --- /dev/null +++ b/postgrest-tandem/tests/test_auth.bats @@ -0,0 +1,77 @@ +#!/usr/bin/env bats + +load ./test_helper.bash + +setup_file() { + start_postgrest_db + ensure_alice_user +} + +teardown_file() { + stop_postgrest_db +} + +@test "storing a user hashes password with bcrypt" { + run psql_super "SELECT password FROM auth.users WHERE username = 'alice';" + [ "$status" -eq 0 ] + [[ "$output" == \$2a\$* || "$output" == \$2b\$* ]] +} + +@test "auth.login returns a JWT with role and ~15 minute expiry" { + run psql_super "SELECT (auth.login('alice', 'correct-password')).token;" + [ "$status" -eq 0 ] + token="$output" + [ -n "$token" ] + + part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')" + [ "$part_count" -eq 3 ] + + payload_json="$(decode_jwt_payload "$token")" + role="$(printf '%s' "$payload_json" | jq -r '.role')" + exp="$(printf '%s' "$payload_json" | jq -r '.exp')" + now_epoch="$(date +%s)" + + [ "$role" = "anon" ] + [ "$exp" -ge $((now_epoch + 14 * 60)) ] + [ "$exp" -le $((now_epoch + 16 * 60)) ] +} + +@test "auth.login wrong password raises invalid_password SQLSTATE 28P01" { + run psql_super "DO \ + \\$\\$ BEGIN \ + PERFORM auth.login('alice', 'wrong-password'); \ + RAISE EXCEPTION 'expected invalid password'; \ + EXCEPTION WHEN invalid_password THEN \ + RAISE NOTICE 'caught:%', SQLSTATE; \ + END \\$\\$;" + [ "$status" -eq 0 ] + [[ "$output" == *"caught:28P01"* ]] +} + +@test "inserting user with unknown role is rejected" { + run psql_super_raw "INSERT INTO auth.users (username, password, role) VALUES ('missing-role', 'correct-password', 'does_not_exist');" + [ "$status" -ne 0 ] + [[ "$output" == *"role \"does_not_exist\" does not exist"* ]] +} + +@test "inserting user with short password is rejected" { + run psql_super_raw "INSERT INTO auth.users (username, password, role) VALUES ('short-pass', 'short', 'anon');" + [ "$status" -ne 0 ] + [[ "$output" == *"violates check constraint"* ]] +} + +@test "updating password re-hashes to a different bcrypt hash" { + run psql_super "SELECT password FROM auth.users WHERE username = 'alice';" + [ "$status" -eq 0 ] + before_hash="$output" + + run psql_super "UPDATE auth.users SET password = 'new-correct-password' WHERE username = 'alice';" + [ "$status" -eq 0 ] + + run psql_super "SELECT password FROM auth.users WHERE username = 'alice';" + [ "$status" -eq 0 ] + after_hash="$output" + + [[ "$after_hash" == \$2a\$* || "$after_hash" == \$2b\$* ]] + [ "$before_hash" != "$after_hash" ] +} diff --git a/postgrest-tandem/tests/test_helper.bash b/postgrest-tandem/tests/test_helper.bash new file mode 100644 index 0000000..e409a77 --- /dev/null +++ b/postgrest-tandem/tests/test_helper.bash @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +POSTGREST_DB_CONTAINER="postgrest-db-bats" +POSTGREST_DB_PORT="55432" +POSTGRES_PASSWORD="postgres-ci-only" +AUTHENTICATOR_PASSWORD="testpw-ci-only" +TEST_IMAGE="${TEST_IMAGE:-local/postgrest-db:ci}" + +psql_super() { + PGPASSWORD="$POSTGRES_PASSWORD" psql -X -qAt -v ON_ERROR_STOP=1 \ + -h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U postgres -d postgres -c "$1" +} + +psql_super_raw() { + PGPASSWORD="$POSTGRES_PASSWORD" psql -X -qAt \ + -h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U postgres -d postgres -c "$1" +} + +psql_authenticator() { + PGPASSWORD="$AUTHENTICATOR_PASSWORD" psql -X -qAt -v ON_ERROR_STOP=1 \ + -h 127.0.0.1 -p "$POSTGREST_DB_PORT" -U authenticator -d postgres -c "$1" +} + +wait_for_postgres() { + local attempts=60 + while (( attempts > 0 )); do + if psql_super 'SELECT 1;' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + attempts=$((attempts - 1)) + done + return 1 +} + +start_postgrest_db() { + docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$POSTGREST_DB_CONTAINER" \ + -p "$POSTGREST_DB_PORT:5432" \ + -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ + -e AUTHENTICATOR_PASSWORD="$AUTHENTICATOR_PASSWORD" \ + "$TEST_IMAGE" >/dev/null + + if ! wait_for_postgres; then + docker logs "$POSTGREST_DB_CONTAINER" >&2 || true + return 1 + fi +} + +stop_postgrest_db() { + docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true +} + +ensure_alice_user() { + psql_super " + INSERT INTO auth.users (username, password, role) + VALUES ('alice', 'correct-password', 'anon') + ON CONFLICT (username) DO UPDATE + SET password = EXCLUDED.password, + role = EXCLUDED.role; + " +} + +decode_jwt_payload() { + local token="$1" + local payload + local rem + + payload="$(printf '%s' "$token" | cut -d'.' -f2 | tr '_-' '/+')" + rem=$(( ${#payload} % 4 )) + if [ "$rem" -eq 2 ]; then + payload+="==" + elif [ "$rem" -eq 3 ]; then + payload+="=" + elif [ "$rem" -eq 1 ]; then + return 1 + fi + + printf '%s' "$payload" | base64 --decode +} diff --git a/postgrest-tandem/tests/test_init.bats b/postgrest-tandem/tests/test_init.bats new file mode 100644 index 0000000..a8f16f0 --- /dev/null +++ b/postgrest-tandem/tests/test_init.bats @@ -0,0 +1,37 @@ +#!/usr/bin/env bats + +load ./test_helper.bash + +setup_file() { + start_postgrest_db +} + +teardown_file() { + stop_postgrest_db +} + +@test "authenticator and anon roles are bootstrapped correctly" { + run psql_super "SELECT rolname || '|' || rolcanlogin || '|' || rolinherit FROM pg_roles WHERE rolname = 'authenticator';" + [ "$status" -eq 0 ] + [ "$output" = "authenticator|t|f" ] + + run psql_super "SELECT rolname || '|' || rolcanlogin FROM pg_roles WHERE rolname = 'anon';" + [ "$status" -eq 0 ] + [ "$output" = "anon|f" ] + + run psql_super "SELECT pg_has_role('authenticator', 'anon', 'member');" + [ "$status" -eq 0 ] + [ "$output" = "t" ] +} + +@test "required schemas exist" { + run psql_super "SELECT string_agg(nspname, ',') FROM (SELECT nspname FROM pg_namespace WHERE nspname IN ('postgrest','auth','api') ORDER BY nspname) s;" + [ "$status" -eq 0 ] + [ "$output" = "api,auth,postgrest" ] +} + +@test "required extensions are installed" { + run psql_super "SELECT string_agg(extname, ',') FROM (SELECT extname FROM pg_extension WHERE extname IN ('plpython3u','pgcrypto') ORDER BY extname) e;" + [ "$status" -eq 0 ] + [ "$output" = "pgcrypto,plpython3u" ] +} diff --git a/postgrest-tandem/tests/test_jwt_secret.bats b/postgrest-tandem/tests/test_jwt_secret.bats new file mode 100644 index 0000000..25cb0ec --- /dev/null +++ b/postgrest-tandem/tests/test_jwt_secret.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats + +load ./test_helper.bash + +setup_file() { + start_postgrest_db +} + +teardown_file() { + stop_postgrest_db +} + +@test "jwt_secret singleton has exactly one non-empty long secret" { + run psql_super "SELECT count(*) FROM postgrest.jwt_secret;" + [ "$status" -eq 0 ] + [ "$output" = "1" ] + + run psql_super "SELECT length(secret) FROM postgrest.jwt_secret LIMIT 1;" + [ "$status" -eq 0 ] + [ "$output" -ge 64 ] +} + +@test "pre_config exposes the persisted secret via pgrst.jwt_secret" { + run psql_super "WITH _ AS (SELECT postgrest.pre_config()) SELECT current_setting('pgrst.jwt_secret', true);" + [ "$status" -eq 0 ] + config_secret="$output" + + run psql_super "SELECT secret FROM postgrest.jwt_secret LIMIT 1;" + [ "$status" -eq 0 ] + [ "$output" = "$config_secret" ] +} + +@test "rotate_jwt_secret changes secret and advances updated_at" { + run psql_super "SELECT secret || '|' || extract(epoch FROM updated_at)::bigint FROM postgrest.jwt_secret LIMIT 1;" + [ "$status" -eq 0 ] + before="$output" + + run psql_super "SELECT pg_sleep(1); SELECT postgrest.rotate_jwt_secret();" + [ "$status" -eq 0 ] + + run psql_super "SELECT secret || '|' || extract(epoch FROM updated_at)::bigint FROM postgrest.jwt_secret LIMIT 1;" + [ "$status" -eq 0 ] + after="$output" + + [ "$before" != "$after" ] + + before_secret="${before%%|*}" + before_updated="${before##*|}" + after_secret="${after%%|*}" + after_updated="${after##*|}" + + [ "$before_secret" != "$after_secret" ] + [ "$after_updated" -gt "$before_updated" ] +} diff --git a/postgrest-tandem/tests/test_security.bats b/postgrest-tandem/tests/test_security.bats new file mode 100644 index 0000000..c607270 --- /dev/null +++ b/postgrest-tandem/tests/test_security.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +load ./test_helper.bash + +setup_file() { + start_postgrest_db + ensure_alice_user +} + +teardown_file() { + stop_postgrest_db +} + +@test "public/anon cannot call auth.sign_jwt" { + run psql_authenticator "SELECT auth.sign_jwt('{\"role\":\"anon\"}'::jsonb);" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] + + run psql_authenticator "SET ROLE anon; SELECT auth.sign_jwt('{\"role\":\"anon\"}'::jsonb);" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] +} + +@test "public/anon cannot rotate JWT secret" { + run psql_authenticator "SELECT postgrest.rotate_jwt_secret();" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] + + run psql_authenticator "SET ROLE anon; SELECT postgrest.rotate_jwt_secret();" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] +} + +@test "anon cannot call auth.login directly" { + run psql_authenticator "SET ROLE anon; SELECT auth.login('alice', 'correct-password');" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] +} + +@test "anon cannot select postgrest.jwt_secret or auth.users" { + run psql_authenticator "SET ROLE anon; SELECT secret FROM postgrest.jwt_secret LIMIT 1;" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] + + run psql_authenticator "SET ROLE anon; SELECT username FROM auth.users LIMIT 1;" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] +} + +@test "authenticator cannot select auth.users directly" { + run psql_authenticator "SELECT username FROM auth.users LIMIT 1;" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] +} + +@test "api.login and auth.login are security definer owned by postgres" { + run psql_super "SELECT n.nspname || '.' || p.proname || '|' || p.prosecdef || '|' || pg_get_userbyid(p.proowner) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE (n.nspname, p.proname) IN (('api', 'login'), ('auth', 'login')) ORDER BY n.nspname;" + [ "$status" -eq 0 ] + expected=$'api.login|t|postgres\nauth.login|t|postgres' + [ "$output" = "$expected" ] +} From 4e2ca2fdcabc9fdcf87df1d471eddac2066e2957 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:30:39 +0000 Subject: [PATCH 03/10] ci: add read-only workflow permissions --- .github/workflows/ci-test-postgrest-db.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-test-postgrest-db.yml b/.github/workflows/ci-test-postgrest-db.yml index 702da9a..e05b9ee 100644 --- a/.github/workflows/ci-test-postgrest-db.yml +++ b/.github/workflows/ci-test-postgrest-db.yml @@ -10,6 +10,9 @@ on: paths: - 'postgrest-tandem/**' +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest From c55a7509917f4ec53e02990f4d7ffb22b65a7675 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:49:57 +0000 Subject: [PATCH 04/10] docs: add local BATS test instructions --- postgrest-tandem/image/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/postgrest-tandem/image/README.md b/postgrest-tandem/image/README.md index fcb1fb7..363cef2 100644 --- a/postgrest-tandem/image/README.md +++ b/postgrest-tandem/image/README.md @@ -125,6 +125,33 @@ echo "$TOKEN" The token is suitable for `Authorization: Bearer `. +## Local integration tests (BATS) + +The integration suite under `postgrest-tandem/tests/` validates bootstrap scripts, auth/JWT behavior, and privilege boundaries directly via `psql`. + +### Prerequisites + +- Docker (or Podman with equivalent commands) +- `bats`, `jq`, and `psql` available on your machine + +### Run locally + +From the repository root: + +```bash +# Build the image used by the test harness. +docker build -f postgrest-tandem/image/Containerfile postgrest-tandem/image \ + -t local/postgrest-db:ci + +# Execute all integration tests. +TEST_IMAGE=local/postgrest-db:ci bats postgrest-tandem/tests/ +``` + +Notes: + +- The test harness uses a fixed dummy credential (`AUTHENTICATOR_PASSWORD=testpw-ci-only`) for CI/local testing only. +- Tests start and remove their own temporary database container. + ## Notes - JWTs are signed (tamper-proof), not encrypted (readable by holder). From eabef558f3f18daecdbd8262c65125fe8a3353f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:59:50 +0000 Subject: [PATCH 05/10] test: support podman runtime in bats harness --- postgrest-tandem/image/README.md | 5 +++++ postgrest-tandem/tests/test_helper.bash | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/postgrest-tandem/image/README.md b/postgrest-tandem/image/README.md index 363cef2..c150066 100644 --- a/postgrest-tandem/image/README.md +++ b/postgrest-tandem/image/README.md @@ -145,6 +145,11 @@ docker build -f postgrest-tandem/image/Containerfile postgrest-tandem/image \ # Execute all integration tests. TEST_IMAGE=local/postgrest-db:ci bats postgrest-tandem/tests/ + +# Or run with podman. +podman build -f postgrest-tandem/image/Containerfile postgrest-tandem/image \ + -t local/postgrest-db:ci +CONTAINER_RUNTIME=podman TEST_IMAGE=local/postgrest-db:ci bats postgrest-tandem/tests/ ``` Notes: diff --git a/postgrest-tandem/tests/test_helper.bash b/postgrest-tandem/tests/test_helper.bash index e409a77..db34bf6 100644 --- a/postgrest-tandem/tests/test_helper.bash +++ b/postgrest-tandem/tests/test_helper.bash @@ -5,6 +5,15 @@ POSTGREST_DB_PORT="55432" POSTGRES_PASSWORD="postgres-ci-only" AUTHENTICATOR_PASSWORD="testpw-ci-only" TEST_IMAGE="${TEST_IMAGE:-local/postgrest-db:ci}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" + +ctr() { + command -v "$CONTAINER_RUNTIME" >/dev/null 2>&1 || { + echo "container runtime not found: $CONTAINER_RUNTIME" >&2 + return 1 + } + "$CONTAINER_RUNTIME" "$@" +} psql_super() { PGPASSWORD="$POSTGRES_PASSWORD" psql -X -qAt -v ON_ERROR_STOP=1 \ @@ -34,21 +43,21 @@ wait_for_postgres() { } start_postgrest_db() { - docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$POSTGREST_DB_CONTAINER" \ + ctr rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true + ctr run -d --name "$POSTGREST_DB_CONTAINER" \ -p "$POSTGREST_DB_PORT:5432" \ -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ -e AUTHENTICATOR_PASSWORD="$AUTHENTICATOR_PASSWORD" \ "$TEST_IMAGE" >/dev/null if ! wait_for_postgres; then - docker logs "$POSTGREST_DB_CONTAINER" >&2 || true + ctr logs "$POSTGREST_DB_CONTAINER" >&2 || true return 1 fi } stop_postgrest_db() { - docker rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true + ctr rm -f "$POSTGREST_DB_CONTAINER" >/dev/null 2>&1 || true } ensure_alice_user() { From 2fc6e47c8336521e4276b25c6493885f93ee5e20 Mon Sep 17 00:00:00 2001 From: Marc Volkert Date: Tue, 26 May 2026 16:47:09 +0200 Subject: [PATCH 06/10] fixed password check logic that lead to failing tests --- postgrest-tandem/image/initdb/03-auth.sql | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/postgrest-tandem/image/initdb/03-auth.sql b/postgrest-tandem/image/initdb/03-auth.sql index dcf0861..e106a4a 100644 --- a/postgrest-tandem/image/initdb/03-auth.sql +++ b/postgrest-tandem/image/initdb/03-auth.sql @@ -14,7 +14,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA auth; CREATE TABLE IF NOT EXISTS auth.users ( id serial PRIMARY KEY, username text UNIQUE NOT NULL, - password TEXT NOT NULL CHECK (length(PASSWORD) BETWEEN 8 AND 512), + password TEXT NOT NULL CHECK (password ~ '^\$2[abxy]\$[0-9]{2}\$[./A-Za-z0-9]{53}$'), -- Enforce bcrypt hash format role TEXT NOT NULL CHECK (length(ROLE) < 512) DEFAULT 'anon' ); CREATE OR REPLACE FUNCTION auth.hash_password () @@ -22,6 +22,10 @@ CREATE OR REPLACE FUNCTION auth.hash_password () AS $$ BEGIN IF NEW.password IS NOT NULL AND (TG_OP = 'INSERT' OR NEW.password <> OLD.password) THEN + IF length(NEW.password) < 8 OR length(NEW.password) > 512 THEN + RAISE EXCEPTION 'password must be between 8 and 512 characters' + USING ERRCODE = '22023'; + END IF; NEW.password := auth.crypt(NEW.password, auth.gen_salt('bf')); END IF; RETURN NEW; From 4a31a7f76524a0d156e3dd2b115d0f01ce8cf300 Mon Sep 17 00:00:00 2001 From: Marc Volkert Date: Tue, 26 May 2026 17:04:13 +0200 Subject: [PATCH 07/10] restructured tests and fixed minor issues --- postgrest-tandem/image/initdb/03-auth.sql | 2 +- ...est_jwt_secret.bats => 01_jwt_secret.bats} | 2 +- .../tests/{test_auth.bats => 02_auth.bats} | 19 ++++++---- .../{test_security.bats => 03_security.bats} | 38 ++++++++++++++++++- postgrest-tandem/tests/test_api.bats | 34 ----------------- postgrest-tandem/tests/test_helper.bash | 4 +- postgrest-tandem/tests/test_init.bats | 37 ------------------ 7 files changed, 52 insertions(+), 84 deletions(-) rename postgrest-tandem/tests/{test_jwt_secret.bats => 01_jwt_secret.bats} (92%) rename postgrest-tandem/tests/{test_auth.bats => 02_auth.bats} (86%) rename postgrest-tandem/tests/{test_security.bats => 03_security.bats} (59%) delete mode 100644 postgrest-tandem/tests/test_api.bats delete mode 100644 postgrest-tandem/tests/test_init.bats diff --git a/postgrest-tandem/image/initdb/03-auth.sql b/postgrest-tandem/image/initdb/03-auth.sql index e106a4a..d05cd24 100644 --- a/postgrest-tandem/image/initdb/03-auth.sql +++ b/postgrest-tandem/image/initdb/03-auth.sql @@ -14,7 +14,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA auth; CREATE TABLE IF NOT EXISTS auth.users ( id serial PRIMARY KEY, username text UNIQUE NOT NULL, - password TEXT NOT NULL CHECK (password ~ '^\$2[abxy]\$[0-9]{2}\$[./A-Za-z0-9]{53}$'), -- Enforce bcrypt hash format + password TEXT NOT NULL CHECK (PASSWORD ~ '^\$2[abxy]\$[0-9]{2}\$[./A-Za-z0-9]{53}$'), -- Enforce bcrypt hash format role TEXT NOT NULL CHECK (length(ROLE) < 512) DEFAULT 'anon' ); CREATE OR REPLACE FUNCTION auth.hash_password () diff --git a/postgrest-tandem/tests/test_jwt_secret.bats b/postgrest-tandem/tests/01_jwt_secret.bats similarity index 92% rename from postgrest-tandem/tests/test_jwt_secret.bats rename to postgrest-tandem/tests/01_jwt_secret.bats index 25cb0ec..2760e77 100644 --- a/postgrest-tandem/tests/test_jwt_secret.bats +++ b/postgrest-tandem/tests/01_jwt_secret.bats @@ -21,7 +21,7 @@ teardown_file() { } @test "pre_config exposes the persisted secret via pgrst.jwt_secret" { - run psql_super "WITH _ AS (SELECT postgrest.pre_config()) SELECT current_setting('pgrst.jwt_secret', true);" + run psql_super "WITH cfg AS (SELECT postgrest.pre_config()) SELECT current_setting('pgrst.jwt_secret', true) FROM cfg;" [ "$status" -eq 0 ] config_secret="$output" diff --git a/postgrest-tandem/tests/test_auth.bats b/postgrest-tandem/tests/02_auth.bats similarity index 86% rename from postgrest-tandem/tests/test_auth.bats rename to postgrest-tandem/tests/02_auth.bats index 7d6773e..0832976 100644 --- a/postgrest-tandem/tests/test_auth.bats +++ b/postgrest-tandem/tests/02_auth.bats @@ -37,13 +37,16 @@ teardown_file() { } @test "auth.login wrong password raises invalid_password SQLSTATE 28P01" { - run psql_super "DO \ - \\$\\$ BEGIN \ - PERFORM auth.login('alice', 'wrong-password'); \ - RAISE EXCEPTION 'expected invalid password'; \ - EXCEPTION WHEN invalid_password THEN \ - RAISE NOTICE 'caught:%', SQLSTATE; \ - END \\$\\$;" + read -r -d '' sql <<'SQL' || true +DO $$ BEGIN + PERFORM auth.login('alice', 'wrong-password'); + RAISE EXCEPTION 'expected invalid password'; +EXCEPTION WHEN invalid_password THEN + RAISE NOTICE 'caught:%', SQLSTATE; +END $$; +SQL + + run psql_super "$sql" [ "$status" -eq 0 ] [[ "$output" == *"caught:28P01"* ]] } @@ -57,7 +60,7 @@ teardown_file() { @test "inserting user with short password is rejected" { run psql_super_raw "INSERT INTO auth.users (username, password, role) VALUES ('short-pass', 'short', 'anon');" [ "$status" -ne 0 ] - [[ "$output" == *"violates check constraint"* ]] + [[ "$output" == *"password must be between 8 and 512 characters"* ]] } @test "updating password re-hashes to a different bcrypt hash" { diff --git a/postgrest-tandem/tests/test_security.bats b/postgrest-tandem/tests/03_security.bats similarity index 59% rename from postgrest-tandem/tests/test_security.bats rename to postgrest-tandem/tests/03_security.bats index c607270..415598e 100644 --- a/postgrest-tandem/tests/test_security.bats +++ b/postgrest-tandem/tests/03_security.bats @@ -37,6 +37,42 @@ teardown_file() { [[ "$output" == *"permission denied"* ]] } +@test "api.login execute privilege is restricted to anon" { + run psql_super "SELECT has_function_privilege('anon', 'api.login(text,text)', 'EXECUTE') || '|' || has_function_privilege('authenticator', 'api.login(text,text)', 'EXECUTE') || '|' || has_function_privilege('public', 'api.login(text,text)', 'EXECUTE');" + [ "$status" -eq 0 ] + [ "$output" = "true|false|false" ] +} + +@test "api.login requires role switch to anon for authenticator session" { + run psql_authenticator "SELECT api.login('alice', 'correct-password');" + [ "$status" -ne 0 ] + [[ "$output" == *"permission denied"* ]] + + run psql_authenticator "SET ROLE anon; SELECT (api.login('alice', 'correct-password')).token;" + [ "$status" -eq 0 ] + token="$(printf '%s\n' "$output" | tail -n1)" + [ -n "$token" ] + + part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')" + [ "$part_count" -eq 3 ] +} + +@test "api.login called as anon preserves invalid_password SQLSTATE 28P01" { + read -r -d '' sql <<'SQL' || true +SET ROLE anon; +DO $$ BEGIN + PERFORM api.login('alice', 'wrong-password'); + RAISE EXCEPTION 'expected invalid password'; +EXCEPTION WHEN invalid_password THEN + RAISE NOTICE 'caught:%', SQLSTATE; +END $$; +SQL + + run psql_authenticator "$sql" + [ "$status" -eq 0 ] + [[ "$output" == *"caught:28P01"* ]] +} + @test "anon cannot select postgrest.jwt_secret or auth.users" { run psql_authenticator "SET ROLE anon; SELECT secret FROM postgrest.jwt_secret LIMIT 1;" [ "$status" -ne 0 ] @@ -56,6 +92,6 @@ teardown_file() { @test "api.login and auth.login are security definer owned by postgres" { run psql_super "SELECT n.nspname || '.' || p.proname || '|' || p.prosecdef || '|' || pg_get_userbyid(p.proowner) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE (n.nspname, p.proname) IN (('api', 'login'), ('auth', 'login')) ORDER BY n.nspname;" [ "$status" -eq 0 ] - expected=$'api.login|t|postgres\nauth.login|t|postgres' + expected=$'api.login|true|postgres\nauth.login|true|postgres' [ "$output" = "$expected" ] } diff --git a/postgrest-tandem/tests/test_api.bats b/postgrest-tandem/tests/test_api.bats deleted file mode 100644 index 6c56ccf..0000000 --- a/postgrest-tandem/tests/test_api.bats +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bats - -load ./test_helper.bash - -setup_file() { - start_postgrest_db - ensure_alice_user -} - -teardown_file() { - stop_postgrest_db -} - -@test "api.login called as anon returns valid JWT" { - run psql_authenticator "SET ROLE anon; SELECT (api.login('alice', 'correct-password')).token;" - [ "$status" -eq 0 ] - token="$(printf '%s\n' "$output" | tail -n1)" - [ -n "$token" ] - - part_count="$(printf '%s' "$token" | awk -F'.' '{print NF}')" - [ "$part_count" -eq 3 ] -} - -@test "api.login called as anon with wrong password raises invalid_password" { - run psql_authenticator "SET ROLE anon; DO \ - \\$\\$ BEGIN \ - PERFORM api.login('alice', 'wrong-password'); \ - RAISE EXCEPTION 'expected invalid password'; \ - EXCEPTION WHEN invalid_password THEN \ - RAISE NOTICE 'caught:%', SQLSTATE; \ - END \\$\\$;" - [ "$status" -eq 0 ] - [[ "$output" == *"caught:28P01"* ]] -} diff --git a/postgrest-tandem/tests/test_helper.bash b/postgrest-tandem/tests/test_helper.bash index db34bf6..a62e6ec 100644 --- a/postgrest-tandem/tests/test_helper.bash +++ b/postgrest-tandem/tests/test_helper.bash @@ -32,7 +32,7 @@ psql_authenticator() { wait_for_postgres() { local attempts=60 - while (( attempts > 0 )); do + while ((attempts > 0)); do if psql_super 'SELECT 1;' >/dev/null 2>&1; then return 0 fi @@ -76,7 +76,7 @@ decode_jwt_payload() { local rem payload="$(printf '%s' "$token" | cut -d'.' -f2 | tr '_-' '/+')" - rem=$(( ${#payload} % 4 )) + rem=$((${#payload} % 4)) if [ "$rem" -eq 2 ]; then payload+="==" elif [ "$rem" -eq 3 ]; then diff --git a/postgrest-tandem/tests/test_init.bats b/postgrest-tandem/tests/test_init.bats deleted file mode 100644 index a8f16f0..0000000 --- a/postgrest-tandem/tests/test_init.bats +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bats - -load ./test_helper.bash - -setup_file() { - start_postgrest_db -} - -teardown_file() { - stop_postgrest_db -} - -@test "authenticator and anon roles are bootstrapped correctly" { - run psql_super "SELECT rolname || '|' || rolcanlogin || '|' || rolinherit FROM pg_roles WHERE rolname = 'authenticator';" - [ "$status" -eq 0 ] - [ "$output" = "authenticator|t|f" ] - - run psql_super "SELECT rolname || '|' || rolcanlogin FROM pg_roles WHERE rolname = 'anon';" - [ "$status" -eq 0 ] - [ "$output" = "anon|f" ] - - run psql_super "SELECT pg_has_role('authenticator', 'anon', 'member');" - [ "$status" -eq 0 ] - [ "$output" = "t" ] -} - -@test "required schemas exist" { - run psql_super "SELECT string_agg(nspname, ',') FROM (SELECT nspname FROM pg_namespace WHERE nspname IN ('postgrest','auth','api') ORDER BY nspname) s;" - [ "$status" -eq 0 ] - [ "$output" = "api,auth,postgrest" ] -} - -@test "required extensions are installed" { - run psql_super "SELECT string_agg(extname, ',') FROM (SELECT extname FROM pg_extension WHERE extname IN ('plpython3u','pgcrypto') ORDER BY extname) e;" - [ "$status" -eq 0 ] - [ "$output" = "pgcrypto,plpython3u" ] -} From 82bbcf567d8d6b9ec4964c0d847a9b898f9b2adf Mon Sep 17 00:00:00 2001 From: Marc Volkert Date: Tue, 26 May 2026 17:11:44 +0200 Subject: [PATCH 08/10] minor changes to gh actions --- .github/workflows/ci-test-postgrest-db.yml | 13 ------------- .github/workflows/publish-postgrest-db.yml | 12 +----------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/.github/workflows/ci-test-postgrest-db.yml b/.github/workflows/ci-test-postgrest-db.yml index e05b9ee..3f32f6f 100644 --- a/.github/workflows/ci-test-postgrest-db.yml +++ b/.github/workflows/ci-test-postgrest-db.yml @@ -1,5 +1,4 @@ name: CI test postgrest-db - on: pull_request: paths: @@ -9,21 +8,16 @@ on: - main paths: - 'postgrest-tandem/**' - permissions: contents: read - jobs: build: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build image tarball uses: docker/build-push-action@v6 with: @@ -37,36 +31,29 @@ jobs: VCS_REF=${{ github.sha }} SOURCE_URL=${{ github.server_url }}/${{ github.repository }} POSTGRES_BASE_TAG=16 - - name: Upload image artifact uses: actions/upload-artifact@v4 with: name: postgrest-db-image path: /tmp/postgrest-db-ci.tar if-no-files-found: error - integration-test: runs-on: ubuntu-latest needs: build - steps: - name: Checkout uses: actions/checkout@v4 - - name: Download image artifact uses: actions/download-artifact@v4 with: name: postgrest-db-image path: /tmp - - name: Load image run: docker load -i /tmp/postgrest-db-ci.tar - - name: Install test dependencies run: | sudo apt-get update sudo apt-get install -y bats bats-support bats-assert postgresql-client jq - - name: Run BATS integration tests env: TEST_IMAGE: local/postgrest-db:ci diff --git a/.github/workflows/publish-postgrest-db.yml b/.github/workflows/publish-postgrest-db.yml index 12ef734..145a068 100644 --- a/.github/workflows/publish-postgrest-db.yml +++ b/.github/workflows/publish-postgrest-db.yml @@ -1,5 +1,4 @@ name: Publish postgrest-db image - on: push: tags: @@ -10,28 +9,23 @@ on: description: "Version tag to publish (for example 0.0.1)" required: true default: "0.0.1" - jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write - steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Compute image version id: vars shell: bash @@ -44,7 +38,6 @@ jobs: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER}/postgrest-db" >> "$GITHUB_OUTPUT" - - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 @@ -53,7 +46,6 @@ jobs: tags: | type=raw,value=${{ steps.vars.outputs.version }} type=raw,value=latest - - name: Build image for vulnerability scan uses: docker/build-push-action@v6 with: @@ -67,16 +59,14 @@ jobs: VCS_REF=${{ github.sha }} SOURCE_URL=${{ github.server_url }}/${{ github.repository }} POSTGRES_BASE_TAG=16 - - name: Scan image with Trivy - uses: aquasecurity/trivy-action@0.25.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.version }} format: table exit-code: '1' severity: CRITICAL,HIGH ignore-unfixed: true - - name: Build and push image uses: docker/build-push-action@v6 with: From 6d57ecaf7b50e030f14f5658f01e700a233e10ab Mon Sep 17 00:00:00 2001 From: Marc Volkert Date: Tue, 26 May 2026 17:18:26 +0200 Subject: [PATCH 09/10] added guard against publishing from non-main branch --- .github/workflows/publish-postgrest-db.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/publish-postgrest-db.yml b/.github/workflows/publish-postgrest-db.yml index 145a068..e68f874 100644 --- a/.github/workflows/publish-postgrest-db.yml +++ b/.github/workflows/publish-postgrest-db.yml @@ -18,6 +18,17 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Guard publish to main lineage + shell: bash + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Ref $GITHUB_SHA is not reachable from origin/main." + echo "Publish is only allowed for commits that are on main." + exit 1 + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR From 39b1e3cc46e9fdfd765b6450d4a409a328122c28 Mon Sep 17 00:00:00 2001 From: Marc Volkert Date: Tue, 26 May 2026 17:27:35 +0200 Subject: [PATCH 10/10] chore: refresh checks after accidental tag