@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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const BUCKET = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
4
4
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
5
|
+
const MAXIMUM_STORAGE_DEPTH = 64;
|
|
6
|
+
const MAXIMUM_STORAGE_PATH_BYTES = 4096;
|
|
5
7
|
|
|
6
8
|
const safeBaseUrl = (value) => {
|
|
7
9
|
let url;
|
|
@@ -34,14 +36,40 @@ const safePath = (value, label) => {
|
|
|
34
36
|
) {
|
|
35
37
|
throw new Error(`${label} is unsafe.`);
|
|
36
38
|
}
|
|
39
|
+
if (Buffer.byteLength(value) > MAXIMUM_STORAGE_PATH_BYTES) {
|
|
40
|
+
throw new Error(`${label} is too long.`);
|
|
41
|
+
}
|
|
37
42
|
return value;
|
|
38
43
|
};
|
|
39
44
|
|
|
45
|
+
const safeInventoryName = (value) => {
|
|
46
|
+
const name = safePath(value, "Storage inventory name");
|
|
47
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
48
|
+
throw new Error("Storage inventory name is not a single path segment.");
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
};
|
|
52
|
+
|
|
40
53
|
const readError = (label, response) =>
|
|
41
54
|
new Error(
|
|
42
55
|
`${label} failed with HTTP ${response.status}; response content was withheld.`,
|
|
43
56
|
);
|
|
44
57
|
|
|
58
|
+
const canonicalContentType = (...values) => {
|
|
59
|
+
for (const value of values) {
|
|
60
|
+
if (typeof value !== "string") continue;
|
|
61
|
+
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
|
|
62
|
+
if (
|
|
63
|
+
/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(
|
|
64
|
+
mediaType,
|
|
65
|
+
)
|
|
66
|
+
) {
|
|
67
|
+
return mediaType;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return "application/octet-stream";
|
|
71
|
+
};
|
|
72
|
+
|
|
45
73
|
const responseBody = async function* ({
|
|
46
74
|
response,
|
|
47
75
|
expectedBytes,
|
|
@@ -69,6 +97,7 @@ export const streamApprovedSupabaseAssets = async function* ({
|
|
|
69
97
|
maximumObjects = 10_000,
|
|
70
98
|
maximumObjectBytes = 50 * 1024 * 1024,
|
|
71
99
|
maximumTotalBytes = 2 * 1024 * 1024 * 1024,
|
|
100
|
+
pathMapper,
|
|
72
101
|
}) {
|
|
73
102
|
const endpoint = safeBaseUrl(baseUrl);
|
|
74
103
|
if (typeof token !== "string" || token.length < 16) {
|
|
@@ -77,101 +106,154 @@ export const streamApprovedSupabaseAssets = async function* ({
|
|
|
77
106
|
if (!Array.isArray(declarations) || declarations.length === 0) return;
|
|
78
107
|
let objectCount = 0;
|
|
79
108
|
let totalBytes = 0;
|
|
109
|
+
const inventoryPaths = new Set();
|
|
80
110
|
for (const declaration of declarations) {
|
|
81
111
|
if (!BUCKET.test(declaration.bucket ?? "")) {
|
|
82
112
|
throw new Error("Approved Storage bucket is invalid.");
|
|
83
113
|
}
|
|
84
|
-
const
|
|
114
|
+
const approvedPrefix = safePath(
|
|
85
115
|
declaration.prefix.replace(/\/$/u, ""),
|
|
86
116
|
"Approved Storage prefix",
|
|
87
117
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"content-type": "application/json",
|
|
97
|
-
},
|
|
98
|
-
body: JSON.stringify({
|
|
99
|
-
prefix,
|
|
100
|
-
limit: 100,
|
|
101
|
-
offset,
|
|
102
|
-
sortBy: { column: "name", order: "asc" },
|
|
103
|
-
}),
|
|
104
|
-
},
|
|
105
|
-
);
|
|
106
|
-
if (!response.ok) throw readError("Storage inventory", response);
|
|
107
|
-
const inventory = await response.json();
|
|
108
|
-
if (!Array.isArray(inventory))
|
|
109
|
-
throw new Error("Storage inventory response is invalid.");
|
|
110
|
-
for (const object of inventory) {
|
|
111
|
-
const name = safePath(
|
|
112
|
-
`${prefix}/${object.name}`,
|
|
113
|
-
"Storage object path",
|
|
114
|
-
);
|
|
115
|
-
if (!name.startsWith(`${prefix}/`))
|
|
116
|
-
throw new Error("Storage inventory escaped its approved prefix.");
|
|
117
|
-
const expectedBytes = Number(
|
|
118
|
-
object.metadata?.size ?? object.metadata?.contentLength,
|
|
119
|
-
);
|
|
120
|
-
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
|
|
121
|
-
throw new Error("Storage inventory omitted an exact object size.");
|
|
122
|
-
}
|
|
123
|
-
if (expectedBytes > maximumObjectBytes) {
|
|
124
|
-
throw new Error("Storage object exceeds its reviewed size limit.");
|
|
125
|
-
}
|
|
126
|
-
objectCount += 1;
|
|
127
|
-
totalBytes += expectedBytes;
|
|
128
|
-
if (objectCount > maximumObjects || totalBytes > maximumTotalBytes) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
"Storage inventory exceeds its reviewed transfer boundary.",
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
const encodedPath = name.split("/").map(encodeURIComponent).join("/");
|
|
134
|
-
const download = await fetchImplementation(
|
|
135
|
-
`${endpoint}/storage/v1/object/authenticated/${encodeURIComponent(declaration.bucket)}/${encodedPath}`,
|
|
118
|
+
const prefixes = [{ path: approvedPrefix, depth: 0 }];
|
|
119
|
+
const scheduledPrefixes = new Set([approvedPrefix]);
|
|
120
|
+
for (let prefixIndex = 0; prefixIndex < prefixes.length; prefixIndex += 1) {
|
|
121
|
+
const prefix = prefixes[prefixIndex];
|
|
122
|
+
let offset = 0;
|
|
123
|
+
while (true) {
|
|
124
|
+
const response = await fetchImplementation(
|
|
125
|
+
`${endpoint}/storage/v1/object/list/${encodeURIComponent(declaration.bucket)}`,
|
|
136
126
|
{
|
|
127
|
+
method: "POST",
|
|
137
128
|
headers: {
|
|
138
129
|
authorization: `Bearer ${token}`,
|
|
139
|
-
|
|
140
|
-
? { "if-match": object.metadata.eTag }
|
|
141
|
-
: {}),
|
|
130
|
+
"content-type": "application/json",
|
|
142
131
|
},
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
prefix: prefix.path,
|
|
134
|
+
limit: 100,
|
|
135
|
+
offset,
|
|
136
|
+
sortBy: { column: "name", order: "asc" },
|
|
137
|
+
}),
|
|
143
138
|
},
|
|
144
139
|
);
|
|
145
|
-
if (!
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
140
|
+
if (!response.ok) throw readError("Storage inventory", response);
|
|
141
|
+
const inventory = await response.json();
|
|
142
|
+
if (!Array.isArray(inventory))
|
|
143
|
+
throw new Error("Storage inventory response is invalid.");
|
|
144
|
+
for (const object of inventory) {
|
|
145
|
+
if (
|
|
146
|
+
object === null ||
|
|
147
|
+
typeof object !== "object" ||
|
|
148
|
+
Array.isArray(object)
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("Storage inventory entry is invalid.");
|
|
151
|
+
}
|
|
152
|
+
const entryName = safeInventoryName(object.name);
|
|
153
|
+
const name = safePath(
|
|
154
|
+
`${prefix.path}/${entryName}`,
|
|
155
|
+
"Storage object path",
|
|
156
|
+
);
|
|
157
|
+
if (!name.startsWith(`${approvedPrefix}/`))
|
|
158
|
+
throw new Error("Storage inventory escaped its approved prefix.");
|
|
159
|
+
const inventoryKey = `${declaration.bucket}\0${name}`;
|
|
160
|
+
if (inventoryPaths.has(inventoryKey)) {
|
|
161
|
+
throw new Error("Storage inventory returned a duplicate path.");
|
|
162
|
+
}
|
|
163
|
+
inventoryPaths.add(inventoryKey);
|
|
164
|
+
objectCount += 1;
|
|
165
|
+
if (objectCount > maximumObjects) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"Storage inventory exceeds its reviewed transfer boundary.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const directory = object.id === null && object.metadata === null;
|
|
171
|
+
if (directory) {
|
|
172
|
+
if (prefix.depth >= MAXIMUM_STORAGE_DEPTH) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"Storage inventory exceeds its safe folder depth.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (scheduledPrefixes.has(name)) {
|
|
178
|
+
throw new Error("Storage inventory returned a duplicate folder.");
|
|
179
|
+
}
|
|
180
|
+
scheduledPrefixes.add(name);
|
|
181
|
+
prefixes.push({ path: name, depth: prefix.depth + 1 });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const expectedBytes = Number(
|
|
185
|
+
object.metadata?.size ?? object.metadata?.contentLength,
|
|
186
|
+
);
|
|
187
|
+
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
|
|
188
|
+
throw new Error("Storage inventory omitted an exact object size.");
|
|
189
|
+
}
|
|
190
|
+
if (expectedBytes > maximumObjectBytes) {
|
|
191
|
+
throw new Error("Storage object exceeds its reviewed size limit.");
|
|
192
|
+
}
|
|
193
|
+
totalBytes += expectedBytes;
|
|
194
|
+
if (totalBytes > maximumTotalBytes) {
|
|
149
195
|
throw new Error(
|
|
150
|
-
"Storage
|
|
196
|
+
"Storage inventory exceeds its reviewed transfer boundary.",
|
|
151
197
|
);
|
|
152
198
|
}
|
|
153
|
-
|
|
199
|
+
const encodedPath = name.split("/").map(encodeURIComponent).join("/");
|
|
200
|
+
const download = await fetchImplementation(
|
|
201
|
+
`${endpoint}/storage/v1/object/authenticated/${encodeURIComponent(declaration.bucket)}/${encodedPath}`,
|
|
202
|
+
{
|
|
203
|
+
headers: {
|
|
204
|
+
authorization: `Bearer ${token}`,
|
|
205
|
+
...(object.metadata?.eTag
|
|
206
|
+
? { "if-match": object.metadata.eTag }
|
|
207
|
+
: {}),
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
if (!download.ok)
|
|
212
|
+
throw readError("Storage object transfer", download);
|
|
213
|
+
const responseEtag = download.headers.get("etag");
|
|
214
|
+
if (object.metadata?.eTag) {
|
|
215
|
+
if (!responseEtag) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
"Storage transfer omitted the reviewed object version.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (responseEtag !== object.metadata.eTag) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"Storage object changed between inventory and transfer.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (declaration.pathMapping && typeof pathMapper !== "function") {
|
|
154
227
|
throw new Error(
|
|
155
|
-
"Storage
|
|
228
|
+
"Storage path mapping requires the package-owned privacy mapper.",
|
|
156
229
|
);
|
|
157
230
|
}
|
|
231
|
+
const objectPath = declaration.pathMapping
|
|
232
|
+
? safePath(
|
|
233
|
+
pathMapper({
|
|
234
|
+
mapping: declaration.pathMapping,
|
|
235
|
+
value: name,
|
|
236
|
+
}),
|
|
237
|
+
"Mapped Storage object path",
|
|
238
|
+
)
|
|
239
|
+
: name;
|
|
240
|
+
yield {
|
|
241
|
+
bucket: declaration.bucket,
|
|
242
|
+
objectPath,
|
|
243
|
+
contentType: canonicalContentType(
|
|
244
|
+
object.metadata?.mimetype,
|
|
245
|
+
download.headers.get("content-type"),
|
|
246
|
+
),
|
|
247
|
+
content: responseBody({
|
|
248
|
+
response: download,
|
|
249
|
+
expectedBytes,
|
|
250
|
+
maximumBytes: maximumObjectBytes,
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
158
253
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
objectPath: name,
|
|
162
|
-
contentType:
|
|
163
|
-
download.headers.get("content-type") ??
|
|
164
|
-
object.metadata?.mimetype ??
|
|
165
|
-
"application/octet-stream",
|
|
166
|
-
content: responseBody({
|
|
167
|
-
response: download,
|
|
168
|
-
expectedBytes,
|
|
169
|
-
maximumBytes: maximumObjectBytes,
|
|
170
|
-
}),
|
|
171
|
-
};
|
|
254
|
+
if (inventory.length < 100) break;
|
|
255
|
+
offset += inventory.length;
|
|
172
256
|
}
|
|
173
|
-
if (inventory.length < 100) break;
|
|
174
|
-
offset += inventory.length;
|
|
175
257
|
}
|
|
176
258
|
}
|
|
177
259
|
};
|
package/src/source/baseline.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
validateExecutablePrivacyPolicy,
|
|
25
25
|
} from "../baseline/privacy_engine.mjs";
|
|
26
26
|
import {
|
|
27
|
+
sourceAccessPolicyFingerprint,
|
|
27
28
|
sourceTargetFingerprint,
|
|
28
29
|
validateSourceAccessPolicy,
|
|
29
30
|
} from "./access.mjs";
|
|
@@ -45,6 +46,15 @@ const quoteIdentifier = (value) => {
|
|
|
45
46
|
|
|
46
47
|
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
47
48
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
49
|
+
const parseEstimateValue = (value) => {
|
|
50
|
+
if (
|
|
51
|
+
(typeof value !== "string" && typeof value !== "number") ||
|
|
52
|
+
(typeof value === "string" && !/^(?:0|[1-9]\d*)$/u.test(value))
|
|
53
|
+
) {
|
|
54
|
+
return Number.NaN;
|
|
55
|
+
}
|
|
56
|
+
return Number(value);
|
|
57
|
+
};
|
|
48
58
|
|
|
49
59
|
const normalizeLimits = (limits = {}) => {
|
|
50
60
|
const normalized = { ...DEFAULT_LIMITS, ...limits };
|
|
@@ -132,10 +142,20 @@ const estimateExport = async (client, policy) => {
|
|
|
132
142
|
coalesce(sum(pg_column_size(export_row)), 0)::bigint as bytes
|
|
133
143
|
from ${quoteIdentifier(policy.exportSchema)}.${quoteIdentifier(relation.view)} export_row`,
|
|
134
144
|
);
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
const estimate = result.rows?.[0];
|
|
146
|
+
const tableRows = parseEstimateValue(estimate?.rows);
|
|
147
|
+
const tableBytes = parseEstimateValue(estimate?.bytes);
|
|
148
|
+
if (
|
|
149
|
+
!Number.isSafeInteger(tableRows) ||
|
|
150
|
+
!Number.isSafeInteger(tableBytes) ||
|
|
151
|
+
tableRows < 0 ||
|
|
152
|
+
tableBytes < 0 ||
|
|
153
|
+
!Number.isSafeInteger(rows + tableRows) ||
|
|
154
|
+
!Number.isSafeInteger(bytes + tableBytes)
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"Source export estimate is missing, negative, or exceeds safe numeric limits.",
|
|
158
|
+
);
|
|
139
159
|
}
|
|
140
160
|
rows += tableRows;
|
|
141
161
|
bytes += tableBytes;
|
|
@@ -238,6 +258,14 @@ const streamSanitizedRecords = async function* ({
|
|
|
238
258
|
const batch = await client.query(
|
|
239
259
|
`fetch forward ${limits.batchRows} from ${quoteIdentifier(cursor)}`,
|
|
240
260
|
);
|
|
261
|
+
if (
|
|
262
|
+
!Array.isArray(batch?.rows) ||
|
|
263
|
+
batch.rows.length > limits.batchRows
|
|
264
|
+
) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
"Source reader returned a malformed or oversized cursor batch.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
241
269
|
if (batch.rows.length === 0) break;
|
|
242
270
|
for (const row of batch.rows) {
|
|
243
271
|
const sanitized = privacyEngine.sanitize({
|
|
@@ -295,6 +323,7 @@ export const refreshPostgresqlBaseline = async ({
|
|
|
295
323
|
);
|
|
296
324
|
}
|
|
297
325
|
const expectedReceiptShape = {
|
|
326
|
+
accessMode: sourcePolicy.reader.mode,
|
|
298
327
|
exportSchema: sourcePolicy.exportSchema,
|
|
299
328
|
views: sourcePolicy.relations.map((relation) => relation.view).sort(),
|
|
300
329
|
relations: sourcePolicy.relations.map((relation) => ({
|
|
@@ -303,19 +332,25 @@ export const refreshPostgresqlBaseline = async ({
|
|
|
303
332
|
})),
|
|
304
333
|
migrationLedger: sourcePolicy.migrationLedger,
|
|
305
334
|
readerRole: sourcePolicy.reader.role,
|
|
306
|
-
ownerRole: sourcePolicy.reader.ownerRole,
|
|
335
|
+
ownerRole: sourcePolicy.reader.ownerRole ?? null,
|
|
307
336
|
};
|
|
308
337
|
const actualReceiptShape = {
|
|
338
|
+
accessMode: sourceAccessReceipt?.accessMode ?? "managed",
|
|
309
339
|
exportSchema: sourceAccessReceipt?.exportSchema,
|
|
310
340
|
views: [...(sourceAccessReceipt?.views ?? [])].sort(),
|
|
311
341
|
relations: sourceAccessReceipt?.relations,
|
|
312
342
|
migrationLedger: sourceAccessReceipt?.migrationLedger,
|
|
313
343
|
readerRole: sourceAccessReceipt?.readerRole,
|
|
314
|
-
ownerRole: sourceAccessReceipt?.ownerRole,
|
|
344
|
+
ownerRole: sourceAccessReceipt?.ownerRole ?? null,
|
|
315
345
|
};
|
|
346
|
+
const policyFingerprint = sourceAccessPolicyFingerprint(sourcePolicy);
|
|
316
347
|
if (
|
|
317
348
|
sourceAccessReceipt?.targetFingerprint !== sourcePolicy.targetFingerprint ||
|
|
318
349
|
!/^[a-f0-9]{64}$/u.test(sourceAccessReceipt?.planDigest ?? "") ||
|
|
350
|
+
(sourcePolicy.reader.mode === "external" &&
|
|
351
|
+
sourceAccessReceipt?.policyFingerprint !== policyFingerprint) ||
|
|
352
|
+
(sourceAccessReceipt?.policyFingerprint !== undefined &&
|
|
353
|
+
sourceAccessReceipt.policyFingerprint !== policyFingerprint) ||
|
|
319
354
|
new Date(sourceAccessReceipt.expiresAt).valueOf() <= Date.now() ||
|
|
320
355
|
JSON.stringify(actualReceiptShape) !== JSON.stringify(expectedReceiptShape)
|
|
321
356
|
) {
|
|
@@ -388,6 +423,7 @@ export const refreshPostgresqlBaseline = async ({
|
|
|
388
423
|
canonicalJson({
|
|
389
424
|
targetFingerprint: sourceAccessReceipt.targetFingerprint,
|
|
390
425
|
planDigest: sourceAccessReceipt.planDigest,
|
|
426
|
+
policyFingerprint: sourceAccessReceipt.policyFingerprint ?? null,
|
|
391
427
|
expiresAt: sourceAccessReceipt.expiresAt,
|
|
392
428
|
}),
|
|
393
429
|
),
|