@clearance/cli 0.1.4
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/LICENSE +29 -0
- package/LICENSE.md +20 -0
- package/dist/api-client.d.ts +18 -0
- package/dist/api-client.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2494 -0
- package/dist/operator-auth.d.ts +28 -0
- package/dist/operator-auth.js +310 -0
- 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 +62 -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/dist/output.d.ts +19 -0
- package/dist/output.js +52 -0
- package/dist/remote-dispatch.d.ts +10 -0
- package/dist/remote-dispatch.js +232 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +43 -0
- package/dist/upgrade.d.ts +114 -0
- package/dist/upgrade.js +262 -0
- package/package.json +38 -0
|
@@ -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'
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Apply an upgrade plan: preflight + verified backup, then controlled apply steps.
|
|
3
|
+
# Fail closed. Records rollback reference. Does not silently mutate without backup.
|
|
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: upgrade-apply.sh --plan PLAN_ID_OR_PATH [--dir DIR] [--backup-dir DIR]
|
|
13
|
+
|
|
14
|
+
Sequence (fail closed):
|
|
15
|
+
1. preflight
|
|
16
|
+
2. backup-create + backup-verify (mandatory)
|
|
17
|
+
3. required deploy/upgrades/steps/<target>/apply.sh
|
|
18
|
+
4. write version marker file + state journal
|
|
19
|
+
5. record rollbackReference = verified backup
|
|
20
|
+
|
|
21
|
+
Does not drop or overwrite the active database.
|
|
22
|
+
EOF
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
|
|
26
|
+
BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
|
|
27
|
+
PLAN_REF=""
|
|
28
|
+
|
|
29
|
+
while [[ $# -gt 0 ]]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--plan) PLAN_REF="$2"; shift 2 ;;
|
|
32
|
+
--dir) PLAN_DIR="$2"; shift 2 ;;
|
|
33
|
+
--backup-dir) BACKUP_DIR="$2"; shift 2 ;;
|
|
34
|
+
-h|--help) usage; exit 0 ;;
|
|
35
|
+
*) die "unknown argument: $1" ;;
|
|
36
|
+
esac
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
[[ -n "$PLAN_REF" ]] || die "--plan is required"
|
|
40
|
+
|
|
41
|
+
if [[ -f "$PLAN_REF" ]]; then
|
|
42
|
+
PLAN_PATH="$PLAN_REF"
|
|
43
|
+
else
|
|
44
|
+
PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"
|
|
45
|
+
fi
|
|
46
|
+
[[ -f "$PLAN_PATH" ]] || die "plan not found"
|
|
47
|
+
require_cmd node
|
|
48
|
+
|
|
49
|
+
PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
|
|
50
|
+
[[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "plan id is unsafe"
|
|
51
|
+
STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
|
|
52
|
+
TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
|
|
53
|
+
CURRENT="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.currentVersion)' "$PLAN_PATH")"
|
|
54
|
+
assert_safe_version "$CURRENT" "current version"
|
|
55
|
+
assert_safe_version "$TARGET" "target version"
|
|
56
|
+
|
|
57
|
+
# 1. Preflight
|
|
58
|
+
bash "$ROOT/scripts/upgrade-preflight.sh" --plan "$PLAN_PATH" --dir "$PLAN_DIR"
|
|
59
|
+
|
|
60
|
+
# 2. Mandatory verified backup when DATABASE_URL is set (or plan has source DB)
|
|
61
|
+
SOURCE_DB="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.sourceDatabase||"")' "$PLAN_PATH")"
|
|
62
|
+
BACKUP_ID=""
|
|
63
|
+
BACKUP_DUMP=""
|
|
64
|
+
BACKUP_META=""
|
|
65
|
+
BACKUP_EVIDENCE=""
|
|
66
|
+
|
|
67
|
+
if [[ -n "$SOURCE_DB" || -n "${DATABASE_URL:-}" ]]; then
|
|
68
|
+
require_pg_client
|
|
69
|
+
URL="$(resolve_database_url)"
|
|
70
|
+
export DATABASE_URL="$URL"
|
|
71
|
+
CREATE_OUT="$(bash "$ROOT/scripts/backup-create.sh" --dir "$BACKUP_DIR")"
|
|
72
|
+
BACKUP_ID="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_ID=//p' | tail -1)"
|
|
73
|
+
BACKUP_DUMP="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_DUMP=//p' | tail -1)"
|
|
74
|
+
BACKUP_META="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_META=//p' | tail -1)"
|
|
75
|
+
BACKUP_EVIDENCE="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_EVIDENCE=//p' | tail -1)"
|
|
76
|
+
[[ -n "$BACKUP_ID" && -f "$BACKUP_DUMP" && -f "$BACKUP_META" && -f "$BACKUP_EVIDENCE" ]] || die "backup-create failed to produce artifacts"
|
|
77
|
+
bash "$ROOT/scripts/backup-verify.sh" --id "$BACKUP_ID" --dir "$BACKUP_DIR" >/dev/null
|
|
78
|
+
# Isolated restore verification before apply
|
|
79
|
+
bash "$ROOT/scripts/backup-restore-verify.sh" --id "$BACKUP_ID" --dir "$BACKUP_DIR" >/dev/null
|
|
80
|
+
else
|
|
81
|
+
die "upgrade-apply requires DATABASE_URL / plan sourceDatabase so a verified backup can be taken"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# Persist the verified rollback reference before any version-specific hook runs.
|
|
85
|
+
# A missing or failing hook therefore leaves recoverable evidence in plan state.
|
|
86
|
+
node -e '
|
|
87
|
+
const fs=require("fs");
|
|
88
|
+
const p=process.argv[1];
|
|
89
|
+
const backupId=process.argv[2];
|
|
90
|
+
const dump=process.argv[3];
|
|
91
|
+
const meta=process.argv[4];
|
|
92
|
+
const evidence=process.argv[5];
|
|
93
|
+
const j=JSON.parse(fs.readFileSync(p,"utf8"));
|
|
94
|
+
const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
|
|
95
|
+
j.status="backup_verified";
|
|
96
|
+
j.updatedAt=at;
|
|
97
|
+
j.backupId=backupId;
|
|
98
|
+
j.backupDump=dump;
|
|
99
|
+
j.backupMeta=meta;
|
|
100
|
+
j.backupEvidence=evidence;
|
|
101
|
+
j.rollbackReference={
|
|
102
|
+
type:"verified_pg_dump",
|
|
103
|
+
backupId,
|
|
104
|
+
dump,
|
|
105
|
+
meta,
|
|
106
|
+
evidence,
|
|
107
|
+
note:"Verified by checksum, archive inspection, and isolated restore before apply"
|
|
108
|
+
};
|
|
109
|
+
j.applyJournal=j.applyJournal||[];
|
|
110
|
+
j.applyJournal.push({at,event:"backup_verified",backupId});
|
|
111
|
+
fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");
|
|
112
|
+
' "$STATE_PATH" "$BACKUP_ID" "$BACKUP_DUMP" "$BACKUP_META" "$BACKUP_EVIDENCE"
|
|
113
|
+
|
|
114
|
+
# 3. Required version-specific apply hook (operator-supplied, fail closed if absent/non-zero)
|
|
115
|
+
STEP="$ROOT/deploy/upgrades/steps/${TARGET}/apply.sh"
|
|
116
|
+
if [[ -f "$STEP" ]]; then
|
|
117
|
+
printf 'upgrade-apply: running step hook %s\n' "$STEP" >&2
|
|
118
|
+
bash "$STEP" --plan "$PLAN_PATH" --from "$CURRENT" --to "$TARGET" \
|
|
119
|
+
|| die "apply step hook failed (fail closed); rollbackReference=$BACKUP_ID"
|
|
120
|
+
else
|
|
121
|
+
die "upgrade step hook missing: $STEP (refusing to record a no-op version transition); verified rollbackReference=$BACKUP_ID"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# 4. Version marker (operational reference; not a fake health claim)
|
|
125
|
+
MARKER_DIR="$PLAN_DIR/markers"
|
|
126
|
+
mkdir -p "$MARKER_DIR"
|
|
127
|
+
MARKER="$MARKER_DIR/${PLAN_ID}.applied"
|
|
128
|
+
cat >"$MARKER" <<EOF
|
|
129
|
+
{
|
|
130
|
+
"planId": $(json_escape "$PLAN_ID"),
|
|
131
|
+
"fromVersion": $(json_escape "$CURRENT"),
|
|
132
|
+
"toVersion": $(json_escape "$TARGET"),
|
|
133
|
+
"appliedAt": $(json_escape "$(iso_now)"),
|
|
134
|
+
"backupId": $(json_escape "$BACKUP_ID"),
|
|
135
|
+
"backupDump": $(json_escape "$BACKUP_DUMP"),
|
|
136
|
+
"backupMeta": $(json_escape "$BACKUP_META")
|
|
137
|
+
,"backupEvidence": $(json_escape "$BACKUP_EVIDENCE")
|
|
138
|
+
}
|
|
139
|
+
EOF
|
|
140
|
+
|
|
141
|
+
# 5. State update with rollback reference
|
|
142
|
+
node -e '
|
|
143
|
+
const fs=require("fs");
|
|
144
|
+
const p=process.argv[1];
|
|
145
|
+
const backupId=process.argv[2];
|
|
146
|
+
const dump=process.argv[3];
|
|
147
|
+
const meta=process.argv[4];
|
|
148
|
+
const marker=process.argv[5];
|
|
149
|
+
const evidence=process.argv[6];
|
|
150
|
+
const j=JSON.parse(fs.readFileSync(p,"utf8"));
|
|
151
|
+
const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
|
|
152
|
+
j.status="applied";
|
|
153
|
+
j.updatedAt=at;
|
|
154
|
+
j.backupId=backupId;
|
|
155
|
+
j.backupDump=dump;
|
|
156
|
+
j.backupMeta=meta;
|
|
157
|
+
j.backupEvidence=evidence;
|
|
158
|
+
j.rollbackReference.note="Apply completed; active restore remains an explicit operator runbook action";
|
|
159
|
+
j.applyJournal=j.applyJournal||[];
|
|
160
|
+
j.applyJournal.push({at, event:"applied", marker, backupId});
|
|
161
|
+
fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
|
|
162
|
+
' "$STATE_PATH" "$BACKUP_ID" "$BACKUP_DUMP" "$BACKUP_META" "$MARKER" "$BACKUP_EVIDENCE"
|
|
163
|
+
|
|
164
|
+
printf 'upgrade-apply: OK plan=%s target=%s backup=%s\n' "$PLAN_ID" "$TARGET" "$BACKUP_ID" >&2
|
|
165
|
+
printf 'PLAN_ID=%s\n' "$PLAN_ID"
|
|
166
|
+
printf 'BACKUP_ID=%s\n' "$BACKUP_ID"
|
|
167
|
+
printf 'ROLLBACK_REF=%s\n' "$BACKUP_ID"
|
|
168
|
+
printf 'APPLY_OK=1\n'
|