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}"