@clearance/management 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # Production overlay for Clearance.
2
+ #
3
+ # Usage (after validating secrets):
4
+ # scripts/validate-production-env.sh
5
+ # docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml config
6
+ # docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml up -d
7
+ #
8
+ # Fail-closed rules:
9
+ # - NODE_ENV is forced to production
10
+ # - DATABASE_URL must be a complete, operator-supplied connection string
11
+ # (no shell construction of password-bearing URLs — avoids @:/#? interpolation breakage)
12
+ # - Every secret is required with no defaults
13
+ # - Postgres is NOT published to the host by default (ports reset)
14
+ # - Credential encryption key + key id are mandatory
15
+ # - No VAR-with-default secret fallbacks (only fail-closed ${VAR:?...} forms)
16
+ services:
17
+ postgres:
18
+ environment:
19
+ POSTGRES_USER: ${CLEARANCE_DB_USER:?set CLEARANCE_DB_USER}
20
+ POSTGRES_PASSWORD: ${CLEARANCE_DB_PASSWORD:?set CLEARANCE_DB_PASSWORD}
21
+ POSTGRES_DB: ${CLEARANCE_DB_NAME:?set CLEARANCE_DB_NAME}
22
+ # Production default: internal network only. Host publish requires a local override file.
23
+ ports: !reset []
24
+ volumes:
25
+ - clearance_pg:/var/lib/postgresql/data
26
+ healthcheck:
27
+ # $$ defers expansion to the container shell (avoids host-side compose interpolation)
28
+ test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
29
+ interval: 5s
30
+ timeout: 5s
31
+ retries: 12
32
+ restart: always
33
+ stop_grace_period: 30s
34
+
35
+ api:
36
+ build: !reset null
37
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
38
+ environment:
39
+ NODE_ENV: production
40
+ CLEARANCE_STRICT_SECRETS: "1"
41
+ CLEARANCE_API_PORT: "3200"
42
+ CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
43
+ CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
44
+ CLEARANCE_CREDENTIAL_KEY: ${CLEARANCE_CREDENTIAL_KEY:?set CLEARANCE_CREDENTIAL_KEY}
45
+ CLEARANCE_CREDENTIAL_KEY_ID: ${CLEARANCE_CREDENTIAL_KEY_ID:?set CLEARANCE_CREDENTIAL_KEY_ID}
46
+ CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
47
+ CLEARANCE_CONSOLE_URL: ${CLEARANCE_CONSOLE_URL:?set CLEARANCE_CONSOLE_URL}
48
+ CLEARANCE_CORS_ORIGINS: ${CLEARANCE_CORS_ORIGINS:?set CLEARANCE_CORS_ORIGINS}
49
+ # Full connection string required — do not embed raw passwords via compose interpolation.
50
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
51
+ # Fail-closed override of the dev base file: this profile publishes the
52
+ # API port, so x-forwarded-for from arbitrary clients must NOT be
53
+ # trusted for rate-limit keying. Hardcoded (this overlay forbids
54
+ # ${VAR:-default} interpolation); edit this line to "1" only if your
55
+ # deployment makes the API reachable EXCLUSIVELY via trusted proxies
56
+ # (e.g. the console BFF or a fronting load balancer).
57
+ CLEARANCE_TRUSTED_PROXY: "0"
58
+ # Replace the base loopback mapping instead of merging a second binding.
59
+ ports: !override
60
+ - "127.0.0.1:${CLEARANCE_API_PORT:?set CLEARANCE_API_PORT}:3200"
61
+ restart: always
62
+ stop_grace_period: 30s
63
+
64
+ console:
65
+ build: !reset null
66
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
67
+ environment:
68
+ NODE_ENV: production
69
+ CLEARANCE_STRICT_SECRETS: "1"
70
+ CLEARANCE_CONSOLE_PORT: "3100"
71
+ CLEARANCE_API_URL: http://api:3200
72
+ CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
73
+ CLEARANCE_CONSOLE_ADMIN_USER: ${CLEARANCE_CONSOLE_ADMIN_USER:?set CLEARANCE_CONSOLE_ADMIN_USER}
74
+ CLEARANCE_CONSOLE_ADMIN_PASSWORD: ${CLEARANCE_CONSOLE_ADMIN_PASSWORD:?set CLEARANCE_CONSOLE_ADMIN_PASSWORD}
75
+ CLEARANCE_CONSOLE_SESSION_SECRET: ${CLEARANCE_CONSOLE_SESSION_SECRET:?set CLEARANCE_CONSOLE_SESSION_SECRET}
76
+ # Replace the base loopback mapping instead of merging a second binding.
77
+ ports: !override
78
+ - "127.0.0.1:${CLEARANCE_CONSOLE_PORT:?set CLEARANCE_CONSOLE_PORT}:3100"
79
+ restart: always
80
+ stop_grace_period: 30s
81
+
82
+ sample-b2b:
83
+ build: !reset null
84
+ image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
85
+ environment:
86
+ NODE_ENV: production
87
+ CLEARANCE_STRICT_SECRETS: "1"
88
+ SAMPLE_APP_PORT: "3000"
89
+ CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
90
+ CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
91
+ # Optional social providers. The application fails startup on incomplete pairs.
92
+ CLEARANCE_GITHUB_CLIENT_ID: ${CLEARANCE_GITHUB_CLIENT_ID-}
93
+ CLEARANCE_GITHUB_CLIENT_SECRET: ${CLEARANCE_GITHUB_CLIENT_SECRET-}
94
+ CLEARANCE_GOOGLE_CLIENT_ID: ${CLEARANCE_GOOGLE_CLIENT_ID-}
95
+ CLEARANCE_GOOGLE_CLIENT_SECRET: ${CLEARANCE_GOOGLE_CLIENT_SECRET-}
96
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
97
+ # Replace the base loopback mapping instead of merging a second binding.
98
+ ports: !override
99
+ - "127.0.0.1:${CLEARANCE_SAMPLE_PORT:?set CLEARANCE_SAMPLE_PORT}:3000"
100
+ restart: always
101
+
102
+ # One-shot scheduled job. Invoke from cron/systemd:
103
+ # docker compose ... --profile backup run --rm backup
104
+ # The script fails closed unless CLEARANCE_BACKUP_COPY_COMMAND succeeds.
105
+ backup:
106
+ profiles: ["backup"]
107
+ build: !reset null
108
+ image: "${CLEARANCE_BACKUP_IMAGE_REPOSITORY:?set CLEARANCE_BACKUP_IMAGE_REPOSITORY}@${CLEARANCE_BACKUP_IMAGE_DIGEST:?set signed CLEARANCE_BACKUP_IMAGE_DIGEST}"
109
+ command: ["bash", "scripts/backup-scheduled.sh", "--dir", "/backups"]
110
+ environment:
111
+ DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
112
+ CLEARANCE_BACKUP_COPY_COMMAND: ${CLEARANCE_BACKUP_COPY_COMMAND-}
113
+ CLEARANCE_BACKUP_RETENTION_DAYS: ${CLEARANCE_BACKUP_RETENTION_DAYS-30}
114
+ CLEARANCE_BACKUP_RESTORE_VERIFY: "1"
115
+ volumes:
116
+ - clearance_backups:/backups
117
+ tmpfs:
118
+ - /tmp
119
+ read_only: true
120
+ depends_on:
121
+ postgres:
122
+ condition: service_healthy
123
+ restart: "no"
124
+
125
+ volumes:
126
+ clearance_pg:
127
+ name: ${CLEARANCE_PG_VOLUME:?set CLEARANCE_PG_VOLUME to an explicit volume name}
128
+ clearance_backups:
129
+ name: ${CLEARANCE_BACKUP_VOLUME:?set CLEARANCE_BACKUP_VOLUME to an explicit volume name}
@@ -0,0 +1,24 @@
1
+ # Upgrade step hooks
2
+
3
+ `scripts/upgrade-apply.sh` requires a hook:
4
+
5
+ ```text
6
+ deploy/upgrades/steps/<targetVersion>/apply.sh
7
+ ```
8
+
9
+ The currently supported transition is `0.1.4` to `0.2.1`. Its shipped hook is
10
+ `deploy/upgrades/steps/0.2.1/apply.sh`; it verifies the release marker is at
11
+ `0.1.4`, advances it to `0.2.1`, and verifies the result. The Clearance CLI
12
+ packages this hook under `dist/ops/deploy/upgrades/steps/0.2.1/apply.sh`.
13
+
14
+ ## Contract
15
+
16
+ - Invoked only after preflight and a **verified** Postgres backup (with isolated restore verification).
17
+ - Args: `--plan PATH --from CURRENT --to TARGET`
18
+ - Exit non-zero to fail closed (no silent partial apply).
19
+ - Must not drop or overwrite the active database; destructive changes require explicit operator runbooks.
20
+ - Rollback reference is always the verified backup id recorded in the plan state sidecar.
21
+
22
+ Without a hook, apply fails after creating and verifying its rollback backup. It never records a no-op version transition as applied.
23
+
24
+ `scripts/upgrade-rollback.sh` is a rollback **drill**: it verifies the deterministic backup reference through an isolated restore and leaves the active environment unchanged. An actual rollback remains an operator-runbook action until active restore automation is designed and tested separately.
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Shipped 0.1.4 -> 0.2.0 database transition.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
6
+ # shellcheck source=scripts/lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ PLAN=""
10
+ FROM=""
11
+ TO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --plan) PLAN="$2"; shift 2 ;;
15
+ --from) FROM="$2"; shift 2 ;;
16
+ --to) TO="$2"; shift 2 ;;
17
+ *) die "unknown upgrade hook argument: $1" ;;
18
+ esac
19
+ done
20
+
21
+ [[ -f "$PLAN" ]] || die "upgrade plan is missing"
22
+ [[ "$FROM" == "0.1.4" && "$TO" == "0.2.0" ]] \
23
+ || die "this hook only supports 0.1.4 to 0.2.0"
24
+
25
+ require_pg_client
26
+ require_cmd node
27
+ URL="$(resolve_database_url)"
28
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN")"
29
+ PLAN_SHA="$(sha256_file "$PLAN")"
30
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "unsafe plan id"
31
+ require_application_release "$URL" "$FROM"
32
+
33
+ # This is the application-owned migration contract: the durable management
34
+ # snapshot used by the running API advances atomically with an append-only
35
+ # migration ledger. It cannot pass against the old test-only ops marker.
36
+ psql_q "$URL" \
37
+ -v from_version="$FROM" -v to_version="$TO" \
38
+ -v plan_id="$PLAN_ID" -v plan_sha="$PLAN_SHA" <<'SQL' >/dev/null
39
+ BEGIN;
40
+ CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
41
+ version text PRIMARY KEY,
42
+ applied_at timestamptz NOT NULL DEFAULT now(),
43
+ plan_id text NOT NULL,
44
+ plan_sha256 text NOT NULL,
45
+ from_version text
46
+ );
47
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
48
+ VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
49
+ ON CONFLICT (version) DO NOTHING;
50
+ WITH advanced AS (
51
+ UPDATE clearance_management_snapshot
52
+ SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
53
+ revision = revision + 1,
54
+ updated_at = now()
55
+ WHERE id = 1 AND data->>'releaseVersion' = :'from_version'
56
+ RETURNING 1
57
+ )
58
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
59
+ SELECT :'to_version', :'plan_id', :'plan_sha', :'from_version'
60
+ FROM advanced;
61
+ COMMIT;
62
+ SQL
63
+
64
+ require_application_release "$URL" "$TO"
65
+ ledger_plan="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TO}'")"
66
+ [[ "$ledger_plan" == "$PLAN_ID" ]] || die "migration ledger did not record plan $PLAN_ID"
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Shipped 0.1.4 -> 0.2.1 database transition.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
6
+ # shellcheck source=scripts/lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ PLAN=""
10
+ FROM=""
11
+ TO=""
12
+ while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --plan) PLAN="$2"; shift 2 ;;
15
+ --from) FROM="$2"; shift 2 ;;
16
+ --to) TO="$2"; shift 2 ;;
17
+ *) die "unknown upgrade hook argument: $1" ;;
18
+ esac
19
+ done
20
+
21
+ [[ -f "$PLAN" ]] || die "upgrade plan is missing"
22
+ [[ "$FROM" == "0.1.4" && "$TO" == "0.2.1" ]] \
23
+ || die "this hook only supports 0.1.4 to 0.2.1"
24
+
25
+ require_pg_client
26
+ require_cmd node
27
+ URL="$(resolve_database_url)"
28
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN")"
29
+ PLAN_SHA="$(sha256_file "$PLAN")"
30
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "unsafe plan id"
31
+ require_application_release "$URL" "$FROM"
32
+
33
+ # This is the application-owned migration contract: the durable management
34
+ # snapshot used by the running API advances atomically with an append-only
35
+ # migration ledger. It cannot pass against the old test-only ops marker.
36
+ psql_q "$URL" \
37
+ -v from_version="$FROM" -v to_version="$TO" \
38
+ -v plan_id="$PLAN_ID" -v plan_sha="$PLAN_SHA" <<'SQL' >/dev/null
39
+ BEGIN;
40
+ CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
41
+ version text PRIMARY KEY,
42
+ applied_at timestamptz NOT NULL DEFAULT now(),
43
+ plan_id text NOT NULL,
44
+ plan_sha256 text NOT NULL,
45
+ from_version text
46
+ );
47
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
48
+ VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
49
+ ON CONFLICT (version) DO NOTHING;
50
+ WITH advanced AS (
51
+ UPDATE clearance_management_snapshot
52
+ SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
53
+ revision = revision + 1,
54
+ updated_at = now()
55
+ WHERE id = 1 AND data->>'releaseVersion' = :'from_version'
56
+ RETURNING 1
57
+ )
58
+ INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
59
+ SELECT :'to_version', :'plan_id', :'plan_sha', :'from_version'
60
+ FROM advanced;
61
+ COMMIT;
62
+ SQL
63
+
64
+ require_application_release "$URL" "$TO"
65
+ ledger_plan="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TO}'")"
66
+ [[ "$ledger_plan" == "$PLAN_ID" ]] || die "migration ledger did not record plan $PLAN_ID"
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # Create a real Postgres backup (pg_dump) with checksum + metadata.
3
+ # Never prints credentials. Never overwrites without a new backup id.
4
+ set -Eeuo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ # shellcheck source=lib/ops-common.sh
8
+ source "$ROOT/scripts/lib/ops-common.sh"
9
+
10
+ # Dumps contain production data. Make every created directory/artifact owner-only.
11
+ umask 077
12
+
13
+ usage() {
14
+ cat <<'EOF'
15
+ Usage: backup-create.sh [--dir DIR]
16
+
17
+ Environment:
18
+ DATABASE_URL Required. Postgres connection string for the active database.
19
+
20
+ Writes:
21
+ DIR/<id>.sql plain-format pg_dump
22
+ DIR/<id>.meta.json checksum, counts, timestamps, source db name
23
+ DIR/<id>.verified.json read-only evidence binding the dump and metadata
24
+ Prints backup id on stdout (last line: BACKUP_ID=<id>)
25
+ EOF
26
+ }
27
+
28
+ BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --dir) BACKUP_DIR="$2"; shift 2 ;;
32
+ -h|--help) usage; exit 0 ;;
33
+ *) die "unknown argument: $1" ;;
34
+ esac
35
+ done
36
+
37
+ require_pg_client
38
+ require_cmd node
39
+ require_cmd openssl
40
+ URL="$(resolve_database_url)"
41
+ require_database_url "$URL"
42
+
43
+ ACTIVE_DB="$(db_name_from_url "$URL")"
44
+ assert_safe_ident "$ACTIVE_DB" "active database name"
45
+
46
+ mkdir -p "$BACKUP_DIR"
47
+ chmod 700 "$BACKUP_DIR"
48
+ # Unique id: bak_<utc>_<random>
49
+ ID="bak_$(date -u +%Y%m%dT%H%M%SZ)_$(openssl rand -hex 4)"
50
+ DUMP="$BACKUP_DIR/${ID}.sql"
51
+ META="$BACKUP_DIR/${ID}.meta.json"
52
+ VERIFIED="$BACKUP_DIR/${ID}.verified.json"
53
+ EVIDENCE_TMP="$(mktemp "${TMPDIR:-/tmp}/clearance-bak-evidence.XXXXXX")"
54
+ SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/clearance-bak-snapshot.XXXXXX")"
55
+ SNAPSHOT_IN="$SNAPSHOT_DIR/in"
56
+ SNAPSHOT_OUT="$SNAPSHOT_DIR/out"
57
+ SNAPSHOT_OPEN=0
58
+ SNAPSHOT_PID=""
59
+ cleanup_tmp() {
60
+ local ec=$?
61
+ if [[ "$SNAPSHOT_OPEN" -eq 1 ]]; then
62
+ printf 'ROLLBACK;\n\\q\n' >&8 2>/dev/null || true
63
+ exec 8>&-
64
+ exec 9<&-
65
+ wait "$SNAPSHOT_PID" 2>/dev/null || true
66
+ fi
67
+ find "$SNAPSHOT_DIR" -type p -delete 2>/dev/null || true
68
+ rmdir "$SNAPSHOT_DIR" 2>/dev/null || true
69
+ find "$EVIDENCE_TMP" -type f -delete 2>/dev/null || true
70
+ if [[ "$ec" -ne 0 ]]; then
71
+ find "$DUMP" "$META" "$VERIFIED" -type f -delete 2>/dev/null || true
72
+ fi
73
+ }
74
+ trap cleanup_tmp EXIT
75
+
76
+ printf 'backup-create: dumping database=%s (url redacted=%s)\n' "$ACTIVE_DB" "$(redact_url "$URL")" >&2
77
+
78
+ # Hold one exported repeatable-read snapshot across both pg_dump and the
79
+ # resource/version evidence queries. Without this, ordinary writes between the
80
+ # dump and count collection can make a valid backup permanently unverifiable.
81
+ mkfifo "$SNAPSHOT_IN" "$SNAPSHOT_OUT"
82
+ psql --no-psqlrc -qAt -v ON_ERROR_STOP=1 "$URL" <"$SNAPSHOT_IN" >"$SNAPSHOT_OUT" &
83
+ SNAPSHOT_PID=$!
84
+ exec 8>"$SNAPSHOT_IN"
85
+ exec 9<"$SNAPSHOT_OUT"
86
+ SNAPSHOT_OPEN=1
87
+ printf 'BEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT pg_export_snapshot();\n' >&8
88
+ IFS= read -r SNAPSHOT_ID <&9 || die "could not export backup snapshot"
89
+ [[ "$SNAPSHOT_ID" =~ ^[0-9A-Fa-f-]+$ ]] || die "postgres returned an invalid backup snapshot id"
90
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" ]]; then
91
+ if [[ -n "${CLEARANCE_BACKUP_TEST_SNAPSHOT_READY_FILE:-}" ]]; then
92
+ : >"$CLEARANCE_BACKUP_TEST_SNAPSHOT_READY_FILE"
93
+ fi
94
+ delay="${CLEARANCE_BACKUP_TEST_DELAY_AFTER_SNAPSHOT_SECONDS:-0}"
95
+ [[ "$delay" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "invalid backup snapshot testing delay"
96
+ if [[ "$delay" != "0" ]]; then sleep "$delay"; fi
97
+ fi
98
+
99
+ # Real pg_dump — plain SQL for inspectability. Fail closed on any dump error.
100
+ # Uses matching major client via Docker when local pg_dump is older/newer than server.
101
+ pg_dump_to_file "$URL" "$DUMP" "$SNAPSHOT_ID"
102
+
103
+ printf '%s\n' \
104
+ 'CREATE TEMP TABLE clearance_backup_counts(table_name text PRIMARY KEY, row_count bigint NOT NULL);' \
105
+ "SELECT format('INSERT INTO clearance_backup_counts SELECT %L, count(*) FROM %I.%I;', table_name, table_schema, table_name) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name;" \
106
+ '\gexec' \
107
+ "SELECT json_build_object('capturedAt', to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), 'tables', COALESCE((SELECT json_object_agg(table_name,row_count ORDER BY table_name) FROM clearance_backup_counts),'{}'::json))::text;" \
108
+ "SELECT CASE WHEN to_regclass('public.clearance_management_snapshot') IS NULL THEN 'SELECT '''' || ''__CLEARANCE_APP_VERSION__'';' ELSE 'SELECT COALESCE((SELECT data->>''releaseVersion'' FROM clearance_management_snapshot WHERE id=1),'''') || ''__CLEARANCE_APP_VERSION__'';' END;" \
109
+ '\gexec' \
110
+ "SELECT '__CLEARANCE_SNAPSHOT_END__';" >&8
111
+
112
+ SNAPSHOT_EVIDENCE=""
113
+ APP_VERSION=""
114
+ while IFS= read -r line <&9; do
115
+ if [[ "$line" == "__CLEARANCE_SNAPSHOT_END__" ]]; then
116
+ break
117
+ elif [[ "$line" == *"__CLEARANCE_APP_VERSION__" ]]; then
118
+ APP_VERSION="${line%__CLEARANCE_APP_VERSION__}"
119
+ elif [[ "$line" == \{* ]]; then
120
+ SNAPSHOT_EVIDENCE="$line"
121
+ fi
122
+ done
123
+ [[ -n "$SNAPSHOT_EVIDENCE" ]] || die "could not collect evidence from the backup snapshot"
124
+ printf '%s\n' "$SNAPSHOT_EVIDENCE" >"$EVIDENCE_TMP"
125
+ printf 'COMMIT;\n\\q\n' >&8
126
+ exec 8>&-
127
+ exec 9<&-
128
+ wait "$SNAPSHOT_PID"
129
+ SNAPSHOT_OPEN=0
130
+
131
+ [[ -s "$DUMP" ]] || die "pg_dump produced empty file"
132
+ if ! grep -q 'PostgreSQL database dump' "$DUMP"; then
133
+ die "pg_dump output missing expected PostgreSQL dump header"
134
+ fi
135
+ grep -q 'PostgreSQL database dump complete' "$DUMP" \
136
+ || die "pg_dump output missing completion marker"
137
+
138
+ CHECKSUM="$(sha256_file "$DUMP")"
139
+ BYTES="$(wc -c <"$DUMP" | tr -d ' ')"
140
+ PG_VER="$(grep -m1 'Dumped from database version' "$DUMP" || pg_dump --version | head -n1 || true)"
141
+
142
+ # Resource counts as flat map from evidence tables
143
+ COUNTS_JSON="$(node -e '
144
+ const fs=require("fs");
145
+ const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
146
+ if (!j.tables || typeof j.tables !== "object" || Array.isArray(j.tables)) process.exit(2);
147
+ process.stdout.write(JSON.stringify(j.tables));
148
+ ' "$EVIDENCE_TMP")" || die "failed to serialize required backup evidence"
149
+
150
+ CREATED="$(iso_now)"
151
+ cat >"$META" <<EOF
152
+ {
153
+ "id": $(json_escape "$ID"),
154
+ "format": "pg_dump_plain",
155
+ "createdAt": $(json_escape "$CREATED"),
156
+ "dumpFile": $(json_escape "$(basename "$DUMP")"),
157
+ "metaFile": $(json_escape "$(basename "$META")"),
158
+ "checksumSha256": $(json_escape "$CHECKSUM"),
159
+ "bytes": $BYTES,
160
+ "sourceDatabase": $(json_escape "$ACTIVE_DB"),
161
+ "applicationVersion": $(if [[ -n "$APP_VERSION" ]]; then json_escape "$APP_VERSION"; else printf 'null'; fi),
162
+ "pgDumpVersion": $(json_escape "$PG_VER"),
163
+ "resourceCounts": $COUNTS_JSON,
164
+ "tool": "scripts/backup-create.sh"
165
+ }
166
+ EOF
167
+
168
+ # Refuse writing secrets into meta
169
+ if grep -qiE 'password|postgresql://[^"]+:[^"]+@' "$META"; then
170
+ find "$DUMP" "$META" -type f -delete 2>/dev/null || true
171
+ die "metadata unexpectedly contained credential material; aborted"
172
+ fi
173
+
174
+ # Bind both artifacts into immutable local verification evidence. The evidence
175
+ # is owner-readable and non-writable; backup-verify checks every digest.
176
+ META_CHECKSUM="$(sha256_file "$META")"
177
+ RESOURCE_FINGERPRINT="$(schema_fingerprint_from_evidence "$EVIDENCE_TMP")"
178
+ cat >"$VERIFIED" <<EOF
179
+ {
180
+ "id": $(json_escape "$ID"),
181
+ "verifiedAt": $(json_escape "$(iso_now)"),
182
+ "dumpFile": $(json_escape "$(basename "$DUMP")"),
183
+ "metaFile": $(json_escape "$(basename "$META")"),
184
+ "dumpSha256": $(json_escape "$CHECKSUM"),
185
+ "metaSha256": $(json_escape "$META_CHECKSUM"),
186
+ "resourceEvidenceSha256": $(json_escape "$RESOURCE_FINGERPRINT"),
187
+ "checks": ["nonempty", "pg_dump_complete", "metadata_bound", "resource_counts_captured"]
188
+ }
189
+ EOF
190
+ chmod 600 "$DUMP" "$META"
191
+ chmod 400 "$VERIFIED"
192
+
193
+ printf 'backup-create: wrote %s (%s bytes, sha256=%s)\n' "$DUMP" "$BYTES" "$CHECKSUM" >&2
194
+ printf 'BACKUP_ID=%s\n' "$ID"
195
+ printf 'BACKUP_DUMP=%s\n' "$DUMP"
196
+ printf 'BACKUP_META=%s\n' "$META"
197
+ printf 'BACKUP_EVIDENCE=%s\n' "$VERIFIED"
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
2
+ # Restore a verified backup into an ISOLATED temporary database, compare evidence,
3
+ # and clean up. Never drops or overwrites the active database.
4
+ set -Eeuo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ # shellcheck source=lib/ops-common.sh
8
+ source "$ROOT/scripts/lib/ops-common.sh"
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ Usage: backup-restore-verify.sh --id ID [--dir DIR]
13
+ backup-restore-verify.sh --dump PATH --meta PATH
14
+
15
+ Restores into a temporary database name (clr_rv_*), queries management/runtime
16
+ tables when present, compares row counts to backup metadata evidence, drops the
17
+ temp database, and exits non-zero on mismatch.
18
+
19
+ Refuses to target the active database name under all circumstances.
20
+ EOF
21
+ }
22
+
23
+ BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
24
+ ID=""
25
+ DUMP=""
26
+ META=""
27
+ KEEP_TEMP=0
28
+
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --id) ID="$2"; shift 2 ;;
32
+ --dir) BACKUP_DIR="$2"; shift 2 ;;
33
+ --dump) DUMP="$2"; shift 2 ;;
34
+ --meta) META="$2"; shift 2 ;;
35
+ --keep-temp) KEEP_TEMP=1; shift ;;
36
+ -h|--help) usage; exit 0 ;;
37
+ *) die "unknown argument: $1" ;;
38
+ esac
39
+ done
40
+
41
+ if [[ -n "$ID" ]]; then
42
+ DUMP="${DUMP:-$BACKUP_DIR/${ID}.sql}"
43
+ META="${META:-$BACKUP_DIR/${ID}.meta.json}"
44
+ fi
45
+ [[ -n "$DUMP" && -f "$DUMP" ]] || die "dump required"
46
+ [[ -n "$META" && -f "$META" ]] || die "meta required"
47
+
48
+ require_pg_client
49
+ require_cmd node
50
+ require_cmd openssl
51
+ URL="$(resolve_database_url)"
52
+ require_database_url "$URL"
53
+ ACTIVE_DB="$(db_name_from_url "$URL")"
54
+ ADMIN_URL="$(admin_url_from_url "$URL")"
55
+
56
+ # Integrity first
57
+ bash "$ROOT/scripts/backup-verify.sh" --dump "$DUMP" --meta "$META" >/dev/null
58
+
59
+ # Isolated temp DB name
60
+ SUFFIX="$(openssl rand -hex 4)"
61
+ TEMP_DB="clr_rv_${SUFFIX}"
62
+ assert_safe_ident "$TEMP_DB" "temp database"
63
+ assert_not_active_db "$TEMP_DB"
64
+ [[ "$TEMP_DB" != "$ACTIVE_DB" ]] || die "temp db collided with active db"
65
+
66
+ TEMP_URL="$(url_with_db "$URL" "$TEMP_DB")"
67
+ CREATED=0
68
+ RESTORED_EV=""
69
+
70
+ drop_temp_database() {
71
+ assert_not_active_db "$TEMP_DB"
72
+ psql_q "$ADMIN_URL" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${TEMP_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
73
+ psql_q "$ADMIN_URL" -c "DROP DATABASE IF EXISTS \"${TEMP_DB}\";" >/dev/null 2>&1 || return 1
74
+ local remains
75
+ remains="$(psql_q "$ADMIN_URL" -At -c "SELECT count(*) FROM pg_database WHERE datname = '${TEMP_DB}'")" || return 1
76
+ [[ "$remains" == "0" ]]
77
+ }
78
+
79
+ cleanup() {
80
+ local ec=$?
81
+ [[ -z "$RESTORED_EV" ]] || rm -f "$RESTORED_EV"
82
+ if [[ "$KEEP_TEMP" -eq 1 ]]; then
83
+ printf 'backup-restore-verify: keeping temp database %s (active remains %s)\n' "$TEMP_DB" "$ACTIVE_DB" >&2
84
+ exit "$ec"
85
+ fi
86
+ if [[ "$CREATED" -eq 1 ]]; then
87
+ if ! drop_temp_database; then
88
+ printf 'backup-restore-verify: failed to clean up isolated database %s\n' "$TEMP_DB" >&2
89
+ ec=1
90
+ fi
91
+ fi
92
+ exit "$ec"
93
+ }
94
+ trap cleanup EXIT INT TERM
95
+
96
+ printf 'backup-restore-verify: creating isolated database %s (active=%s remains untouched)\n' \
97
+ "$TEMP_DB" "$ACTIVE_DB" >&2
98
+
99
+ psql_q "$ADMIN_URL" -c "CREATE DATABASE \"${TEMP_DB}\";"
100
+ CREATED=1
101
+
102
+ printf 'backup-restore-verify: restoring dump into isolated database\n' >&2
103
+ psql_restore_file "$TEMP_URL" "$DUMP"
104
+
105
+ # Evidence from restored DB
106
+ RESTORED_EV="$(mktemp "${TMPDIR:-/tmp}/clearance-restore-ev.XXXXXX")"
107
+ collect_db_evidence "$TEMP_URL" "$RESTORED_EV"
108
+
109
+ # Compare table evidence and the application release captured with the backup.
110
+ compare_backup_evidence "$META" "$RESTORED_EV"
111
+ EXPECTED_APP_VERSION="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.applicationVersion||"")' "$META")"
112
+ if [[ -n "$EXPECTED_APP_VERSION" ]]; then
113
+ require_application_release "$TEMP_URL" "$EXPECTED_APP_VERSION"
114
+ fi
115
+ rm -f "$RESTORED_EV"
116
+ RESTORED_EV=""
117
+
118
+ # Confirm active DB still exists and was not the restore target
119
+ STILL="$(psql_q "$ADMIN_URL" -At -c "SELECT 1 FROM pg_database WHERE datname = '${ACTIVE_DB}'")"
120
+ [[ "$STILL" == "1" ]] || die "active database disappeared — aborting (investigation required)"
121
+ assert_not_active_db "$TEMP_DB"
122
+
123
+ if [[ "$KEEP_TEMP" -eq 0 ]]; then
124
+ drop_temp_database || die "isolated restore succeeded but temporary database cleanup failed"
125
+ CREATED=0
126
+ fi
127
+
128
+ printf 'backup-restore-verify: OK (isolated restore compared; active db %s untouched; cleanup verified)\n' "$ACTIVE_DB"
129
+ printf 'RESTORE_VERIFIED=1\n'