Spaces:
Sleeping
Sleeping
File size: 9,470 Bytes
4a8ceaa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | #!/usr/bin/env bash
# backup_db.sh β DATABASE_URL-aware backup with audit-surface CSV export.
#
# Phase 5 ops (docs/HARDENING.md). Cron-ready: no prompts, meaningful exit
# codes, absolute paths derived from this script's location.
#
# DATABASE_URL unset / sqlite:///β¦ β sqlite3 .backup snapshot
# DATABASE_URL postgresβ¦ β pg_dump --format=custom
#
# Every run additionally exports the audit surfaces (docs/TENANCY.md) as CSV:
# atp_evidence, atp_hitl, access_log, usage_events
# and writes a sha256 manifest which is verified before the script exits 0.
#
# Env knobs:
# DATABASE_URL same value the app uses (postgres://β¦ is fine;
# the +psycopg2 driver suffix is stripped for pg_dump)
# BRAIN_DB sqlite path when DATABASE_URL is unset
# (default: <repo>/data/brain_university.db)
# BACKUP_DIR output directory (default: <repo>/backups)
# BACKUP_KEEP retention β keep this many newest runs (default: 14)
# BACKUP_GPG_RECIPIENT when set, every artifact is GPG-encrypted to this
# recipient and the plaintext removed (leak-surface
# #7 in docs/TENANCY.md: backups must not leak T2).
# The manifest hashes the ciphertext, so integrity
# checks never require the private key.
#
# Exit codes:
# 0 backup written, manifest verified, retention pruned
# 1 configuration / missing-tool error (nothing written)
# 2 dump or export step failed
# 3 manifest verification failed (artifacts are suspect β do not trust)
#
# Postgres + RLS note: the ATP tables are FORCE ROW LEVEL SECURITY
# (migrations 005/006). This script sets row_security=off for the CSV
# exports, and pg_dump does the same by default β so a role WITHOUT
# BYPASSRLS fails LOUDLY instead of silently exporting 0 rows. Run backups
# as a superuser or a dedicated role with BYPASSRLS.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-${REPO_ROOT}/backups}"
BACKUP_KEEP="${BACKUP_KEEP:-14}"
GPG_RECIPIENT="${BACKUP_GPG_RECIPIENT:-}"
# Audit surfaces (docs/TENANCY.md) exported as CSV on every run.
AUDIT_TABLES=(atp_evidence atp_hitl access_log usage_events)
STAMP="$(date -u +%Y%m%d-%H%M%S)"
STEM="atp-${STAMP}"
log() { printf '[backup_db] %s\n' "$*" >&2; }
die() { printf '[backup_db] ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
case "${BACKUP_KEEP}" in
''|*[!0-9]*) die "BACKUP_KEEP must be a non-negative integer (got '${BACKUP_KEEP}')" 1 ;;
esac
# sha256 tool: GNU coreutils on Linux, shasum on stock macOS.
if command -v sha256sum >/dev/null 2>&1; then
SHA=(sha256sum)
elif command -v shasum >/dev/null 2>&1; then
SHA=(shasum -a 256)
else
die "need sha256sum or shasum on PATH" 1
fi
# ββ Resolve the database from DATABASE_URL (mirror atp/db.py) βββββββββββββββ
DB_KIND="" PG_URL="" SQLITE_PATH=""
url="${DATABASE_URL:-}"
case "${url}" in
'' )
DB_KIND="sqlite"
SQLITE_PATH="${BRAIN_DB:-${REPO_ROOT}/data/brain_university.db}"
;;
sqlite:///* )
DB_KIND="sqlite"
SQLITE_PATH="${url#sqlite:///}"
# sqlite:////abs/path keeps its leading slash after stripping; a
# relative path is resolved against the repo root (app runs from there).
[ "${SQLITE_PATH#/}" = "${SQLITE_PATH}" ] && SQLITE_PATH="${REPO_ROOT}/${SQLITE_PATH}"
;;
postgres://* | postgresql://* | postgresql+psycopg2://* )
DB_KIND="postgres"
# pg_dump/psql want a plain postgresql:// URL β strip driver suffixes.
PG_URL="${url/postgresql+psycopg2:\/\//postgresql://}"
PG_URL="${PG_URL/postgres:\/\//postgresql://}"
;;
* )
die "unsupported DATABASE_URL scheme: ${url%%://*}://β¦" 1
;;
esac
if [ "${DB_KIND}" = "sqlite" ]; then
command -v sqlite3 >/dev/null 2>&1 || die "sqlite3 not on PATH" 1
[ -f "${SQLITE_PATH}" ] || die "sqlite database not found: ${SQLITE_PATH}" 1
else
command -v pg_dump >/dev/null 2>&1 || die "pg_dump not on PATH (install postgresql client tools)" 1
command -v psql >/dev/null 2>&1 || die "psql not on PATH (needed for CSV export)" 1
fi
if [ -n "${GPG_RECIPIENT}" ]; then
command -v gpg >/dev/null 2>&1 || die "BACKUP_GPG_RECIPIENT set but gpg not on PATH" 1
fi
mkdir -p "${BACKUP_DIR}"
# Backups must never be committed (leak-surface #7) β self-ignore the dir.
[ -f "${BACKUP_DIR}/.gitignore" ] || printf '*\n' > "${BACKUP_DIR}/.gitignore"
ARTIFACTS=() # basenames, relative to BACKUP_DIR
# ββ 1. Full database snapshot ββββββββββββββββββββββββββββββββββββββββββββββββ
if [ "${DB_KIND}" = "sqlite" ]; then
SNAP="${STEM}.sqlite3"
log "sqlite .backup ${SQLITE_PATH} -> ${SNAP}"
sqlite3 "${SQLITE_PATH}" ".backup '${BACKUP_DIR}/${SNAP}'" \
|| die "sqlite3 .backup failed" 2
ARTIFACTS+=("${SNAP}")
else
SNAP="${STEM}.dump"
log "pg_dump (custom format) -> ${SNAP}"
pg_dump --format=custom --no-owner --file "${BACKUP_DIR}/${SNAP}" "${PG_URL}" \
|| die "pg_dump failed (a non-BYPASSRLS role errors on FORCE RLS tables β see header)" 2
ARTIFACTS+=("${SNAP}")
fi
# ββ 2. CSV export of the audit surfaces ββββββββββββββββββββββββββββββββββββββ
for t in "${AUDIT_TABLES[@]}"; do
CSV="${STEM}.${t}.csv"
if [ "${DB_KIND}" = "sqlite" ]; then
exists="$(sqlite3 -readonly "${SQLITE_PATH}" \
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='${t}' LIMIT 1;")"
if [ -z "${exists}" ]; then
log "WARN: table ${t} not present (migration not applied yet) β skipping"
continue
fi
sqlite3 -readonly "${SQLITE_PATH}" \
".headers on" ".mode csv" "SELECT * FROM ${t};" \
> "${BACKUP_DIR}/${CSV}" || die "CSV export of ${t} failed" 2
# sqlite3 emits nothing for an empty result set β still write the header
# row so empty exports match the Postgres \copy HEADER shape.
if [ ! -s "${BACKUP_DIR}/${CSV}" ]; then
sqlite3 -readonly "${SQLITE_PATH}" \
"SELECT group_concat(name, ',') FROM pragma_table_info('${t}');" \
> "${BACKUP_DIR}/${CSV}" || die "CSV header export of ${t} failed" 2
fi
else
exists="$(psql "${PG_URL}" -qAt -v ON_ERROR_STOP=1 \
-c "SELECT COALESCE(to_regclass('public.${t}')::text, '')" )" \
|| die "table check for ${t} failed" 2
if [ -z "${exists}" ]; then
log "WARN: table ${t} not present (migration not applied yet) β skipping"
continue
fi
# row_security=off: fail loudly if this role cannot bypass FORCE RLS,
# instead of exporting an empty (org-filtered) CSV.
psql "${PG_URL}" -q -v ON_ERROR_STOP=1 \
-c "SET row_security = off" \
-c "\\copy (SELECT * FROM ${t}) TO '${BACKUP_DIR}/${CSV}' WITH (FORMAT csv, HEADER)" \
|| die "CSV export of ${t} failed (BYPASSRLS role required β see header)" 2
fi
rows=$(( $(wc -l < "${BACKUP_DIR}/${CSV}") - 1 ))
[ "${rows}" -lt 0 ] && rows=0
log "exported ${t}: ${rows} row(s) -> ${CSV}"
ARTIFACTS+=("${CSV}")
done
# ββ 3. Optional GPG encryption (leak-surface #7) βββββββββββββββββββββββββββββ
if [ -n "${GPG_RECIPIENT}" ]; then
ENC=()
for f in "${ARTIFACTS[@]}"; do
if ! gpg --batch --yes --trust-model always \
--recipient "${GPG_RECIPIENT}" \
--output "${BACKUP_DIR}/${f}.gpg" --encrypt "${BACKUP_DIR}/${f}"; then
# Never leave plaintext behind when encryption was requested (#7):
# scrap the whole run so cron alerts and nothing sensitive lingers.
rm -f "${BACKUP_DIR}/${STEM}."*
die "gpg encrypt of ${f} failed β run ${STEM} removed" 2
fi
rm -f "${BACKUP_DIR}/${f}"
ENC+=("${f}.gpg")
done
ARTIFACTS=("${ENC[@]}")
log "encrypted ${#ARTIFACTS[@]} artifact(s) to ${GPG_RECIPIENT}"
fi
# ββ 4. sha256 manifest + verification ββββββββββββββββββββββββββββββββββββββββ
MANIFEST="${STEM}.sha256"
( cd "${BACKUP_DIR}" && "${SHA[@]}" "${ARTIFACTS[@]}" > "${MANIFEST}" ) \
|| die "manifest write failed" 2
( cd "${BACKUP_DIR}" && "${SHA[@]}" --check --status "${MANIFEST}" ) \
|| die "manifest verification FAILED for ${STEM} β artifacts are suspect" 3
log "manifest verified: ${MANIFEST}"
# ββ 5. Retention prune: keep the newest BACKUP_KEEP runs βββββββββββββββββββββ
# A "run" is every file sharing an atp-YYYYmmdd-HHMMSS stem; the stamp sorts
# lexicographically = chronologically. Only stem-matching files are touched.
if [ "${BACKUP_KEEP}" -gt 0 ]; then
stems="$(find "${BACKUP_DIR}" -maxdepth 1 -name 'atp-*' \
| grep -oE 'atp-[0-9]{8}-[0-9]{6}' | sort -u || true)"
total="$(printf '%s\n' "${stems}" | grep -c . || true)"
if [ "${total}" -gt "${BACKUP_KEEP}" ]; then
printf '%s\n' "${stems}" | head -n "$(( total - BACKUP_KEEP ))" \
| while IFS= read -r stem; do
[ -n "${stem}" ] || continue
log "prune: removing run ${stem}"
find "${BACKUP_DIR}" -maxdepth 1 -name "${stem}.*" -delete
done
fi
fi
log "OK ${STEM} (${#ARTIFACTS[@]} artifact(s) + manifest) in ${BACKUP_DIR}"
|