Files
cozystack/hack/postgres-release-system-databases.bats
T
Mattia Eleuteri f003851338 fix(postgres): refuse system databases and never drop them
postgres, template0 and template1 were accepted in databases and handled
like an application database. The init job made <db>_admin their owner,
installed the auto-grant event trigger in them and, once the entry left
values, ran DROP DATABASE on them. That DROP can only fail (template
databases cannot be dropped, and the job is connected to postgres), so
every later upgrade failed in the init job and the release stayed in
UpgradeFailed even after the entry was put back. template1 also passed
the takeover on to every database created from it afterwards, where the
trigger, naming a role about to be dropped, makes CREATE SCHEMA fail.

A release a previous version let take one of them over has to be
repaired without dropping it, and without touching what this chart did
not create: a role or owner that merely carries an expected name may
belong to a tenant or an administrator, and a role comment can be set
by any CREATEROLE holder, so only the cozystack:managed mark is trusted.
What the marked roles own goes to cozystack:orphaned rather than to the
superuser, since a tenant's SECURITY DEFINER function would otherwise
run with superuser rights. A failed lookup of the databases cloned from
template1 has to stop the run before template1 stops being selected, or
those clones would never be repaired.

Assisted-by: LLM
Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
2026-11-09 11:18:27 +01:00

262 lines
10 KiB
Bash

