diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 55fed4ad..09ff7e71 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -43,6 +43,20 @@ maintenance interface. Run it only with files protected by `0600`, then copy the password into `THT_VECTOR_BOOTSTRAP_PASSWORD` in the bundle before restarting `vector-reconcile`/the application. The helper never prints password contents. +The helper has no implicit operator-env default. Pass the same protected env file used for the +deployment explicitly; it must be a readable regular non-symlink file and must not be writable by +group or other users: + +```sh +chmod 600 deploy/env/local.env +./scripts/vector-rotate-bootstrap-password.sh \ + --env-file "$(pwd)/deploy/env/local.env" \ + /secure/thoth/bootstrap-password /secure/thoth/bootstrap-password.next +``` + +Automation may set the narrowly scoped `THT_VECTOR_OPERATOR_ENV_FILE` instead. An explicit +`--env-file` takes precedence. Missing or unsafe env files are rejected before Compose runs. + Hosted Pi providers must use a single provider key. Compound providers (Bedrock, Azure OpenAI Responses, Cloudflare Workers AI/Gateway) fail closed until a provider-specific credential adapter is implemented. diff --git a/scripts/local-vector-smoke.sh b/scripts/local-vector-smoke.sh index eb45a916..45f451f9 100755 --- a/scripts/local-vector-smoke.sh +++ b/scripts/local-vector-smoke.sh @@ -241,6 +241,7 @@ chmod 0600 "$secret_dir/bootstrap-wrong" "$secret_dir/bootstrap-next" \ "$secret_dir/bootstrap-before-negative" "$secret_dir/bootstrap-whitespace" if COMPOSE_PROJECT_NAME="$smoke_project" \ ./scripts/vector-rotate-bootstrap-password.sh \ + --env-file "$operator_env" \ "$secret_dir/bootstrap" "$secret_dir/bootstrap-whitespace" \ >/dev/null 2>&1; then echo "bootstrap rotation accepted whitespace in a secret" >&2 @@ -254,6 +255,7 @@ compose run --rm --no-deps --entrypoint psql \ if COMPOSE_PROJECT_NAME="$smoke_project" \ ./scripts/vector-rotate-bootstrap-password.sh \ + --env-file "$operator_env" \ "$secret_dir/bootstrap-wrong" "$secret_dir/bootstrap-next" \ >/dev/null 2>&1; then echo "bootstrap rotation accepted the wrong old secret" >&2 @@ -263,6 +265,7 @@ cmp "$secret_dir/bootstrap" "$secret_dir/bootstrap-before-negative" COMPOSE_PROJECT_NAME="$smoke_project" \ ./scripts/vector-rotate-bootstrap-password.sh \ + --env-file "$operator_env" \ "$secret_dir/bootstrap" "$secret_dir/bootstrap-next" new_bootstrap_password=$(cat "$secret_dir/bootstrap") bootstrap_password="$new_bootstrap_password" diff --git a/scripts/test-vector-bootstrap-rotation.sh b/scripts/test-vector-bootstrap-rotation.sh index 76db768a..04c0f3f7 100755 --- a/scripts/test-vector-bootstrap-rotation.sh +++ b/scripts/test-vector-bootstrap-rotation.sh @@ -10,7 +10,29 @@ log="$tmp/docker.log" cat >"$fake" <<'SH' #!/bin/sh set -eu -printf '%s:%s\n' "${THT_VECTOR_BOOTSTRAP_USER:-unset}" "$*" >>"$FAKE_DOCKER_LOG" +invocation=$* +test "${1:-}" = compose +shift +env_file= +while [ "$#" -gt 0 ]; do + case "$1" in + --env-file) + [ "$#" -ge 2 ] || exit 64 + env_file=$2 + shift 2 + ;; + --env-file=*) + env_file=${1#--env-file=} + shift + ;; + *) shift ;; + esac +done +[ -n "$env_file" ] && [ -f "$env_file" ] || { + echo "fake docker rejected missing env file: $env_file" >&2 + exit 64 +} +printf '%s:%s\n' "${THT_VECTOR_BOOTSTRAP_USER:-unset}" "$invocation" >>"$FAKE_DOCKER_LOG" exit "${FAKE_DOCKER_EXIT:-0}" SH chmod 0755 "$fake" @@ -20,10 +42,46 @@ printf '%s' "new-'quoted-\$-password" >"$tmp/new" cp "$tmp/old" "$tmp/original" printf 'invalid password\n' >"$tmp/whitespace" -chmod 0600 "$tmp/old" "$tmp/new" "$tmp/original" "$tmp/whitespace" +printf '%s\n' 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/thoth-workspaces.git' \ + >"$tmp/operator.env" +printf '%s\n' 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/unsafe.git' \ + >"$tmp/unsafe.env" +ln -s "$tmp/operator.env" "$tmp/operator-link.env" +chmod 0600 "$tmp/old" "$tmp/new" "$tmp/original" "$tmp/whitespace" "$tmp/operator.env" +chmod 0660 "$tmp/unsafe.env" + +if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" \ + ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ + >"$tmp/out" 2>"$tmp/err"; then + echo "rotation silently assumed an operator env file" >&2 + exit 1 +fi +grep -q 'requires --env-file or THT_VECTOR_OPERATOR_ENV_FILE' "$tmp/err" +test ! -s "$log" + +if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" \ + ./scripts/vector-rotate-bootstrap-password.sh --env-file "$tmp/missing.env" \ + "$tmp/old" "$tmp/new" >"$tmp/out" 2>"$tmp/err"; then + echo "rotation accepted a nonexistent operator env file" >&2 + exit 1 +fi +grep -q 'operator env must be a readable regular file' "$tmp/err" +test ! -s "$log" + +for unsafe_env in "$tmp/unsafe.env" "$tmp/operator-link.env"; do + if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" \ + ./scripts/vector-rotate-bootstrap-password.sh --env-file "$unsafe_env" \ + "$tmp/old" "$tmp/new" >"$tmp/out" 2>"$tmp/err"; then + echo "rotation accepted unsafe operator env file $unsafe_env" >&2 + exit 1 + fi + test ! -s "$log" +done + : >"$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" \ + ./scripts/vector-rotate-bootstrap-password.sh --env-file "$tmp/operator.env" \ + "$tmp/old" "$tmp/whitespace" \ >"$tmp/out" 2>"$tmp/err"; then echo "rotation accepted a whitespace-containing secret" >&2 exit 1 @@ -36,7 +94,8 @@ if find "$tmp" -name 'old.rotate.*' -print | grep -q .; then fi if PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" FAKE_DOCKER_EXIT=1 \ - ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ + ./scripts/vector-rotate-bootstrap-password.sh --env-file "$tmp/operator.env" \ + "$tmp/old" "$tmp/new" \ >"$tmp/out" 2>"$tmp/err"; then echo "rotation unexpectedly succeeded when database verification failed" >&2 exit 1 @@ -45,12 +104,22 @@ cmp "$tmp/old" "$tmp/original" : >"$log" PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" THT_VECTOR_BOOTSTRAP_USER=custom_admin \ - ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ + ./scripts/vector-rotate-bootstrap-password.sh --env-file "$tmp/operator.env" \ + "$tmp/old" "$tmp/new" \ >"$tmp/out" 2>"$tmp/err" cmp "$tmp/old" "$tmp/new" +grep -q -- "--env-file $tmp/operator.env" "$log" 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." +printf '%s' old-password >"$tmp/old" +: >"$log" +PATH="$tmp:$PATH" FAKE_DOCKER_LOG="$log" \ + THT_VECTOR_OPERATOR_ENV_FILE="$tmp/operator.env" \ + ./scripts/vector-rotate-bootstrap-password.sh "$tmp/old" "$tmp/new" \ + >"$tmp/out" 2>"$tmp/err" +grep -q -- "--env-file $tmp/operator.env" "$log" + +echo "bootstrap rotation env propagation, validation, ordering, and failure contracts passed." diff --git a/scripts/vector-rotate-bootstrap-password.sh b/scripts/vector-rotate-bootstrap-password.sh index af1e99e3..3dfd1011 100755 --- a/scripts/vector-rotate-bootstrap-password.sh +++ b/scripts/vector-rotate-bootstrap-password.sh @@ -4,9 +4,49 @@ set -eu cd "$(dirname "$0")/.." . ./deploy/vector/secret-policy.sh -if [ "$#" -ne 2 ]; then - echo "usage: $0 OLD_SECRET_FILE NEW_SECRET_FILE" >&2 +usage() { + echo "usage: $0 [--env-file OPERATOR_ENV] OLD_SECRET_FILE NEW_SECRET_FILE" >&2 + echo "set THT_VECTOR_OPERATOR_ENV_FILE instead of --env-file when required by automation" >&2 exit 2 +} + +operator_env=${THT_VECTOR_OPERATOR_ENV_FILE:-} +while [ "$#" -gt 0 ]; do + case "$1" in + --env-file) + [ "$#" -ge 2 ] || usage + operator_env=$2 + shift 2 + ;; + --env-file=*) + operator_env=${1#--env-file=} + shift + ;; + --) shift; break ;; + -*) usage ;; + *) break ;; + esac +done + +if [ -z "$operator_env" ]; then + echo "rotation requires --env-file or THT_VECTOR_OPERATOR_ENV_FILE; there is no implicit default" >&2 + exit 2 +fi +if [ -L "$operator_env" ] || [ ! -f "$operator_env" ] || [ ! -r "$operator_env" ]; then + echo "operator env must be a readable regular file, not a symlink: $operator_env" >&2 + exit 2 +fi +operator_env_mode=$(stat -c '%a' "$operator_env" 2>/dev/null || stat -f '%Lp' "$operator_env" 2>/dev/null) || { + echo "cannot inspect operator env permissions: $operator_env" >&2 + exit 2 +} +if [ $((0$operator_env_mode & 022)) -ne 0 ]; then + echo "operator env must not be writable by group or other users: $operator_env" >&2 + exit 2 +fi + +if [ "$#" -ne 2 ]; then + usage fi absolute_file() { @@ -14,6 +54,8 @@ absolute_file() { printf '%s/%s\n' "$directory" "$(basename -- "$1")" } +operator_env=$(absolute_file "$operator_env") + old_secret=$(absolute_file "$1") new_secret=$(absolute_file "$2") validate_secret_file "$old_secret" old_bootstrap_secret @@ -29,7 +71,7 @@ trap 'rm -f "$replacement"' EXIT HUP INT TERM cp "$new_secret" "$replacement" chmod 0600 "$replacement" -docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local-vector.yaml \ +docker compose --env-file "$operator_env" -f compose.yaml -f deploy/compose.local-vector.yaml \ --project-name "$project" --profile local-vector run --rm --no-deps \ --user 0:0 \ --entrypoint /opt/venv/bin/python \ @@ -43,4 +85,5 @@ mv -f "$replacement" "$old_secret" trap - EXIT HUP INT TERM echo "Deployment bootstrap secret atomically replaced only after verified database login." -echo "Re-run: docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local-vector.yaml --project-name $project --profile local-vector up --wait vector-reconcile vector-migrate core" +echo "Re-run with the same operator env file: $operator_env" +echo "docker compose --env-file OPERATOR_ENV -f compose.yaml -f deploy/compose.local-vector.yaml --project-name $project --profile local-vector up --wait vector-reconcile vector-migrate core"