fix(release): keep owners and grants in production recovery points

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 21:01:52 +08:00
co-authored by Grok (agent loop)
parent d13250f40a
commit 09906cdccc
13 changed files with 381 additions and 145 deletions
+118 -102
View File
@@ -5,12 +5,16 @@
# Never restores into or migrates the database named jyotisha.
#
# Host mode runs every SQL statement inside the postgres container.
# It does not call node on the host. Exit 4 means: after this rehearsal
# copy was restored and migrated, service_role still cannot SELECT
# public.chart_profiles. Grants come from the backup plus migrations,
# not from a live probe of the database that is serving production.
# If removing the rehearsal database fails, the script exits non-zero
# and names that database so it can be dropped by hand.
# It does not call node on the host. Restore keeps the owners and grants
# stored in the backup. Migrations then run as schema_owner.
# Exit 1: refused, including when a migration removed a table or column
# grant that was present on the restored copy.
# Exit 4: service_role still lacks column SELECT on public.chart_profiles
# for the columns named by BUG-1063. Do not migrate production.
# Exit 75: another production mutation holds the host lock.
# Exit 0 prints rehearsal_ok=1 only after those checks, and only when the
# rehearsal database was removed. A failed drop is non-zero and names
# that database so it can be dropped by hand.
set -euo pipefail
set +x
umask 077
@@ -98,11 +102,12 @@ print_safe_log() {
}
run_node_migrator() {
local batch="$1" log="$2" status
local batch="$1" log="$2" status migrate_url
migrate_url="${SCHEMA_DATABASE_URL}?options=-c%20role%3Dschema_owner"
set +e
(
cd "$frontend_dir"
MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
SCHEMA_DATABASE_URL="$migrate_url" MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
) >"$log" 2>&1
status=$?
set -e
@@ -131,33 +136,58 @@ if (kind === "count") {
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count)) process.exit(2);
process.stdout.write(count);
} else if (kind === "archived") {
const result = await client.query(
"select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chat_sessions' and column_name = 'archived_at') then (select count(*)::text from public.chat_sessions where archived_at is not null) else 'absent' end as n",
} else if (kind === "archived" || kind === "self") {
const spec = kind === "archived"
? { table: "chat_sessions", column: "archived_at", whereSql: "archived_at is not null" }
: { table: "chart_profiles", column: "role", whereSql: "role = 'self'" };
const exists = await client.query(
"select exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = $1 and column_name = $2) as present",
[spec.table, spec.column],
);
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2);
process.stdout.write(count);
} else if (kind === "self") {
const result = await client.query(
"select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chart_profiles' and column_name = 'role') then (select count(*)::text from public.chart_profiles where role = 'self') else 'absent' end as n",
);
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2);
process.stdout.write(count);
} else if (kind === "smoke") {
const checks = [
["authenticated", "public.profiles"],
["authenticated", "public.chat_sessions"],
["service_role", "public.chart_profiles"],
];
for (const [role, table] of checks) {
let count = "absent";
if (exists.rows[0]?.present === true) {
const result = await client.query(
"select has_table_privilege($1, $2, 'SELECT') as ok",
[role, table],
`select count(*)::text as n from public.${spec.table} where ${spec.whereSql}`,
);
process.stdout.write(`${role} ${table} ${result.rows[0].ok}\n`);
count = result.rows[0]?.n ?? "";
}
if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2);
process.stdout.write(count);
} else if (kind === "grants") {
const result = await client.query(`
select line from (
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line
from information_schema.role_table_grants
where table_schema not in ('pg_catalog', 'information_schema')
union all
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name
from information_schema.column_privileges
where table_schema not in ('pg_catalog', 'information_schema')
) s
order by 1
`);
for (const row of result.rows) process.stdout.write(`${row.line}\n`);
} else if (kind === "bug1063") {
const columns = [
"id", "user_id", "role", "name", "birth_date", "reported_birth_time",
"birth_time_source", "birth_time_period", "declared_window_start",
"declared_window_end", "uncertainty_before_minutes", "uncertainty_after_minutes",
"latitude", "longitude", "timezone_id", "timezone_offset", "birth_place_label",
"birth_place_type", "birth_place_provider", "birth_place_provider_id", "ayanamsa",
"active_birth_time", "active_birth_date", "active_birth_timezone_offset",
"birth_time_status", "gender",
];
const result = await client.query(
`select count(*)::text as n
from unnest($1::text[]) as cols(column_name)
where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')`,
[columns],
);
const missing = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(missing)) process.exit(2);
process.stdout.write(missing);
} else if (kind === "grant_create") {
await client.query(`grant connect, create on database ${name} to schema_owner`);
} else if (kind === "drop") {
await client.end();
const admin = new URL(source);
@@ -313,63 +343,9 @@ if [ "$local_mode" -eq 0 ]; then
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$RECOVERY_DUMP" |
docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$database_name" \
--no-owner --exit-on-error
--exit-on-error
set +o pipefail
unset PRODUCTION_BACKUP_ENCRYPTION_KEY
docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -f - <<'SQL'
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT n.nspname, c.relname, c.relkind
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
AND n.nspname NOT LIKE 'pg_%'
AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
LOOP
IF r.relkind = 'S' THEN
EXECUTE format('ALTER SEQUENCE %I.%I OWNER TO schema_owner', r.nspname, r.relname);
ELSIF r.relkind = 'v' THEN
EXECUTE format('ALTER VIEW %I.%I OWNER TO schema_owner', r.nspname, r.relname);
ELSIF r.relkind = 'm' THEN
EXECUTE format('ALTER MATERIALIZED VIEW %I.%I OWNER TO schema_owner', r.nspname, r.relname);
ELSE
EXECUTE format('ALTER TABLE %I.%I OWNER TO schema_owner', r.nspname, r.relname);
END IF;
END LOOP;
FOR r IN
SELECT n.nspname, p.proname, pg_get_function_identity_arguments(p.oid) AS args
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname NOT LIKE 'pg_%'
LOOP
EXECUTE format('ALTER FUNCTION %I.%I(%s) OWNER TO schema_owner', r.nspname, r.proname, r.args);
END LOOP;
FOR r IN
SELECT n.nspname, t.typname
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname NOT LIKE 'pg_%'
AND t.typtype IN ('e', 'c', 'd')
LOOP
EXECUTE format('ALTER TYPE %I.%I OWNER TO schema_owner', r.nspname, r.typname);
END LOOP;
FOR r IN
SELECT n.nspname
FROM pg_namespace n
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
AND n.nspname NOT LIKE 'pg_%'
LOOP
EXECUTE format('ALTER SCHEMA %I OWNER TO schema_owner', r.nspname);
END LOOP;
EXECUTE 'ALTER SCHEMA public OWNER TO schema_owner';
END
$$;
SQL
# The URL is captured only into the environment, never echoed.
SCHEMA_DATABASE_URL="$(python3 - "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$database_name" <<'PY'
import sys, urllib.parse
@@ -407,6 +383,14 @@ else
}
fi
column_present() {
local table="$1" column="$2" present
present="$(docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = '${column}') then 'yes' else 'no' end")"
[ "$present" = "yes" ]
}
sql_scalar() {
local kind="$1" sql
if [ "$local_mode" -eq 1 ]; then
@@ -418,10 +402,18 @@ sql_scalar() {
sql="select count(*)::text from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)"
;;
archived)
sql="select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chat_sessions' and column_name = 'archived_at') then (select count(*)::text from public.chat_sessions where archived_at is not null) else 'absent' end"
if ! column_present chat_sessions archived_at; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chat_sessions where archived_at is not null"
;;
self)
sql="select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'chart_profiles' and column_name = 'role') then (select count(*)::text from public.chart_profiles where role = 'self') else 'absent' end"
if ! column_present chart_profiles role; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chart_profiles where role = 'self'"
;;
*)
return 2
@@ -431,14 +423,34 @@ sql_scalar() {
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c "$sql"
}
sql_smoke() {
capture_grants() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql smoke
run_node_sql grants
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select line from (select 1 as n, 'authenticated public.profiles ' || case when has_table_privilege('authenticated', 'public.profiles', 'SELECT') then 'true' else 'false' end as line union all select 2, 'authenticated public.chat_sessions ' || case when has_table_privilege('authenticated', 'public.chat_sessions', 'SELECT') then 'true' else 'false' end union all select 3, 'service_role public.chart_profiles ' || case when has_table_privilege('service_role', 'public.chart_profiles', 'SELECT') then 'true' else 'false' end) s order by n"
"select line from (select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line from information_schema.role_table_grants where table_schema not in ('pg_catalog', 'information_schema') union all select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema not in ('pg_catalog', 'information_schema')) s order by 1"
}
chart_profiles_select_missing() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql bug1063
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select count(*)::text from (values ('id'),('user_id'),('role'),('name'),('birth_date'),('reported_birth_time'),('birth_time_source'),('birth_time_period'),('declared_window_start'),('declared_window_end'),('uncertainty_before_minutes'),('uncertainty_after_minutes'),('latitude'),('longitude'),('timezone_id'),('timezone_offset'),('birth_place_label'),('birth_place_type'),('birth_place_provider'),('birth_place_provider_id'),('ayanamsa'),('active_birth_time'),('active_birth_date'),('active_birth_timezone_offset'),('birth_time_status'),('gender')) as cols(column_name) where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')"
}
grant_migration_database() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql grant_create
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 \
-c "GRANT CONNECT, CREATE ON DATABASE \"${database_name}\" TO schema_owner;" >/dev/null
}
echo "rehearsal_database=$database_name"
@@ -448,6 +460,9 @@ archived_before="$(sql_scalar archived)" || refuse "could not count archived ses
self_before="$(sql_scalar self)" || refuse "could not count self chart rows before migrations"
echo "archived_sessions_before=$archived_before"
echo "self_chart_rows_before=$self_before"
grants_before="$work_dir/grants-before.txt"
capture_grants >"$grants_before" || refuse "could not read grants before migrations"
grant_migration_database || refuse "could not grant the migration role on the rehearsal database"
set +e
run_batch "$work_dir/before" "$work_dir/before.log"
before_status=$?
@@ -481,19 +496,20 @@ for log in "$work_dir/before.log" "$work_dir/all.log"; do
notice_lines=$((notice_lines + $(grep -c '^notice ' "$log" || true)))
done
echo "notice_lines=$notice_lines"
smoke_file="$work_dir/smoke.txt"
if ! sql_smoke >"$smoke_file"; then
refuse "rehearsal smoke could not check role privileges"
grants_after="$work_dir/grants-after.txt"
capture_grants >"$grants_after" || refuse "could not read grants after migrations"
revoked="$(comm -23 <(sort "$grants_before") <(sort "$grants_after") | wc -l | tr -d '[:space:]')"
echo "revoked_grants=$revoked"
if [ "$revoked" != 0 ]; then
refuse "migration revoked existing grants"
fi
cat "$smoke_file"
if ! grep -q '^authenticated public.profiles true$' "$smoke_file"; then
refuse "web role cannot read profiles"
if ! missing="$(chart_profiles_select_missing | tr -d '[:space:]')"; then
refuse "could not check chart_profiles column grants"
fi
if ! grep -q '^authenticated public.chat_sessions true$' "$smoke_file"; then
refuse "web role cannot read chat sessions"
fi
if ! grep -q '^service_role public.chart_profiles true$' "$smoke_file"; then
echo "stop: rehearsal copy still has no service_role SELECT on chart_profiles after migrations; do not migrate production" >&2
[[ "$missing" =~ ^[0-9]+$ ]] || refuse "could not check chart_profiles column grants"
echo "chart_profiles_service_role_select_missing=$missing"
if [ "$missing" != 0 ]; then
echo "stop: rehearsal copy still lacks service_role column SELECT on chart_profiles after migrations; do not migrate production" >&2
exit 4
fi
echo "rehearsal_ok=1"
+12
View File
@@ -34,6 +34,18 @@ if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then
echo "invalid rollback authorization" >&2
exit 1
fi
if [ "$ALLOW_ROLLBACK" = "true" ]; then
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
fi
if [ "$VERIFICATION_MODE" != "internal" ] && [ "$VERIFICATION_MODE" != "public" ]; then
echo "invalid production verification mode" >&2
exit 1
+10
View File
@@ -51,6 +51,16 @@ esac
echo "production recovery point must have restore_verified=true" >&2
exit 1
}
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_created_epoch="$(date -u -d "$RECOVERY_CREATED_AT" +%s 2>/dev/null)" || {
echo "invalid production recovery creation time" >&2
exit 1
+59 -5
View File
@@ -88,6 +88,48 @@ set -a
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
privilege_sql() {
case "$1" in
table_grants)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name from information_schema.role_table_grants where table_schema = 'public' order by 1"
;;
column_privileges)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema = 'public' order by 1"
;;
table_owners)
printf '%s' "select c.relname || '|' || pg_get_userbyid(c.relowner) from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r', 'p') order by 1"
;;
*)
echo "unknown privilege check" >&2
exit 1
;;
esac
}
privilege_snapshot() {
local database="$1" kind="$2" destination="$3"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database" -At -v ON_ERROR_STOP=1 \
-c "$(privilege_sql "$kind")" | sort >"$destination"
}
privileges_match() {
local kind="$1" source_file restore_file status=0
source_file="$(mktemp)"
restore_file="$(mktemp)"
if ! privilege_snapshot "$POSTGRES_DB" "$kind" "$source_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
if ! privilege_snapshot "$restore_database" "$kind" "$restore_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
cmp -s "$source_file" "$restore_file" || status=$?
rm -f -- "$source_file" "$restore_file"
return "$status"
}
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || {
echo "production backup disk usage must remain below 70 percent" >&2
@@ -111,7 +153,7 @@ cleanup() {
trap cleanup EXIT HUP INT TERM
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-acl |
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom |
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY >"$backup_partial"
[ -s "$backup_partial" ]
@@ -127,7 +169,7 @@ openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup_file" |
sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$restore_database" \
--no-owner --no-acl --exit-on-error
--exit-on-error
counts="$({
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
@@ -146,6 +188,17 @@ for value in "$identity_users" "$profiles" "$credit_transactions" "$public_table
}
done
table_grants_match=false
column_privileges_match=false
table_owners_match=false
if privileges_match table_grants; then table_grants_match=true; fi
if privileges_match column_privileges; then column_privileges_match=true; fi
if privileges_match table_owners; then table_owners_match=true; fi
if [ "$table_grants_match" != true ] || [ "$column_privileges_match" != true ] || [ "$table_owners_match" != true ]; then
echo "restore privilege verification failed" >&2
exit 1
fi
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE \"$restore_database\" WITH (FORCE);" >/dev/null
@@ -154,14 +207,15 @@ trap - EXIT HUP INT TERM
verify_basename="${backup_basename%.dump.enc}-restore-verify.json"
verify_file="$backup_directory/$verify_basename"
printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha256": "%s",\n "created_at": "%s",\n "verified_at": "%s",\n "restore_database_removed": true,\n "checks": {"identity_users": %s, "profiles": %s, "credit_transactions": %s, "public_tables": %s}\n}\n' \
printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha256": "%s",\n "created_at": "%s",\n "verified_at": "%s",\n "restore_database_removed": true,\n "checks": {"identity_users": %s, "profiles": %s, "credit_transactions": %s, "public_tables": %s, "table_grants_match": %s, "column_privileges_match": %s, "table_owners_match": %s}\n}\n' \
"$backup_basename" "$backup_sha256" "$created_at" "$verified_at" \
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" >"$verify_file"
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" \
"$table_grants_match" "$column_privileges_match" "$table_owners_match" >"$verify_file"
chmod 600 "$verify_file"
recovery_reference="gitea-actions-run-${RECOVERY_RUN_ID}/${backup_basename}"
state_tmp="$state_directory/recovery-baseline.env.tmp.$$"
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nVERIFY_FILE=%s\n' \
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nRECOVERY_FORMAT=2\nVERIFY_FILE=%s\n' \
"$recovery_reference" "$created_at" "$backup_sha256" "$verify_basename" >"$state_tmp"
chmod 600 "$state_tmp"
mv "$state_tmp" "$state_directory/recovery-baseline.env"