@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14
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 +116 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +5 -3
- package/docs/commands.md +15 -11
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +32 -3
- package/docs/production-source.md +175 -17
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +479 -12
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +34 -13
- package/docs/troubleshooting.md +17 -6
- package/docs/tutorial.md +7 -1
- package/package.json +2 -2
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +865 -207
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +23 -3
- package/src/cli/renderers.mjs +29 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +37 -8
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +14 -24
- package/src/runtime/plan.mjs +7 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +271 -51
- package/src/source/asset_transfer.mjs +156 -74
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +368 -0
- package/src/source/postgresql_access.mjs +213 -55
- package/src/source/privacy_paths.mjs +38 -0
package/src/source/access.mjs
CHANGED
|
@@ -50,6 +50,13 @@ const positiveInteger = (value, label) => {
|
|
|
50
50
|
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
51
51
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
52
52
|
|
|
53
|
+
export const externalViewDefinitionFingerprint = (definition) => {
|
|
54
|
+
if (typeof definition !== "string" || !definition.trim()) {
|
|
55
|
+
throw new Error("External view definition must be a non-empty string.");
|
|
56
|
+
}
|
|
57
|
+
return sha256(definition.trim());
|
|
58
|
+
};
|
|
59
|
+
|
|
53
60
|
export const sourceTargetFingerprint = (connectionString) => {
|
|
54
61
|
let target;
|
|
55
62
|
try {
|
|
@@ -95,40 +102,119 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
95
102
|
throw new Error("Source access policy must use accessVersion 1.");
|
|
96
103
|
}
|
|
97
104
|
fingerprint(policy.targetFingerprint, "sourcePolicy.targetFingerprint");
|
|
98
|
-
envKey(
|
|
99
|
-
policy.administratorEnvironmentVariable,
|
|
100
|
-
"sourcePolicy.administratorEnvironmentVariable",
|
|
101
|
-
);
|
|
102
105
|
if (!isObject(policy.reader))
|
|
103
106
|
throw new Error("sourcePolicy.reader must be an object.");
|
|
107
|
+
const readerMode = policy.reader.mode ?? "managed";
|
|
108
|
+
if (!["managed", "external"].includes(readerMode)) {
|
|
109
|
+
throw new Error("sourcePolicy.reader.mode must be managed or external.");
|
|
110
|
+
}
|
|
104
111
|
knownKeys(
|
|
105
112
|
policy.reader,
|
|
106
|
-
|
|
113
|
+
readerMode === "managed"
|
|
114
|
+
? ["mode", "role", "ownerRole", "credentialFile", "validForMinutes"]
|
|
115
|
+
: [
|
|
116
|
+
"mode",
|
|
117
|
+
"role",
|
|
118
|
+
"allowedMemberships",
|
|
119
|
+
"connectionEnvironmentVariable",
|
|
120
|
+
"credentialFile",
|
|
121
|
+
"maximumValidForMinutes",
|
|
122
|
+
],
|
|
107
123
|
"sourcePolicy.reader",
|
|
108
124
|
);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
typeof policy.reader.credentialFile !== "string" ||
|
|
118
|
-
!policy.reader.credentialFile.startsWith(".rehearsal/") ||
|
|
119
|
-
policy.reader.credentialFile.split(/[\\/]/u).includes("..")
|
|
120
|
-
) {
|
|
125
|
+
let administratorEnvironmentVariable;
|
|
126
|
+
if (readerMode === "managed") {
|
|
127
|
+
administratorEnvironmentVariable = envKey(
|
|
128
|
+
policy.administratorEnvironmentVariable,
|
|
129
|
+
"sourcePolicy.administratorEnvironmentVariable",
|
|
130
|
+
);
|
|
131
|
+
} else if (policy.administratorEnvironmentVariable !== undefined) {
|
|
121
132
|
throw new Error(
|
|
122
|
-
"sourcePolicy.
|
|
133
|
+
"sourcePolicy.administratorEnvironmentVariable is not allowed for an external reader.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const role = identifier(policy.reader.role, "sourcePolicy.reader.role");
|
|
137
|
+
let ownerRole;
|
|
138
|
+
let validForMinutes;
|
|
139
|
+
let connectionEnvironmentVariable;
|
|
140
|
+
let maximumValidForMinutes;
|
|
141
|
+
let allowedMemberships = Object.freeze([]);
|
|
142
|
+
if (readerMode === "managed") {
|
|
143
|
+
ownerRole = identifier(
|
|
144
|
+
policy.reader.ownerRole,
|
|
145
|
+
"sourcePolicy.reader.ownerRole",
|
|
146
|
+
);
|
|
147
|
+
if (role === ownerRole)
|
|
148
|
+
throw new Error("Source reader and owner roles must differ.");
|
|
149
|
+
if (
|
|
150
|
+
!Number.isSafeInteger(policy.reader.validForMinutes) ||
|
|
151
|
+
policy.reader.validForMinutes < 5 ||
|
|
152
|
+
policy.reader.validForMinutes > 240
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"sourcePolicy.reader.validForMinutes must be from 5 through 240.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
validForMinutes = policy.reader.validForMinutes;
|
|
159
|
+
} else {
|
|
160
|
+
if (
|
|
161
|
+
policy.reader.allowedMemberships !== undefined &&
|
|
162
|
+
(!Array.isArray(policy.reader.allowedMemberships) ||
|
|
163
|
+
policy.reader.allowedMemberships.length > 8)
|
|
164
|
+
) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
"sourcePolicy.reader.allowedMemberships must contain at most 8 role names.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
allowedMemberships = Object.freeze(
|
|
170
|
+
(policy.reader.allowedMemberships ?? []).map((membership, index) =>
|
|
171
|
+
identifier(
|
|
172
|
+
membership,
|
|
173
|
+
`sourcePolicy.reader.allowedMemberships[${index}]`,
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
if (new Set(allowedMemberships).size !== allowedMemberships.length) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"sourcePolicy.reader.allowedMemberships must not contain duplicates.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (allowedMemberships.includes(role)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"sourcePolicy.reader.allowedMemberships must not include the login role.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
connectionEnvironmentVariable = envKey(
|
|
188
|
+
policy.reader.connectionEnvironmentVariable,
|
|
189
|
+
"sourcePolicy.reader.connectionEnvironmentVariable",
|
|
123
190
|
);
|
|
191
|
+
if (
|
|
192
|
+
!Number.isSafeInteger(policy.reader.maximumValidForMinutes) ||
|
|
193
|
+
policy.reader.maximumValidForMinutes < 5 ||
|
|
194
|
+
policy.reader.maximumValidForMinutes > 240
|
|
195
|
+
) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
"sourcePolicy.reader.maximumValidForMinutes must be from 5 through 240.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
maximumValidForMinutes = policy.reader.maximumValidForMinutes;
|
|
124
201
|
}
|
|
202
|
+
const credentialParts =
|
|
203
|
+
typeof policy.reader.credentialFile === "string"
|
|
204
|
+
? policy.reader.credentialFile.split(/[\\/]/u)
|
|
205
|
+
: [];
|
|
206
|
+
const rehearsalIndex = credentialParts.lastIndexOf(".rehearsal");
|
|
125
207
|
if (
|
|
126
|
-
|
|
127
|
-
policy.reader.
|
|
128
|
-
policy.reader.
|
|
208
|
+
typeof policy.reader.credentialFile !== "string" ||
|
|
209
|
+
policy.reader.credentialFile.startsWith("/") ||
|
|
210
|
+
/^[A-Za-z]:[\\/]/u.test(policy.reader.credentialFile) ||
|
|
211
|
+
credentialParts.some((part) => !part || part === "." || part === "..") ||
|
|
212
|
+
rehearsalIndex < 0 ||
|
|
213
|
+
credentialParts[rehearsalIndex + 1] !== "secrets" ||
|
|
214
|
+
rehearsalIndex + 2 >= credentialParts.length
|
|
129
215
|
) {
|
|
130
216
|
throw new Error(
|
|
131
|
-
"sourcePolicy.reader.
|
|
217
|
+
"sourcePolicy.reader.credentialFile must be a project-relative file under the configured .rehearsal/secrets directory.",
|
|
132
218
|
);
|
|
133
219
|
}
|
|
134
220
|
const exportSchema = identifier(
|
|
@@ -185,6 +271,7 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
185
271
|
"columns",
|
|
186
272
|
"orderBy",
|
|
187
273
|
"rowScope",
|
|
274
|
+
"viewDefinitionSha256",
|
|
188
275
|
],
|
|
189
276
|
label,
|
|
190
277
|
);
|
|
@@ -235,6 +322,17 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
235
322
|
throw new Error(`${label}.orderBy must name unique exported columns.`);
|
|
236
323
|
}
|
|
237
324
|
const orderBy = Object.freeze([...relation.orderBy]);
|
|
325
|
+
let viewDefinitionSha256;
|
|
326
|
+
if (readerMode === "external") {
|
|
327
|
+
viewDefinitionSha256 = fingerprint(
|
|
328
|
+
relation.viewDefinitionSha256,
|
|
329
|
+
`${label}.viewDefinitionSha256`,
|
|
330
|
+
);
|
|
331
|
+
} else if (relation.viewDefinitionSha256 !== undefined) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${label}.viewDefinitionSha256 is supported only for an external reader.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
238
336
|
if (!isObject(relation.rowScope))
|
|
239
337
|
throw new Error(`${label}.rowScope must be an object.`);
|
|
240
338
|
knownKeys(
|
|
@@ -282,6 +380,7 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
282
380
|
columns,
|
|
283
381
|
orderBy,
|
|
284
382
|
rowScope,
|
|
383
|
+
...(viewDefinitionSha256 ? { viewDefinitionSha256 } : {}),
|
|
285
384
|
});
|
|
286
385
|
});
|
|
287
386
|
const assets = policy.assets ?? [];
|
|
@@ -290,29 +389,79 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
290
389
|
const normalizedAssets = assets.map((asset, index) => {
|
|
291
390
|
const label = `sourcePolicy.assets[${index}]`;
|
|
292
391
|
if (!isObject(asset)) throw new Error(`${label} must be an object.`);
|
|
293
|
-
knownKeys(
|
|
392
|
+
knownKeys(
|
|
393
|
+
asset,
|
|
394
|
+
[
|
|
395
|
+
"bucket",
|
|
396
|
+
"prefix",
|
|
397
|
+
"prefixEnvironmentVariable",
|
|
398
|
+
"rights",
|
|
399
|
+
"pathMapping",
|
|
400
|
+
],
|
|
401
|
+
label,
|
|
402
|
+
);
|
|
294
403
|
if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
|
|
295
404
|
throw new Error(`${label}.bucket is invalid.`);
|
|
296
405
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
prefixParts
|
|
306
|
-
|
|
307
|
-
|
|
406
|
+
const hasFixedPrefix = asset.prefix !== undefined;
|
|
407
|
+
const hasPrivatePrefix = asset.prefixEnvironmentVariable !== undefined;
|
|
408
|
+
if (hasFixedPrefix === hasPrivatePrefix) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`${label} must declare exactly one of prefix or prefixEnvironmentVariable.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (hasFixedPrefix) {
|
|
414
|
+
const prefixParts =
|
|
415
|
+
typeof asset.prefix === "string"
|
|
416
|
+
? asset.prefix.replace(/\/$/u, "").split("/")
|
|
417
|
+
: [];
|
|
418
|
+
if (
|
|
419
|
+
typeof asset.prefix !== "string" ||
|
|
420
|
+
!asset.prefix ||
|
|
421
|
+
asset.prefix.startsWith("/") ||
|
|
422
|
+
prefixParts.some((part) => !part || part === "." || part === "..")
|
|
423
|
+
) {
|
|
424
|
+
throw new Error(`${label}.prefix is unsafe.`);
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
envKey(
|
|
428
|
+
asset.prefixEnvironmentVariable,
|
|
429
|
+
`${label}.prefixEnvironmentVariable`,
|
|
430
|
+
);
|
|
308
431
|
}
|
|
309
432
|
if (!["approved-public", "approved-owner"].includes(asset.rights)) {
|
|
310
433
|
throw new Error(`${label}.rights must be explicitly approved.`);
|
|
311
434
|
}
|
|
435
|
+
if (
|
|
436
|
+
asset.pathMapping !== undefined &&
|
|
437
|
+
!/^[a-z][a-z0-9-]{0,62}$/u.test(asset.pathMapping)
|
|
438
|
+
) {
|
|
439
|
+
throw new Error(`${label}.pathMapping is invalid.`);
|
|
440
|
+
}
|
|
441
|
+
if (hasPrivatePrefix && asset.rights !== "approved-owner") {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`${label}.prefixEnvironmentVariable requires approved-owner rights.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (hasPrivatePrefix && asset.pathMapping === undefined) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`${label}.prefixEnvironmentVariable requires pathMapping.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (hasFixedPrefix && asset.pathMapping !== undefined) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`${label}.pathMapping requires prefixEnvironmentVariable so the raw identity stays out of tracked policy.`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
312
456
|
return Object.freeze({
|
|
313
457
|
bucket: asset.bucket,
|
|
314
|
-
|
|
458
|
+
...(hasFixedPrefix
|
|
459
|
+
? { prefix: asset.prefix }
|
|
460
|
+
: { prefixEnvironmentVariable: asset.prefixEnvironmentVariable }),
|
|
315
461
|
rights: asset.rights,
|
|
462
|
+
...(asset.pathMapping === undefined
|
|
463
|
+
? {}
|
|
464
|
+
: { pathMapping: asset.pathMapping }),
|
|
316
465
|
});
|
|
317
466
|
});
|
|
318
467
|
let assetReader = null;
|
|
@@ -368,12 +517,20 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
368
517
|
return Object.freeze({
|
|
369
518
|
accessVersion: 1,
|
|
370
519
|
targetFingerprint: policy.targetFingerprint,
|
|
371
|
-
administratorEnvironmentVariable
|
|
520
|
+
...(administratorEnvironmentVariable
|
|
521
|
+
? { administratorEnvironmentVariable }
|
|
522
|
+
: {}),
|
|
372
523
|
reader: Object.freeze({
|
|
524
|
+
mode: readerMode,
|
|
373
525
|
role,
|
|
374
|
-
ownerRole,
|
|
375
526
|
credentialFile: policy.reader.credentialFile,
|
|
376
|
-
|
|
527
|
+
...(readerMode === "managed"
|
|
528
|
+
? { ownerRole, validForMinutes }
|
|
529
|
+
: {
|
|
530
|
+
allowedMemberships,
|
|
531
|
+
connectionEnvironmentVariable,
|
|
532
|
+
maximumValidForMinutes,
|
|
533
|
+
}),
|
|
377
534
|
}),
|
|
378
535
|
exportSchema,
|
|
379
536
|
migrationLedger,
|
|
@@ -389,10 +546,18 @@ export const createSourceAccessPlan = ({
|
|
|
389
546
|
}) => {
|
|
390
547
|
const normalized = validateSourceAccessPolicy(policy);
|
|
391
548
|
const sourceCredential =
|
|
392
|
-
environment[
|
|
549
|
+
environment[
|
|
550
|
+
normalized.reader.mode === "managed"
|
|
551
|
+
? normalized.administratorEnvironmentVariable
|
|
552
|
+
: normalized.reader.connectionEnvironmentVariable
|
|
553
|
+
];
|
|
393
554
|
if (!sourceCredential) {
|
|
394
555
|
throw new Error(
|
|
395
|
-
`Source preparation requires ${
|
|
556
|
+
`Source preparation requires ${
|
|
557
|
+
normalized.reader.mode === "managed"
|
|
558
|
+
? normalized.administratorEnvironmentVariable
|
|
559
|
+
: normalized.reader.connectionEnvironmentVariable
|
|
560
|
+
}; the value is read from the environment and never printed.`,
|
|
396
561
|
);
|
|
397
562
|
}
|
|
398
563
|
const actualTarget = sourceTargetFingerprint(sourceCredential);
|
|
@@ -411,6 +576,16 @@ export const createSourceAccessPlan = ({
|
|
|
411
576
|
return [relation.view, sha256(value)];
|
|
412
577
|
}),
|
|
413
578
|
);
|
|
579
|
+
const assetPrefixReceipts = Object.fromEntries(
|
|
580
|
+
normalized.assets
|
|
581
|
+
.filter((asset) => asset.prefixEnvironmentVariable)
|
|
582
|
+
.map((asset) => {
|
|
583
|
+
const key = asset.prefixEnvironmentVariable;
|
|
584
|
+
const value = environment[key];
|
|
585
|
+
if (!value) throw new Error(`Source preparation requires ${key}.`);
|
|
586
|
+
return [`${asset.bucket}:${asset.pathMapping}`, sha256(value)];
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
414
589
|
if (normalized.assetReader) {
|
|
415
590
|
const baseUrl =
|
|
416
591
|
environment[normalized.assetReader.baseUrlEnvironmentVariable];
|
|
@@ -435,10 +610,22 @@ export const createSourceAccessPlan = ({
|
|
|
435
610
|
targetFingerprint: normalized.targetFingerprint,
|
|
436
611
|
exportSchema: normalized.exportSchema,
|
|
437
612
|
reader: {
|
|
613
|
+
mode: normalized.reader.mode,
|
|
438
614
|
role: normalized.reader.role,
|
|
439
|
-
ownerRole: normalized.reader.ownerRole,
|
|
440
|
-
validForMinutes: normalized.reader.validForMinutes,
|
|
441
615
|
credentialFile: normalized.reader.credentialFile,
|
|
616
|
+
...(normalized.reader.mode === "managed"
|
|
617
|
+
? {
|
|
618
|
+
ownerRole: normalized.reader.ownerRole,
|
|
619
|
+
validForMinutes: normalized.reader.validForMinutes,
|
|
620
|
+
sourceChanges: true,
|
|
621
|
+
}
|
|
622
|
+
: {
|
|
623
|
+
connectionEnvironmentVariable:
|
|
624
|
+
normalized.reader.connectionEnvironmentVariable,
|
|
625
|
+
allowedMemberships: normalized.reader.allowedMemberships,
|
|
626
|
+
maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
|
|
627
|
+
sourceChanges: false,
|
|
628
|
+
}),
|
|
442
629
|
},
|
|
443
630
|
relations: normalized.relations.map((relation) => ({
|
|
444
631
|
source: relation.source,
|
|
@@ -446,6 +633,9 @@ export const createSourceAccessPlan = ({
|
|
|
446
633
|
targetTable: relation.targetTable,
|
|
447
634
|
columns: relation.columns,
|
|
448
635
|
orderBy: relation.orderBy,
|
|
636
|
+
...(relation.viewDefinitionSha256
|
|
637
|
+
? { viewDefinitionSha256: relation.viewDefinitionSha256 }
|
|
638
|
+
: {}),
|
|
449
639
|
rowScope: {
|
|
450
640
|
kind: relation.rowScope.kind,
|
|
451
641
|
...(relation.rowScope.kind === "approved-owner"
|
|
@@ -456,7 +646,15 @@ export const createSourceAccessPlan = ({
|
|
|
456
646
|
: {}),
|
|
457
647
|
},
|
|
458
648
|
})),
|
|
459
|
-
assets: normalized.assets
|
|
649
|
+
assets: normalized.assets.map((asset) =>
|
|
650
|
+
asset.prefixEnvironmentVariable
|
|
651
|
+
? {
|
|
652
|
+
...asset,
|
|
653
|
+
prefixValueSha256:
|
|
654
|
+
assetPrefixReceipts[`${asset.bucket}:${asset.pathMapping}`],
|
|
655
|
+
}
|
|
656
|
+
: asset,
|
|
657
|
+
),
|
|
460
658
|
assetReader: normalized.assetReader
|
|
461
659
|
? {
|
|
462
660
|
endpointFingerprint: normalized.assetReader.endpointFingerprint,
|
|
@@ -465,13 +663,25 @@ export const createSourceAccessPlan = ({
|
|
|
465
663
|
maximumTotalBytes: normalized.assetReader.maximumTotalBytes,
|
|
466
664
|
}
|
|
467
665
|
: null,
|
|
468
|
-
denyChecks:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
666
|
+
denyChecks:
|
|
667
|
+
normalized.reader.mode === "managed"
|
|
668
|
+
? [
|
|
669
|
+
"raw relation reads",
|
|
670
|
+
"writes",
|
|
671
|
+
"role creation and escalation",
|
|
672
|
+
"network functions",
|
|
673
|
+
"unrelated private assets",
|
|
674
|
+
]
|
|
675
|
+
: [
|
|
676
|
+
"all non-system readable columns match the approved views and migration ledger",
|
|
677
|
+
"no relation, column, schema, or database writes",
|
|
678
|
+
"role memberships exactly match the reviewed allowlist",
|
|
679
|
+
"allowed groups cannot log in, escalate, switch roles, delegate membership, or inherit other roles",
|
|
680
|
+
"no privileged role attributes",
|
|
681
|
+
"no executable non-system security-definer functions",
|
|
682
|
+
"database-enforced credential expiration",
|
|
683
|
+
"unrelated private assets",
|
|
684
|
+
],
|
|
475
685
|
};
|
|
476
686
|
return Object.freeze({
|
|
477
687
|
policy: normalized,
|
|
@@ -480,6 +690,9 @@ export const createSourceAccessPlan = ({
|
|
|
480
690
|
});
|
|
481
691
|
};
|
|
482
692
|
|
|
693
|
+
export const sourceAccessPolicyFingerprint = (policy) =>
|
|
694
|
+
sha256(canonicalJson(validateSourceAccessPolicy(policy)));
|
|
695
|
+
|
|
483
696
|
export const assetEndpointFingerprint = (value) => {
|
|
484
697
|
let endpoint;
|
|
485
698
|
try {
|
|
@@ -525,6 +738,10 @@ export const createSourceRetirementPlan = ({
|
|
|
525
738
|
if (!Array.isArray(receipt.views) || !Array.isArray(receipt.relations)) {
|
|
526
739
|
throw new Error("Source access receipt object inventory is invalid.");
|
|
527
740
|
}
|
|
741
|
+
const accessMode = receipt.accessMode ?? "managed";
|
|
742
|
+
if (!["managed", "external"].includes(accessMode)) {
|
|
743
|
+
throw new Error("Source access receipt mode is invalid.");
|
|
744
|
+
}
|
|
528
745
|
const relations = receipt.relations.map((relation, index) => {
|
|
529
746
|
if (!isObject(relation))
|
|
530
747
|
throw new Error(`receipt.relations[${index}] is invalid.`);
|
|
@@ -560,6 +777,7 @@ export const createSourceRetirementPlan = ({
|
|
|
560
777
|
const review = {
|
|
561
778
|
planVersion: 1,
|
|
562
779
|
operation: "retire-source-access",
|
|
780
|
+
accessMode,
|
|
563
781
|
targetFingerprint: target,
|
|
564
782
|
preparationDigest: fingerprint(receipt.planDigest, "receipt.planDigest"),
|
|
565
783
|
exportSchema: identifier(receipt.exportSchema, "receipt.exportSchema"),
|
|
@@ -569,7 +787,9 @@ export const createSourceRetirementPlan = ({
|
|
|
569
787
|
relations,
|
|
570
788
|
migrationLedger: ledger,
|
|
571
789
|
readerRole: identifier(receipt.readerRole, "receipt.readerRole"),
|
|
572
|
-
|
|
790
|
+
...(accessMode === "managed"
|
|
791
|
+
? { ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole") }
|
|
792
|
+
: {}),
|
|
573
793
|
};
|
|
574
794
|
return Object.freeze({
|
|
575
795
|
review: Object.freeze(review),
|