@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.
- package/dist/index.d.mts +241 -52
- package/dist/index.mjs +1169 -10462
- package/dist/ops/deploy/compose/docker-compose.production.yml +129 -0
- package/dist/ops/deploy/upgrades/README.md +24 -0
- package/dist/ops/deploy/upgrades/steps/0.2.0/apply.sh +66 -0
- package/dist/ops/deploy/upgrades/steps/0.2.1/apply.sh +66 -0
- package/dist/ops/scripts/backup-create.sh +197 -0
- package/dist/ops/scripts/backup-restore-verify.sh +129 -0
- package/dist/ops/scripts/backup-verify.sh +121 -0
- package/dist/ops/scripts/lib/ops-common.sh +375 -0
- package/dist/ops/scripts/scim-legacy-preflight.sh +103 -0
- package/dist/ops/scripts/upgrade-apply.sh +168 -0
- package/dist/ops/scripts/upgrade-plan.sh +129 -0
- package/dist/ops/scripts/upgrade-preflight.sh +136 -0
- package/dist/ops/scripts/upgrade-rollback.sh +394 -0
- package/dist/ops/scripts/upgrade-verify.sh +98 -0
- package/dist/ops/scripts/validate-production-env.sh +232 -0
- package/package.json +7 -7
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Verify a Clearance Postgres backup archive: checksum + dump structure + metadata.
|
|
3
|
+
# Does not connect to the live database and never restores.
|
|
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-verify.sh --id ID [--dir DIR]
|
|
13
|
+
backup-verify.sh --dump PATH [--meta PATH] [--evidence PATH]
|
|
14
|
+
|
|
15
|
+
Inspects archive integrity only (checksum, pg_dump markers, metadata consistency).
|
|
16
|
+
Does not restore and does not touch the active database.
|
|
17
|
+
EOF
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
|
|
21
|
+
ID=""
|
|
22
|
+
DUMP=""
|
|
23
|
+
META=""
|
|
24
|
+
EVIDENCE=""
|
|
25
|
+
|
|
26
|
+
while [[ $# -gt 0 ]]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--id) ID="$2"; shift 2 ;;
|
|
29
|
+
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
|
30
|
+
--dump) DUMP="$2"; shift 2 ;;
|
|
31
|
+
--meta) META="$2"; shift 2 ;;
|
|
32
|
+
--evidence) EVIDENCE="$2"; shift 2 ;;
|
|
33
|
+
-h|--help) usage; exit 0 ;;
|
|
34
|
+
*) die "unknown argument: $1" ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
if [[ -n "$ID" ]]; then
|
|
39
|
+
DUMP="${DUMP:-$BACKUP_DIR/${ID}.sql}"
|
|
40
|
+
META="${META:-$BACKUP_DIR/${ID}.meta.json}"
|
|
41
|
+
EVIDENCE="${EVIDENCE:-$BACKUP_DIR/${ID}.verified.json}"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
[[ -n "$DUMP" ]] || die "provide --id or --dump"
|
|
45
|
+
require_cmd node
|
|
46
|
+
[[ -f "$DUMP" ]] || die "dump file missing: $DUMP"
|
|
47
|
+
[[ -s "$DUMP" ]] || die "dump file empty: $DUMP"
|
|
48
|
+
|
|
49
|
+
if [[ -z "$META" && -n "$ID" ]]; then
|
|
50
|
+
META="$BACKUP_DIR/${ID}.meta.json"
|
|
51
|
+
fi
|
|
52
|
+
[[ -n "$META" && -f "$META" ]] || die "metadata file missing (expected alongside dump)"
|
|
53
|
+
if [[ -z "$EVIDENCE" ]]; then
|
|
54
|
+
EVIDENCE="${DUMP%.sql}.verified.json"
|
|
55
|
+
fi
|
|
56
|
+
[[ -f "$EVIDENCE" ]] || die "immutable verification evidence missing: $EVIDENCE"
|
|
57
|
+
|
|
58
|
+
# Checksum
|
|
59
|
+
ACTUAL="$(sha256_file "$DUMP")"
|
|
60
|
+
EXPECTED="$(node -e '
|
|
61
|
+
const fs=require("fs");
|
|
62
|
+
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
63
|
+
if(!j.checksumSha256) process.exit(2);
|
|
64
|
+
process.stdout.write(j.checksumSha256);
|
|
65
|
+
' "$META")" || die "metadata missing checksumSha256"
|
|
66
|
+
|
|
67
|
+
[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch (archive may be corrupted)"
|
|
68
|
+
|
|
69
|
+
# Archive structure inspection (plain pg_dump)
|
|
70
|
+
grep -q 'PostgreSQL database dump' "$DUMP" || die "archive missing PostgreSQL dump header"
|
|
71
|
+
grep -q 'PostgreSQL database dump complete' "$DUMP" || die "archive missing PostgreSQL dump completion marker"
|
|
72
|
+
# At least one CREATE or COPY / table structure signal
|
|
73
|
+
if ! grep -qE '^(CREATE TABLE|COPY |CREATE SCHEMA)' "$DUMP"; then
|
|
74
|
+
printf 'backup-verify: archive contains no table definitions (valid empty database)\n' >&2
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Metadata consistency
|
|
78
|
+
node -e '
|
|
79
|
+
const fs=require("fs");
|
|
80
|
+
const meta=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
81
|
+
const dumpBase=require("path").basename(process.argv[2]);
|
|
82
|
+
if(!meta.id) { console.error("meta missing id"); process.exit(1); }
|
|
83
|
+
if(meta.format !== "pg_dump_plain") { console.error("unsupported format"); process.exit(1); }
|
|
84
|
+
if(meta.dumpFile && meta.dumpFile !== dumpBase) {
|
|
85
|
+
console.error("meta.dumpFile does not match dump basename");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
if(typeof meta.bytes === "number") {
|
|
89
|
+
const st=fs.statSync(process.argv[2]);
|
|
90
|
+
if(st.size !== meta.bytes) {
|
|
91
|
+
console.error("meta.bytes does not match dump size");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Fail closed: meta must not embed credentials
|
|
96
|
+
const raw=fs.readFileSync(process.argv[1],"utf8");
|
|
97
|
+
if(/password\s*[:=]/i.test(raw) || /postgres(ql)?:\/\/[^/\s"]+:[^@\s"]+@/i.test(raw)) {
|
|
98
|
+
console.error("metadata appears to contain credentials");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
' "$META" "$DUMP"
|
|
102
|
+
|
|
103
|
+
# Verification evidence is content-addressed. This catches metadata rewriting
|
|
104
|
+
# as well as dump corruption, even when only one checksum was updated.
|
|
105
|
+
node -e '
|
|
106
|
+
const fs=require("fs");
|
|
107
|
+
const path=require("path");
|
|
108
|
+
const crypto=require("crypto");
|
|
109
|
+
const ev=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
110
|
+
const dump=process.argv[2];
|
|
111
|
+
const meta=process.argv[3];
|
|
112
|
+
const sha=(p)=>crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
|
|
113
|
+
if(!ev.id || !ev.verifiedAt || !Array.isArray(ev.checks)) process.exit(2);
|
|
114
|
+
if(ev.dumpFile!==path.basename(dump) || ev.metaFile!==path.basename(meta)) process.exit(3);
|
|
115
|
+
if(ev.dumpSha256!==sha(dump) || ev.metaSha256!==sha(meta)) process.exit(4);
|
|
116
|
+
if((fs.statSync(process.argv[1]).mode & 0o222)!==0) process.exit(5);
|
|
117
|
+
' "$EVIDENCE" "$DUMP" "$META" || die "immutable backup verification evidence mismatch"
|
|
118
|
+
|
|
119
|
+
printf 'backup-verify: OK id=%s sha256=%s\n' "$(basename "$DUMP" .sql)" "$ACTUAL"
|
|
120
|
+
printf 'BACKUP_VERIFIED=1\n'
|
|
121
|
+
printf 'BACKUP_EVIDENCE=%s\n' "$EVIDENCE"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# Shared helpers for Clearance operational scripts.
|
|
2
|
+
# shellcheck shell=bash
|
|
3
|
+
# Sourced by backup/upgrade/validate scripts — not executed directly.
|
|
4
|
+
#
|
|
5
|
+
# Callers must run under: set -Eeuo pipefail
|
|
6
|
+
|
|
7
|
+
if [[ -n "${OPS_COMMON_LOADED:-}" ]]; then
|
|
8
|
+
return 0 2>/dev/null || exit 0
|
|
9
|
+
fi
|
|
10
|
+
OPS_COMMON_LOADED=1
|
|
11
|
+
|
|
12
|
+
die() {
|
|
13
|
+
printf 'error: %s\n' "$*" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
require_cmd() {
|
|
18
|
+
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Never print secret values. Label-only diagnostics.
|
|
22
|
+
redact_url() {
|
|
23
|
+
local url="${1:-}"
|
|
24
|
+
if [[ -z "$url" ]]; then
|
|
25
|
+
printf '%s' "(empty)"
|
|
26
|
+
return
|
|
27
|
+
fi
|
|
28
|
+
# Strip userinfo credentials if present.
|
|
29
|
+
printf '%s' "$url" | sed -E \
|
|
30
|
+
-e 's#(postgres(ql)?://)[^/@]+@#\1***@#' \
|
|
31
|
+
-e 's#([?&](password|sslpassword|token|secret)=)[^&]*#\1***#g'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Known-insecure defaults (mirrors packages/management secret policy).
|
|
35
|
+
is_forbidden_secret() {
|
|
36
|
+
local secret="${1:-}"
|
|
37
|
+
local lower
|
|
38
|
+
if [[ -z "$secret" ]]; then
|
|
39
|
+
return 0
|
|
40
|
+
fi
|
|
41
|
+
if (( ${#secret} < 16 )); then
|
|
42
|
+
return 0
|
|
43
|
+
fi
|
|
44
|
+
lower="$(printf '%s' "$secret" | tr '[:upper:]' '[:lower:]')"
|
|
45
|
+
case "$lower" in
|
|
46
|
+
"dev-secret-change-me"|"dev-secret-change-me-please-32chars!!"|"secret"|"local-compose-secret-change-me-32"|"test-secret-value-32-characters"|"test-secret-value-that-is-long-enough"|"test-secret-value-that-is-long-enough-32"|"change-me"|"password"|"clearance"|"clearance-secret"|"postgres"|"admin"|"changeme")
|
|
47
|
+
return 0
|
|
48
|
+
;;
|
|
49
|
+
esac
|
|
50
|
+
if [[ "$lower" == *"change-me"* || "$lower" == *"dev-secret"* || "$lower" == *"replace-me"* ]]; then
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
return 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
require_strong_secret() {
|
|
57
|
+
local label="$1"
|
|
58
|
+
local value="${2:-}"
|
|
59
|
+
if is_forbidden_secret "$value"; then
|
|
60
|
+
die "$label is missing, empty, shorter than 16 chars, or a known weak default"
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Reject obvious non-production placeholders used as DATABASE_URL.
|
|
65
|
+
is_weak_database_url() {
|
|
66
|
+
local url="${1:-}"
|
|
67
|
+
local lower
|
|
68
|
+
if [[ -z "$url" ]]; then
|
|
69
|
+
return 0
|
|
70
|
+
fi
|
|
71
|
+
lower="$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]')"
|
|
72
|
+
case "$lower" in
|
|
73
|
+
*"://clearance:clearance@"*|*"://postgres:postgres@"*|*"://user:pass@"*|*"://user:password@"*|*changeme*|*change-me*|*example.com*)
|
|
74
|
+
return 0
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
# Missing scheme or host
|
|
78
|
+
if [[ ! "$url" =~ ^postgres(ql)?://[^[:space:]]+$ ]]; then
|
|
79
|
+
return 0
|
|
80
|
+
fi
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
require_database_url() {
|
|
85
|
+
local url="${1:-}"
|
|
86
|
+
if is_weak_database_url "$url"; then
|
|
87
|
+
die "DATABASE_URL is missing, not a postgres URL, or uses a weak/default credential (redacted=$(redact_url "$url"))"
|
|
88
|
+
fi
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
sha256_file() {
|
|
92
|
+
local path="$1"
|
|
93
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
94
|
+
sha256sum "$path" | awk '{print $1}'
|
|
95
|
+
else
|
|
96
|
+
shasum -a 256 "$path" | awk '{print $1}'
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
iso_now() {
|
|
101
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Resolve DATABASE_URL for host-side pg tools. Prefer explicit DATABASE_URL.
|
|
105
|
+
resolve_database_url() {
|
|
106
|
+
if [[ -n "${DATABASE_URL:-}" ]]; then
|
|
107
|
+
printf '%s' "$DATABASE_URL"
|
|
108
|
+
return
|
|
109
|
+
fi
|
|
110
|
+
die "DATABASE_URL is required (postgres connection string for the active environment)"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Parse active database name from DATABASE_URL without printing secrets.
|
|
114
|
+
db_name_from_url() {
|
|
115
|
+
local url="$1"
|
|
116
|
+
local path
|
|
117
|
+
path="$(printf '%s' "$url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^/]+/##' | sed 's#[?#].*##')"
|
|
118
|
+
# URL-decode minimal %xx if present
|
|
119
|
+
if [[ -z "$path" || "$path" == *"/"* ]]; then
|
|
120
|
+
die "could not parse database name from DATABASE_URL"
|
|
121
|
+
fi
|
|
122
|
+
printf '%s' "$path"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Admin URL that connects to maintenance db (postgres) for CREATE/DROP DATABASE.
|
|
126
|
+
admin_url_from_url() {
|
|
127
|
+
local url="$1"
|
|
128
|
+
# Replace path with /postgres while preserving query string.
|
|
129
|
+
if [[ "$url" == *"?"* ]]; then
|
|
130
|
+
printf '%s' "$url" | sed -E 's#(postgres(ql)?://[^/]+)/[^?]+#\1/postgres#'
|
|
131
|
+
else
|
|
132
|
+
printf '%s' "$url" | sed -E 's#(postgres(ql)?://[^/]+)/.*#\1/postgres#'
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Build a URL pointing at a different database name (same host/user).
|
|
137
|
+
url_with_db() {
|
|
138
|
+
local url="$1"
|
|
139
|
+
local db="$2"
|
|
140
|
+
if [[ "$url" == *"?"* ]]; then
|
|
141
|
+
printf '%s' "$url" | sed -E "s#(postgres(ql)?://[^/]+)/[^?]+#\1/${db}#"
|
|
142
|
+
else
|
|
143
|
+
printf '%s' "$url" | sed -E "s#(postgres(ql)?://[^/]+)/.*#\1/${db}#"
|
|
144
|
+
fi
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
json_escape() {
|
|
148
|
+
# Minimal JSON string escape for plan/meta files.
|
|
149
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
150
|
+
printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()), end="")'
|
|
151
|
+
else
|
|
152
|
+
JSON_ESCAPE_VALUE="$1" node -e 'process.stdout.write(JSON.stringify(process.env.JSON_ESCAPE_VALUE))'
|
|
153
|
+
fi
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Active DB protection: refuse operations that target the live database name for drop/overwrite.
|
|
157
|
+
assert_not_active_db() {
|
|
158
|
+
local candidate="$1"
|
|
159
|
+
local active
|
|
160
|
+
active="$(db_name_from_url "$(resolve_database_url)")"
|
|
161
|
+
if [[ "$candidate" == "$active" ]]; then
|
|
162
|
+
die "refusing operation on active database '$candidate' (isolated names only)"
|
|
163
|
+
fi
|
|
164
|
+
case "$candidate" in
|
|
165
|
+
postgres|template0|template1)
|
|
166
|
+
die "refusing operation on system database '$candidate'"
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Safe identifier: lowercase alphanumeric + underscore only.
|
|
172
|
+
assert_safe_ident() {
|
|
173
|
+
local name="$1"
|
|
174
|
+
local label="${2:-identifier}"
|
|
175
|
+
if [[ ! "$name" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
|
176
|
+
die "$label must be a simple SQL identifier (got unsafe value)"
|
|
177
|
+
fi
|
|
178
|
+
if (( ${#name} > 63 )); then
|
|
179
|
+
die "$label exceeds 63 characters"
|
|
180
|
+
fi
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Release values are passed to SQL hooks and filesystem paths. Keep the
|
|
184
|
+
# accepted grammar deliberately narrower than arbitrary operator input.
|
|
185
|
+
assert_safe_version() {
|
|
186
|
+
local version="$1"
|
|
187
|
+
local label="${2:-version}"
|
|
188
|
+
if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$ ]]; then
|
|
189
|
+
die "$label is not a safe release version"
|
|
190
|
+
fi
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
psql_q() {
|
|
194
|
+
# Quiet psql: never echo connection string beyond env; use argv.
|
|
195
|
+
local url="$1"
|
|
196
|
+
shift
|
|
197
|
+
PGPASSWORD="${PGPASSWORD:-}" psql --no-psqlrc -v ON_ERROR_STOP=1 "$url" "$@"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Collect lightweight resource evidence from a database (counts, table list).
|
|
201
|
+
# Schema fingerprint consumers should hash only the stable "tables" object
|
|
202
|
+
# (see schema_fingerprint_from_evidence); capturedAt is observational only.
|
|
203
|
+
collect_db_evidence() {
|
|
204
|
+
local url="$1"
|
|
205
|
+
local out_file="$2"
|
|
206
|
+
|
|
207
|
+
{
|
|
208
|
+
printf '{\n'
|
|
209
|
+
printf ' "capturedAt": %s,\n' "$(json_escape "$(iso_now)")"
|
|
210
|
+
printf ' "tables": {'
|
|
211
|
+
local first=1
|
|
212
|
+
# Prefer known management/runtime tables when present; otherwise all public base tables.
|
|
213
|
+
local tables
|
|
214
|
+
tables="$(psql_q "$url" -At -c "
|
|
215
|
+
SELECT table_name FROM information_schema.tables
|
|
216
|
+
WHERE table_schema='public' AND table_type='BASE TABLE'
|
|
217
|
+
ORDER BY table_name;
|
|
218
|
+
" 2>/dev/null || true)"
|
|
219
|
+
if [[ -n "$tables" ]]; then
|
|
220
|
+
while IFS= read -r t; do
|
|
221
|
+
[[ -z "$t" ]] && continue
|
|
222
|
+
assert_safe_ident "$t" "table"
|
|
223
|
+
local c
|
|
224
|
+
c="$(psql_q "$url" -At -c "SELECT count(*)::text FROM \"${t}\";" 2>/dev/null || echo "null")"
|
|
225
|
+
if [[ $first -eq 1 ]]; then
|
|
226
|
+
first=0
|
|
227
|
+
else
|
|
228
|
+
printf ','
|
|
229
|
+
fi
|
|
230
|
+
printf '\n %s: %s' "$(json_escape "$t")" "$c"
|
|
231
|
+
done <<<"$tables"
|
|
232
|
+
fi
|
|
233
|
+
printf '\n }\n'
|
|
234
|
+
printf '}\n'
|
|
235
|
+
} >"$out_file"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Stable sha256 over table->count map only (ignores capturedAt).
|
|
239
|
+
schema_fingerprint_from_evidence() {
|
|
240
|
+
local evidence_file="$1"
|
|
241
|
+
node -e '
|
|
242
|
+
const fs=require("fs");
|
|
243
|
+
const crypto=require("crypto");
|
|
244
|
+
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
245
|
+
const tables=j.tables||{};
|
|
246
|
+
const keys=Object.keys(tables).sort();
|
|
247
|
+
const stable={};
|
|
248
|
+
for (const k of keys) stable[k]=tables[k];
|
|
249
|
+
process.stdout.write(crypto.createHash("sha256").update(JSON.stringify(stable)).digest("hex"));
|
|
250
|
+
' "$evidence_file"
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# The application release contract lives in the durable management snapshot,
|
|
254
|
+
# which is created and consumed by the running Clearance API.
|
|
255
|
+
application_release_version() {
|
|
256
|
+
local url="$1"
|
|
257
|
+
{ psql_q "$url" -At -c "
|
|
258
|
+
SELECT data->>'releaseVersion'
|
|
259
|
+
FROM clearance_management_snapshot
|
|
260
|
+
WHERE id = 1;
|
|
261
|
+
" 2>/dev/null || true; } | head -n1
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
require_application_release() {
|
|
265
|
+
local url="$1"
|
|
266
|
+
local expected="$2"
|
|
267
|
+
assert_safe_version "$expected" "expected application release"
|
|
268
|
+
local actual
|
|
269
|
+
actual="$(application_release_version "$url")"
|
|
270
|
+
[[ -n "$actual" ]] || die "active database has no Clearance application release contract"
|
|
271
|
+
[[ "$actual" == "$expected" ]] \
|
|
272
|
+
|| die "application release mismatch: expected $expected, found $actual"
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Compare the complete public-table count map captured in backup metadata with
|
|
276
|
+
# evidence collected after a restore. New, missing, or changed tables all fail.
|
|
277
|
+
compare_backup_evidence() {
|
|
278
|
+
local meta_file="$1"
|
|
279
|
+
local restored_evidence_file="$2"
|
|
280
|
+
node -e '
|
|
281
|
+
const fs=require("fs");
|
|
282
|
+
const meta=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
283
|
+
const restored=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));
|
|
284
|
+
const expected=meta.resourceCounts || {};
|
|
285
|
+
const actual=restored.tables || {};
|
|
286
|
+
const keys=Object.keys(expected).sort();
|
|
287
|
+
const actualKeys=Object.keys(actual).sort();
|
|
288
|
+
let mismatches=0;
|
|
289
|
+
for (const k of keys) {
|
|
290
|
+
const e=expected[k];
|
|
291
|
+
const a=actual[k];
|
|
292
|
+
if (typeof e !== "number" || a === undefined || a === null || Number(a) !== Number(e)) {
|
|
293
|
+
console.error(`count mismatch for ${k}: expected ${e}, restored ${a}`);
|
|
294
|
+
mismatches++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (JSON.stringify(keys) !== JSON.stringify(actualKeys)) {
|
|
298
|
+
console.error(`table set mismatch: expected ${keys.join(",")}; restored ${actualKeys.join(",")}`);
|
|
299
|
+
mismatches++;
|
|
300
|
+
}
|
|
301
|
+
if (mismatches > 0) process.exit(1);
|
|
302
|
+
process.stdout.write(JSON.stringify({ok:true, comparedTables:keys.length, restoredTables:actualKeys.length, sourceDatabase:meta.sourceDatabase}));
|
|
303
|
+
' "$meta_file" "$restored_evidence_file"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
require_pg_client() {
|
|
307
|
+
require_cmd psql
|
|
308
|
+
require_cmd pg_dump
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# Map DATABASE_URL host for tools running inside Docker (Desktop/Mac/Windows).
|
|
312
|
+
dockerized_database_url() {
|
|
313
|
+
local url="$1"
|
|
314
|
+
printf '%s' "$url" \
|
|
315
|
+
| sed -E 's#(@|://)127\.0\.0\.1#\1host.docker.internal#g' \
|
|
316
|
+
| sed -E 's#(@|://)localhost#\1host.docker.internal#g'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# Detect server major version (e.g. 16) without printing secrets.
|
|
320
|
+
pg_server_major() {
|
|
321
|
+
local url="$1"
|
|
322
|
+
local full
|
|
323
|
+
full="$(psql_q "$url" -At -c 'SHOW server_version;' 2>/dev/null | head -n1 | awk '{print $1}')"
|
|
324
|
+
if [[ -z "$full" ]]; then
|
|
325
|
+
die "could not determine postgres server version"
|
|
326
|
+
fi
|
|
327
|
+
printf '%s' "${full%%.*}"
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Real pg_dump to file. Prefer local client; on version mismatch use matching postgres image.
|
|
331
|
+
# Never prints the connection string.
|
|
332
|
+
pg_dump_to_file() {
|
|
333
|
+
local url="$1"
|
|
334
|
+
local out="$2"
|
|
335
|
+
local snapshot="${3:-}"
|
|
336
|
+
local -a snapshot_arg=()
|
|
337
|
+
if [[ -n "$snapshot" ]]; then
|
|
338
|
+
snapshot_arg=("--snapshot=$snapshot")
|
|
339
|
+
fi
|
|
340
|
+
local err
|
|
341
|
+
err="$(mktemp "${TMPDIR:-/tmp}/clearance-pgdump-err.XXXXXX")"
|
|
342
|
+
if pg_dump --no-owner --no-acl --format=plain "${snapshot_arg[@]}" --dbname="$url" --file="$out" 2>"$err"; then
|
|
343
|
+
find "$err" -type f -delete 2>/dev/null || true
|
|
344
|
+
return 0
|
|
345
|
+
fi
|
|
346
|
+
if ! grep -qi 'version mismatch' "$err"; then
|
|
347
|
+
# surface non-secret error text only
|
|
348
|
+
printf 'pg_dump failed: %s\n' "$(redact_url "$(tr '\n' ' ' <"$err")")" >&2
|
|
349
|
+
find "$err" -type f -delete 2>/dev/null || true
|
|
350
|
+
die "pg_dump failed"
|
|
351
|
+
fi
|
|
352
|
+
find "$err" -type f -delete 2>/dev/null || true
|
|
353
|
+
require_cmd docker
|
|
354
|
+
local major docker_url img
|
|
355
|
+
major="$(pg_server_major "$url")"
|
|
356
|
+
docker_url="$(dockerized_database_url "$url")"
|
|
357
|
+
img="postgres:${major}-alpine"
|
|
358
|
+
printf 'backup: local pg_dump version mismatch; using %s\n' "$img" >&2
|
|
359
|
+
# host.docker.internal works on Docker Desktop; host-gateway helps Linux.
|
|
360
|
+
if ! docker run --rm \
|
|
361
|
+
--add-host=host.docker.internal:host-gateway \
|
|
362
|
+
"$img" \
|
|
363
|
+
pg_dump --no-owner --no-acl --format=plain "${snapshot_arg[@]}" --dbname="$docker_url" >"$out"
|
|
364
|
+
then
|
|
365
|
+
die "dockerized pg_dump failed (image=$img)"
|
|
366
|
+
fi
|
|
367
|
+
[[ -s "$out" ]] || die "dockerized pg_dump produced empty file"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Restore plain SQL dump into target URL (local psql is generally forward-compatible).
|
|
371
|
+
psql_restore_file() {
|
|
372
|
+
local url="$1"
|
|
373
|
+
local dump="$2"
|
|
374
|
+
psql_q "$url" -f "$dump" >/dev/null
|
|
375
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Inventory and optionally revoke legacy personal/global SCIM credentials.
|
|
3
|
+
set -Eeuo pipefail
|
|
4
|
+
|
|
5
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
6
|
+
# shellcheck source=lib/ops-common.sh
|
|
7
|
+
source "$ROOT/scripts/lib/ops-common.sh"
|
|
8
|
+
umask 077
|
|
9
|
+
|
|
10
|
+
usage() {
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
Usage: scim-legacy-preflight.sh [--revoke --confirm TOKEN] [--receipt-dir DIR]
|
|
13
|
+
|
|
14
|
+
Rows in the runtime scimProvider table with no organizationId were minted by
|
|
15
|
+
the legacy personal/global path. Inventory is the default and fails closed when
|
|
16
|
+
any remain. To revoke them, rerun with the exact token printed by inventory:
|
|
17
|
+
REVOKE_LEGACY_SCIM:<database>:<count>
|
|
18
|
+
|
|
19
|
+
No bearer token or encrypted credential is read or printed.
|
|
20
|
+
EOF
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
REVOKE=0
|
|
24
|
+
CONFIRM=""
|
|
25
|
+
RECEIPT_DIR="${CLEARANCE_SECURITY_RECEIPT_DIR:-$ROOT/.clearance/security-receipts}"
|
|
26
|
+
while [[ $# -gt 0 ]]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--revoke) REVOKE=1; shift ;;
|
|
29
|
+
--confirm) CONFIRM="$2"; shift 2 ;;
|
|
30
|
+
--receipt-dir) RECEIPT_DIR="$2"; shift 2 ;;
|
|
31
|
+
-h|--help) usage; exit 0 ;;
|
|
32
|
+
*) die "unknown argument: $1" ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
|
|
36
|
+
require_pg_client
|
|
37
|
+
URL="$(resolve_database_url)"
|
|
38
|
+
require_database_url "$URL"
|
|
39
|
+
DB="$(db_name_from_url "$URL")"
|
|
40
|
+
assert_safe_ident "$DB" "active database"
|
|
41
|
+
HAS_TABLE="$(psql_q "$URL" -At -c "SELECT CASE WHEN to_regclass('public.\"scimProvider\"') IS NULL THEN 0 ELSE 1 END")"
|
|
42
|
+
if [[ "$HAS_TABLE" == "0" ]]; then
|
|
43
|
+
printf 'scim-legacy-preflight: OK; runtime SCIM table is absent (zero issued credentials)\n'
|
|
44
|
+
printf 'LEGACY_SCIM_COUNT=0\nSCIM_LEGACY_PREFLIGHT_OK=1\n'
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
COUNT="$(psql_q "$URL" -At -c "SELECT count(*) FROM \"scimProvider\" WHERE \"organizationId\" IS NULL OR btrim(\"organizationId\") = ''")"
|
|
49
|
+
[[ "$COUNT" =~ ^[0-9]+$ ]] || die "could not inventory legacy SCIM credentials"
|
|
50
|
+
if [[ "$COUNT" == "0" ]]; then
|
|
51
|
+
printf 'scim-legacy-preflight: OK; no personal/global SCIM credentials remain\n'
|
|
52
|
+
printf 'LEGACY_SCIM_COUNT=0\nSCIM_LEGACY_PREFLIGHT_OK=1\n'
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
TOKEN="REVOKE_LEGACY_SCIM:${DB}:${COUNT}"
|
|
57
|
+
if [[ "$REVOKE" -eq 0 ]]; then
|
|
58
|
+
printf 'scim-legacy-preflight: BLOCKED; %s legacy personal/global credential(s) remain\n' "$COUNT" >&2
|
|
59
|
+
printf 'rerun with: --revoke --confirm %s\n' "$TOKEN" >&2
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
[[ "$CONFIRM" == "$TOKEN" ]] || die "revoke confirmation mismatch; required --confirm $TOKEN"
|
|
63
|
+
|
|
64
|
+
if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" && -n "${CLEARANCE_SCIM_PREFLIGHT_TEST_DELAY_SECONDS:-}" ]]; then
|
|
65
|
+
sleep "$CLEARANCE_SCIM_PREFLIGHT_TEST_DELAY_SECONDS"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
DELETED="$(psql_q "$URL" -At -v expected="$COUNT" <<'SQL'
|
|
69
|
+
BEGIN;
|
|
70
|
+
SELECT pg_advisory_xact_lock(7176324950072027);
|
|
71
|
+
LOCK TABLE "scimProvider" IN ACCESS EXCLUSIVE MODE;
|
|
72
|
+
WITH deleted AS (
|
|
73
|
+
DELETE FROM "scimProvider"
|
|
74
|
+
WHERE ("organizationId" IS NULL OR btrim("organizationId") = '')
|
|
75
|
+
AND (SELECT count(*) FROM "scimProvider"
|
|
76
|
+
WHERE "organizationId" IS NULL OR btrim("organizationId") = '') = :'expected'::bigint
|
|
77
|
+
RETURNING id
|
|
78
|
+
)
|
|
79
|
+
SELECT count(*) FROM deleted;
|
|
80
|
+
COMMIT;
|
|
81
|
+
SQL
|
|
82
|
+
)"
|
|
83
|
+
DELETED="$(printf '%s\n' "$DELETED" | awk '/^[0-9]+$/ {v=$0} END {print v}')"
|
|
84
|
+
[[ "$DELETED" == "$COUNT" ]] || die "legacy SCIM revoke count mismatch"
|
|
85
|
+
REMAINING="$(psql_q "$URL" -At -c "SELECT count(*) FROM \"scimProvider\" WHERE \"organizationId\" IS NULL OR btrim(\"organizationId\") = ''")"
|
|
86
|
+
[[ "$REMAINING" == "0" ]] || die "legacy SCIM credentials remain after revoke"
|
|
87
|
+
|
|
88
|
+
mkdir -p "$RECEIPT_DIR"
|
|
89
|
+
chmod 700 "$RECEIPT_DIR"
|
|
90
|
+
RECEIPT="$RECEIPT_DIR/scim-legacy-revoke-$(date -u +%Y%m%dT%H%M%SZ).json"
|
|
91
|
+
cat >"$RECEIPT" <<EOF
|
|
92
|
+
{
|
|
93
|
+
"database": $(json_escape "$DB"),
|
|
94
|
+
"revokedAt": $(json_escape "$(iso_now)"),
|
|
95
|
+
"legacyCredentialsRevoked": $DELETED,
|
|
96
|
+
"remainingLegacyCredentials": 0,
|
|
97
|
+
"tokenMaterialInspected": false
|
|
98
|
+
}
|
|
99
|
+
EOF
|
|
100
|
+
chmod 400 "$RECEIPT"
|
|
101
|
+
printf 'scim-legacy-preflight: OK; revoked %s personal/global credentials\n' "$DELETED"
|
|
102
|
+
printf 'SCIM_REVOKE_RECEIPT=%s\n' "$RECEIPT"
|
|
103
|
+
printf 'SCIM_LEGACY_PREFLIGHT_OK=1\n'
|