From 1145ae20bcf8d5cf0f0a8e758cd0cac94791fa41 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 12 Jul 2026 02:04:57 +0200 Subject: [PATCH] fix(deploy): align vector bootstrap identity policy --- .superpowers/sdd/pgvector-task-3-report.md | 15 +++++++++++++ compose.yaml | 3 +++ deploy/env.example | 1 + deploy/secrets/README.md | 5 +++++ deploy/vector/reconcile-roles.sh | 17 +++++---------- deploy/vector/rotate-bootstrap-password.py | 6 +++--- deploy/vector/secret-policy.sh | 19 ++++++++++++++++ scripts/local-vector-smoke.sh | 21 +++++++++++++++--- scripts/test-vector-bootstrap-rotation.sh | 20 +++++++++++++++-- scripts/test-vector-secret-policy.sh | 24 +++++++++++++++++++++ scripts/vector-rotate-bootstrap-password.sh | 9 +++----- 11 files changed, 114 insertions(+), 26 deletions(-) create mode 100755 deploy/vector/secret-policy.sh create mode 100755 scripts/test-vector-secret-policy.sh diff --git a/.superpowers/sdd/pgvector-task-3-report.md b/.superpowers/sdd/pgvector-task-3-report.md index a391b093..32199b42 100644 --- a/.superpowers/sdd/pgvector-task-3-report.md +++ b/.superpowers/sdd/pgvector-task-3-report.md @@ -116,3 +116,18 @@ Final tests: - `./scripts/test-vector-bootstrap-rotation.sh`: PASS - `./scripts/local-vector-smoke.sh`: PASS with negative and positive live bootstrap rotation - existing local-vector collision/safety and Compose deployment contracts: PASS + +## Final identity and secret-policy alignment + +- `THT_VECTOR_BOOTSTRAP_USER` is now passed through core as well as vector-db and reconciliation, + so the rotation helper uses the authoritative configured role instead of defaulting to `postgres`. +- Rotation and reconciliation source the same raw-file `secret-policy.sh`: non-empty and no + whitespace, including trailing newlines. Rotation validates both files before Docker, + PostgreSQL, or atomic replacement staging; `test-vector-secret-policy.sh` pins empty, newline, + internal-space, and valid metacharacter cases. +- Fake-Docker tests prove a non-default identity reaches the helper path and whitespace rejection + performs no Docker call and creates no staged replacement. +- The real smoke runs the entire stack as `thoth_bootstrap_smoke`. Its whitespace-negative case + leaves the deployment file unchanged and proves the existing database login still succeeds; + non-default-account bootstrap rotation, reconciliation, migration, core health, restart, and + persisted retrieval all pass. diff --git a/compose.yaml b/compose.yaml index e0b15bf9..98161f2e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,6 +17,7 @@ services: THT_DATA_ROOT: /data SETTINGS_FILE: /data/settings/settings.json THT_VECTOR_DATABASE: "${THT_VECTOR_DATABASE:-thoth}" + THT_VECTOR_BOOTSTRAP_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}" THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}" THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}" THT_VECTOR_READER_PASSWORD: "${THT_VECTOR_READER_PASSWORD:-}" @@ -92,6 +93,7 @@ services: PGPORT: 5432 PGDATABASE: "${THT_VECTOR_DATABASE:-thoth}" PGUSER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}" + THT_VECTOR_BOOTSTRAP_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}" THT_VECTOR_MIGRATOR_USER: "${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}" THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}" THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}" @@ -103,6 +105,7 @@ services: - vector_writer_password volumes: - ./deploy/vector/reconcile-roles.sh:/opt/thoth/reconcile-roles.sh:ro + - ./deploy/vector/secret-policy.sh:/opt/thoth/secret-policy.sh:ro depends_on: vector-db: condition: service_healthy diff --git a/deploy/env.example b/deploy/env.example index 12d9ef1e..caa373f1 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -20,6 +20,7 @@ THT_VEC_WRITE_API_KEY= # Optional local-vector profile. Keep these secret files outside Git and readable by Docker. THT_VECTOR_DATABASE=thoth +THT_VECTOR_BOOTSTRAP_USER=postgres THT_VECTOR_MIGRATOR_USER=thoth_vector_migrator THT_VECTOR_READER_USER=thoth_vector_reader THT_VECTOR_WRITER_USER=thoth_vector_writer diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 4014db21..7803c17d 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -33,5 +33,10 @@ authentication or new-login verification fails, it exits without changing the de verification failure also attempts to restore the old database password over the still-open authenticated connection. After success, run the printed `vector-reconcile`/migration/core command. +`THT_VECTOR_BOOTSTRAP_USER` is authoritative for database initialization, reconciliation, and +rotation; non-default bootstrap role names are supported. Bootstrap, migrator, reader, and writer +secret files must be non-empty and contain no whitespace (including trailing newlines). Rotation +rejects invalid files before contacting PostgreSQL or staging a deployment-file replacement. + Keep the staged new file on the same trusted host, mode `0600`, and retain a secure backup until the post-rotation reconciliation and application health checks pass. diff --git a/deploy/vector/reconcile-roles.sh b/deploy/vector/reconcile-roles.sh index ed8564a1..ef3486d3 100755 --- a/deploy/vector/reconcile-roles.sh +++ b/deploy/vector/reconcile-roles.sh @@ -1,19 +1,12 @@ #!/bin/sh set -eu -read_secret() { - value=$(cat "/run/secrets/$1") - if [ -z "$value" ] || printf '%s' "$value" | grep -q '[[:space:]]'; then - echo "$1 must be non-empty and contain no whitespace" >&2 - exit 2 - fi - printf '%s' "$value" -} +. /opt/thoth/secret-policy.sh -export PGPASSWORD=$(read_secret vector_bootstrap_password) -migrator_password=$(read_secret vector_migrator_password) -reader_password=$(read_secret vector_reader_password) -writer_password=$(read_secret vector_writer_password) +export PGPASSWORD=$(read_secret_file /run/secrets/vector_bootstrap_password vector_bootstrap_password) +migrator_password=$(read_secret_file /run/secrets/vector_migrator_password vector_migrator_password) +reader_password=$(read_secret_file /run/secrets/vector_reader_password vector_reader_password) +writer_password=$(read_secret_file /run/secrets/vector_writer_password vector_writer_password) psql --set=ON_ERROR_STOP=1 \ --set=migrator_user="$THT_VECTOR_MIGRATOR_USER" \ diff --git a/deploy/vector/rotate-bootstrap-password.py b/deploy/vector/rotate-bootstrap-password.py index e04c9a97..042c49d7 100755 --- a/deploy/vector/rotate-bootstrap-password.py +++ b/deploy/vector/rotate-bootstrap-password.py @@ -12,9 +12,9 @@ from psycopg2 import sql def read_secret(path: str) -> str: - value = Path(path).read_text().rstrip("\r\n") - if not value or "\x00" in value: - raise ValueError("secret must be non-empty and contain no NUL bytes") + value = Path(path).read_text() + if not value or "\x00" in value or any(character.isspace() for character in value): + raise ValueError("secret must be non-empty and contain no whitespace or NUL bytes") return value diff --git a/deploy/vector/secret-policy.sh b/deploy/vector/secret-policy.sh new file mode 100755 index 00000000..7c096465 --- /dev/null +++ b/deploy/vector/secret-policy.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +validate_secret_file() { + secret_path=$1 + secret_name=$2 + if [ ! -f "$secret_path" ] || [ ! -r "$secret_path" ] || [ ! -s "$secret_path" ]; then + echo "$secret_name must be a readable, non-empty regular file" >&2 + return 2 + fi + if LC_ALL=C grep -q '[[:space:]]' "$secret_path"; then + echo "$secret_name must contain no whitespace" >&2 + return 2 + fi +} + +read_secret_file() { + validate_secret_file "$1" "$2" || return + cat "$1" +} diff --git a/scripts/local-vector-smoke.sh b/scripts/local-vector-smoke.sh index d058876c..4e7f1229 100755 --- a/scripts/local-vector-smoke.sh +++ b/scripts/local-vector-smoke.sh @@ -32,6 +32,7 @@ export THT_VECTOR_READER_PASSWORD_SECRET_FILE="$secret_dir/reader" export THT_VECTOR_WRITER_PASSWORD_SECRET_FILE="$secret_dir/writer" export THT_VECTOR_READER_PASSWORD="smoke-reader-${smoke_project}" export THT_VECTOR_WRITER_PASSWORD="smoke-writer-${smoke_project}" +export THT_VECTOR_BOOTSTRAP_USER=thoth_bootstrap_smoke export THOTH_SMOKE_OWNER="$smoke_owner" compose() { @@ -167,7 +168,7 @@ migration_status=$(compose run --rm --no-deps vector-migrate) printf '%s\n' "$migration_status" | grep -q '"pending": \[\]' migrator_flags=$(compose run --rm --no-deps --entrypoint sh vector-reconcile -ec ' export PGPASSWORD=$(cat /run/secrets/vector_bootstrap_password) - psql -At --host vector-db --username postgres --dbname thoth \ + psql -At --host vector-db --username "$THT_VECTOR_BOOTSTRAP_USER" --dbname thoth \ --command "SELECT (NOT rolcreaterole) AND (NOT rolcreatedb) AND (NOT rolsuper) FROM pg_roles WHERE rolname = '\''thoth_vector_migrator'\''" ') test "$migrator_flags" = t @@ -199,6 +200,20 @@ old_bootstrap_password=$(cat "$secret_dir/bootstrap") printf '%s' "wrong-bootstrap-${smoke_project}" >"$secret_dir/bootstrap-wrong" printf '%s' "next-bootstrap-'quoted-${smoke_project}" >"$secret_dir/bootstrap-next" cp "$secret_dir/bootstrap" "$secret_dir/bootstrap-before-negative" +printf 'invalid bootstrap password\n' >"$secret_dir/bootstrap-whitespace" +if COMPOSE_PROJECT_NAME="$smoke_project" \ + ./scripts/vector-rotate-bootstrap-password.sh \ + "$secret_dir/bootstrap" "$secret_dir/bootstrap-whitespace" \ + >/dev/null 2>&1; then + echo "bootstrap rotation accepted whitespace in a secret" >&2 + exit 1 +fi +cmp "$secret_dir/bootstrap" "$secret_dir/bootstrap-before-negative" +compose run --rm --no-deps --entrypoint psql \ + -e PGPASSWORD="$old_bootstrap_password" vector-reconcile \ + --host vector-db --username "$THT_VECTOR_BOOTSTRAP_USER" --dbname thoth \ + --command 'SELECT 1' >/dev/null + if COMPOSE_PROJECT_NAME="$smoke_project" \ ./scripts/vector-rotate-bootstrap-password.sh \ "$secret_dir/bootstrap-wrong" "$secret_dir/bootstrap-next" \ @@ -215,14 +230,14 @@ new_bootstrap_password=$(cat "$secret_dir/bootstrap") test "$new_bootstrap_password" != "$old_bootstrap_password" if compose run --rm --no-deps --entrypoint psql \ -e PGPASSWORD="$old_bootstrap_password" vector-reconcile \ - --host vector-db --username postgres --dbname thoth --command 'SELECT 1' \ + --host vector-db --username "$THT_VECTOR_BOOTSTRAP_USER" --dbname thoth --command 'SELECT 1' \ >/dev/null 2>&1; then echo "old bootstrap credential still works after rotation" >&2 exit 1 fi compose run --rm --no-deps --entrypoint psql \ -e PGPASSWORD="$new_bootstrap_password" vector-reconcile \ - --host vector-db --username postgres --dbname thoth --command 'SELECT 1' \ + --host vector-db --username "$THT_VECTOR_BOOTSTRAP_USER" --dbname thoth --command 'SELECT 1' \ >/dev/null compose run --rm vector-reconcile bootstrap_rotation_status=$(compose run --rm --no-deps vector-migrate) diff --git a/scripts/test-vector-bootstrap-rotation.sh b/scripts/test-vector-bootstrap-rotation.sh index 97435c6a..31a20909 100755 --- a/scripts/test-vector-bootstrap-rotation.sh +++ b/scripts/test-vector-bootstrap-rotation.sh @@ -10,7 +10,7 @@ log="$tmp/docker.log" cat >"$fake" <<'SH' #!/bin/sh set -eu -printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG" +printf '%s:%s\n' "${THT_VECTOR_BOOTSTRAP_USER:-unset}" "$*" >>"$FAKE_DOCKER_LOG" exit "${FAKE_DOCKER_EXIT:-0}" SH chmod 0755 "$fake" @@ -19,6 +19,21 @@ printf '%s' old-password >"$tmp/old" printf '%s' "new-'quoted-\$-password" >"$tmp/new" cp "$tmp/old" "$tmp/original" +printf 'invalid password\n' >"$tmp/whitespace" +: >"$log" +if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" THT_VECTOR_BOOTSTRAP_USER=custom_admin \ + ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/whitespace" \ + >"$tmp/out" 2>"$tmp/err"; then + echo "rotation accepted a whitespace-containing secret" >&2 + exit 1 +fi +cmp "$tmp/old" "$tmp/original" +test ! -s "$log" +if find "$tmp" -name 'old.rotate.*' -print | grep -q .; then + echo "rotation staged a deployment file before secret validation" >&2 + exit 1 +fi + if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" FAKE_DOCKER_EXIT=1 \ ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ >"$tmp/out" 2>"$tmp/err"; then @@ -28,12 +43,13 @@ fi cmp "$tmp/old" "$tmp/original" : >"$log" -PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" \ +PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" THT_VECTOR_BOOTSTRAP_USER=custom_admin \ ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ >"$tmp/out" 2>"$tmp/err" cmp "$tmp/old" "$tmp/new" grep -q '/run/secrets/bootstrap-old:ro' "$log" grep -q '/run/secrets/bootstrap-new:ro' "$log" +grep -q '^custom_admin:' "$log" grep -q 'atomically replaced only after verified database login' "$tmp/out" echo "bootstrap rotation ordering and no-config-change failure contracts passed." diff --git a/scripts/test-vector-secret-policy.sh b/scripts/test-vector-secret-policy.sh new file mode 100755 index 00000000..e0da973a --- /dev/null +++ b/scripts/test-vector-secret-policy.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +. ./deploy/vector/secret-policy.sh + +: >"$tmp/empty" +printf 'has newline\n' >"$tmp/newline" +printf 'has space' >"$tmp/space" +printf 'safe-quoted-\047-dollar-$' >"$tmp/valid" + +for invalid in empty newline space; do + if validate_secret_file "$tmp/$invalid" "$invalid" >/dev/null 2>&1; then + echo "secret policy accepted $invalid" >&2 + exit 1 + fi +done +validate_secret_file "$tmp/valid" valid +test "$(read_secret_file "$tmp/valid" valid)" = "safe-quoted-'-dollar-$" + +echo "shared vector secret policy contracts passed." diff --git a/scripts/vector-rotate-bootstrap-password.sh b/scripts/vector-rotate-bootstrap-password.sh index 9f0536ad..0cddc2e4 100755 --- a/scripts/vector-rotate-bootstrap-password.sh +++ b/scripts/vector-rotate-bootstrap-password.sh @@ -2,6 +2,7 @@ set -eu cd "$(dirname "$0")/.." +. ./deploy/vector/secret-policy.sh if [ "$#" -ne 2 ]; then echo "usage: $0 OLD_SECRET_FILE NEW_SECRET_FILE" >&2 @@ -15,12 +16,8 @@ absolute_file() { old_secret=$(absolute_file "$1") new_secret=$(absolute_file "$2") -for secret in "$old_secret" "$new_secret"; do - if [ ! -f "$secret" ] || [ ! -r "$secret" ] || [ ! -s "$secret" ]; then - echo "secret file must be a readable, non-empty regular file: $secret" >&2 - exit 2 - fi -done +validate_secret_file "$old_secret" old_bootstrap_secret +validate_secret_file "$new_secret" new_bootstrap_secret if [ "$old_secret" -ef "$new_secret" ]; then echo "old and new secret files must be distinct" >&2 exit 2