@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9
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/CHANGELOG.md +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/** Associate a reviewed copied owner graph with one verified local Auth identity. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import pg from "pg";
|
|
6
|
+
import { IDENTITY_CLAIM_RECEIPT_SQL } from "../runtime/policy.mjs";
|
|
7
|
+
|
|
8
|
+
const { Client } = pg;
|
|
9
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
10
|
+
const LABEL = /^[a-z0-9][a-z0-9-]{1,62}$/u;
|
|
11
|
+
const UUID =
|
|
12
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
13
|
+
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
14
|
+
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
15
|
+
|
|
16
|
+
const isObject = (value) =>
|
|
17
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
const id = (value, label) => {
|
|
19
|
+
if (!IDENTIFIER.test(value ?? ""))
|
|
20
|
+
throw new Error(`${label} is not a safe identifier.`);
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
const qid = (value) => `"${id(value, "SQL identifier")}"`;
|
|
24
|
+
const canonical = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
25
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
26
|
+
const keys = (value, allowed, label) => {
|
|
27
|
+
for (const key of Object.keys(value ?? {})) {
|
|
28
|
+
if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const relation = (value, label) => {
|
|
32
|
+
if (!isObject(value)) throw new Error(`${label} must be an object.`);
|
|
33
|
+
keys(value, ["schema", "table"], label);
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
schema: id(value.schema, `${label}.schema`),
|
|
36
|
+
table: id(value.table, `${label}.table`),
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
const jsonValue = (value, label) => {
|
|
40
|
+
let serialized;
|
|
41
|
+
try {
|
|
42
|
+
serialized = JSON.stringify(value);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`${label} must be JSON-compatible.`);
|
|
45
|
+
}
|
|
46
|
+
if (serialized === undefined) {
|
|
47
|
+
throw new Error(`${label} must be JSON-compatible.`);
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(serialized);
|
|
50
|
+
};
|
|
51
|
+
const defaultMatcher = (value, label) => {
|
|
52
|
+
if (isObject(value) && value.kind === "pattern") {
|
|
53
|
+
keys(value, ["kind", "pattern"], label);
|
|
54
|
+
if (
|
|
55
|
+
typeof value.pattern !== "string" ||
|
|
56
|
+
value.pattern.length > 256 ||
|
|
57
|
+
!value.pattern.startsWith("^") ||
|
|
58
|
+
!value.pattern.endsWith("$")
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${label}.pattern must be anchored and at most 256 characters.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
new RegExp(value.pattern, "u");
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`${label}.pattern is invalid.`);
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({ kind: "pattern", pattern: value.pattern });
|
|
70
|
+
}
|
|
71
|
+
if (isObject(value) && value.kind === "exact") {
|
|
72
|
+
keys(value, ["kind", "value"], label);
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
kind: "exact",
|
|
75
|
+
value: jsonValue(value.value, `${label}.value`),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({ kind: "exact", value: jsonValue(value, label) });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const validateIdentityPolicy = (policy) => {
|
|
82
|
+
if (
|
|
83
|
+
!isObject(policy) ||
|
|
84
|
+
policy.identityVersion !== 1 ||
|
|
85
|
+
!Array.isArray(policy.identities)
|
|
86
|
+
) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Identity policy must use identityVersion 1 and list identities.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(policy)) {
|
|
92
|
+
if (!["identityVersion", "identities"].includes(key)) {
|
|
93
|
+
throw new Error(`identityPolicy.${key} is unknown.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const names = new Set();
|
|
97
|
+
const identities = policy.identities.map((entry, index) => {
|
|
98
|
+
const label = `identityPolicy.identities[${index}]`;
|
|
99
|
+
for (const key of Object.keys(entry ?? {})) {
|
|
100
|
+
if (
|
|
101
|
+
![
|
|
102
|
+
"name",
|
|
103
|
+
"provider",
|
|
104
|
+
"emailEnvironmentVariable",
|
|
105
|
+
"approvedEmailSha256",
|
|
106
|
+
"placeholderUserId",
|
|
107
|
+
"references",
|
|
108
|
+
"jsonReferences",
|
|
109
|
+
"signupDefaults",
|
|
110
|
+
"pathReferences",
|
|
111
|
+
"assets",
|
|
112
|
+
"claims",
|
|
113
|
+
"tokenHook",
|
|
114
|
+
].includes(key)
|
|
115
|
+
) {
|
|
116
|
+
throw new Error(`${label}.${key} is unknown.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!LABEL.test(entry.name ?? ""))
|
|
120
|
+
throw new Error(`${label}.name is invalid.`);
|
|
121
|
+
const name = entry.name;
|
|
122
|
+
if (names.has(name)) throw new Error(`Identity policy duplicates ${name}.`);
|
|
123
|
+
names.add(name);
|
|
124
|
+
if (!["google", "email"].includes(entry.provider))
|
|
125
|
+
throw new Error(`${label}.provider is unsupported.`);
|
|
126
|
+
if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
|
|
127
|
+
throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
|
|
128
|
+
}
|
|
129
|
+
if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
|
|
130
|
+
throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
|
|
131
|
+
}
|
|
132
|
+
if (!UUID.test(entry.placeholderUserId ?? ""))
|
|
133
|
+
throw new Error(`${label}.placeholderUserId is invalid.`);
|
|
134
|
+
const references = (entry.references ?? []).map(
|
|
135
|
+
(reference, referenceIndex) => {
|
|
136
|
+
const referenceLabel = `${label}.references[${referenceIndex}]`;
|
|
137
|
+
if (!isObject(reference))
|
|
138
|
+
throw new Error(`${referenceLabel} must be an object.`);
|
|
139
|
+
keys(
|
|
140
|
+
reference,
|
|
141
|
+
["schema", "table", "column", "required", "strategy"],
|
|
142
|
+
referenceLabel,
|
|
143
|
+
);
|
|
144
|
+
if (
|
|
145
|
+
reference.required !== undefined &&
|
|
146
|
+
typeof reference.required !== "boolean"
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(`${referenceLabel}.required must be boolean.`);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
reference.strategy !== undefined &&
|
|
152
|
+
!["transfer", "preserve-audit"].includes(reference.strategy)
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`${referenceLabel}.strategy must be transfer or preserve-audit.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
schema: id(reference.schema, `${referenceLabel}.schema`),
|
|
160
|
+
table: id(reference.table, `${referenceLabel}.table`),
|
|
161
|
+
column: id(reference.column, `${referenceLabel}.column`),
|
|
162
|
+
required: reference.required === true,
|
|
163
|
+
strategy: reference.strategy ?? "transfer",
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
const jsonReferences = (entry.jsonReferences ?? []).map(
|
|
168
|
+
(reference, referenceIndex) => {
|
|
169
|
+
const referenceLabel = `${label}.jsonReferences[${referenceIndex}]`;
|
|
170
|
+
if (!isObject(reference))
|
|
171
|
+
throw new Error(`${referenceLabel} must be an object.`);
|
|
172
|
+
keys(reference, ["schema", "table", "column", "path"], referenceLabel);
|
|
173
|
+
if (!Array.isArray(reference.path) || reference.path.length === 0) {
|
|
174
|
+
throw new Error(`${referenceLabel}.path is invalid.`);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
schema: id(reference.schema, `${referenceLabel}.schema`),
|
|
178
|
+
table: id(reference.table, `${referenceLabel}.table`),
|
|
179
|
+
column: id(reference.column, `${referenceLabel}.column`),
|
|
180
|
+
path: reference.path.map((part, pathIndex) =>
|
|
181
|
+
id(part, `${referenceLabel}.path[${pathIndex}]`),
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const signupDefaults = (entry.signupDefaults ?? []).map(
|
|
187
|
+
(declaration, declarationIndex) => {
|
|
188
|
+
const declarationLabel = `${label}.signupDefaults[${declarationIndex}]`;
|
|
189
|
+
if (!isObject(declaration))
|
|
190
|
+
throw new Error(`${declarationLabel} must be an object.`);
|
|
191
|
+
keys(
|
|
192
|
+
declaration,
|
|
193
|
+
["table", "identityColumn", "ignoredColumns", "values"],
|
|
194
|
+
declarationLabel,
|
|
195
|
+
);
|
|
196
|
+
const table = relation(declaration.table, `${declarationLabel}.table`);
|
|
197
|
+
const identityColumn = id(
|
|
198
|
+
declaration.identityColumn,
|
|
199
|
+
`${declarationLabel}.identityColumn`,
|
|
200
|
+
);
|
|
201
|
+
if (!isObject(declaration.values)) {
|
|
202
|
+
throw new Error(`${declarationLabel}.values must be an object.`);
|
|
203
|
+
}
|
|
204
|
+
const values = Object.fromEntries(
|
|
205
|
+
Object.entries(declaration.values).map(([column, value]) => [
|
|
206
|
+
id(column, `${declarationLabel}.values key`),
|
|
207
|
+
defaultMatcher(value, `${declarationLabel}.values.${column}`),
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
if (Object.keys(values).length === 0) {
|
|
211
|
+
throw new Error(`${declarationLabel}.values must not be empty.`);
|
|
212
|
+
}
|
|
213
|
+
const ignoredColumns = (declaration.ignoredColumns ?? []).map(
|
|
214
|
+
(column, columnIndex) =>
|
|
215
|
+
id(column, `${declarationLabel}.ignoredColumns[${columnIndex}]`),
|
|
216
|
+
);
|
|
217
|
+
if (
|
|
218
|
+
new Set([identityColumn, ...ignoredColumns, ...Object.keys(values)])
|
|
219
|
+
.size !==
|
|
220
|
+
1 + ignoredColumns.length + Object.keys(values).length
|
|
221
|
+
) {
|
|
222
|
+
throw new Error(`${declarationLabel} repeats a column.`);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
!references.some(
|
|
226
|
+
(reference) =>
|
|
227
|
+
reference.schema === table.schema &&
|
|
228
|
+
reference.table === table.table &&
|
|
229
|
+
reference.column === identityColumn,
|
|
230
|
+
)
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`${declarationLabel} must match a declared relational reference.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
table,
|
|
238
|
+
identityColumn,
|
|
239
|
+
ignoredColumns: Object.freeze(ignoredColumns),
|
|
240
|
+
values: Object.freeze(values),
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
const pathReferences = (entry.pathReferences ?? []).map(
|
|
245
|
+
(reference, referenceIndex) => {
|
|
246
|
+
const referenceLabel = `${label}.pathReferences[${referenceIndex}]`;
|
|
247
|
+
if (!isObject(reference))
|
|
248
|
+
throw new Error(`${referenceLabel} must be an object.`);
|
|
249
|
+
keys(
|
|
250
|
+
reference,
|
|
251
|
+
["schema", "table", "column", "valueType"],
|
|
252
|
+
referenceLabel,
|
|
253
|
+
);
|
|
254
|
+
if (!["text", "jsonb"].includes(reference.valueType)) {
|
|
255
|
+
throw new Error(`${referenceLabel}.valueType is unsupported.`);
|
|
256
|
+
}
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
schema: id(reference.schema, `${referenceLabel}.schema`),
|
|
259
|
+
table: id(reference.table, `${referenceLabel}.table`),
|
|
260
|
+
column: id(reference.column, `${referenceLabel}.column`),
|
|
261
|
+
valueType: reference.valueType,
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
const assets = (entry.assets ?? []).map((asset, assetIndex) => {
|
|
266
|
+
const assetLabel = `${label}.assets[${assetIndex}]`;
|
|
267
|
+
if (!isObject(asset)) throw new Error(`${assetLabel} must be an object.`);
|
|
268
|
+
keys(asset, ["bucket", "prefix", "rewritePath"], assetLabel);
|
|
269
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
|
|
270
|
+
throw new Error(`${assetLabel}.bucket is invalid.`);
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
typeof asset.prefix !== "string" ||
|
|
274
|
+
!asset.prefix ||
|
|
275
|
+
asset.prefix.startsWith("/") ||
|
|
276
|
+
asset.prefix
|
|
277
|
+
.replace(/\/$/u, "")
|
|
278
|
+
.split("/")
|
|
279
|
+
.some(
|
|
280
|
+
(part) =>
|
|
281
|
+
!part || part === "." || part === ".." || /[%_]/u.test(part),
|
|
282
|
+
)
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(`${assetLabel}.prefix is invalid.`);
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
asset.rewritePath !== undefined &&
|
|
288
|
+
typeof asset.rewritePath !== "boolean"
|
|
289
|
+
) {
|
|
290
|
+
throw new Error(`${assetLabel}.rewritePath must be boolean.`);
|
|
291
|
+
}
|
|
292
|
+
return Object.freeze({
|
|
293
|
+
bucket: asset.bucket,
|
|
294
|
+
prefix: asset.prefix,
|
|
295
|
+
rewritePath: asset.rewritePath === true,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
for (const asset of assets) {
|
|
299
|
+
if (
|
|
300
|
+
asset.rewritePath &&
|
|
301
|
+
asset.prefix !== `${entry.placeholderUserId.toLowerCase()}/`
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`${label}.assets rewritePath requires the placeholder UUID prefix.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!isObject(entry.claims ?? {}))
|
|
309
|
+
throw new Error(`${label}.claims must be an object.`);
|
|
310
|
+
JSON.stringify(entry.claims);
|
|
311
|
+
let tokenHook = null;
|
|
312
|
+
if (entry.tokenHook != null) {
|
|
313
|
+
const hookLabel = `${label}.tokenHook`;
|
|
314
|
+
if (!isObject(entry.tokenHook))
|
|
315
|
+
throw new Error(`${hookLabel} must be an object.`);
|
|
316
|
+
keys(entry.tokenHook, ["function", "expectedClaims"], hookLabel);
|
|
317
|
+
if (!isObject(entry.tokenHook.function))
|
|
318
|
+
throw new Error(`${hookLabel}.function must be an object.`);
|
|
319
|
+
keys(
|
|
320
|
+
entry.tokenHook.function,
|
|
321
|
+
["schema", "name"],
|
|
322
|
+
`${hookLabel}.function`,
|
|
323
|
+
);
|
|
324
|
+
if (!isObject(entry.tokenHook.expectedClaims))
|
|
325
|
+
throw new Error(`${hookLabel}.expectedClaims must be an object.`);
|
|
326
|
+
tokenHook = Object.freeze({
|
|
327
|
+
function: Object.freeze({
|
|
328
|
+
schema: id(
|
|
329
|
+
entry.tokenHook.function.schema,
|
|
330
|
+
`${hookLabel}.function.schema`,
|
|
331
|
+
),
|
|
332
|
+
name: id(entry.tokenHook.function.name, `${hookLabel}.function.name`),
|
|
333
|
+
}),
|
|
334
|
+
expectedClaims: Object.freeze(
|
|
335
|
+
jsonValue(
|
|
336
|
+
entry.tokenHook.expectedClaims,
|
|
337
|
+
`${hookLabel}.expectedClaims`,
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return Object.freeze({
|
|
343
|
+
name,
|
|
344
|
+
provider: entry.provider,
|
|
345
|
+
emailEnvironmentVariable: entry.emailEnvironmentVariable,
|
|
346
|
+
approvedEmailSha256: entry.approvedEmailSha256,
|
|
347
|
+
placeholderUserId: entry.placeholderUserId.toLowerCase(),
|
|
348
|
+
references: Object.freeze(references),
|
|
349
|
+
jsonReferences: Object.freeze(jsonReferences),
|
|
350
|
+
signupDefaults: Object.freeze(signupDefaults),
|
|
351
|
+
pathReferences: Object.freeze(pathReferences),
|
|
352
|
+
assets: Object.freeze(assets),
|
|
353
|
+
claims: Object.freeze(entry.claims ?? {}),
|
|
354
|
+
tokenHook,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
return Object.freeze({
|
|
358
|
+
identityVersion: 1,
|
|
359
|
+
identities: Object.freeze(identities),
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export const createIdentityClaimPlan = ({
|
|
364
|
+
policy,
|
|
365
|
+
name,
|
|
366
|
+
environment = process.env,
|
|
367
|
+
}) => {
|
|
368
|
+
const normalized = validateIdentityPolicy(policy);
|
|
369
|
+
const identity = normalized.identities.find((entry) => entry.name === name);
|
|
370
|
+
if (!identity) throw new Error(`Identity policy does not declare ${name}.`);
|
|
371
|
+
const email = environment[identity.emailEnvironmentVariable]
|
|
372
|
+
?.trim()
|
|
373
|
+
.toLowerCase();
|
|
374
|
+
if (!email || hash(email) !== identity.approvedEmailSha256) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"Local identity email does not match the reviewed receipt.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const review = {
|
|
380
|
+
planVersion: 2,
|
|
381
|
+
operation: "claim-local-identity",
|
|
382
|
+
name: identity.name,
|
|
383
|
+
provider: identity.provider,
|
|
384
|
+
approvedEmailSha256: identity.approvedEmailSha256,
|
|
385
|
+
placeholderUserId: identity.placeholderUserId,
|
|
386
|
+
references: identity.references,
|
|
387
|
+
jsonReferences: identity.jsonReferences,
|
|
388
|
+
signupDefaults: identity.signupDefaults.map((declaration) => ({
|
|
389
|
+
table: declaration.table,
|
|
390
|
+
identityColumn: declaration.identityColumn,
|
|
391
|
+
ignoredColumns: declaration.ignoredColumns,
|
|
392
|
+
valuesSha256: hash(canonical(declaration.values)),
|
|
393
|
+
})),
|
|
394
|
+
pathReferences: identity.pathReferences,
|
|
395
|
+
assets: identity.assets,
|
|
396
|
+
claimKeys: Object.keys(identity.claims).sort(),
|
|
397
|
+
claimsSha256: hash(canonical(identity.claims)),
|
|
398
|
+
tokenHook: identity.tokenHook
|
|
399
|
+
? {
|
|
400
|
+
function: identity.tokenHook.function,
|
|
401
|
+
expectedClaimsSha256: hash(
|
|
402
|
+
canonical(identity.tokenHook.expectedClaims),
|
|
403
|
+
),
|
|
404
|
+
}
|
|
405
|
+
: null,
|
|
406
|
+
};
|
|
407
|
+
return Object.freeze({
|
|
408
|
+
identity,
|
|
409
|
+
email,
|
|
410
|
+
review: Object.freeze(review),
|
|
411
|
+
digest: hash(canonical(review)),
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const inspectDeclaredDefault = ({ row, declaration }) => {
|
|
416
|
+
if (!isObject(row)) {
|
|
417
|
+
return { matches: false, actualColumns: [], mismatchedColumns: [] };
|
|
418
|
+
}
|
|
419
|
+
const comparable = { ...row };
|
|
420
|
+
delete comparable[declaration.identityColumn];
|
|
421
|
+
for (const column of declaration.ignoredColumns) delete comparable[column];
|
|
422
|
+
const actualColumns = Object.keys(comparable).sort();
|
|
423
|
+
const declaredColumns = Object.keys(declaration.values).sort();
|
|
424
|
+
const mismatchedColumns = Object.entries(declaration.values)
|
|
425
|
+
.filter(([column, matcher]) =>
|
|
426
|
+
matcher.kind === "pattern"
|
|
427
|
+
? typeof comparable[column] !== "string" ||
|
|
428
|
+
!new RegExp(matcher.pattern, "u").test(comparable[column])
|
|
429
|
+
: !isDeepStrictEqual(comparable[column], matcher.value),
|
|
430
|
+
)
|
|
431
|
+
.map(([column]) => column)
|
|
432
|
+
.sort();
|
|
433
|
+
return {
|
|
434
|
+
matches:
|
|
435
|
+
isDeepStrictEqual(actualColumns, declaredColumns) &&
|
|
436
|
+
mismatchedColumns.length === 0,
|
|
437
|
+
actualColumns,
|
|
438
|
+
mismatchedColumns,
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const containsJson = (actual, expected) => {
|
|
443
|
+
if (!isObject(expected)) return isDeepStrictEqual(actual, expected);
|
|
444
|
+
if (!isObject(actual)) return false;
|
|
445
|
+
return Object.entries(expected).every(([key, value]) =>
|
|
446
|
+
containsJson(actual[key], value),
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const valueKind = (value) =>
|
|
451
|
+
value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
452
|
+
|
|
453
|
+
const discoverPublicAuthReferences = async (client) => {
|
|
454
|
+
const result = await client.query(`select distinct
|
|
455
|
+
source_namespace.nspname as schema,
|
|
456
|
+
source_table.relname as table,
|
|
457
|
+
source_column.attname as column
|
|
458
|
+
from pg_constraint constraint_record
|
|
459
|
+
join pg_class source_table on source_table.oid = constraint_record.conrelid
|
|
460
|
+
join pg_namespace source_namespace on source_namespace.oid = source_table.relnamespace
|
|
461
|
+
join unnest(constraint_record.conkey) with ordinality source_key(attnum, position) on true
|
|
462
|
+
join unnest(constraint_record.confkey) with ordinality target_key(attnum, position)
|
|
463
|
+
on target_key.position = source_key.position
|
|
464
|
+
join pg_attribute source_column
|
|
465
|
+
on source_column.attrelid = constraint_record.conrelid
|
|
466
|
+
and source_column.attnum = source_key.attnum
|
|
467
|
+
join pg_attribute target_column
|
|
468
|
+
on target_column.attrelid = constraint_record.confrelid
|
|
469
|
+
and target_column.attnum = target_key.attnum
|
|
470
|
+
where constraint_record.contype = 'f'
|
|
471
|
+
and constraint_record.confrelid = 'auth.users'::regclass
|
|
472
|
+
and target_column.attname = 'id'
|
|
473
|
+
and source_namespace.nspname = 'public'`);
|
|
474
|
+
return result.rows.map((reference, index) => ({
|
|
475
|
+
schema: id(reference.schema, `discoveredReference[${index}].schema`),
|
|
476
|
+
table: id(reference.table, `discoveredReference[${index}].table`),
|
|
477
|
+
column: id(reference.column, `discoveredReference[${index}].column`),
|
|
478
|
+
}));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const countIdentityRows = async ({ client, reference, userId }) => {
|
|
482
|
+
const result = await client.query(
|
|
483
|
+
`select count(*)::integer as count
|
|
484
|
+
from ${qid(reference.schema)}.${qid(reference.table)}
|
|
485
|
+
where ${qid(reference.column)} = $1::uuid`,
|
|
486
|
+
[userId],
|
|
487
|
+
);
|
|
488
|
+
const count = Number(result.rows[0]?.count);
|
|
489
|
+
if (!Number.isSafeInteger(count) || count < 0) {
|
|
490
|
+
throw new Error("A local identity reference returned an invalid count.");
|
|
491
|
+
}
|
|
492
|
+
return count;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
export const applyIdentityClaim = async ({
|
|
496
|
+
plan,
|
|
497
|
+
confirmation,
|
|
498
|
+
connectionString,
|
|
499
|
+
clientFactory,
|
|
500
|
+
storageTransfer,
|
|
501
|
+
}) => {
|
|
502
|
+
if (confirmation !== plan.digest) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Identity claim confirmation does not match. Expected ${plan.digest}.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const target = new URL(connectionString);
|
|
508
|
+
if (!["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
"Identity claims may target only the local Rehearsal database.",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const client = clientFactory
|
|
514
|
+
? await clientFactory(connectionString)
|
|
515
|
+
: new Client({
|
|
516
|
+
connectionString,
|
|
517
|
+
application_name: "rehearsal-identity-claim",
|
|
518
|
+
});
|
|
519
|
+
await client.connect?.();
|
|
520
|
+
let removedSignupDefaults = 0;
|
|
521
|
+
let tokenHookVerified = false;
|
|
522
|
+
let placeholderRetainedForAudit = false;
|
|
523
|
+
const stagedStorageCopies = [];
|
|
524
|
+
let committed = false;
|
|
525
|
+
let storageObjectsTransferred = 0;
|
|
526
|
+
try {
|
|
527
|
+
await client.query("begin");
|
|
528
|
+
const match = await client.query(
|
|
529
|
+
`select u.id::text
|
|
530
|
+
from auth.users u
|
|
531
|
+
join auth.identities i on i.user_id = u.id
|
|
532
|
+
where lower(coalesce(i.identity_data->>'email', u.email)) = $1
|
|
533
|
+
and i.provider = $2
|
|
534
|
+
and (
|
|
535
|
+
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
536
|
+
or u.email_confirmed_at is not null
|
|
537
|
+
)
|
|
538
|
+
for update of u`,
|
|
539
|
+
[plan.email, plan.identity.provider],
|
|
540
|
+
);
|
|
541
|
+
if (match.rows.length !== 1) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
match.rows.length === 0
|
|
544
|
+
? "No single verified local provider identity matches the reviewed email."
|
|
545
|
+
: "More than one local provider identity matches the reviewed email.",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const localUserId = match.rows[0].id;
|
|
549
|
+
if (!UUID.test(localUserId))
|
|
550
|
+
throw new Error("Matched local identity is invalid.");
|
|
551
|
+
const placeholderUser = await client.query(
|
|
552
|
+
"select 1 from auth.users where id = $1::uuid limit 1",
|
|
553
|
+
[plan.identity.placeholderUserId],
|
|
554
|
+
);
|
|
555
|
+
const placeholderReferenceCounts =
|
|
556
|
+
localUserId === plan.identity.placeholderUserId
|
|
557
|
+
? []
|
|
558
|
+
: await Promise.all(
|
|
559
|
+
plan.identity.references.map((reference) =>
|
|
560
|
+
countIdentityRows({
|
|
561
|
+
client,
|
|
562
|
+
reference,
|
|
563
|
+
userId: plan.identity.placeholderUserId,
|
|
564
|
+
}),
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
const transferReferences = plan.identity.references.filter(
|
|
568
|
+
(reference) => reference.strategy === "transfer",
|
|
569
|
+
);
|
|
570
|
+
const preservedReferences = plan.identity.references.filter(
|
|
571
|
+
(reference) => reference.strategy === "preserve-audit",
|
|
572
|
+
);
|
|
573
|
+
const transferPlaceholderCounts = transferReferences.map((reference) => {
|
|
574
|
+
const index = plan.identity.references.indexOf(reference);
|
|
575
|
+
return placeholderReferenceCounts[index] ?? 0;
|
|
576
|
+
});
|
|
577
|
+
const transferLocalCounts =
|
|
578
|
+
localUserId === plan.identity.placeholderUserId ||
|
|
579
|
+
preservedReferences.length === 0
|
|
580
|
+
? []
|
|
581
|
+
: await Promise.all(
|
|
582
|
+
transferReferences.map((reference) =>
|
|
583
|
+
countIdentityRows({ client, reference, userId: localUserId }),
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
const alreadyClaimed =
|
|
587
|
+
localUserId !== plan.identity.placeholderUserId &&
|
|
588
|
+
transferPlaceholderCounts.every((count) => count === 0) &&
|
|
589
|
+
(preservedReferences.length > 0
|
|
590
|
+
? transferLocalCounts.some((count) => count > 0)
|
|
591
|
+
: placeholderUser.rows.length === 0);
|
|
592
|
+
const publicAuthReferences =
|
|
593
|
+
localUserId === plan.identity.placeholderUserId
|
|
594
|
+
? []
|
|
595
|
+
: await discoverPublicAuthReferences(client);
|
|
596
|
+
if (localUserId !== plan.identity.placeholderUserId) {
|
|
597
|
+
const placeholderIdentity = await client.query(
|
|
598
|
+
"select 1 from auth.identities where user_id = $1::uuid limit 1",
|
|
599
|
+
[plan.identity.placeholderUserId],
|
|
600
|
+
);
|
|
601
|
+
if (placeholderIdentity.rows.length > 0) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
"The copied placeholder already belongs to a conflicting local identity.",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const reference of alreadyClaimed ? [] : publicAuthReferences) {
|
|
608
|
+
const declared = plan.identity.references.find(
|
|
609
|
+
(candidate) =>
|
|
610
|
+
candidate.schema === reference.schema &&
|
|
611
|
+
candidate.table === reference.table &&
|
|
612
|
+
candidate.column === reference.column,
|
|
613
|
+
);
|
|
614
|
+
if (declared?.strategy === "preserve-audit") continue;
|
|
615
|
+
const localRows = await countIdentityRows({
|
|
616
|
+
client,
|
|
617
|
+
reference,
|
|
618
|
+
userId: localUserId,
|
|
619
|
+
});
|
|
620
|
+
if (localRows === 0) continue;
|
|
621
|
+
const declaredDefault = plan.identity.signupDefaults.find(
|
|
622
|
+
(declaration) =>
|
|
623
|
+
declaration.table.schema === reference.schema &&
|
|
624
|
+
declaration.table.table === reference.table &&
|
|
625
|
+
declaration.identityColumn === reference.column,
|
|
626
|
+
);
|
|
627
|
+
if (!declaredDefault) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
"The local account already contains independent application data; nothing was changed.",
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (localUserId !== plan.identity.placeholderUserId && !alreadyClaimed) {
|
|
634
|
+
for (const declaration of plan.identity.signupDefaults) {
|
|
635
|
+
const target = `${qid(declaration.table.schema)}.${qid(declaration.table.table)}`;
|
|
636
|
+
const rows = await client.query(
|
|
637
|
+
`select candidate.${qid(declaration.identityColumn)}::text as identity,
|
|
638
|
+
to_jsonb(candidate) as row
|
|
639
|
+
from ${target} candidate
|
|
640
|
+
where candidate.${qid(declaration.identityColumn)} in ($1::uuid, $2::uuid)
|
|
641
|
+
for update`,
|
|
642
|
+
[localUserId, plan.identity.placeholderUserId],
|
|
643
|
+
);
|
|
644
|
+
const localRows = rows.rows.filter(
|
|
645
|
+
(row) => row.identity === localUserId,
|
|
646
|
+
);
|
|
647
|
+
const placeholderRows = rows.rows.filter(
|
|
648
|
+
(row) => row.identity === plan.identity.placeholderUserId,
|
|
649
|
+
);
|
|
650
|
+
if (localRows.length > 1 || placeholderRows.length > 1) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
"The declared signup-default relation is ambiguous; nothing was changed.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (localRows.length === 1 && placeholderRows.length === 1) {
|
|
656
|
+
const defaultInspection = inspectDeclaredDefault({
|
|
657
|
+
row: localRows[0].row,
|
|
658
|
+
declaration,
|
|
659
|
+
});
|
|
660
|
+
if (!defaultInspection.matches) {
|
|
661
|
+
const declaredColumns = Object.keys(declaration.values).sort();
|
|
662
|
+
const mismatchKinds = defaultInspection.mismatchedColumns.map(
|
|
663
|
+
(column) =>
|
|
664
|
+
`${column}(${valueKind(localRows[0].row[column])}/${declaration.values[column].kind === "exact" ? valueKind(declaration.values[column].value) : "pattern"})`,
|
|
665
|
+
);
|
|
666
|
+
throw new Error(
|
|
667
|
+
`The local signup row was edited or does not match its complete declared default; nothing was changed. Declared columns: ${declaredColumns.join(", ")}. Actual columns: ${defaultInspection.actualColumns.join(", ") || "none"}. Mismatched column shapes: ${mismatchKinds.join(", ") || "none"}.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
await client.query(
|
|
671
|
+
`delete from ${target}
|
|
672
|
+
where ${qid(declaration.identityColumn)} = $1::uuid`,
|
|
673
|
+
[localUserId],
|
|
674
|
+
);
|
|
675
|
+
removedSignupDefaults += 1;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const reference of plan.identity.references) {
|
|
680
|
+
const expectedUserId =
|
|
681
|
+
reference.strategy === "preserve-audit"
|
|
682
|
+
? plan.identity.placeholderUserId
|
|
683
|
+
: localUserId;
|
|
684
|
+
if (reference.strategy === "transfer") {
|
|
685
|
+
await client.query(
|
|
686
|
+
`update ${qid(reference.schema)}.${qid(reference.table)}
|
|
687
|
+
set ${qid(reference.column)} = $1::uuid
|
|
688
|
+
where ${qid(reference.column)} = $2::uuid`,
|
|
689
|
+
[localUserId, plan.identity.placeholderUserId],
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (reference.required) {
|
|
693
|
+
const required = await client.query(
|
|
694
|
+
`select 1
|
|
695
|
+
from ${qid(reference.schema)}.${qid(reference.table)}
|
|
696
|
+
where ${qid(reference.column)} = $1::uuid
|
|
697
|
+
limit 1`,
|
|
698
|
+
[expectedUserId],
|
|
699
|
+
);
|
|
700
|
+
if (required.rows.length !== 1) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
"A required local identity reference is missing; nothing was changed.",
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const reference of plan.identity.jsonReferences) {
|
|
708
|
+
await client.query(
|
|
709
|
+
`update ${qid(reference.schema)}.${qid(reference.table)}
|
|
710
|
+
set ${qid(reference.column)} = jsonb_set(
|
|
711
|
+
${qid(reference.column)}, $3::text[], to_jsonb($1::text), false
|
|
712
|
+
)
|
|
713
|
+
where ${qid(reference.column)} #>> $3::text[] = $2`,
|
|
714
|
+
[localUserId, plan.identity.placeholderUserId, reference.path],
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const sourcePathPrefix = `${plan.identity.placeholderUserId}/`;
|
|
718
|
+
let sourcePathReferenceCount = 0;
|
|
719
|
+
if (plan.identity.assets.some((asset) => asset.rewritePath)) {
|
|
720
|
+
for (const reference of plan.identity.pathReferences) {
|
|
721
|
+
const target = `${qid(reference.schema)}.${qid(reference.table)}`;
|
|
722
|
+
const column = qid(reference.column);
|
|
723
|
+
const result = await client.query(
|
|
724
|
+
reference.valueType === "text"
|
|
725
|
+
? `select count(*)::integer as count from ${target} where ${column} like $1::text || '%'`
|
|
726
|
+
: `select count(*)::integer as count from ${target} where ${column}::text like '%' || $1::text || '%'`,
|
|
727
|
+
[sourcePathPrefix],
|
|
728
|
+
);
|
|
729
|
+
const count = Number(result.rows[0]?.count);
|
|
730
|
+
if (!Number.isSafeInteger(count) || count < 0) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
"A Storage path reference returned an invalid count.",
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
sourcePathReferenceCount += count;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
for (const asset of plan.identity.assets) {
|
|
739
|
+
const sourceObjects = await client.query(
|
|
740
|
+
`select name, owner_id
|
|
741
|
+
from storage.objects
|
|
742
|
+
where bucket_id = $1
|
|
743
|
+
and name like $2::text || '%'
|
|
744
|
+
order by name`,
|
|
745
|
+
[asset.bucket, asset.prefix],
|
|
746
|
+
);
|
|
747
|
+
if (
|
|
748
|
+
sourceObjects.rows.some(
|
|
749
|
+
({ owner_id: ownerId }) =>
|
|
750
|
+
ownerId !== null &&
|
|
751
|
+
ownerId !== plan.identity.placeholderUserId &&
|
|
752
|
+
!(alreadyClaimed && ownerId === localUserId),
|
|
753
|
+
)
|
|
754
|
+
) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
"A Storage object in the reviewed placeholder path belongs to another identity; nothing was changed.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
if (sourceObjects.rows.length === 0) continue;
|
|
760
|
+
if (!asset.rewritePath) {
|
|
761
|
+
await client.query(
|
|
762
|
+
`update storage.objects
|
|
763
|
+
set owner_id = $1::text
|
|
764
|
+
where bucket_id = $2 and name = any($3::text[])`,
|
|
765
|
+
[
|
|
766
|
+
localUserId,
|
|
767
|
+
asset.bucket,
|
|
768
|
+
sourceObjects.rows.map(({ name }) => name),
|
|
769
|
+
],
|
|
770
|
+
);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (!storageTransfer) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
"Storage path transfer requires the local Supabase Storage service; nothing was changed.",
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
const copies = sourceObjects.rows.map(({ name }) => {
|
|
779
|
+
if (typeof name !== "string" || !name.startsWith(asset.prefix)) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
"A Storage object escaped its reviewed identity scope.",
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
bucket: asset.bucket,
|
|
786
|
+
source: name,
|
|
787
|
+
destination: `${localUserId}/${name.slice(asset.prefix.length)}`,
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
const collisions = await client.query(
|
|
791
|
+
`select name
|
|
792
|
+
from storage.objects
|
|
793
|
+
where bucket_id = $1 and name = any($2::text[])
|
|
794
|
+
limit 1`,
|
|
795
|
+
[asset.bucket, copies.map((copy) => copy.destination)],
|
|
796
|
+
);
|
|
797
|
+
if (collisions.rows.length > 0) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
"A destination Storage path already exists; nothing was changed.",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
for (const copy of copies) {
|
|
803
|
+
await storageTransfer.copyAndVerify(copy);
|
|
804
|
+
stagedStorageCopies.push(copy);
|
|
805
|
+
}
|
|
806
|
+
await client.query(
|
|
807
|
+
`update storage.objects
|
|
808
|
+
set owner_id = $1::text
|
|
809
|
+
where bucket_id = $2 and name = any($3::text[])`,
|
|
810
|
+
[localUserId, asset.bucket, copies.map((copy) => copy.destination)],
|
|
811
|
+
);
|
|
812
|
+
const transferred = await client.query(
|
|
813
|
+
`select count(*)::integer as count
|
|
814
|
+
from storage.objects
|
|
815
|
+
where owner_id = $1::text
|
|
816
|
+
and bucket_id = $2
|
|
817
|
+
and name = any($3::text[])`,
|
|
818
|
+
[localUserId, asset.bucket, copies.map((copy) => copy.destination)],
|
|
819
|
+
);
|
|
820
|
+
if (Number(transferred.rows[0]?.count) !== copies.length) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
"Storage ownership did not match the verified physical copies; nothing was changed.",
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
storageObjectsTransferred += copies.length;
|
|
826
|
+
}
|
|
827
|
+
if (sourcePathReferenceCount > 0 && storageObjectsTransferred === 0) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
"Copied records reference Storage paths, but no matching objects were available; nothing was changed.",
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
const targetPathPrefix = `${localUserId}/`;
|
|
833
|
+
for (const reference of plan.identity.pathReferences) {
|
|
834
|
+
const target = `${qid(reference.schema)}.${qid(reference.table)}`;
|
|
835
|
+
const column = qid(reference.column);
|
|
836
|
+
await client.query(
|
|
837
|
+
reference.valueType === "text"
|
|
838
|
+
? `update ${target}
|
|
839
|
+
set ${column} = $1::text || substring(${column} from char_length($2::text) + 1)
|
|
840
|
+
where ${column} like $2::text || '%'`
|
|
841
|
+
: `update ${target}
|
|
842
|
+
set ${column} = replace(${column}::text, $2::text, $1::text)::jsonb
|
|
843
|
+
where ${column}::text like '%' || $2::text || '%'`,
|
|
844
|
+
[targetPathPrefix, sourcePathPrefix],
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
await client.query(
|
|
848
|
+
`update auth.users
|
|
849
|
+
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || $2::jsonb,
|
|
850
|
+
updated_at = now()
|
|
851
|
+
where id = $1::uuid`,
|
|
852
|
+
[localUserId, JSON.stringify(plan.identity.claims)],
|
|
853
|
+
);
|
|
854
|
+
if (plan.identity.tokenHook) {
|
|
855
|
+
const hook = plan.identity.tokenHook;
|
|
856
|
+
const tokenResult = await client.query(
|
|
857
|
+
`select ${qid(hook.function.schema)}.${qid(hook.function.name)}(
|
|
858
|
+
jsonb_build_object('user_id', $1::text, 'claims', '{}'::jsonb)
|
|
859
|
+
) as event`,
|
|
860
|
+
[localUserId],
|
|
861
|
+
);
|
|
862
|
+
const claims = tokenResult.rows[0]?.event?.claims;
|
|
863
|
+
if (!containsJson(claims, hook.expectedClaims)) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
"The configured local token hook did not return the declared claims; nothing was changed.",
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
tokenHookVerified = true;
|
|
869
|
+
}
|
|
870
|
+
await client.query(IDENTITY_CLAIM_RECEIPT_SQL);
|
|
871
|
+
await client.query(
|
|
872
|
+
`insert into rehearsal_internal.identity_claims (
|
|
873
|
+
identity_name, placeholder_user_id, local_user_id
|
|
874
|
+
) values ($1, $2::uuid, $3::uuid)
|
|
875
|
+
on conflict (identity_name) do nothing`,
|
|
876
|
+
[plan.identity.name, plan.identity.placeholderUserId, localUserId],
|
|
877
|
+
);
|
|
878
|
+
const claimReceipt = await client.query(
|
|
879
|
+
`select 1
|
|
880
|
+
from rehearsal_internal.identity_claims
|
|
881
|
+
where identity_name = $1
|
|
882
|
+
and placeholder_user_id = $2::uuid
|
|
883
|
+
and local_user_id = $3::uuid`,
|
|
884
|
+
[plan.identity.name, plan.identity.placeholderUserId, localUserId],
|
|
885
|
+
);
|
|
886
|
+
if (claimReceipt.rows.length !== 1) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
"The local identity claim conflicts with an existing association receipt; nothing was changed.",
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
if (localUserId !== plan.identity.placeholderUserId) {
|
|
892
|
+
for (const reference of publicAuthReferences) {
|
|
893
|
+
const remaining = await countIdentityRows({
|
|
894
|
+
client,
|
|
895
|
+
reference,
|
|
896
|
+
userId: plan.identity.placeholderUserId,
|
|
897
|
+
});
|
|
898
|
+
if (remaining === 0) continue;
|
|
899
|
+
const declared = preservedReferences.find(
|
|
900
|
+
(candidate) =>
|
|
901
|
+
candidate.schema === reference.schema &&
|
|
902
|
+
candidate.table === reference.table &&
|
|
903
|
+
candidate.column === reference.column,
|
|
904
|
+
);
|
|
905
|
+
if (!declared) {
|
|
906
|
+
throw new Error(
|
|
907
|
+
"The identity policy omitted a copied application reference; nothing was changed.",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
placeholderRetainedForAudit = true;
|
|
911
|
+
}
|
|
912
|
+
if (!placeholderRetainedForAudit) {
|
|
913
|
+
await client.query(
|
|
914
|
+
`delete from auth.users
|
|
915
|
+
where id = $1::uuid
|
|
916
|
+
and not exists (select 1 from auth.identities where user_id = $1::uuid)`,
|
|
917
|
+
[plan.identity.placeholderUserId],
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
await client.query("commit");
|
|
922
|
+
committed = true;
|
|
923
|
+
const copiesByBucket = Map.groupBy(
|
|
924
|
+
stagedStorageCopies,
|
|
925
|
+
(copy) => copy.bucket,
|
|
926
|
+
);
|
|
927
|
+
for (const [bucket, copies] of copiesByBucket) {
|
|
928
|
+
await storageTransfer.remove({
|
|
929
|
+
bucket,
|
|
930
|
+
paths: copies.map((copy) => copy.source),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
name: plan.identity.name,
|
|
935
|
+
provider: plan.identity.provider,
|
|
936
|
+
claimed: true,
|
|
937
|
+
removedSignupDefaults,
|
|
938
|
+
tokenHookVerified,
|
|
939
|
+
placeholderRetainedForAudit,
|
|
940
|
+
storageObjectsTransferred,
|
|
941
|
+
idempotent: alreadyClaimed,
|
|
942
|
+
};
|
|
943
|
+
} catch (error) {
|
|
944
|
+
if (committed) {
|
|
945
|
+
throw new Error(
|
|
946
|
+
"The identity claim committed and the new Storage copies are usable, but old Storage cleanup failed.",
|
|
947
|
+
{ cause: error },
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
await client.query("rollback").catch(() => undefined);
|
|
951
|
+
if (stagedStorageCopies.length > 0) {
|
|
952
|
+
const cleanupErrors = [];
|
|
953
|
+
for (const [bucket, copies] of Map.groupBy(
|
|
954
|
+
stagedStorageCopies,
|
|
955
|
+
(copy) => copy.bucket,
|
|
956
|
+
)) {
|
|
957
|
+
await storageTransfer
|
|
958
|
+
.remove({
|
|
959
|
+
bucket,
|
|
960
|
+
paths: copies.map((copy) => copy.destination),
|
|
961
|
+
})
|
|
962
|
+
.catch((cleanupError) => cleanupErrors.push(cleanupError));
|
|
963
|
+
}
|
|
964
|
+
if (cleanupErrors.length > 0) {
|
|
965
|
+
throw new AggregateError(
|
|
966
|
+
[error, ...cleanupErrors],
|
|
967
|
+
"The identity claim rolled back, but copied Storage cleanup needs attention.",
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
throw error;
|
|
972
|
+
} finally {
|
|
973
|
+
await client.end?.();
|
|
974
|
+
}
|
|
975
|
+
};
|