@bevel-software/platform-core-backend 0.24.0 → 0.25.0
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +87 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +166 -48
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +58 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +40 -0
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
- package/src/modules/database/migrate.ts +238 -52
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
- package/src/modules/workspace/workspace.tools.ts +59 -21
|
@@ -83,8 +83,19 @@ export async function runCoreMigrations(db: Database, folder: string): Promise<v
|
|
|
83
83
|
* Apply the ENTERPRISE migration history from `folder`, tracked in
|
|
84
84
|
* `__drizzle_migrations_enterprise`. Run AFTER {@link runCoreMigrations} —
|
|
85
85
|
* enterprise tables FK into core tables.
|
|
86
|
+
*
|
|
87
|
+
* `piiBackfill` is the overlay's own personal data, for the overlay that has
|
|
88
|
+
* sealed columns of its own (`encryptedText` / `blindIndexText` in its
|
|
89
|
+
* schema): the rows written before those columns were sealed are sealed here,
|
|
90
|
+
* right after the history that adds the index columns and under the same
|
|
91
|
+
* lock, exactly as {@link runCoreMigrations} does for core's tables. See
|
|
92
|
+
* {@link runPiiBackfill}.
|
|
86
93
|
*/
|
|
87
|
-
export async function runEnterpriseMigrations(
|
|
94
|
+
export async function runEnterpriseMigrations(
|
|
95
|
+
db: Database,
|
|
96
|
+
folder: string,
|
|
97
|
+
opts: { piiBackfill?: PiiBackfillSpec } = {},
|
|
98
|
+
): Promise<void> {
|
|
88
99
|
const { migrationsSchema, tenantKey } = ledgerOptions(db);
|
|
89
100
|
await withAdvisoryLock(
|
|
90
101
|
db,
|
|
@@ -97,6 +108,7 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
|
|
|
97
108
|
migrationsSchema,
|
|
98
109
|
});
|
|
99
110
|
log.info('Enterprise migrations complete.');
|
|
111
|
+
if (opts.piiBackfill) await runPiiBackfill(db, opts.piiBackfill);
|
|
100
112
|
},
|
|
101
113
|
{ tenantKey },
|
|
102
114
|
);
|
|
@@ -147,14 +159,61 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
|
|
|
147
159
|
* ciphertext, which it passes through.
|
|
148
160
|
*/
|
|
149
161
|
|
|
150
|
-
|
|
162
|
+
/** A blind-index column and the sealed column whose plaintext it is the index of. */
|
|
163
|
+
export interface PiiBlindIndex {
|
|
164
|
+
/** One of the table's `encrypted` columns. */
|
|
165
|
+
source: string;
|
|
166
|
+
column: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One table of personal data, as the backfill works on it: the columns `encryptedText` and `blindIndexText` are declared on. */
|
|
170
|
+
export interface PiiBackfillTable {
|
|
151
171
|
table: string;
|
|
152
172
|
/** Columns that uniquely identify a row for the write-back UPDATE. */
|
|
153
173
|
key: string[];
|
|
154
174
|
/** Columns whose plaintext values get rewritten as ciphertext. */
|
|
155
175
|
encrypted: string[];
|
|
156
|
-
/** Blind-index column to fill from the plaintext of `source`. */
|
|
157
|
-
bidx?:
|
|
176
|
+
/** Blind-index column(s) to fill, each from the plaintext of its `source`. */
|
|
177
|
+
bidx?: PiiBlindIndex | PiiBlindIndex[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whose rows a backfill seals, and what closes it. Core's own tables are one
|
|
182
|
+
* such spec ({@link runPiiEncryptionBackfill}); an overlay that seals columns
|
|
183
|
+
* of its own schema writes another and hands it to
|
|
184
|
+
* {@link runEnterpriseMigrations} (or to {@link runPiiBackfill}, for a
|
|
185
|
+
* database it migrates under a lock of its own).
|
|
186
|
+
*/
|
|
187
|
+
export interface PiiBackfillSpec {
|
|
188
|
+
/** Whose tables these are, for the log and the refusals: `core`, `enterprise`, … */
|
|
189
|
+
name: string;
|
|
190
|
+
tables: PiiBackfillTable[];
|
|
191
|
+
/**
|
|
192
|
+
* A blind-index column of one of `tables` that the SQL history adds
|
|
193
|
+
* NULLABLE and `finalize` makes NOT NULL. Its nullability is how a start
|
|
194
|
+
* knows whether this backfill has ever committed here: the sealing, the
|
|
195
|
+
* indexing and `finalize` are one transaction, so nullable means nothing is
|
|
196
|
+
* sealed yet, whatever a value looks like (see `needsSealing`), and NOT
|
|
197
|
+
* NULL means everything is. A spec whose `finalize` leaves it nullable is
|
|
198
|
+
* refused: every later start would take sealed rows for plaintext and seal
|
|
199
|
+
* them again.
|
|
200
|
+
*/
|
|
201
|
+
marker: { table: string; column: string };
|
|
202
|
+
/**
|
|
203
|
+
* Runs in the same transaction once every row is sealed and indexed, before
|
|
204
|
+
* `finalize`: what has to happen before the constraints can go up (core
|
|
205
|
+
* collapses rows that collide under the normalised index). `first` is true
|
|
206
|
+
* on the first backfill of this database.
|
|
207
|
+
*/
|
|
208
|
+
afterSealing?: (tx: PiiBackfillExecutor, run: { first: boolean }) => Promise<void>;
|
|
209
|
+
/**
|
|
210
|
+
* Statements applied last, on every start, in order: `SET NOT NULL` on the
|
|
211
|
+
* index columns, the unique indexes that move onto them. Each must be safe
|
|
212
|
+
* to run again (`IF EXISTS` / `IF NOT EXISTS`; `SET NOT NULL` is).
|
|
213
|
+
*/
|
|
214
|
+
finalize: string[];
|
|
215
|
+
/** What the key is called where the operator sets it, for the refusals. Default: `SECRETS_ENC_KEY`, with its tenant note. */
|
|
216
|
+
keyName?: string;
|
|
158
217
|
}
|
|
159
218
|
|
|
160
219
|
const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
|
|
@@ -171,7 +230,15 @@ const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
|
|
|
171
230
|
const ident = (name: string) => sql.raw(`"${name}"`);
|
|
172
231
|
|
|
173
232
|
/** What the backfill needs from a drizzle client — the db or a transaction. */
|
|
174
|
-
type
|
|
233
|
+
export type PiiBackfillExecutor = Pick<Database, 'execute'>;
|
|
234
|
+
type Executor = PiiBackfillExecutor;
|
|
235
|
+
|
|
236
|
+
/** A table's blind indexes, however many it declares. */
|
|
237
|
+
const indexesOf = (t: PiiBackfillTable): PiiBlindIndex[] => (t.bidx === undefined ? [] : Array.isArray(t.bidx) ? t.bidx : [t.bidx]);
|
|
238
|
+
|
|
239
|
+
/** The name the refusals call the key by: the operator's own, with core's tenant note when it is core's. */
|
|
240
|
+
const keyNameOf = (spec: Pick<PiiBackfillSpec, 'keyName'>): string =>
|
|
241
|
+
spec.keyName ?? 'SECRETS_ENC_KEY (for a tenant: the one derived from TENANT_MASTER_KEY)';
|
|
175
242
|
|
|
176
243
|
/** A text column as it is stored: bytes, so the handle's connection does not open it. */
|
|
177
244
|
const asStored = (col: string) => sql`convert_to(${ident(col)}, 'UTF8') AS ${ident(col)}`;
|
|
@@ -197,11 +264,11 @@ function needsSealing(col: string, trustShape: boolean) {
|
|
|
197
264
|
}
|
|
198
265
|
|
|
199
266
|
/**
|
|
200
|
-
* Whether this database has never been through the backfill: the
|
|
201
|
-
* column of `users` is still nullable.
|
|
202
|
-
* and the constraints run in ONE
|
|
203
|
-
*
|
|
204
|
-
* sealed, whatever it looks like.
|
|
267
|
+
* Whether this database has never been through the backfill: the spec's
|
|
268
|
+
* marker column (for core, the blind index of `users`) is still nullable.
|
|
269
|
+
* The backfill, the duplicate collapse and the constraints run in ONE
|
|
270
|
+
* transaction, so a nullable column means no backfill has ever committed
|
|
271
|
+
* here — and therefore that nothing in it is sealed, whatever it looks like.
|
|
205
272
|
*
|
|
206
273
|
* That is what lets a first backfill trust no shape at all. A blob is
|
|
207
274
|
* recognised by its shape, and a title or a display name is text a person
|
|
@@ -210,13 +277,25 @@ function needsSealing(col: string, trustShape: boolean) {
|
|
|
210
277
|
* {@link assertKeyOpensSealedRows}, would refuse the start for a key that is
|
|
211
278
|
* perfectly right. Once the first backfill has committed, every write goes
|
|
212
279
|
* through a handle that seals it, so no such value can arrive again.
|
|
280
|
+
*
|
|
281
|
+
* A marker that is not there at all is the SQL history not having run (or a
|
|
282
|
+
* spec naming a column it does not add): refused, rather than read as "not
|
|
283
|
+
* the first run", which would trust every shape in a database nobody sealed.
|
|
213
284
|
*/
|
|
214
|
-
async function isFirstBackfill(tx: Executor): Promise<boolean> {
|
|
285
|
+
async function isFirstBackfill(tx: Executor, spec: Pick<PiiBackfillSpec, 'name' | 'marker'>): Promise<boolean> {
|
|
286
|
+
const { table, column } = spec.marker;
|
|
215
287
|
const result = await tx.execute(sql`
|
|
216
288
|
SELECT is_nullable FROM information_schema.columns
|
|
217
|
-
WHERE table_schema = current_schema() AND table_name =
|
|
289
|
+
WHERE table_schema = current_schema() AND table_name = ${table} AND column_name = ${column}
|
|
218
290
|
`);
|
|
219
|
-
|
|
291
|
+
const nullable = (result.rows[0] as { is_nullable?: string } | undefined)?.is_nullable;
|
|
292
|
+
if (nullable === undefined) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`PII encryption backfill (${spec.name}): the marker column ${table}.${column} does not exist. ` +
|
|
295
|
+
'Apply the migration that adds the blind-index columns before the backfill runs.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return nullable === 'YES';
|
|
220
299
|
}
|
|
221
300
|
|
|
222
301
|
/** How many rows one backfill query reads: a table is walked in batches, never loaded whole. */
|
|
@@ -228,22 +307,40 @@ const BACKFILL_BATCH_ROWS = 500;
|
|
|
228
307
|
* are exactly the ones the scan skips — and surface only as every login
|
|
229
308
|
* failing and every lookup by email missing, because the blind indexes would
|
|
230
309
|
* be computed under the new key. One sealed value per table is enough: all
|
|
231
|
-
* rows of a deployment are sealed under one key
|
|
232
|
-
*
|
|
310
|
+
* rows of a deployment are sealed under one key, so any one of them answers
|
|
311
|
+
* for the rest. Refusing the start is the loud failure; re-keying a database
|
|
312
|
+
* is a deliberate operation, not a boot.
|
|
313
|
+
*
|
|
314
|
+
* The sample is a sealed value from WHICHEVER column has one, not from a
|
|
315
|
+
* column chosen to stand for its table. A column may hold nothing in any
|
|
316
|
+
* row (an optional address nobody gave, an error text that never occurred),
|
|
317
|
+
* and a check that looked only there would find no sample, say nothing, and
|
|
318
|
+
* let a wrong key through to a table whose other columns are full of values
|
|
319
|
+
* it cannot open.
|
|
233
320
|
*/
|
|
234
|
-
async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys): Promise<void> {
|
|
235
|
-
for (const t of
|
|
236
|
-
|
|
321
|
+
async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys, spec: Pick<PiiBackfillSpec, 'tables' | 'keyName'>): Promise<void> {
|
|
322
|
+
for (const t of spec.tables) {
|
|
323
|
+
// ONE pass over the table, which ends at the first row that has a sealed
|
|
324
|
+
// value in ANY of its sealed columns: on a sealed database, the first
|
|
325
|
+
// row. Whatever that row holds sealed is tried. (A value in it that is
|
|
326
|
+
// not sealed opens as itself, so it is no evidence either way.)
|
|
327
|
+
const sealedSomewhere = sql.join(
|
|
328
|
+
t.encrypted.map((col) => sql`${ident(col)} ~ ${PII_SEALED_SHAPE_SQL_REGEX}`),
|
|
329
|
+
sql` OR `,
|
|
330
|
+
);
|
|
237
331
|
const sample = await tx.execute(
|
|
238
|
-
sql`SELECT ${asStored
|
|
332
|
+
sql`SELECT ${sql.join(t.encrypted.map(asStored), sql`, `)} FROM ${ident(t.table)} WHERE ${sealedSomewhere} LIMIT 1`,
|
|
239
333
|
);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
334
|
+
const row = (sample.rows[0] ?? {}) as Record<string, unknown>;
|
|
335
|
+
for (const col of t.encrypted) {
|
|
336
|
+
const value = storedText(row[col]);
|
|
337
|
+
if (typeof value === 'string' && !keys.open(value).ok) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`PII encryption: ${t.table}.${col} is sealed with a key the configured ${keyNameOf(spec)} ` +
|
|
340
|
+
'does not open — refusing to start. ' +
|
|
341
|
+
'Restore the key that sealed it; changing the key is a re-keying of the database, not a configuration change.',
|
|
342
|
+
);
|
|
343
|
+
}
|
|
247
344
|
}
|
|
248
345
|
}
|
|
249
346
|
}
|
|
@@ -253,7 +350,9 @@ async function backfillTable(
|
|
|
253
350
|
keys: PiiKeys,
|
|
254
351
|
t: PiiBackfillTable,
|
|
255
352
|
trustShape: boolean,
|
|
353
|
+
keyName: string,
|
|
256
354
|
): Promise<number> {
|
|
355
|
+
const indexes = indexesOf(t);
|
|
257
356
|
// The key columns are read as text beside themselves, so the next batch can
|
|
258
357
|
// be asked for by value whatever their type is (a uuid, a serial, a path).
|
|
259
358
|
const keyText = (k: string) => `${k}__key`;
|
|
@@ -261,13 +360,19 @@ async function backfillTable(
|
|
|
261
360
|
...t.key.map(ident),
|
|
262
361
|
...t.key.map((k) => sql`${ident(k)}::text AS ${ident(keyText(k))}`),
|
|
263
362
|
...t.encrypted.map(asStored),
|
|
264
|
-
...(
|
|
363
|
+
...indexes.map((index) => ident(index.column)),
|
|
265
364
|
];
|
|
266
365
|
// Only rows with work left: the ciphertext prefix makes "unsealed" a plain
|
|
267
366
|
// SQL predicate, so a fully-sealed table costs one empty-result query.
|
|
367
|
+
// AN INDEX IS THERE EXACTLY WHERE ITS SOURCE IS. A row is owed work when
|
|
368
|
+
// the two disagree, either way: a source with no index beside it, and an
|
|
369
|
+
// index left beside a source that has since been emptied to NULL (which
|
|
370
|
+
// would go on answering for an address the row no longer holds). A source
|
|
371
|
+
// that is NULL with no index is how an optional address looks, and is not
|
|
372
|
+
// asked for: it would come back on every start for nothing.
|
|
268
373
|
const pending = [
|
|
269
374
|
...t.encrypted.map((col) => needsSealing(col, trustShape)),
|
|
270
|
-
...(
|
|
375
|
+
...indexes.map((index) => sql`((${ident(index.column)} IS NULL) <> (${ident(index.source)} IS NULL))`),
|
|
271
376
|
];
|
|
272
377
|
const keyTuple = sql`(${sql.join(t.key.map((k) => sql`${ident(k)}::text`), sql`, `)})`;
|
|
273
378
|
// A blob, as far as this pass may believe one. On a first backfill nothing
|
|
@@ -302,7 +407,7 @@ async function backfillTable(
|
|
|
302
407
|
where.push(sql`${ident(col)} = ${value}`);
|
|
303
408
|
}
|
|
304
409
|
}
|
|
305
|
-
|
|
410
|
+
for (const index of indexes) {
|
|
306
411
|
// The blind index is derived from its source, so it is (re)computed
|
|
307
412
|
// whenever the source is being sealed in this pass — a legacy writer
|
|
308
413
|
// that put a NEW plaintext email on an already-indexed row left a stale
|
|
@@ -311,25 +416,43 @@ async function backfillTable(
|
|
|
311
416
|
// ciphertext (an index that was never filled beside a sealed address);
|
|
312
417
|
// the index is always computed over the plaintext, never over a blob
|
|
313
418
|
// the key cannot open.
|
|
314
|
-
const source = row[
|
|
315
|
-
const stored = row[
|
|
316
|
-
|
|
419
|
+
const source = row[index.source];
|
|
420
|
+
const stored = row[index.column];
|
|
421
|
+
// NULL is "no value", and has no index: not the index of the empty
|
|
422
|
+
// string, which every row without an address would then share where
|
|
423
|
+
// a plain unique index let any number of NULLs stand. An index found
|
|
424
|
+
// beside a NULL source is taken away, pinned to what was read like
|
|
425
|
+
// every other write here.
|
|
426
|
+
//
|
|
427
|
+
// The EMPTY STRING is a value, and keeps its index: it is what the
|
|
428
|
+
// handle writes for one (`blindIndexText`), so a row is found by it
|
|
429
|
+
// whether the application wrote it or this did, and two empty
|
|
430
|
+
// strings are equal under a unique index, as they were in clear.
|
|
431
|
+
if (typeof source !== 'string') {
|
|
432
|
+
if (stored != null) {
|
|
433
|
+
sets.push(sql`${ident(index.column)} = NULL`);
|
|
434
|
+
where.push(sql`${ident(index.column)} = ${stored}`);
|
|
435
|
+
where.push(sql`${ident(index.source)} IS NULL`);
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const text = source;
|
|
317
440
|
const sealingSource = text !== '' && !sealedAlready(text);
|
|
318
441
|
if (sealingSource || stored == null) {
|
|
319
442
|
const opened = sealedAlready(text) ? keys.open(text) : ({ ok: true, plain: text } as const);
|
|
320
443
|
if (!opened.ok) {
|
|
321
444
|
throw new Error(
|
|
322
|
-
`PII encryption backfill: ${t.table}.${
|
|
323
|
-
|
|
445
|
+
`PII encryption backfill: ${t.table}.${index.source} cannot be decrypted with the ` +
|
|
446
|
+
`configured ${keyName} — refusing to derive a blind index from ciphertext. ` +
|
|
324
447
|
'Restore the key that sealed it, then restart.',
|
|
325
448
|
);
|
|
326
449
|
}
|
|
327
|
-
sets.push(sql`${ident(
|
|
450
|
+
sets.push(sql`${ident(index.column)} = ${keys.index(opened.plain)}`);
|
|
328
451
|
// Pin the index AND its source: if a concurrent writer replaces the
|
|
329
452
|
// email between scan and write, the CAS must not attach the OLD
|
|
330
453
|
// email's blind index to the NEW value.
|
|
331
|
-
where.push(sql`${ident(
|
|
332
|
-
where.push(sql`${ident(
|
|
454
|
+
where.push(sql`${ident(index.column)} IS NOT DISTINCT FROM ${stored ?? null}`);
|
|
455
|
+
where.push(sql`${ident(index.source)} IS NOT DISTINCT FROM ${source ?? null}`);
|
|
333
456
|
}
|
|
334
457
|
}
|
|
335
458
|
if (sets.length === 0) continue;
|
|
@@ -414,6 +537,25 @@ const PII_FINALIZE_STATEMENTS = [
|
|
|
414
537
|
'DROP INDEX IF EXISTS "plugin_join_requests_requester_plugin_unq"',
|
|
415
538
|
];
|
|
416
539
|
|
|
540
|
+
/** Core's own personal data: the tables above, and what migration 0016 deferred. */
|
|
541
|
+
const CORE_PII_BACKFILL: PiiBackfillSpec = {
|
|
542
|
+
name: 'core',
|
|
543
|
+
tables: PII_BACKFILL_TABLES,
|
|
544
|
+
marker: { table: 'users', column: 'email_bidx' },
|
|
545
|
+
afterSealing: async (tx, { first }) => {
|
|
546
|
+
const cleared = await clearUnindexedRefusalNames(tx);
|
|
547
|
+
if (cleared > 0) {
|
|
548
|
+
log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
|
|
549
|
+
}
|
|
550
|
+
// Only where the unique indexes are not up yet. Once they are, they are
|
|
551
|
+
// what keeps two rows from sharing an index, so there is nothing to
|
|
552
|
+
// collapse — and the collapse is two self-joins and an aggregate over
|
|
553
|
+
// whole tables, which every later start paid for nothing.
|
|
554
|
+
if (first) await resolveBidxCollisions(tx);
|
|
555
|
+
},
|
|
556
|
+
finalize: PII_FINALIZE_STATEMENTS,
|
|
557
|
+
};
|
|
558
|
+
|
|
417
559
|
/**
|
|
418
560
|
* Encrypt pre-existing plaintext PII rows, fill the blind-index columns, and
|
|
419
561
|
* apply the constraints migration 0016 deferred. Idempotent; `runCoreMigrations`
|
|
@@ -422,29 +564,73 @@ const PII_FINALIZE_STATEMENTS = [
|
|
|
422
564
|
* root's does): one that holds none is refused before anything is read.
|
|
423
565
|
*/
|
|
424
566
|
export async function runPiiEncryptionBackfill(db: Database): Promise<void> {
|
|
567
|
+
await runPiiBackfill(db, CORE_PII_BACKFILL);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Seal the rows `spec` names that were written before their columns were
|
|
572
|
+
* sealed, fill their blind indexes, and apply `spec.finalize` — the DATA half
|
|
573
|
+
* of a migration that adds `encryptedText` / `blindIndexText` columns, which
|
|
574
|
+
* SQL cannot do because it holds no key. ONE implementation for every schema
|
|
575
|
+
* on a handle: core's own tables ({@link runPiiEncryptionBackfill}) and an
|
|
576
|
+
* overlay's, so the rules above (one transaction, a first run that trusts no
|
|
577
|
+
* shape, a key that must open what is sealed, columns read as stored, writes
|
|
578
|
+
* that pin what they read) are not rewritten per schema and cannot drift.
|
|
579
|
+
*
|
|
580
|
+
* Idempotent, and to be run on every start under the lock that guards the
|
|
581
|
+
* schema's history: {@link runEnterpriseMigrations} does that for an overlay
|
|
582
|
+
* that passes its spec. The handle must hold the key the rows are sealed
|
|
583
|
+
* with; one that holds none is refused before anything is read.
|
|
584
|
+
*/
|
|
585
|
+
export async function runPiiBackfill(db: Database, spec: PiiBackfillSpec): Promise<void> {
|
|
425
586
|
const keys = piiKeysOf(db);
|
|
587
|
+
assertSpecIsWhole(spec);
|
|
588
|
+
const label = spec.name === CORE_PII_BACKFILL.name ? 'PII encryption backfill' : `PII encryption backfill (${spec.name})`;
|
|
426
589
|
await db.transaction(async (tx) => {
|
|
427
|
-
const first = await isFirstBackfill(tx);
|
|
590
|
+
const first = await isFirstBackfill(tx, spec);
|
|
428
591
|
// Nothing is sealed before the first backfill, so there is no sealed row
|
|
429
592
|
// for the key to be checked against — and a value that only LOOKS sealed
|
|
430
593
|
// must not be taken for one (see `isFirstBackfill`).
|
|
431
|
-
if (!first) await assertKeyOpensSealedRows(tx, keys);
|
|
594
|
+
if (!first) await assertKeyOpensSealedRows(tx, keys, spec);
|
|
432
595
|
let rewritten = 0;
|
|
433
|
-
for (const t of
|
|
434
|
-
rewritten += await backfillTable(tx, keys, t, !first);
|
|
596
|
+
for (const t of spec.tables) {
|
|
597
|
+
rewritten += await backfillTable(tx, keys, t, !first, keyNameOf(spec));
|
|
435
598
|
}
|
|
436
|
-
if (rewritten > 0) log.info(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
|
|
440
|
-
}
|
|
441
|
-
// Only where the unique indexes are not up yet. Once they are, they are
|
|
442
|
-
// what keeps two rows from sharing an index, so there is nothing to
|
|
443
|
-
// collapse — and the collapse is two self-joins and an aggregate over
|
|
444
|
-
// whole tables, which every later start paid for nothing.
|
|
445
|
-
if (first) await resolveBidxCollisions(tx);
|
|
446
|
-
for (const statement of PII_FINALIZE_STATEMENTS) {
|
|
599
|
+
if (rewritten > 0) log.info(`${label}: rewrote ${rewritten} row(s).`);
|
|
600
|
+
await spec.afterSealing?.(tx, { first });
|
|
601
|
+
for (const statement of spec.finalize) {
|
|
447
602
|
await tx.execute(sql.raw(statement));
|
|
448
603
|
}
|
|
604
|
+
// The marker is what the NEXT start reads. Left nullable, that start
|
|
605
|
+
// would be a "first" one again: it would believe no shape, take every
|
|
606
|
+
// sealed value for plaintext and seal it a second time, and nothing
|
|
607
|
+
// would open afterwards. So a spec that does not close its own marker
|
|
608
|
+
// does not commit.
|
|
609
|
+
if (await isFirstBackfill(tx, spec)) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`${label}: finalize left the marker column ${spec.marker.table}.${spec.marker.column} nullable. ` +
|
|
612
|
+
'It must set it NOT NULL, or the next start would seal the sealed rows again. Nothing was changed.',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
449
615
|
});
|
|
450
616
|
}
|
|
617
|
+
|
|
618
|
+
/** A spec is refused for what would otherwise fail halfway: a marker that is no index of its tables, an index of a column that is not sealed. */
|
|
619
|
+
function assertSpecIsWhole(spec: PiiBackfillSpec): void {
|
|
620
|
+
let markerIsAnIndex = false;
|
|
621
|
+
for (const t of spec.tables) {
|
|
622
|
+
for (const index of indexesOf(t)) {
|
|
623
|
+
if (!t.encrypted.includes(index.source)) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`PII encryption backfill (${spec.name}): ${t.table}.${index.column} indexes "${index.source}", which is not one of the table's encrypted columns.`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (t.table === spec.marker.table && index.column === spec.marker.column) markerIsAnIndex = true;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!markerIsAnIndex) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`PII encryption backfill (${spec.name}): the marker ${spec.marker.table}.${spec.marker.column} is not a blind-index column of the spec's tables.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
@@ -409,6 +409,99 @@ describe('LockingFilesystem — the caller judges under the lock', () => {
|
|
|
409
409
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
+
it('rewriteFile reads, computes and writes with the lock HELD, from the bytes on disk at that moment', async () => {
|
|
413
|
+
await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
|
|
414
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'owner: \n');
|
|
415
|
+
|
|
416
|
+
const workflow = makeWorkflow();
|
|
417
|
+
const order: string[] = [];
|
|
418
|
+
(workflow.acquireLock as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
419
|
+
order.push('acquire');
|
|
420
|
+
// Another writer lands while this call waits for the lock.
|
|
421
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'owner: alice\n');
|
|
422
|
+
return { acquired: true, lock: { holderName: 'Alice' } };
|
|
423
|
+
});
|
|
424
|
+
(workflow.releaseLock as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
425
|
+
order.push('release');
|
|
426
|
+
return null;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let seen = '';
|
|
430
|
+
await layer(workflow).rewriteFile('knowledge-base/Foo.md', (current) => {
|
|
431
|
+
order.push('rewrite');
|
|
432
|
+
seen = current!.toString('utf8');
|
|
433
|
+
return `${seen}note: kept\n`;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(order).toEqual(['acquire', 'rewrite', 'release']);
|
|
437
|
+
// It was handed what the other writer left, not what was there when the call began.
|
|
438
|
+
expect(seen).toBe('owner: alice\n');
|
|
439
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('owner: alice\nnote: kept\n');
|
|
440
|
+
expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Foo.md', USER);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('a rewriteFile that throws writes nothing, releases UNTOUCHED, and gives the caller its own error', async () => {
|
|
444
|
+
await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
|
|
445
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'theirs\n');
|
|
446
|
+
|
|
447
|
+
const workflow = makeWorkflow();
|
|
448
|
+
const refusal = new Error('old text is gone');
|
|
449
|
+
await expect(
|
|
450
|
+
layer(workflow).rewriteFile('knowledge-base/Foo.md', () => {
|
|
451
|
+
throw refusal;
|
|
452
|
+
}),
|
|
453
|
+
).rejects.toBe(refusal);
|
|
454
|
+
|
|
455
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('theirs\n');
|
|
456
|
+
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Foo.md', USER);
|
|
457
|
+
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
|
|
458
|
+
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('rewriteFile hands over null when nothing is at the path', async () => {
|
|
462
|
+
await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
|
|
463
|
+
const workflow = makeWorkflow();
|
|
464
|
+
let seen: Buffer | null | undefined;
|
|
465
|
+
await layer(workflow).rewriteFile('knowledge-base/New.md', (current) => {
|
|
466
|
+
seen = current;
|
|
467
|
+
return 'made\n';
|
|
468
|
+
});
|
|
469
|
+
expect(seen).toBeNull();
|
|
470
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/New.md'), 'utf-8')).toBe('made\n');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('rewriteFile judges the bytes it is about to write with the pre-disk validator, under the lock', async () => {
|
|
474
|
+
await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
|
|
475
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Foo.md'), 'ok\n');
|
|
476
|
+
const workflow = makeWorkflow();
|
|
477
|
+
const refusal = new Error('refused by the validator');
|
|
478
|
+
const judged: string[] = [];
|
|
479
|
+
const guarded = new LockingFilesystem(
|
|
480
|
+
{ basePath: root, contained: true },
|
|
481
|
+
{
|
|
482
|
+
workflow,
|
|
483
|
+
workspaceId: 'ws-feat',
|
|
484
|
+
branch: 'feat',
|
|
485
|
+
user: USER,
|
|
486
|
+
kbDirName: KB,
|
|
487
|
+
validateWrite: async (_p, content) => {
|
|
488
|
+
judged.push(String(content));
|
|
489
|
+
throw refusal;
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
await expect(guarded.rewriteFile('knowledge-base/Foo.md', () => 'bad\n')).rejects.toBe(refusal);
|
|
494
|
+
expect(judged).toEqual(['bad\n']);
|
|
495
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/Foo.md'), 'utf-8')).toBe('ok\n');
|
|
496
|
+
expect(workflow.releaseLockUntouched).toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('rewriteFile refuses a path outside the repository before any lock', async () => {
|
|
500
|
+
const workflow = makeWorkflow();
|
|
501
|
+
await expect(layer(workflow).rewriteFile('elsewhere/Foo.md', () => 'x')).rejects.toThrow();
|
|
502
|
+
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
412
505
|
it('writeFiles runs its check once EVERY lock is held, and lands only what the check keeps', async () => {
|
|
413
506
|
const workflow = makeWorkflow();
|
|
414
507
|
const acquired: string[] = [];
|
|
@@ -187,6 +187,43 @@ export class LockingFilesystem extends GitGuardedFilesystem {
|
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Write a file whose new content DEPENDS on what it holds: the read, the
|
|
192
|
+
* computing of the new bytes and the write all happen with the path's lock
|
|
193
|
+
* held, so what lands is derived from the very bytes it replaces.
|
|
194
|
+
*
|
|
195
|
+
* `writeFile` cannot give that. Its content is fixed before the lock is
|
|
196
|
+
* taken, so a caller that read the file first is writing over a state
|
|
197
|
+
* anyone may have moved in between: an `edit_file` replacing an empty owner
|
|
198
|
+
* field with one name would silently overwrite another name written a
|
|
199
|
+
* moment earlier, and both callers would be told their edit landed.
|
|
200
|
+
*
|
|
201
|
+
* `rewrite` receives the file as it is under the lock (null when nothing is
|
|
202
|
+
* there) and answers the bytes to write. Throwing refuses: nothing is
|
|
203
|
+
* written, the lock is released untouched, and the caller gets what was
|
|
204
|
+
* thrown.
|
|
205
|
+
*/
|
|
206
|
+
async rewriteFile(
|
|
207
|
+
inputPath: string,
|
|
208
|
+
rewrite: (current: Buffer | null) => FileContent | Promise<FileContent>,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
await this.assertNotGitInternals(inputPath);
|
|
211
|
+
this.assertInsideRepo(inputPath);
|
|
212
|
+
const validate = this.lockContext.validateWrite;
|
|
213
|
+
let toWrite: FileContent | null = null;
|
|
214
|
+
return this.withLock(
|
|
215
|
+
inputPath,
|
|
216
|
+
() => super.writeFile(inputPath, toWrite as FileContent),
|
|
217
|
+
async () => {
|
|
218
|
+
const next = await rewrite(await this.readIfExists(inputPath));
|
|
219
|
+
// The pre-disk gate judges the bytes that will land, as it does for
|
|
220
|
+
// every other write, and here those exist only now.
|
|
221
|
+
await validate?.(inputPath, next);
|
|
222
|
+
toWrite = next;
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
190
227
|
override async appendFile(inputPath: string, content: FileContent): Promise<void> {
|
|
191
228
|
await this.assertNotGitInternals(inputPath);
|
|
192
229
|
this.assertInsideRepo(inputPath);
|