Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
60 changes: 60 additions & 0 deletions .github/workflows/ci-test-postgrest-db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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/
40 changes: 32 additions & 8 deletions .github/workflows/publish-postgrest-db.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
name: Publish postgrest-db image

on:
push:
tags:
Expand All @@ -10,28 +9,34 @@ 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

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
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image version
id: vars
shell: bash
Expand All @@ -44,7 +49,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
Expand All @@ -53,7 +57,27 @@ 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:
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@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:
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" ]
}
Loading
Loading