Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/ci-test-postgrest-db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI test postgrest-db

on:
pull_request:
paths:
- 'postgrest-tandem/**'
push:
branches:
- 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:
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/
23 changes: 23 additions & 0 deletions .github/workflows/publish-postgrest-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions postgrest-tandem/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,38 @@ echo "$TOKEN"

The token is suitable for `Authorization: Bearer <token>`.

## 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/

# 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:

- 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).
Expand Down
6 changes: 5 additions & 1 deletion postgrest-tandem/image/initdb/03-auth.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ 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 ()
RETURNS TRIGGER
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;
Expand Down
54 changes: 54 additions & 0 deletions postgrest-tandem/tests/01_jwt_secret.bats
Original file line number Diff line number Diff line change
@@ -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 cfg AS (SELECT postgrest.pre_config()) SELECT current_setting('pgrst.jwt_secret', true) FROM cfg;"
[ "$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" ]
}
80 changes: 80 additions & 0 deletions postgrest-tandem/tests/02_auth.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/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" {
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"* ]]
}

@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" == *"password must be between 8 and 512 characters"* ]]
}

@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" ]
}
97 changes: 97 additions & 0 deletions postgrest-tandem/tests/03_security.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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 "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 ]
[[ "$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|true|postgres\nauth.login|true|postgres'
[ "$output" = "$expected" ]
}
Loading
Loading