From cb258b6d20a33b96a5540d9fb8519de6b150fe68 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 08:02:34 -0400 Subject: [PATCH] feat(db): dedicated non-superuser runtime role for RLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app connected as 'postgres' — the bootstrap SUPERUSER — which bypasses row-level security unconditionally (FORCE RLS does not apply to superusers), making any future policy a silent no-op. This is the prerequisite commit for the RLS visibility work (plan ~/.opencode/plan/server-rls-library-visibility.md REV 2, §4.0). What changed: - database/schema/z_rls_role.sh: creates the bookhoard_srv role (LOGIN, non-superuser, non-bypassrls) and transfers ownership of every public table/sequence/function + schema + database to it. Ownership (not mere grants) because executeSchema re-runs the whole schema.sql on EVERY boot and needs owner DDL. Idempotent, catalog- only — no user data is read or written. - docker-compose.yml app service + docker-compose.dev.yml tests service: DATABASE_USER postgres -> bookhoard_srv, DATABASE_PASSWORD DBPASS -> DBSRVPASS (new .env key, generated locally). - POSTGRES_USER: postgres stays as the container bootstrap account; on FRESH volumes z_rls_role.sh runs automatically via docker-entrypoint-initdb.d AFTER schema.sql (alphabetical: z > s). Verified on the dev stack: role has rolsuper=f, rolbypassrls=f, owns all 40 tables and every public function; single boot as bookhoard_srv executed schema.sql cleanly (CREATE EXTENSION IF NOT EXISTS pg_trgm skips fine as non-superuser when the extension exists); health + authenticated smoke pass. Behavior otherwise unchanged — no policies yet. --- database/schema/z_rls_role.sh | 69 +++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 6 ++- docker-compose.yml | 7 +++- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100755 database/schema/z_rls_role.sh diff --git a/database/schema/z_rls_role.sh b/database/schema/z_rls_role.sh new file mode 100755 index 00000000..815a0628 --- /dev/null +++ b/database/schema/z_rls_role.sh @@ -0,0 +1,69 @@ +#!/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 AUTOMATICALLY on fresh volumes: docker-entrypoint-initdb.d executes +# initdb.d entries in alphabetical order — "z_rls_role.sh" sorts AFTER +# "schema.sql" (digits/uppercase sort before lowercase 's'), so the tables +# exist before ownership transfers. It runs as the postgres superuser with +# the db container's env (DBSRVPASS comes from .env via compose env_file). +# +# EXISTING volumes (initdb.d never re-runs): apply manually ONCE, as the +# superuser, BEFORE switching the app's DATABASE_USER: +# docker exec -i 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)}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -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.dev.yml b/docker-compose.dev.yml index 9d69c8a3..bebd5356 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,10 +22,12 @@ services: container_name: bookhoard_tests environment: # Database Configuration + # Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS + # via FORCE) — same role the app service uses. DATABASE_HOST: db DATABASE_PORT: ${DB_PORT:-5432} - DATABASE_USER: postgres - DATABASE_PASSWORD: ${DBPASS} + DATABASE_USER: bookhoard_srv + DATABASE_PASSWORD: ${DBSRVPASS} DATABASE_NAME: bookhoard COOKIE_SECURE: false diff --git a/docker-compose.yml b/docker-compose.yml index ec12944f..4d14fdc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,10 +35,13 @@ services: restart: unless-stopped environment: # Database Configuration + # Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS + # via FORCE). POSTGRES_USER above is the bootstrap account ONLY — + # the app must NEVER connect as it (superusers bypass RLS). DATABASE_HOST: db DATABASE_PORT: ${DB_PORT:-5432} - DATABASE_USER: postgres - DATABASE_PASSWORD: ${DBPASS} + DATABASE_USER: bookhoard_srv + DATABASE_PASSWORD: ${DBSRVPASS} DATABASE_NAME: bookhoard # Application Configuration