@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.13
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 +40 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +2 -2
- package/docs/commands.md +15 -11
- package/docs/getting-started.md +4 -3
- package/docs/production-source.md +138 -16
- package/docs/sanitization.md +73 -7
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +24 -9
- package/docs/troubleshooting.md +12 -5
- package/docs/tutorial.md +2 -1
- package/package.json +2 -2
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +220 -9
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +20 -2
- package/src/cli/renderers.mjs +17 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +26 -5
- package/src/project/setup.mjs +5 -23
- 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 +224 -49
- package/src/source/asset_transfer.mjs +35 -5
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +255 -0
- package/src/source/postgresql_access.mjs +143 -42
- package/src/source/privacy_paths.mjs +38 -0
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
pruneBaselineGenerations,
|
|
14
14
|
verifyActiveBaseline,
|
|
15
15
|
} from "../baseline/artifact.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createPrivacyEngine,
|
|
18
|
+
readPrivacyKey,
|
|
19
|
+
} from "../baseline/privacy_engine.mjs";
|
|
17
20
|
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
18
21
|
import {
|
|
19
22
|
createSourceAccessPlan,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
} from "../source/access.mjs";
|
|
22
25
|
import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
|
|
23
26
|
import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
|
|
27
|
+
import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
|
|
24
28
|
import {
|
|
25
29
|
applyPostgresqlSourceAccess,
|
|
26
30
|
planPostgresqlSourceAccessRetirement,
|
|
@@ -114,6 +118,15 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
114
118
|
join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
|
|
115
119
|
),
|
|
116
120
|
]);
|
|
121
|
+
const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
|
|
122
|
+
const assetPrivacyEngine = createPrivacyEngine({
|
|
123
|
+
policy: privacyPolicy,
|
|
124
|
+
key: privacyKey,
|
|
125
|
+
});
|
|
126
|
+
const assetDeclarations = resolvePrivacyMappedAssets({
|
|
127
|
+
sourcePolicy,
|
|
128
|
+
privacyPolicy,
|
|
129
|
+
});
|
|
117
130
|
return refreshPostgresqlBaseline({
|
|
118
131
|
connectionString,
|
|
119
132
|
sourcePolicy,
|
|
@@ -133,10 +146,12 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
133
146
|
baseUrl:
|
|
134
147
|
process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable],
|
|
135
148
|
token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable],
|
|
136
|
-
declarations:
|
|
149
|
+
declarations: assetDeclarations,
|
|
137
150
|
maximumObjects: sourcePolicy.assetReader.maximumObjects,
|
|
138
151
|
maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
|
|
139
152
|
maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
|
|
153
|
+
pathMapper: ({ mapping, value }) =>
|
|
154
|
+
assetPrivacyEngine.remapPath({ mapping, value }),
|
|
140
155
|
})
|
|
141
156
|
: [],
|
|
142
157
|
});
|
|
@@ -244,9 +259,15 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
|
|
|
244
259
|
};
|
|
245
260
|
|
|
246
261
|
export const createRefreshWorkflow =
|
|
247
|
-
({
|
|
262
|
+
({
|
|
263
|
+
runRuntimeStack,
|
|
264
|
+
prepareDependentTargets,
|
|
265
|
+
topologyCommandEnvironment,
|
|
266
|
+
buildPlan = buildRefreshWorkflowPlan,
|
|
267
|
+
refreshBaseline = runSourceBaselineRefresh,
|
|
268
|
+
}) =>
|
|
248
269
|
async ({ flags, planOptions }) => {
|
|
249
|
-
const context = await
|
|
270
|
+
const context = await buildPlan(planOptions);
|
|
250
271
|
if (!flags.refreshConfirmation) {
|
|
251
272
|
return { mode: "preview", plan: context.plan };
|
|
252
273
|
}
|
|
@@ -259,7 +280,7 @@ export const createRefreshWorkflow =
|
|
|
259
280
|
let runtime;
|
|
260
281
|
let preparations = [];
|
|
261
282
|
try {
|
|
262
|
-
refreshed = await
|
|
283
|
+
refreshed = await refreshBaseline({ planOptions });
|
|
263
284
|
const stack = await runRuntimeStack({
|
|
264
285
|
command: "reset",
|
|
265
286
|
flags,
|
package/src/project/setup.mjs
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
renderDetectedPostgresqlConfig,
|
|
24
24
|
} from "./configuration.mjs";
|
|
25
25
|
import { resolveRuntimeTarget } from "../targets/target.mjs";
|
|
26
|
+
export {
|
|
27
|
+
assertSupportedRehearsalNodeRuntime,
|
|
28
|
+
inspectRehearsalNodeRuntime,
|
|
29
|
+
} from "../shared/node_runtime.mjs";
|
|
30
|
+
import { assertSupportedRehearsalNodeRuntime } from "../shared/node_runtime.mjs";
|
|
26
31
|
|
|
27
32
|
const CONFIG_PATH = "rehearsal.config.mjs";
|
|
28
33
|
const LOCAL_SUPABASE_CONFIG_PATH =
|
|
@@ -82,29 +87,6 @@ export const isRehearsalPortAvailable = async (port) => {
|
|
|
82
87
|
return canBindToPort(port);
|
|
83
88
|
};
|
|
84
89
|
|
|
85
|
-
export const inspectRehearsalNodeRuntime = (
|
|
86
|
-
version = process.versions.node,
|
|
87
|
-
) => {
|
|
88
|
-
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
89
|
-
return {
|
|
90
|
-
version: String(version),
|
|
91
|
-
major,
|
|
92
|
-
supported: major === 24,
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export const assertSupportedRehearsalNodeRuntime = (
|
|
97
|
-
version = process.versions.node,
|
|
98
|
-
) => {
|
|
99
|
-
const runtime = inspectRehearsalNodeRuntime(version);
|
|
100
|
-
if (!runtime.supported) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
return runtime;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
90
|
export const findAvailableRehearsalPorts = async ({
|
|
109
91
|
isAvailable = isRehearsalPortAvailable,
|
|
110
92
|
} = {}) => {
|
package/src/runtime/plan.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
import { createCandidateMigrationReceipt } from "./restore.mjs";
|
|
23
23
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
24
24
|
import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
|
|
25
|
+
import {
|
|
26
|
+
inspectRehearsalNodeRuntime,
|
|
27
|
+
REHEARSAL_NODE_LABEL,
|
|
28
|
+
} from "../shared/node_runtime.mjs";
|
|
25
29
|
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
26
30
|
import {
|
|
27
31
|
readBoundRuntimeSanitizationPolicy,
|
|
@@ -357,17 +361,17 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
357
361
|
};
|
|
358
362
|
}
|
|
359
363
|
const { config, paths, projectRoot } = loaded;
|
|
360
|
-
const
|
|
364
|
+
const nodeRuntime = inspectRehearsalNodeRuntime();
|
|
361
365
|
const checkDefinitions = [
|
|
362
366
|
{
|
|
363
367
|
id: "node",
|
|
364
368
|
label: "Node.js compatibility",
|
|
365
369
|
run: async () => {
|
|
366
|
-
if (
|
|
370
|
+
if (!nodeRuntime.supported)
|
|
367
371
|
throw new Error(`Node ${process.versions.node} is unsupported.`);
|
|
368
372
|
return `Node ${process.versions.node}`;
|
|
369
373
|
},
|
|
370
|
-
remediation:
|
|
374
|
+
remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
|
|
371
375
|
},
|
|
372
376
|
{
|
|
373
377
|
id: "operating-system",
|
package/src/runtime/restore.mjs
CHANGED
|
@@ -194,11 +194,19 @@ const buildIdentitySequenceSql = (table) =>
|
|
|
194
194
|
const schema = tableSchema(table);
|
|
195
195
|
const relation = qualifiedRelation(table);
|
|
196
196
|
const columnName = quoteIdentifier(column.name);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
|
|
198
|
+
return `do $rehearsal_sequence$
|
|
199
|
+
declare
|
|
200
|
+
sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
|
|
201
|
+
maximum_value bigint;
|
|
202
|
+
has_rows boolean;
|
|
203
|
+
begin
|
|
204
|
+
if sequence_name is not null then
|
|
205
|
+
execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
|
|
206
|
+
perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
|
|
207
|
+
end if;
|
|
208
|
+
end
|
|
209
|
+
$rehearsal_sequence$;`;
|
|
202
210
|
})
|
|
203
211
|
.join("\n");
|
|
204
212
|
|
|
@@ -94,6 +94,13 @@ export class RehearsalError extends Error {
|
|
|
94
94
|
const inferCategory = (error) => {
|
|
95
95
|
const message = String(error?.message ?? error);
|
|
96
96
|
if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
|
|
97
|
+
if (
|
|
98
|
+
/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(
|
|
99
|
+
message,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
return "runtime_dependency_failure";
|
|
103
|
+
}
|
|
97
104
|
if (/candidate|migration history diverges/iu.test(message)) {
|
|
98
105
|
return "migration_candidate_failure";
|
|
99
106
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported Node.js release lines. Keep this aligned with package.json and the
|
|
3
|
+
* verification matrix.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const REHEARSAL_NODE_MAJORS = Object.freeze([22, 24, 26]);
|
|
7
|
+
export const REHEARSAL_NODE_LABEL = "Node.js 22, 24, or 26";
|
|
8
|
+
export const REHEARSAL_RECOMMENDED_NODE_MAJOR = 24;
|
|
9
|
+
|
|
10
|
+
export const inspectRehearsalNodeRuntime = (
|
|
11
|
+
version = process.versions.node,
|
|
12
|
+
) => {
|
|
13
|
+
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
14
|
+
return {
|
|
15
|
+
version: String(version),
|
|
16
|
+
major,
|
|
17
|
+
supported: REHEARSAL_NODE_MAJORS.includes(major),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const assertSupportedRehearsalNodeRuntime = (
|
|
22
|
+
version = process.versions.node,
|
|
23
|
+
) => {
|
|
24
|
+
const runtime = inspectRehearsalNodeRuntime(version);
|
|
25
|
+
if (!runtime.supported) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Rehearsal requires ${REHEARSAL_NODE_LABEL}; this shell is using Node.js ${runtime.version}. Switch to a supported release, then rerun setup. With nvm: nvm install --lts && nvm use --lts.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return runtime;
|
|
31
|
+
};
|
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,24 +102,74 @@ 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
|
+
"connectionEnvironmentVariable",
|
|
119
|
+
"credentialFile",
|
|
120
|
+
"maximumValidForMinutes",
|
|
121
|
+
],
|
|
107
122
|
"sourcePolicy.reader",
|
|
108
123
|
);
|
|
124
|
+
let administratorEnvironmentVariable;
|
|
125
|
+
if (readerMode === "managed") {
|
|
126
|
+
administratorEnvironmentVariable = envKey(
|
|
127
|
+
policy.administratorEnvironmentVariable,
|
|
128
|
+
"sourcePolicy.administratorEnvironmentVariable",
|
|
129
|
+
);
|
|
130
|
+
} else if (policy.administratorEnvironmentVariable !== undefined) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"sourcePolicy.administratorEnvironmentVariable is not allowed for an external reader.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
109
135
|
const role = identifier(policy.reader.role, "sourcePolicy.reader.role");
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
136
|
+
let ownerRole;
|
|
137
|
+
let validForMinutes;
|
|
138
|
+
let connectionEnvironmentVariable;
|
|
139
|
+
let maximumValidForMinutes;
|
|
140
|
+
if (readerMode === "managed") {
|
|
141
|
+
ownerRole = identifier(
|
|
142
|
+
policy.reader.ownerRole,
|
|
143
|
+
"sourcePolicy.reader.ownerRole",
|
|
144
|
+
);
|
|
145
|
+
if (role === ownerRole)
|
|
146
|
+
throw new Error("Source reader and owner roles must differ.");
|
|
147
|
+
if (
|
|
148
|
+
!Number.isSafeInteger(policy.reader.validForMinutes) ||
|
|
149
|
+
policy.reader.validForMinutes < 5 ||
|
|
150
|
+
policy.reader.validForMinutes > 240
|
|
151
|
+
) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"sourcePolicy.reader.validForMinutes must be from 5 through 240.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
validForMinutes = policy.reader.validForMinutes;
|
|
157
|
+
} else {
|
|
158
|
+
connectionEnvironmentVariable = envKey(
|
|
159
|
+
policy.reader.connectionEnvironmentVariable,
|
|
160
|
+
"sourcePolicy.reader.connectionEnvironmentVariable",
|
|
161
|
+
);
|
|
162
|
+
if (
|
|
163
|
+
!Number.isSafeInteger(policy.reader.maximumValidForMinutes) ||
|
|
164
|
+
policy.reader.maximumValidForMinutes < 5 ||
|
|
165
|
+
policy.reader.maximumValidForMinutes > 240
|
|
166
|
+
) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"sourcePolicy.reader.maximumValidForMinutes must be from 5 through 240.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
maximumValidForMinutes = policy.reader.maximumValidForMinutes;
|
|
172
|
+
}
|
|
116
173
|
if (
|
|
117
174
|
typeof policy.reader.credentialFile !== "string" ||
|
|
118
175
|
!policy.reader.credentialFile.startsWith(".rehearsal/") ||
|
|
@@ -122,15 +179,6 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
122
179
|
"sourcePolicy.reader.credentialFile must stay under .rehearsal/.",
|
|
123
180
|
);
|
|
124
181
|
}
|
|
125
|
-
if (
|
|
126
|
-
!Number.isSafeInteger(policy.reader.validForMinutes) ||
|
|
127
|
-
policy.reader.validForMinutes < 5 ||
|
|
128
|
-
policy.reader.validForMinutes > 240
|
|
129
|
-
) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
"sourcePolicy.reader.validForMinutes must be from 5 through 240.",
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
182
|
const exportSchema = identifier(
|
|
135
183
|
policy.exportSchema,
|
|
136
184
|
"sourcePolicy.exportSchema",
|
|
@@ -185,6 +233,7 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
185
233
|
"columns",
|
|
186
234
|
"orderBy",
|
|
187
235
|
"rowScope",
|
|
236
|
+
"viewDefinitionSha256",
|
|
188
237
|
],
|
|
189
238
|
label,
|
|
190
239
|
);
|
|
@@ -235,6 +284,17 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
235
284
|
throw new Error(`${label}.orderBy must name unique exported columns.`);
|
|
236
285
|
}
|
|
237
286
|
const orderBy = Object.freeze([...relation.orderBy]);
|
|
287
|
+
let viewDefinitionSha256;
|
|
288
|
+
if (readerMode === "external") {
|
|
289
|
+
viewDefinitionSha256 = fingerprint(
|
|
290
|
+
relation.viewDefinitionSha256,
|
|
291
|
+
`${label}.viewDefinitionSha256`,
|
|
292
|
+
);
|
|
293
|
+
} else if (relation.viewDefinitionSha256 !== undefined) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`${label}.viewDefinitionSha256 is supported only for an external reader.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
238
298
|
if (!isObject(relation.rowScope))
|
|
239
299
|
throw new Error(`${label}.rowScope must be an object.`);
|
|
240
300
|
knownKeys(
|
|
@@ -282,6 +342,7 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
282
342
|
columns,
|
|
283
343
|
orderBy,
|
|
284
344
|
rowScope,
|
|
345
|
+
...(viewDefinitionSha256 ? { viewDefinitionSha256 } : {}),
|
|
285
346
|
});
|
|
286
347
|
});
|
|
287
348
|
const assets = policy.assets ?? [];
|
|
@@ -290,29 +351,79 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
290
351
|
const normalizedAssets = assets.map((asset, index) => {
|
|
291
352
|
const label = `sourcePolicy.assets[${index}]`;
|
|
292
353
|
if (!isObject(asset)) throw new Error(`${label} must be an object.`);
|
|
293
|
-
knownKeys(
|
|
354
|
+
knownKeys(
|
|
355
|
+
asset,
|
|
356
|
+
[
|
|
357
|
+
"bucket",
|
|
358
|
+
"prefix",
|
|
359
|
+
"prefixEnvironmentVariable",
|
|
360
|
+
"rights",
|
|
361
|
+
"pathMapping",
|
|
362
|
+
],
|
|
363
|
+
label,
|
|
364
|
+
);
|
|
294
365
|
if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
|
|
295
366
|
throw new Error(`${label}.bucket is invalid.`);
|
|
296
367
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
prefixParts
|
|
306
|
-
|
|
307
|
-
|
|
368
|
+
const hasFixedPrefix = asset.prefix !== undefined;
|
|
369
|
+
const hasPrivatePrefix = asset.prefixEnvironmentVariable !== undefined;
|
|
370
|
+
if (hasFixedPrefix === hasPrivatePrefix) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`${label} must declare exactly one of prefix or prefixEnvironmentVariable.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (hasFixedPrefix) {
|
|
376
|
+
const prefixParts =
|
|
377
|
+
typeof asset.prefix === "string"
|
|
378
|
+
? asset.prefix.replace(/\/$/u, "").split("/")
|
|
379
|
+
: [];
|
|
380
|
+
if (
|
|
381
|
+
typeof asset.prefix !== "string" ||
|
|
382
|
+
!asset.prefix ||
|
|
383
|
+
asset.prefix.startsWith("/") ||
|
|
384
|
+
prefixParts.some((part) => !part || part === "." || part === "..")
|
|
385
|
+
) {
|
|
386
|
+
throw new Error(`${label}.prefix is unsafe.`);
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
envKey(
|
|
390
|
+
asset.prefixEnvironmentVariable,
|
|
391
|
+
`${label}.prefixEnvironmentVariable`,
|
|
392
|
+
);
|
|
308
393
|
}
|
|
309
394
|
if (!["approved-public", "approved-owner"].includes(asset.rights)) {
|
|
310
395
|
throw new Error(`${label}.rights must be explicitly approved.`);
|
|
311
396
|
}
|
|
397
|
+
if (
|
|
398
|
+
asset.pathMapping !== undefined &&
|
|
399
|
+
!/^[a-z][a-z0-9-]{0,62}$/u.test(asset.pathMapping)
|
|
400
|
+
) {
|
|
401
|
+
throw new Error(`${label}.pathMapping is invalid.`);
|
|
402
|
+
}
|
|
403
|
+
if (hasPrivatePrefix && asset.rights !== "approved-owner") {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`${label}.prefixEnvironmentVariable requires approved-owner rights.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (hasPrivatePrefix && asset.pathMapping === undefined) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`${label}.prefixEnvironmentVariable requires pathMapping.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (hasFixedPrefix && asset.pathMapping !== undefined) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`${label}.pathMapping requires prefixEnvironmentVariable so the raw identity stays out of tracked policy.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
312
418
|
return Object.freeze({
|
|
313
419
|
bucket: asset.bucket,
|
|
314
|
-
|
|
420
|
+
...(hasFixedPrefix
|
|
421
|
+
? { prefix: asset.prefix }
|
|
422
|
+
: { prefixEnvironmentVariable: asset.prefixEnvironmentVariable }),
|
|
315
423
|
rights: asset.rights,
|
|
424
|
+
...(asset.pathMapping === undefined
|
|
425
|
+
? {}
|
|
426
|
+
: { pathMapping: asset.pathMapping }),
|
|
316
427
|
});
|
|
317
428
|
});
|
|
318
429
|
let assetReader = null;
|
|
@@ -368,12 +479,16 @@ export const validateSourceAccessPolicy = (policy) => {
|
|
|
368
479
|
return Object.freeze({
|
|
369
480
|
accessVersion: 1,
|
|
370
481
|
targetFingerprint: policy.targetFingerprint,
|
|
371
|
-
administratorEnvironmentVariable
|
|
482
|
+
...(administratorEnvironmentVariable
|
|
483
|
+
? { administratorEnvironmentVariable }
|
|
484
|
+
: {}),
|
|
372
485
|
reader: Object.freeze({
|
|
486
|
+
mode: readerMode,
|
|
373
487
|
role,
|
|
374
|
-
ownerRole,
|
|
375
488
|
credentialFile: policy.reader.credentialFile,
|
|
376
|
-
|
|
489
|
+
...(readerMode === "managed"
|
|
490
|
+
? { ownerRole, validForMinutes }
|
|
491
|
+
: { connectionEnvironmentVariable, maximumValidForMinutes }),
|
|
377
492
|
}),
|
|
378
493
|
exportSchema,
|
|
379
494
|
migrationLedger,
|
|
@@ -389,10 +504,18 @@ export const createSourceAccessPlan = ({
|
|
|
389
504
|
}) => {
|
|
390
505
|
const normalized = validateSourceAccessPolicy(policy);
|
|
391
506
|
const sourceCredential =
|
|
392
|
-
environment[
|
|
507
|
+
environment[
|
|
508
|
+
normalized.reader.mode === "managed"
|
|
509
|
+
? normalized.administratorEnvironmentVariable
|
|
510
|
+
: normalized.reader.connectionEnvironmentVariable
|
|
511
|
+
];
|
|
393
512
|
if (!sourceCredential) {
|
|
394
513
|
throw new Error(
|
|
395
|
-
`Source preparation requires ${
|
|
514
|
+
`Source preparation requires ${
|
|
515
|
+
normalized.reader.mode === "managed"
|
|
516
|
+
? normalized.administratorEnvironmentVariable
|
|
517
|
+
: normalized.reader.connectionEnvironmentVariable
|
|
518
|
+
}; the value is read from the environment and never printed.`,
|
|
396
519
|
);
|
|
397
520
|
}
|
|
398
521
|
const actualTarget = sourceTargetFingerprint(sourceCredential);
|
|
@@ -411,6 +534,16 @@ export const createSourceAccessPlan = ({
|
|
|
411
534
|
return [relation.view, sha256(value)];
|
|
412
535
|
}),
|
|
413
536
|
);
|
|
537
|
+
const assetPrefixReceipts = Object.fromEntries(
|
|
538
|
+
normalized.assets
|
|
539
|
+
.filter((asset) => asset.prefixEnvironmentVariable)
|
|
540
|
+
.map((asset) => {
|
|
541
|
+
const key = asset.prefixEnvironmentVariable;
|
|
542
|
+
const value = environment[key];
|
|
543
|
+
if (!value) throw new Error(`Source preparation requires ${key}.`);
|
|
544
|
+
return [`${asset.bucket}:${asset.pathMapping}`, sha256(value)];
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
414
547
|
if (normalized.assetReader) {
|
|
415
548
|
const baseUrl =
|
|
416
549
|
environment[normalized.assetReader.baseUrlEnvironmentVariable];
|
|
@@ -435,10 +568,21 @@ export const createSourceAccessPlan = ({
|
|
|
435
568
|
targetFingerprint: normalized.targetFingerprint,
|
|
436
569
|
exportSchema: normalized.exportSchema,
|
|
437
570
|
reader: {
|
|
571
|
+
mode: normalized.reader.mode,
|
|
438
572
|
role: normalized.reader.role,
|
|
439
|
-
ownerRole: normalized.reader.ownerRole,
|
|
440
|
-
validForMinutes: normalized.reader.validForMinutes,
|
|
441
573
|
credentialFile: normalized.reader.credentialFile,
|
|
574
|
+
...(normalized.reader.mode === "managed"
|
|
575
|
+
? {
|
|
576
|
+
ownerRole: normalized.reader.ownerRole,
|
|
577
|
+
validForMinutes: normalized.reader.validForMinutes,
|
|
578
|
+
sourceChanges: true,
|
|
579
|
+
}
|
|
580
|
+
: {
|
|
581
|
+
connectionEnvironmentVariable:
|
|
582
|
+
normalized.reader.connectionEnvironmentVariable,
|
|
583
|
+
maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
|
|
584
|
+
sourceChanges: false,
|
|
585
|
+
}),
|
|
442
586
|
},
|
|
443
587
|
relations: normalized.relations.map((relation) => ({
|
|
444
588
|
source: relation.source,
|
|
@@ -446,6 +590,9 @@ export const createSourceAccessPlan = ({
|
|
|
446
590
|
targetTable: relation.targetTable,
|
|
447
591
|
columns: relation.columns,
|
|
448
592
|
orderBy: relation.orderBy,
|
|
593
|
+
...(relation.viewDefinitionSha256
|
|
594
|
+
? { viewDefinitionSha256: relation.viewDefinitionSha256 }
|
|
595
|
+
: {}),
|
|
449
596
|
rowScope: {
|
|
450
597
|
kind: relation.rowScope.kind,
|
|
451
598
|
...(relation.rowScope.kind === "approved-owner"
|
|
@@ -456,7 +603,15 @@ export const createSourceAccessPlan = ({
|
|
|
456
603
|
: {}),
|
|
457
604
|
},
|
|
458
605
|
})),
|
|
459
|
-
assets: normalized.assets
|
|
606
|
+
assets: normalized.assets.map((asset) =>
|
|
607
|
+
asset.prefixEnvironmentVariable
|
|
608
|
+
? {
|
|
609
|
+
...asset,
|
|
610
|
+
prefixValueSha256:
|
|
611
|
+
assetPrefixReceipts[`${asset.bucket}:${asset.pathMapping}`],
|
|
612
|
+
}
|
|
613
|
+
: asset,
|
|
614
|
+
),
|
|
460
615
|
assetReader: normalized.assetReader
|
|
461
616
|
? {
|
|
462
617
|
endpointFingerprint: normalized.assetReader.endpointFingerprint,
|
|
@@ -465,13 +620,23 @@ export const createSourceAccessPlan = ({
|
|
|
465
620
|
maximumTotalBytes: normalized.assetReader.maximumTotalBytes,
|
|
466
621
|
}
|
|
467
622
|
: null,
|
|
468
|
-
denyChecks:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
623
|
+
denyChecks:
|
|
624
|
+
normalized.reader.mode === "managed"
|
|
625
|
+
? [
|
|
626
|
+
"raw relation reads",
|
|
627
|
+
"writes",
|
|
628
|
+
"role creation and escalation",
|
|
629
|
+
"network functions",
|
|
630
|
+
"unrelated private assets",
|
|
631
|
+
]
|
|
632
|
+
: [
|
|
633
|
+
"all non-system readable columns match the approved views and migration ledger",
|
|
634
|
+
"no relation, column, schema, or database writes",
|
|
635
|
+
"no inherited roles or privileged role attributes",
|
|
636
|
+
"no executable non-system security-definer functions",
|
|
637
|
+
"database-enforced credential expiration",
|
|
638
|
+
"unrelated private assets",
|
|
639
|
+
],
|
|
475
640
|
};
|
|
476
641
|
return Object.freeze({
|
|
477
642
|
policy: normalized,
|
|
@@ -480,6 +645,9 @@ export const createSourceAccessPlan = ({
|
|
|
480
645
|
});
|
|
481
646
|
};
|
|
482
647
|
|
|
648
|
+
export const sourceAccessPolicyFingerprint = (policy) =>
|
|
649
|
+
sha256(canonicalJson(validateSourceAccessPolicy(policy)));
|
|
650
|
+
|
|
483
651
|
export const assetEndpointFingerprint = (value) => {
|
|
484
652
|
let endpoint;
|
|
485
653
|
try {
|
|
@@ -525,6 +693,10 @@ export const createSourceRetirementPlan = ({
|
|
|
525
693
|
if (!Array.isArray(receipt.views) || !Array.isArray(receipt.relations)) {
|
|
526
694
|
throw new Error("Source access receipt object inventory is invalid.");
|
|
527
695
|
}
|
|
696
|
+
const accessMode = receipt.accessMode ?? "managed";
|
|
697
|
+
if (!["managed", "external"].includes(accessMode)) {
|
|
698
|
+
throw new Error("Source access receipt mode is invalid.");
|
|
699
|
+
}
|
|
528
700
|
const relations = receipt.relations.map((relation, index) => {
|
|
529
701
|
if (!isObject(relation))
|
|
530
702
|
throw new Error(`receipt.relations[${index}] is invalid.`);
|
|
@@ -560,6 +732,7 @@ export const createSourceRetirementPlan = ({
|
|
|
560
732
|
const review = {
|
|
561
733
|
planVersion: 1,
|
|
562
734
|
operation: "retire-source-access",
|
|
735
|
+
accessMode,
|
|
563
736
|
targetFingerprint: target,
|
|
564
737
|
preparationDigest: fingerprint(receipt.planDigest, "receipt.planDigest"),
|
|
565
738
|
exportSchema: identifier(receipt.exportSchema, "receipt.exportSchema"),
|
|
@@ -569,7 +742,9 @@ export const createSourceRetirementPlan = ({
|
|
|
569
742
|
relations,
|
|
570
743
|
migrationLedger: ledger,
|
|
571
744
|
readerRole: identifier(receipt.readerRole, "receipt.readerRole"),
|
|
572
|
-
|
|
745
|
+
...(accessMode === "managed"
|
|
746
|
+
? { ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole") }
|
|
747
|
+
: {}),
|
|
573
748
|
};
|
|
574
749
|
return Object.freeze({
|
|
575
750
|
review: Object.freeze(review),
|