Files
computer-playbook/roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh
T
kevinveenbirkenbachandClaude Opus 5 ffed838bc7 fix(roles): keep credentials out of the container exec argv, and lint for it
`container exec -e NAME=VALUE` puts VALUE in the argv of the host-side
process, where ps shows it to every local account for as long as the call runs.
no_log cannot mask a process table. The credentials that travel this way are
live: platform_administrator and every secrets.credentials.* value rotate
between the two passes of a matrix-deploy round.

tests/lint/ansible/shell/container/test_exec_env_passes_no_value.py rejects the
form for credential-bearing values, recognised by either the variable's name or
a credential source in the expression behind it. The second signal is what
keeps it from being a name denylist, which would be no protection on its own: a
secret handed over as -e FOO={{ lookup('users','x').password }} is caught by its
source although its name says nothing. Literal values pass; ports, paths and
model names are not worth moving.

The 24 sites it found now pass the name alone and bind the value in the task's
environment. Value-shaping filters stay, because the container needs the shaped
form; the shell-quoting | quote goes, and that is safe: shlex.quote builds
armour the host shell consumes while parsing argv, so what docker receives is
byte-identical either way, checked with a password of @, space, apostrophe
and $.

Three sites needed more than the pattern. set_postgres_superuser_password.sh
took the password as $2, so it stood in the argv of the script and of three
container exec calls; it now reads POSTGRES_SUPERUSER_PASSWORD, and both callers
follow. svc-db-elasticsearch built an exec prefix with the password inside it for
five tasks, so the binding moved to the two include sites and is inherited.
web-app-taiga had its upsert as a semicolon-chained one-liner inside the command;
it is a file read on stdin now, which took the name and the e-mail out of the
argv too.

What this does not do, stated because the docstring first claimed otherwise:
with ansible.builtin.script the value reaches no argv, but with shell and
command ansible-core still prefixes NAME='value' onto the module wrapper's own
line, from Shell.env_prefix in build_module_command. The window shrinks from the
whole container call, re-executed on every pass of an until: loop, to the
module's runtime. It does not vanish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-12 22:54:33 +02:00

122 lines
3.5 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# Usage:
# set_postgres_superuser_password.sh <container_name> <role>
# POSTGRES_SUPERUSER_PASSWORD: the password to write, read from the
# environment only, so it reaches neither this script's argv nor that of the
# container exec calls it makes.
#
# The role is the cluster's superuser: "postgres" for the shared engine, and
# whatever POSTGRES_USER a dedicated one was initialised with. Both are reached
# the same way, over the container's local socket, which authenticates by peer
# and therefore needs no password -- that is what lets a rotated credential be
# written into an engine nobody can log into any more.
container_raw="${1:-}"
role="${2:-}"
new_pw="${POSTGRES_SUPERUSER_PASSWORD:-}"
if [[ -z "$container_raw" || -z "$role" ]]; then
echo "Usage: $0 <container_name> <role>" >&2
exit 2
fi
if [[ -z "$new_pw" ]]; then
echo "[FAIL] POSTGRES_SUPERUSER_PASSWORD is empty; refusing to set an empty password." >&2
exit 1
fi
export PGPASSWORD="$new_pw"
export NEW_POSTGRES_PASSWORD="$new_pw"
container=""
eval "container=$container_raw"
pg_exec() {
container exec -e PG_ROLE="$role" "$container" bash -lc \
"psql -U \"\$PG_ROLE\" -d postgres -Atc '$1'" 2>/dev/null
}
# Retry TCP auth up to 15 times (30s) to handle HBA reload delay
auth_test() {
for _ in {1..15}; do
# shellcheck disable=SC2016
if container exec -e PGPASSWORD -e PG_ROLE="$role" "$container" bash -lc \
'psql -h 127.0.0.1 -p 5432 -U "$PG_ROLE" -d postgres -Atc "SELECT 1" >/dev/null 2>&1'; then
return 0
fi
sleep 2
done
return 1
}
result=""
for _ in {1..30}; do
hba_file=""
for _ in {1..30}; do
if hba_file=$(pg_exec 'SHOW hba_file;'); then
break
fi
sleep 2
done
if [[ -z "$hba_file" ]]; then
sleep 2; continue
fi
backup="${hba_file}.bak_ansible_pwcheck_$$"
if ! container exec "$container" bash -lc "cp -a \"$hba_file\" \"$backup\"" 2>/dev/null; then
sleep 2; continue
fi
cleanup() {
container exec "$container" bash -lc "if [ -f \"$backup\" ]; then cp -a \"$backup\" \"$hba_file\" && rm -f \"$backup\"; fi" >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
pg_exec 'SELECT pg_reload_conf();' >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
}
trap 'cleanup' EXIT
if ! container exec "$container" bash -lc "{
echo 'host all all 127.0.0.1/32 scram-sha-256'
cat \"$backup\"
} > \"$hba_file\"" 2>/dev/null; then
cleanup; sleep 2; continue
fi
if ! pg_exec 'SELECT pg_reload_conf();' >/dev/null; then
cleanup; sleep 2; continue
fi
pre_ok=0
if auth_test; then pre_ok=1; fi
# shellcheck disable=SC2016
if ! container exec -e NEW_POSTGRES_PASSWORD -e PG_ROLE="$role" "$container" bash -lc '
psql -U "$PG_ROLE" -d postgres -v ON_ERROR_STOP=1 -v role="$PG_ROLE" -v password="$NEW_POSTGRES_PASSWORD" <<'"'"'SQL'"'"'
ALTER USER :"role" WITH PASSWORD :'"'"'password'"'"';
SQL
' 2>/dev/null; then
cleanup; sleep 2; continue
fi
post_ok=0
if auth_test; then post_ok=1; fi
cleanup
if [[ "$pre_ok" -eq 0 && "$post_ok" -eq 1 ]]; then
result="[CHANGED] New password did not work before, and works now."
elif [[ "$pre_ok" -eq 1 && "$post_ok" -eq 1 ]]; then
result="[UNCHANGED] New password already worked before."
fi
break
done
if [[ -z "$result" ]]; then
echo "[FAIL] Password auth test failed even after ALTER USER." >&2
exit 1
fi
echo "$result"