fix(backup): fail closed on forced RLS
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m38s
CD Pipeline / build-and-deploy (push) Successful in 15m59s
CD Pipeline / post-deploy-checks (push) Successful in 3m3s
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m38s
CD Pipeline / build-and-deploy (push) Successful in 15m59s
CD Pipeline / post-deploy-checks (push) Successful in 3m3s
This commit is contained in:
@@ -25,8 +25,6 @@ AWOOOI_K8S_HOSTS="${AWOOOI_K8S_HOSTS:-${AWOOOI_K8S_HOST} 192.168.0.121 192.168.0
|
|||||||
AWOOOI_K8S_SECRET_NAME="${AWOOOI_K8S_SECRET_NAME:-awoooi-secrets}"
|
AWOOOI_K8S_SECRET_NAME="${AWOOOI_K8S_SECRET_NAME:-awoooi-secrets}"
|
||||||
AWOOOI_K8S_NAMESPACE="${AWOOOI_K8S_NAMESPACE:-awoooi-prod}"
|
AWOOOI_K8S_NAMESPACE="${AWOOOI_K8S_NAMESPACE:-awoooi-prod}"
|
||||||
AWOOOI_K8S_DATABASE_URL_KEYS="${AWOOOI_K8S_DATABASE_URL_KEYS:-AWOOOI_BACKUP_DATABASE_URL BACKUP_DATABASE_URL DATABASE_URL}"
|
AWOOOI_K8S_DATABASE_URL_KEYS="${AWOOOI_K8S_DATABASE_URL_KEYS:-AWOOOI_BACKUP_DATABASE_URL BACKUP_DATABASE_URL DATABASE_URL}"
|
||||||
FORCE_RLS_RESTORE_SQL=""
|
|
||||||
FORCE_RLS_RESTORE_DB=""
|
|
||||||
|
|
||||||
# 高頻備份保留策略
|
# 高頻備份保留策略
|
||||||
# 2026-05-19 ogt + Codex: 保留策略統一交給 common.sh。
|
# 2026-05-19 ogt + Codex: 保留策略統一交給 common.sh。
|
||||||
@@ -137,60 +135,47 @@ latest_restic_snapshot_id() {
|
|||||||
2>/dev/null || echo "unknown"
|
2>/dev/null || echo "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_force_rls_sql() {
|
logical_backup_preflight() {
|
||||||
local database="$1"
|
local database="$1"
|
||||||
local mode="$2"
|
local role_flags
|
||||||
local query
|
local forced_rls_count
|
||||||
|
local role_query
|
||||||
|
local forced_rls_query
|
||||||
|
|
||||||
query="
|
role_query="select (rolsuper::int)::text || '|' || (rolbypassrls::int)::text from pg_roles where rolname = current_user;"
|
||||||
select format('ALTER TABLE %I.%I ${mode} ROW LEVEL SECURITY;', n.nspname, c.relname)
|
role_flags="$(run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${role_query}")" 2>/dev/null || true)"
|
||||||
|
if [ "${role_flags}" != "0|0" ]; then
|
||||||
|
log_error "${database} logical backup blocked: backup role must not be superuser or global BYPASSRLS"
|
||||||
|
return 78
|
||||||
|
fi
|
||||||
|
|
||||||
|
forced_rls_query="
|
||||||
|
select count(*)
|
||||||
from pg_class c
|
from pg_class c
|
||||||
join pg_namespace n on n.oid = c.relnamespace
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
where c.relkind in ('r', 'p')
|
where c.relkind in ('r', 'p')
|
||||||
and c.relforcerowsecurity
|
and c.relforcerowsecurity
|
||||||
and pg_get_userbyid(c.relowner) = current_user
|
and n.nspname not in ('pg_catalog', 'information_schema');
|
||||||
order by 1;
|
|
||||||
"
|
"
|
||||||
run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${query}")"
|
forced_rls_count="$(run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${forced_rls_query}")" 2>/dev/null || true)"
|
||||||
}
|
if ! [[ "${forced_rls_count}" =~ ^[0-9]+$ ]]; then
|
||||||
|
log_error "${database} logical backup blocked: FORCE RLS preflight readback failed"
|
||||||
apply_remote_sql() {
|
return 78
|
||||||
local database="$1"
|
|
||||||
local sql="$2"
|
|
||||||
[ -n "${sql}" ] || return 0
|
|
||||||
run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -c $(quote_remote "${sql}") >/dev/null"
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_force_rls() {
|
|
||||||
if [ -n "${FORCE_RLS_RESTORE_DB}" ] && [ -n "${FORCE_RLS_RESTORE_SQL}" ]; then
|
|
||||||
if apply_remote_sql "${FORCE_RLS_RESTORE_DB}" "${FORCE_RLS_RESTORE_SQL}"; then
|
|
||||||
log_info "FORCE ROW LEVEL SECURITY 已恢復 (${FORCE_RLS_RESTORE_DB})"
|
|
||||||
else
|
|
||||||
log_error "FORCE ROW LEVEL SECURITY 恢復失敗 (${FORCE_RLS_RESTORE_DB})"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
FORCE_RLS_RESTORE_DB=""
|
|
||||||
FORCE_RLS_RESTORE_SQL=""
|
|
||||||
fi
|
fi
|
||||||
|
if [ "${forced_rls_count}" -gt 0 ]; then
|
||||||
|
log_error "${database} logical backup blocked: forced_rls_tables=${forced_rls_count}; physical/WAL backup with isolated restore verifier is required"
|
||||||
|
return 78
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
trap restore_force_rls EXIT
|
dump_database_fail_closed() {
|
||||||
|
|
||||||
dump_database_with_rls_guard() {
|
|
||||||
local database="$1"
|
local database="$1"
|
||||||
local output_file="$2"
|
local output_file="$2"
|
||||||
local stderr_file="${output_file}.stderr"
|
local stderr_file="${output_file}.stderr"
|
||||||
local noforce_sql force_sql dump_rc
|
local dump_rc
|
||||||
|
|
||||||
noforce_sql="$(collect_force_rls_sql "${database}" "NO FORCE")"
|
logical_backup_preflight "${database}" || return $?
|
||||||
force_sql="$(printf '%s\n' "${noforce_sql}" | sed 's/NO FORCE/FORCE/')"
|
|
||||||
|
|
||||||
if [ -n "${noforce_sql}" ]; then
|
|
||||||
FORCE_RLS_RESTORE_DB="${database}"
|
|
||||||
FORCE_RLS_RESTORE_SQL="${force_sql}"
|
|
||||||
log_info "暫時解除 FORCE RLS 以完成完整 pg_dump (${database}, tables=$(printf '%s\n' "${noforce_sql}" | awk 'NF {count++} END {print count+0}'))"
|
|
||||||
apply_remote_sql "${database}" "${noforce_sql}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
run_remote_pgpass_command "${database}" "pg_dump --no-password \
|
run_remote_pgpass_command "${database}" "pg_dump --no-password \
|
||||||
@@ -199,8 +184,6 @@ dump_database_with_rls_guard() {
|
|||||||
dump_rc=$?
|
dump_rc=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
restore_force_rls
|
|
||||||
|
|
||||||
if [ "${dump_rc}" -ne 0 ]; then
|
if [ "${dump_rc}" -ne 0 ]; then
|
||||||
log_error "${database} dump 失敗,pg_dump stderr 尾端如下(已避免輸出 credential):"
|
log_error "${database} dump 失敗,pg_dump stderr 尾端如下(已避免輸出 credential):"
|
||||||
tail -40 "${stderr_file}" | sed -E 's/(password=)[^ ]+/\1REDACTED/g' || true
|
tail -40 "${stderr_file}" | sed -E 's/(password=)[^ ]+/\1REDACTED/g' || true
|
||||||
@@ -223,7 +206,7 @@ main() {
|
|||||||
local timestamp=$(date "+%Y%m%d_%H%M%S")
|
local timestamp=$(date "+%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
# 只備份 awoooi_prod(高頻核心)
|
# 只備份 awoooi_prod(高頻核心)
|
||||||
if dump_database_with_rls_guard "awoooi_prod" "${DUMP_DIR}/awoooi_prod_${timestamp}.sql"; then
|
if dump_database_fail_closed "awoooi_prod" "${DUMP_DIR}/awoooi_prod_${timestamp}.sql"; then
|
||||||
local size=$(du -h "${DUMP_DIR}/awoooi_prod_${timestamp}.sql" | cut -f1)
|
local size=$(du -h "${DUMP_DIR}/awoooi_prod_${timestamp}.sql" | cut -f1)
|
||||||
log_success "awoooi_prod dump 完成 (${size})"
|
log_success "awoooi_prod dump 完成 (${size})"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ AWOOOI_K8S_HOSTS="${AWOOOI_K8S_HOSTS:-${AWOOOI_K8S_HOST} 192.168.0.121 192.168.0
|
|||||||
AWOOOI_K8S_SECRET_NAME="${AWOOOI_K8S_SECRET_NAME:-awoooi-secrets}"
|
AWOOOI_K8S_SECRET_NAME="${AWOOOI_K8S_SECRET_NAME:-awoooi-secrets}"
|
||||||
AWOOOI_K8S_NAMESPACE="${AWOOOI_K8S_NAMESPACE:-awoooi-prod}"
|
AWOOOI_K8S_NAMESPACE="${AWOOOI_K8S_NAMESPACE:-awoooi-prod}"
|
||||||
AWOOOI_K8S_DATABASE_URL_KEYS="${AWOOOI_K8S_DATABASE_URL_KEYS:-AWOOOI_BACKUP_DATABASE_URL BACKUP_DATABASE_URL DATABASE_URL}"
|
AWOOOI_K8S_DATABASE_URL_KEYS="${AWOOOI_K8S_DATABASE_URL_KEYS:-AWOOOI_BACKUP_DATABASE_URL BACKUP_DATABASE_URL DATABASE_URL}"
|
||||||
FORCE_RLS_RESTORE_SQL=""
|
|
||||||
FORCE_RLS_RESTORE_DB=""
|
|
||||||
|
|
||||||
resolve_database_url() {
|
resolve_database_url() {
|
||||||
if [ -n "${AWOOOI_DATABASE_URL:-}" ]; then
|
if [ -n "${AWOOOI_DATABASE_URL:-}" ]; then
|
||||||
@@ -127,59 +125,47 @@ run_remote_pgpass_command() {
|
|||||||
pgpass_line "${database}" | ssh "ollama@${AWOOOI_HOST}" "$(remote_pgpass_wrapper "${command}")"
|
pgpass_line "${database}" | ssh "ollama@${AWOOOI_HOST}" "$(remote_pgpass_wrapper "${command}")"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_force_rls_sql() {
|
logical_backup_preflight() {
|
||||||
local database="$1"
|
local database="$1"
|
||||||
local mode="$2"
|
local role_flags
|
||||||
local query
|
local forced_rls_count
|
||||||
|
local role_query
|
||||||
|
local forced_rls_query
|
||||||
|
|
||||||
query="
|
role_query="select (rolsuper::int)::text || '|' || (rolbypassrls::int)::text from pg_roles where rolname = current_user;"
|
||||||
select format('ALTER TABLE %I.%I ${mode} ROW LEVEL SECURITY;', n.nspname, c.relname)
|
role_flags="$(run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${role_query}")" 2>/dev/null || true)"
|
||||||
|
if [ "${role_flags}" != "0|0" ]; then
|
||||||
|
log_error "${database} logical backup blocked: backup role must not be superuser or global BYPASSRLS"
|
||||||
|
return 78
|
||||||
|
fi
|
||||||
|
|
||||||
|
forced_rls_query="
|
||||||
|
select count(*)
|
||||||
from pg_class c
|
from pg_class c
|
||||||
join pg_namespace n on n.oid = c.relnamespace
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
where c.relkind in ('r', 'p')
|
where c.relkind in ('r', 'p')
|
||||||
and c.relforcerowsecurity
|
and c.relforcerowsecurity
|
||||||
and pg_get_userbyid(c.relowner) = current_user
|
and n.nspname not in ('pg_catalog', 'information_schema');
|
||||||
order by 1;
|
|
||||||
"
|
"
|
||||||
run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${query}")"
|
forced_rls_count="$(run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -At -c $(quote_remote "${forced_rls_query}")" 2>/dev/null || true)"
|
||||||
}
|
if ! [[ "${forced_rls_count}" =~ ^[0-9]+$ ]]; then
|
||||||
|
log_error "${database} logical backup blocked: FORCE RLS preflight readback failed"
|
||||||
apply_remote_sql() {
|
return 78
|
||||||
local database="$1"
|
|
||||||
local sql="$2"
|
|
||||||
[ -n "${sql}" ] || return 0
|
|
||||||
run_remote_pgpass_command "${database}" "$(remote_psql_command "${database}") -c $(quote_remote "${sql}")" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_force_rls() {
|
|
||||||
if [ -n "${FORCE_RLS_RESTORE_DB}" ] && [ -n "${FORCE_RLS_RESTORE_SQL}" ]; then
|
|
||||||
if apply_remote_sql "${FORCE_RLS_RESTORE_DB}" "${FORCE_RLS_RESTORE_SQL}"; then
|
|
||||||
log_info "FORCE ROW LEVEL SECURITY 已恢復 (${FORCE_RLS_RESTORE_DB})"
|
|
||||||
else
|
|
||||||
log_error "FORCE ROW LEVEL SECURITY 恢復失敗 (${FORCE_RLS_RESTORE_DB})"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
FORCE_RLS_RESTORE_DB=""
|
|
||||||
FORCE_RLS_RESTORE_SQL=""
|
|
||||||
fi
|
fi
|
||||||
|
if [ "${forced_rls_count}" -gt 0 ]; then
|
||||||
|
log_error "${database} logical backup blocked: forced_rls_tables=${forced_rls_count}; physical/WAL backup with isolated restore verifier is required"
|
||||||
|
return 78
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
trap restore_force_rls EXIT
|
dump_database_fail_closed() {
|
||||||
|
|
||||||
dump_database_with_rls_guard() {
|
|
||||||
local database="$1"
|
local database="$1"
|
||||||
local output_file="$2"
|
local output_file="$2"
|
||||||
local stderr_file="${output_file}.stderr"
|
local stderr_file="${output_file}.stderr"
|
||||||
local noforce_sql force_sql dump_rc
|
local dump_rc
|
||||||
|
|
||||||
noforce_sql="$(collect_force_rls_sql "${database}" "NO FORCE")"
|
logical_backup_preflight "${database}" || return $?
|
||||||
force_sql="$(printf '%s\n' "${noforce_sql}" | sed 's/NO FORCE/FORCE/')"
|
|
||||||
if [ -n "${noforce_sql}" ]; then
|
|
||||||
FORCE_RLS_RESTORE_DB="${database}"
|
|
||||||
FORCE_RLS_RESTORE_SQL="${force_sql}"
|
|
||||||
log_info "暫時解除 FORCE RLS 以完成完整 pg_dump (${database}, tables=$(printf '%s\n' "${noforce_sql}" | awk 'NF {count++} END {print count+0}'))"
|
|
||||||
apply_remote_sql "${database}" "${noforce_sql}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
run_remote_pgpass_command "${database}" "pg_dump --no-password \
|
run_remote_pgpass_command "${database}" "pg_dump --no-password \
|
||||||
@@ -188,7 +174,6 @@ dump_database_with_rls_guard() {
|
|||||||
dump_rc=$?
|
dump_rc=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
restore_force_rls
|
|
||||||
if [ "${dump_rc}" -eq 0 ]; then
|
if [ "${dump_rc}" -eq 0 ]; then
|
||||||
rm -f "${stderr_file}"
|
rm -f "${stderr_file}"
|
||||||
return 0
|
return 0
|
||||||
@@ -221,7 +206,7 @@ main() {
|
|||||||
log_info "Dump awoooi_prod..."
|
log_info "Dump awoooi_prod..."
|
||||||
local timestamp=$(date "+%Y%m%d_%H%M%S")
|
local timestamp=$(date "+%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
if dump_database_with_rls_guard \
|
if dump_database_fail_closed \
|
||||||
"awoooi_prod" "${DUMP_DIR}/awoooi_prod_${timestamp}.sql"; then
|
"awoooi_prod" "${DUMP_DIR}/awoooi_prod_${timestamp}.sql"; then
|
||||||
local size=$(du -h "${DUMP_DIR}/awoooi_prod_${timestamp}.sql" | cut -f1)
|
local size=$(du -h "${DUMP_DIR}/awoooi_prod_${timestamp}.sql" | cut -f1)
|
||||||
log_success "awoooi_prod dump 完成 (${size})"
|
log_success "awoooi_prod dump 完成 (${size})"
|
||||||
@@ -232,7 +217,7 @@ main() {
|
|||||||
|
|
||||||
# Step 2: awoooi_dev dump
|
# Step 2: awoooi_dev dump
|
||||||
log_info "Dump awoooi_dev..."
|
log_info "Dump awoooi_dev..."
|
||||||
if dump_database_with_rls_guard \
|
if dump_database_fail_closed \
|
||||||
"awoooi_dev" "${DUMP_DIR}/awoooi_dev_${timestamp}.sql"; then
|
"awoooi_dev" "${DUMP_DIR}/awoooi_dev_${timestamp}.sql"; then
|
||||||
local size=$(du -h "${DUMP_DIR}/awoooi_dev_${timestamp}.sql" | cut -f1)
|
local size=$(du -h "${DUMP_DIR}/awoooi_dev_${timestamp}.sql" | cut -f1)
|
||||||
log_success "awoooi_dev dump 完成 (${size})"
|
log_success "awoooi_dev dump 完成 (${size})"
|
||||||
@@ -242,7 +227,7 @@ main() {
|
|||||||
|
|
||||||
# Step 3: k3s_datastore dump(Kine 後端)
|
# Step 3: k3s_datastore dump(Kine 後端)
|
||||||
log_info "Dump k3s_datastore..."
|
log_info "Dump k3s_datastore..."
|
||||||
if dump_database_with_rls_guard \
|
if dump_database_fail_closed \
|
||||||
"k3s_datastore" "${DUMP_DIR}/k3s_datastore_${timestamp}.sql"; then
|
"k3s_datastore" "${DUMP_DIR}/k3s_datastore_${timestamp}.sql"; then
|
||||||
local size=$(du -h "${DUMP_DIR}/k3s_datastore_${timestamp}.sql" | cut -f1)
|
local size=$(du -h "${DUMP_DIR}/k3s_datastore_${timestamp}.sql" | cut -f1)
|
||||||
log_success "k3s_datastore dump 完成 (${size})"
|
log_success "k3s_datastore dump 完成 (${size})"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
SCRIPT = ROOT / "scripts" / "backup" / "backup-awoooi.sh"
|
SCRIPT = ROOT / "scripts" / "backup" / "backup-awoooi.sh"
|
||||||
|
FREQUENT_SCRIPT = ROOT / "scripts" / "backup" / "backup-awoooi-frequent.sh"
|
||||||
|
|
||||||
|
|
||||||
def test_daily_backup_has_no_hardcoded_database_password() -> None:
|
def test_daily_backup_has_no_hardcoded_database_password() -> None:
|
||||||
@@ -29,11 +30,36 @@ def test_daily_backup_resolves_runtime_database_url_and_fails_closed() -> None:
|
|||||||
assert 'printf \'%s\\n\' "${decoded}"' in text
|
assert 'printf \'%s\\n\' "${decoded}"' in text
|
||||||
|
|
||||||
|
|
||||||
def test_daily_backup_uses_same_rls_safe_pg_dump_contract_as_frequent_lane() -> None:
|
def test_daily_and_frequent_backups_fail_closed_without_mutating_rls() -> None:
|
||||||
|
for script in (SCRIPT, FREQUENT_SCRIPT):
|
||||||
|
text = script.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "logical_backup_preflight()" in text
|
||||||
|
assert "dump_database_fail_closed()" in text
|
||||||
|
assert "rolsuper" in text
|
||||||
|
assert "rolbypassrls" in text
|
||||||
|
assert "backup role must not be superuser or global BYPASSRLS" in text
|
||||||
|
assert "forced_rls_tables=${forced_rls_count}" in text
|
||||||
|
assert "physical/WAL backup with isolated restore verifier is required" in text
|
||||||
|
assert "return 78" in text
|
||||||
|
assert 'PGOPTIONS="-c statement_timeout=0 -c max_parallel_workers_per_gather=0"' in text
|
||||||
|
assert "ALTER TABLE" not in text
|
||||||
|
assert "NO FORCE" not in text
|
||||||
|
assert "collect_force_rls_sql" not in text
|
||||||
|
assert "apply_remote_sql" not in text
|
||||||
|
assert "restore_force_rls" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_backup_routes_every_database_through_fail_closed_dump() -> None:
|
||||||
text = SCRIPT.read_text(encoding="utf-8")
|
text = SCRIPT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "dump_database_with_rls_guard()" in text
|
assert text.count("if dump_database_fail_closed \\") == 3
|
||||||
assert 'PGOPTIONS="-c statement_timeout=0 -c max_parallel_workers_per_gather=0"' in text
|
assert 'dump_database_fail_closed \\\n "awoooi_prod"' in text
|
||||||
assert "ALTER TABLE %I.%I ${mode} ROW LEVEL SECURITY" in text
|
assert 'dump_database_fail_closed \\\n "awoooi_dev"' in text
|
||||||
assert "trap restore_force_rls EXIT" in text
|
assert 'dump_database_fail_closed \\\n "k3s_datastore"' in text
|
||||||
assert 'dump_database_with_rls_guard \\\n "awoooi_prod"' in text
|
|
||||||
|
|
||||||
|
def test_frequent_backup_routes_production_database_through_fail_closed_dump() -> None:
|
||||||
|
text = FREQUENT_SCRIPT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'dump_database_fail_closed "awoooi_prod"' in text
|
||||||
|
|||||||
Reference in New Issue
Block a user