diff --git a/AGENTS.md b/AGENTS.md index 34f2760d..d79b0df4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,8 +108,8 @@ inside quoted attribute values (gotcha #1 below), so dynamic Per-user library visibility (`library_visibility` table, default-ALLOW: every user sees every library unless an admin hides one) is enforced by **Postgres RLS** — one source of truth: the labeled policy block in -`database/schema/schema.sql` + the runtime role -(`database/schema/z_rls_role.sh`). +`database/schema/schema.sql` + the runtime role provisioned by the +`db-init` compose service. - **The engine is the enforcement.** Every `media_items` read is filtered by the policy, which joins `library_visibility` on the diff --git a/README.md b/README.md index 416fcf66..cc5bd1cc 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,42 @@ services: image: postgres:15-alpine container_name: bookhoard_db_init environment: - POSTGRES_USER: postgres - POSTGRES_DB: bookhoard PGHOST: db - DBPASS: ${DBPASS} + PGUSER: postgres + PGDATABASE: bookhoard + PGPASSWORD: ${DBPASS} DBSRVPASS: ${DBSRVPASS} - volumes: - - ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro - command: bash /z_rls_role.sh + entrypoint: ["bash", "-c"] + command: + - | + set -e + : "${DBSRVPASS:?DBSRVPASS required in .env}" + psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL' + DO $$$$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN + CREATE ROLE bookhoard_srv LOGIN; + END IF; + END $$$$; + ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass'; + DO $$$$ + DECLARE r RECORD; + BEGIN + FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP + EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename); + END LOOP; + FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP + EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename); + END LOOP; + FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP + EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn); + END LOOP; + END $$$$; + ALTER SCHEMA public OWNER TO bookhoard_srv; + ALTER DATABASE bookhoard OWNER TO bookhoard_srv; + EOSQL depends_on: db: condition: service_healthy diff --git a/database/schema/z_rls_role.sh b/database/schema/z_rls_role.sh deleted file mode 100755 index a98b97a2..00000000 --- a/database/schema/z_rls_role.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -# RLS role bootstrap — plan §4.0 (~/.opencode/plan/server-rls-library-visibility.md REV 2). -# -# Creates the NON-SUPERUSER runtime role `bookhoard_srv` and makes it the -# OWNER of everything in the public schema, so the app can keep running -# executeSchema's per-boot DDL while being subject to RLS (FORCE). -# -# The bootstrap superuser (POSTGRES_USER, "postgres") is UNTOUCHED and -# remains the cluster/database bootstrap account; the app never connects -# as it. -# -# Runs on EVERY `docker compose up` via the one-shot `db-init` service -# (idempotent — the app's depends_on gates on its success), AND on -# fresh volumes via docker-entrypoint-initdb.d ('z' sorts after -# 'schema.sql'). Manual equivalent for existing volumes: -# docker exec -i -e DBSRVPASS=... bookhoard_db bash -s < database/schema/z_rls_role.sh -# -# IDEMPOTENT: safe to re-run (role creation is guarded; ownership ALTERs -# and the password set are idempotent). -# -# DATA SAFETY: every statement here is catalog-only. No user row is read, -# rewritten, or deleted — reading progress, annotations, highlights, -# bookmarks, collections, and media rows are untouched. -set -euo pipefail -: "${DBSRVPASS:?DBSRVPASS must be set in the db container env (.env via env_file)}" -: "${POSTGRES_USER:?POSTGRES_USER must be set (the image sets it)}" -: "${POSTGRES_DB:?POSTGRES_DB must be set (the image sets it)}" - -# Connection mode: local socket (docker exec into the db container) or -# network (the db-init compose service, which sets PGHOST=db and needs -# the bootstrap password for scram auth). -PSQL_OPTS=(-v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB") -if [ -n "${PGHOST:-}" ]; then - : "${DBPASS:?DBPASS is required when connecting over the network (PGHOST set)}" - PSQL_OPTS+=(-h "$PGHOST") - export PGPASSWORD="$DBPASS" -fi - -psql "${PSQL_OPTS[@]}" \ - -v role_pass="$DBSRVPASS" <<'EOSQL' -\echo '== RLS role bootstrap: creating/refreshing bookhoard_srv ==' - --- 1. The runtime role: LOGIN, NO superuser, NO bypassrls, NO createrole. -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN - CREATE ROLE bookhoard_srv LOGIN; - END IF; -END $$; -ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass'; - --- 2. Ownership transfer — the runtime role must own the objects so --- executeSchema (schema.sql re-runs on EVERY app boot) keeps working, --- and so FORCE ROW LEVEL SECURITY can make the owner subject to the --- policies. Catalog-only: flips ownership pointers, touches no rows. -DO $$ -DECLARE r RECORD; -BEGIN - FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP - EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename); - END LOOP; - FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP - EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename); - END LOOP; - FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP - EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn); - END LOOP; -END $$; -ALTER SCHEMA public OWNER TO bookhoard_srv; -ALTER DATABASE bookhoard OWNER TO bookhoard_srv; - -\echo '== done: bookhoard_srv owns the public schema ==' -EOSQL diff --git a/docker-compose.yml b/docker-compose.yml index e8ffcd13..9f762913 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: PGPORT: ${DB_PORT:-5432} volumes: - postgres_data:/var/lib/postgresql/data - - ./database/schema:/docker-entrypoint-initdb.d ports: - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}" - "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}" @@ -28,18 +27,49 @@ services: # transfers object ownership to it (RLS prerequisite). Idempotent — # safe on every startup; the app waits for its success. Passwords # accept inline values or .env, like the rest of this file. + # NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar + # per pair before the container sees the SQL. db-init: image: postgres:15-alpine container_name: bookhoard_db_init environment: - POSTGRES_USER: postgres - POSTGRES_DB: bookhoard PGHOST: db - DBPASS: ${DBPASS} + PGPORT: ${DB_PORT:-5432} + PGUSER: postgres + PGDATABASE: bookhoard + PGPASSWORD: ${DBPASS} DBSRVPASS: ${DBSRVPASS} - volumes: - - ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro - command: bash /z_rls_role.sh + entrypoint: ["bash", "-c"] + command: + - | + set -e + : "${DBSRVPASS:?DBSRVPASS required in .env}" + psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL' + DO $$$$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN + CREATE ROLE bookhoard_srv LOGIN; + END IF; + END $$$$; + ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass'; + DO $$$$ + DECLARE r RECORD; + BEGIN + FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP + EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename); + END LOOP; + FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP + EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename); + END LOOP; + FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP + EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn); + END LOOP; + END $$$$; + ALTER SCHEMA public OWNER TO bookhoard_srv; + ALTER DATABASE bookhoard OWNER TO bookhoard_srv; + EOSQL depends_on: db: condition: service_healthy