feat(deploy): inline bootstrap into db-init — compose is the single source; DB_PORT everywhere

z_rls_role.sh is gone. The db-init service carries the bootstrap SQL
inline (the script's docker-exec/local-socket mode was its only reason
to exist as a file, and the no-clone deployment path could not use it:
docker bind-mounts of missing host paths auto-create empty DIRECTORIES,
so a compose-only install got '/z_rls_role.sh: no such file').

The inline form surfaced two silent bugs the script had masked:
- psql defaulted its DATABASE to the username (POSTGRES_DB is not a
  libpq variable) — the ownership loop ran against 'postgres' and
  no-opped on existing volumes. Fixed with PGDATABASE: bookhoard.
- PGPORT was never propagated: with a custom DB_PORT, db-init dialed
  5432 while Postgres listened on the custom port. Fixed with
  PGPORT: ${DB_PORT:-5432} — DB_PORT is now a variable in every
  connection path (db listener, host mappings, app, db-init).

Also removed the db service's initdb.d mount: the app executes the full
schema as the runtime-role owner on every boot incl. first (pg_trgm is
a trusted extension; the db owner can create it), so the bootstrap SQL
was its only content.

Verified: dev two-boot against the existing 40-table volume (owners
converged, 0 off-owner tables/functions), custom-port throwaway
(DB_PORT=15432: db-init + app both on the custom port, healthy),
default-port throwaway (fresh volume, healthy), full Go suite green.
Manual repair path: docker compose run --rm db-init.
This commit is contained in:
John O'Keefe committed 2026-10-29 13:06:16 -04:00
1 parent cf73eec2e4
commit 304ea9599f
4 files changed
+73 -90

No files matched your search

+2 -2
View File
@@ -108,8 +108,8 @@ inside quoted attribute values (gotcha #1 below), so dynamic
Per-user library visibility (`library_visibility` table, default-ALLOW: Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) is enforced by every user sees every library unless an admin hides one) is enforced by
**Postgres RLS** — one source of truth: the labeled policy block in **Postgres RLS** — one source of truth: the labeled policy block in
`database/schema/schema.sql` + the runtime role `database/schema/schema.sql` + the runtime role provisioned by the
(`database/schema/z_rls_role.sh`). `db-init` compose service.
- **The engine is the enforcement.** Every `media_items` read is - **The engine is the enforcement.** Every `media_items` read is
filtered by the policy, which joins `library_visibility` on the filtered by the policy, which joins `library_visibility` on the
+34 -6
View File
@@ -69,14 +69,42 @@ services:
image: postgres:15-alpine image: postgres:15-alpine
container_name: bookhoard_db_init container_name: bookhoard_db_init
environment: environment:
POSTGRES_USER: postgres
POSTGRES_DB: bookhoard
PGHOST: db PGHOST: db
DBPASS: ${DBPASS} PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS} DBSRVPASS: ${DBSRVPASS}
volumes: entrypoint: ["bash", "-c"]
- ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro command:
command: bash /z_rls_role.sh - |
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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
-75
View File
@@ -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
+37 -7
View File
@@ -11,7 +11,6 @@ services:
PGPORT: ${DB_PORT:-5432} PGPORT: ${DB_PORT:-5432}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
ports: ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}" - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::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 — # transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords # safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file. # 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: db-init:
image: postgres:15-alpine image: postgres:15-alpine
container_name: bookhoard_db_init container_name: bookhoard_db_init
environment: environment:
POSTGRES_USER: postgres
POSTGRES_DB: bookhoard
PGHOST: db PGHOST: db
DBPASS: ${DBPASS} PGPORT: ${DB_PORT:-5432}
PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS} DBSRVPASS: ${DBSRVPASS}
volumes: entrypoint: ["bash", "-c"]
- ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro command:
command: bash /z_rls_role.sh - |
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: depends_on:
db: db:
condition: service_healthy condition: service_healthy