#!/usr/bin/env bats
# Behavioural tests for the "release system databases" pass of the Postgres
# init script:
#
# packages/apps/postgres/templates/init-script.yaml
#
# The pass is cut out of the rendered script and RUN against a fake psql that
# records which database each session connects to. The helm-unittest suite in
# packages/apps/postgres/tests/system_databases_test.yaml matches the SQL text,
# and stayed green when the template0 guard was inverted: the rendered pass then
# connected to template0, which accepts no connection, and skipped postgres and
# template1, which are the two it exists to hand back.
#
# What is pinned here:
# * no session ever connects to template0;
# * postgres and template1 are each connected to and released;
# * every taken-over system database, template0 included, has its comment
# cleared;
# * releasing template1 visits every other connectable database, for the
# trigger it passed on to them, and a failed lookup of those databases
# stops the pass before the comment that selects template1 is cleared;
# * the session that releases a system database hands its public schema
# and the database itself back only from a marked <db>_admin or
# <db>_readonly owner (or cozystack:orphaned), before the reassignment,
# an inherited public only from a marked template1_admin, and touches
# only the roles that carry the
# "cozystack:managed" mark, not a role that merely has one of the names.
#
# Run from the repo root with make bats-unit-tests. Direct status checks
# retain compatibility with cozytest, which has no Bats run/setup helpers.
# Inline cleanup preserves Bats' TAP handler on failed assertions.
load test_helper
PG_CHART=packages/apps/postgres
# render_pass <out>
# Extract the release pass, from its banner to the next one, out of the rendered
# init script. The non-empty check must be an early `return 1`: hack/cozytest.sh
# rewrites every line matching ^}$ into `return 0` plus `}`, helpers included,
# so a check left in last-command position has its status discarded.
render_pass() {
helm template pg-test "$PG_CHART" --namespace tenant-test --set-json '_cluster={"scheduling":{}}' 2>/dev/null \
| yq eval 'select(.metadata.name == "pg-test-init-script") | .stringData["init.sh"]' - \
| awk '/^echo "== release system databases"/ { f = 1 } /^echo "== delete databases"/ { f = 0 } f' > "$1"
grep -q 'SYSTEM_DBS=' "$1" || return 1
}
# write_fake_psql <dir>
# Emit a fake psql. A `-c` query answers from $SYSTEM_DBS for the system
# database lookup and from $OTHER_DBS for the connectable-database lookup,
# which exits with $CLONE_LOOKUP_FAILS instead when that is set. A
# session fed on stdin logs "connect <dbname>" ("connect -" without --dbname)
# followed by its first line and every -v db= value, to $PLOG, and its whole
# input under a "-- session <dbname> db=<value>" header to $PLOG.sql.
# No line below is a bare column-0 `}`, so cozytest.sh's parser leaves it alone.
write_fake_psql() {
cat > "$1/psql" <<'PEOF'
#!/bin/sh
dbname=- query= vdb=
while [ $# -gt 0 ]; do
case "$1" in
--dbname) dbname=$2; shift ;;
-c) query=$2; shift ;;
-v) case "$2" in db=*) vdb=${2#db=} ;; esac; shift ;;
esac
shift
done
if [ -n "$query" ]; then
case "$query" in
*"d.datname IN ('postgres', 'template0', 'template1')"*) printf '%s\n' $SYSTEM_DBS ;;
*datallowconn*)
if [ -n "${CLONE_LOOKUP_FAILS:-}" ]; then
echo "fake psql: connectable-database lookup failed" >&2
exit "$CLONE_LOOKUP_FAILS"
fi
printf '%s\n' $OTHER_DBS ;;
*) echo "fake psql: unexpected query: $query" >&2; exit 64 ;;
esac
exit 0
fi
sql=$(cat)
echo "connect $dbname db=$vdb $(printf '%s\n' "$sql" | head -n 1)" >> "$PLOG"
printf -- '-- session %s db=%s\n%s\n' "$dbname" "$vdb" "$sql" >> "$PLOG.sql"
PEOF
chmod +x "$1/psql"
}
# run_pass <dir>
# Run the extracted pass with the fake psql first on PATH; status in $rc.
run_pass() {
export PLOG="$1/calls"
: > "$PLOG"
rc=0
PATH="$1:$PATH" sh -e "$1/pass.sh" > "$1/out" 2> "$1/err" || rc=$?
}
@test "a render that produces no release pass fails instead of passing as a no-op" {
tmp=$(mktemp -d)
if PG_CHART=packages/apps/no-such-chart render_pass "$tmp/pass.sh"; then
echo "FAIL: render_pass reported success for a chart that does not exist"
false
fi
rm -rf "$tmp"
}
@test "release pass connects to postgres and template1, never to template0" {
tmp=$(mktemp -d)
write_fake_psql "$tmp"
render_pass "$tmp/pass.sh"
export SYSTEM_DBS="postgres template0 template1" OTHER_DBS="postgres app"
run_pass "$tmp"
if [ "$rc" -ne 0 ]; then
echo "FAIL: the pass exited $rc"
cat "$tmp/out" "$tmp/err"
false
fi
if grep -q '^connect template0 ' "$tmp/calls"; then
echo "FAIL: the pass connected to template0, which accepts no connection"
cat "$tmp/calls"
false
fi
for db in postgres template1; do
if ! grep -q "^connect $db db=$db DROP EVENT TRIGGER IF EXISTS trigger_auto_grant;" "$tmp/calls"; then
echo "FAIL: $db was not connected to and released"
cat "$tmp/calls"
false
fi
done
for db in postgres template0 template1; do
if ! grep -q "^connect - db=$db COMMENT ON DATABASE :\"db\" IS NULL;" "$tmp/calls"; then
echo "FAIL: the comment on $db was not cleared"
cat "$tmp/calls"
false
fi
done
rm -rf "$tmp"
}
@test "release session hands public back and touches only marked roles" {
tmp=$(mktemp -d)
write_fake_psql "$tmp"
render_pass "$tmp/pass.sh"
export SYSTEM_DBS="postgres" OTHER_DBS="postgres"
run_pass "$tmp"
if [ "$rc" -ne 0 ]; then
echo "FAIL: the pass exited $rc"
cat "$tmp/out" "$tmp/err"
false
fi
awk '/^-- session / { f = ($0 == "-- session postgres db=postgres") } f' "$tmp/calls.sql" > "$tmp/release.sql"
# Each statement must end on its whole predicate: a clause appended after the
# mark (an OR, say) would select an unmarked role again.
reassign_where="WHERE EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid JOIN pg_roles u ON u.oid = m.member WHERE g.rolname = 'cozystack:managed' AND u.rolname = r)\\gexec"
owner_marked="(o.rolname = 'cozystack:orphaned' OR o.rolname IN (:'db' || '_admin', :'db' || '_readonly') AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid))\\gexec"
public_where="WHERE n.nspname = 'public' AND $owner_marked"
database_where="WHERE d.datname = :'db' AND $owner_marked"
public_line=$(grep -n 'ALTER SCHEMA public OWNER TO' "$tmp/release.sql" | head -1)
database_line=$(grep -n 'ALTER DATABASE' "$tmp/release.sql" | head -1)
reassign_line=$(grep -n 'REASSIGN OWNED BY' "$tmp/release.sql" | head -1)
if [ "${public_line##*"$public_where"}" != "" ] || [ -z "$public_line" ]; then
echo "FAIL: the release session does not hand public back only from a marked <db>_admin or <db>_readonly owner"
cat "$tmp/release.sql"
false
fi
if [ "${database_line##*"$database_where"}" != "" ] || [ -z "$database_line" ]; then
echo "FAIL: the release session does not take the database back only from a marked owner"
cat "$tmp/release.sql"
false
fi
if [ "${reassign_line##*"$reassign_where"}" != "" ] || [ -z "$reassign_line" ]; then
echo "FAIL: the release session does not reassign only roles carrying the cozystack:managed mark"
cat "$tmp/release.sql"
false
fi
# public and the database must be handed back before REASSIGN OWNED would
# move them to cozystack:orphaned.
if [ "${public_line%%:*}" -ge "${reassign_line%%:*}" ] || [ "${database_line%%:*}" -ge "${reassign_line%%:*}" ]; then
echo "FAIL: public or the database is handed back after the reassignment has already moved it"
cat "$tmp/release.sql"
false
fi
rm -rf "$tmp"
}
@test "release pass of template1 visits every other connectable database" {
tmp=$(mktemp -d)
write_fake_psql "$tmp"
render_pass "$tmp/pass.sh"
export SYSTEM_DBS="template1" OTHER_DBS="postgres app"
run_pass "$tmp"
if [ "$rc" -ne 0 ]; then
echo "FAIL: the pass exited $rc"
cat "$tmp/out" "$tmp/err"
false
fi
for db in postgres app; do
if ! grep -q "^connect $db db= SELECT 'DROP EVENT TRIGGER IF EXISTS trigger_auto_grant'" "$tmp/calls"; then
echo "FAIL: $db was not checked for the trigger inherited from template1"
cat "$tmp/calls"
false
fi
done
# An inherited public changes hands only from a template1_admin the chart
# marked; an owner that merely has the name keeps it.
inherited_where="WHERE n.nspname = 'public' AND o.rolname = 'template1_admin' AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid)\\gexec"
inherited_line=$(awk '/^-- session / { f = ($0 == "-- session app db=") } f' "$tmp/calls.sql" | grep 'ALTER SCHEMA public OWNER TO' | head -1)
if [ -z "$inherited_line" ] || [ "${inherited_line##*"$inherited_where"}" != "" ]; then
echo "FAIL: an inherited public is handed back without checking that a marked template1_admin owns it"
cat "$tmp/calls.sql"
false
fi
rm -rf "$tmp"
}
@test "release pass of template1 stops before clearing its comment when the clone lookup fails" {
# Clearing the comment is what stops later runs from selecting template1, so
# a lookup failure that let the pass go on would skip the clone repair for
# good.
tmp=$(mktemp -d)
write_fake_psql "$tmp"
render_pass "$tmp/pass.sh"
export SYSTEM_DBS="template1" OTHER_DBS="postgres app" CLONE_LOOKUP_FAILS=73
run_pass "$tmp"
unset CLONE_LOOKUP_FAILS
if [ "$rc" -ne 73 ]; then
echo "FAIL: the pass exited $rc instead of stopping on the failed lookup (73)"
cat "$tmp/out" "$tmp/err" "$tmp/calls"
false
fi
if grep -q 'COMMENT ON DATABASE' "$tmp/calls"; then
echo "FAIL: the comment on template1 was cleared although its clones were never visited"
cat "$tmp/calls"
false
fi
rm -rf "$tmp"
}
@test "release pass of postgres alone leaves the other databases alone" {
tmp=$(mktemp -d)
write_fake_psql "$tmp"
render_pass "$tmp/pass.sh"
export SYSTEM_DBS="postgres" OTHER_DBS="postgres app"
run_pass "$tmp"
if [ "$rc" -ne 0 ]; then
echo "FAIL: the pass exited $rc"
cat "$tmp/out" "$tmp/err"
false
fi
if grep -q '^connect app ' "$tmp/calls"; then
echo "FAIL: the pass visited app although template1 was not taken over"
cat "$tmp/calls"
false
fi
rm -rf "$tmp"
}