@proteinjs/db 1.35.1 → 1.37.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/CHANGELOG.md +22 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +7 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +7 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Columns.d.ts +5 -1
- package/dist/src/Columns.d.ts.map +1 -1
- package/dist/src/Columns.js +2 -1
- package/dist/src/Columns.js.map +1 -1
- package/dist/src/Db.d.ts +25 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +219 -22
- package/dist/src/Db.js.map +1 -1
- package/dist/src/Record.d.ts +7 -1
- package/dist/src/Record.d.ts.map +1 -1
- package/dist/src/Record.js +56 -21
- package/dist/src/Record.js.map +1 -1
- package/dist/src/Table.d.ts +82 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js +24 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/TableQueryTransformProvider.d.ts +17 -0
- package/dist/src/TableQueryTransformProvider.d.ts.map +1 -0
- package/dist/src/TableQueryTransformProvider.js +34 -0
- package/dist/src/TableQueryTransformProvider.js.map +1 -0
- package/dist/src/encryption/Base64Url.d.ts +12 -0
- package/dist/src/encryption/Base64Url.d.ts.map +1 -0
- package/dist/src/encryption/Base64Url.js +21 -0
- package/dist/src/encryption/Base64Url.js.map +1 -0
- package/dist/src/encryption/DataEncryptionKeyTable.d.ts +33 -0
- package/dist/src/encryption/DataEncryptionKeyTable.d.ts.map +1 -0
- package/dist/src/encryption/DataEncryptionKeyTable.js +45 -0
- package/dist/src/encryption/DataEncryptionKeyTable.js.map +1 -0
- package/dist/src/encryption/DataKeyStore.d.ts +84 -0
- package/dist/src/encryption/DataKeyStore.d.ts.map +1 -0
- package/dist/src/encryption/DataKeyStore.js +481 -0
- package/dist/src/encryption/DataKeyStore.js.map +1 -0
- package/dist/src/encryption/DbEncryptionConfig.d.ts +62 -0
- package/dist/src/encryption/DbEncryptionConfig.d.ts.map +1 -0
- package/dist/src/encryption/DbEncryptionConfig.js +67 -0
- package/dist/src/encryption/DbEncryptionConfig.js.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryError.d.ts +10 -0
- package/dist/src/encryption/EncryptedColumnQueryError.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryError.js +38 -0
- package/dist/src/encryption/EncryptedColumnQueryError.js.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts +52 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.js +423 -0
- package/dist/src/encryption/EncryptedColumnQueryTransform.js.map +1 -0
- package/dist/src/encryption/EncryptedColumns.d.ts +69 -0
- package/dist/src/encryption/EncryptedColumns.d.ts.map +1 -0
- package/dist/src/encryption/EncryptedColumns.js +286 -0
- package/dist/src/encryption/EncryptedColumns.js.map +1 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts +20 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.js +36 -0
- package/dist/src/encryption/EncryptionDerivedTableRegistry.js.map +1 -0
- package/dist/src/encryption/EncryptionEnvelope.d.ts +25 -0
- package/dist/src/encryption/EncryptionEnvelope.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionEnvelope.js +59 -0
- package/dist/src/encryption/EncryptionEnvelope.js.map +1 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.d.ts +65 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.js +343 -0
- package/dist/src/encryption/EncryptionLifecycleWalker.js.map +1 -0
- package/dist/src/encryption/EncryptionRecordHooks.d.ts +47 -0
- package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionRecordHooks.js +229 -0
- package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.d.ts +34 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.d.ts.map +1 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.js +211 -0
- package/dist/src/encryption/EncryptionTokenMaintenance.js.map +1 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts +20 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts.map +1 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.js +87 -0
- package/dist/src/encryption/InMemoryMasterKeyProvider.js.map +1 -0
- package/dist/src/encryption/MasterKeyProvider.d.ts +27 -0
- package/dist/src/encryption/MasterKeyProvider.d.ts.map +1 -0
- package/dist/src/encryption/MasterKeyProvider.js +3 -0
- package/dist/src/encryption/MasterKeyProvider.js.map +1 -0
- package/dist/src/encryption/SearchTokenizer.d.ts +49 -0
- package/dist/src/encryption/SearchTokenizer.d.ts.map +1 -0
- package/dist/src/encryption/SearchTokenizer.js +106 -0
- package/dist/src/encryption/SearchTokenizer.js.map +1 -0
- package/dist/src/schema/TableManager.d.ts +10 -0
- package/dist/src/schema/TableManager.d.ts.map +1 -1
- package/dist/src/schema/TableManager.js +62 -5
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +2 -2
- package/dist/test/EncryptedColumnsSchema.test.d.ts +2 -0
- package/dist/test/EncryptedColumnsSchema.test.d.ts.map +1 -0
- package/dist/test/EncryptedColumnsSchema.test.js +151 -0
- package/dist/test/EncryptedColumnsSchema.test.js.map +1 -0
- package/dist/test/EncryptionEnvelope.test.d.ts +2 -0
- package/dist/test/EncryptionEnvelope.test.d.ts.map +1 -0
- package/dist/test/EncryptionEnvelope.test.js +109 -0
- package/dist/test/EncryptionEnvelope.test.js.map +1 -0
- package/dist/test/EncryptionQueryContract.test.d.ts +2 -0
- package/dist/test/EncryptionQueryContract.test.d.ts.map +1 -0
- package/dist/test/EncryptionQueryContract.test.js +399 -0
- package/dist/test/EncryptionQueryContract.test.js.map +1 -0
- package/dist/test/SearchTokenizer.test.d.ts +2 -0
- package/dist/test/SearchTokenizer.test.d.ts.map +1 -0
- package/dist/test/SearchTokenizer.test.js +64 -0
- package/dist/test/SearchTokenizer.test.js.map +1 -0
- package/dist/test/reusable/CascadeDeleteTests.js +2 -2
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
- package/dist/test/reusable/ColumnTypesTests.js +2 -2
- package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
- package/dist/test/reusable/CrudTests.js +2 -2
- package/dist/test/reusable/CrudTests.js.map +1 -1
- package/dist/test/reusable/DynamicReferenceColumnTests.js +2 -2
- package/dist/test/reusable/DynamicReferenceColumnTests.js.map +1 -1
- package/dist/test/reusable/PreloadReferencesTests.js +2 -2
- package/dist/test/reusable/PreloadReferencesTests.js.map +1 -1
- package/dist/test/reusable/RecordIteratorTests.js +2 -2
- package/dist/test/reusable/RecordIteratorTests.js.map +1 -1
- package/dist/test/reusable/TableManagerTests.js +2 -2
- package/dist/test/reusable/TableManagerTests.js.map +1 -1
- package/dist/test/reusable/TransactionTests.js +2 -2
- package/dist/test/reusable/TransactionTests.js.map +1 -1
- package/generated/index.ts +7 -1
- package/generated/test/index.ts +7 -1
- package/index.ts +16 -0
- package/package.json +3 -3
- package/src/Columns.ts +6 -2
- package/src/Db.ts +137 -12
- package/src/Record.ts +21 -4
- package/src/Table.ts +109 -2
- package/src/TableQueryTransformProvider.ts +31 -0
- package/src/encryption/Base64Url.ts +14 -0
- package/src/encryption/DataEncryptionKeyTable.ts +40 -0
- package/src/encryption/DataKeyStore.ts +305 -0
- package/src/encryption/DbEncryptionConfig.ts +92 -0
- package/src/encryption/EncryptedColumnQueryError.ts +15 -0
- package/src/encryption/EncryptedColumnQueryTransform.ts +383 -0
- package/src/encryption/EncryptedColumns.ts +318 -0
- package/src/encryption/EncryptionDerivedTableRegistry.ts +34 -0
- package/src/encryption/EncryptionEnvelope.ts +60 -0
- package/src/encryption/EncryptionLifecycleWalker.ts +243 -0
- package/src/encryption/EncryptionRecordHooks.ts +167 -0
- package/src/encryption/EncryptionTokenMaintenance.ts +123 -0
- package/src/encryption/InMemoryMasterKeyProvider.ts +42 -0
- package/src/encryption/MasterKeyProvider.ts +26 -0
- package/src/encryption/SearchTokenizer.ts +103 -0
- package/src/schema/TableManager.ts +43 -1
- package/test/EncryptedColumnsSchema.test.ts +172 -0
- package/test/EncryptionEnvelope.test.ts +66 -0
- package/test/EncryptionQueryContract.test.ts +195 -0
- package/test/SearchTokenizer.test.ts +67 -0
- package/test/reusable/CascadeDeleteTests.ts +2 -2
- package/test/reusable/ColumnTypesTests.ts +2 -2
- package/test/reusable/CrudTests.ts +2 -2
- package/test/reusable/DynamicReferenceColumnTests.ts +2 -2
- package/test/reusable/PreloadReferencesTests.ts +2 -2
- package/test/reusable/RecordIteratorTests.ts +2 -2
- package/test/reusable/TableManagerTests.ts +2 -2
- package/test/reusable/TransactionTests.ts +2 -2
package/index.ts
CHANGED
|
@@ -29,6 +29,22 @@ export * from './src/schema/SchemaOperations';
|
|
|
29
29
|
export * from './src/schema/SchemaMetadata';
|
|
30
30
|
export * from './src/schema/TableManager';
|
|
31
31
|
|
|
32
|
+
export * from './src/encryption/MasterKeyProvider';
|
|
33
|
+
export * from './src/encryption/InMemoryMasterKeyProvider';
|
|
34
|
+
export * from './src/encryption/DbEncryptionConfig';
|
|
35
|
+
export * from './src/encryption/DataEncryptionKeyTable';
|
|
36
|
+
export * from './src/encryption/DataKeyStore';
|
|
37
|
+
export * from './src/encryption/EncryptionEnvelope';
|
|
38
|
+
export * from './src/encryption/SearchTokenizer';
|
|
39
|
+
export * from './src/encryption/EncryptedColumns';
|
|
40
|
+
export * from './src/encryption/EncryptedColumnQueryError';
|
|
41
|
+
export * from './src/encryption/EncryptedColumnQueryTransform';
|
|
42
|
+
export * from './src/TableQueryTransformProvider';
|
|
43
|
+
export * from './src/encryption/EncryptionRecordHooks';
|
|
44
|
+
export * from './src/encryption/EncryptionTokenMaintenance';
|
|
45
|
+
export * from './src/encryption/EncryptionLifecycleWalker';
|
|
46
|
+
export * from './src/encryption/EncryptionDerivedTableRegistry';
|
|
47
|
+
|
|
32
48
|
export * from './src/tables/tables';
|
|
33
49
|
export * from './src/tables/MigrationTable';
|
|
34
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.
|
|
44
|
+
"@proteinjs/db-query": "^1.8.0",
|
|
45
45
|
"@proteinjs/logger": "^1.0.21",
|
|
46
46
|
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "cb0fda126d5d2bd5b17d1bc13e87c2373eb61f52"
|
|
70
70
|
}
|
package/src/Columns.ts
CHANGED
|
@@ -294,9 +294,13 @@ export class ReferenceColumn<T extends Record> extends StringColumn<Reference<T>
|
|
|
294
294
|
name: string,
|
|
295
295
|
public referenceTable: string,
|
|
296
296
|
public cascadeDelete: boolean,
|
|
297
|
-
options?: ReferenceColumnOptions
|
|
297
|
+
options?: ReferenceColumnOptions & {
|
|
298
|
+
/** Id-column width (default 36, uuid). Pass the EXISTING width when adopting a column
|
|
299
|
+
* that predates the reference type — Spanner cannot narrow a STRING in place. */
|
|
300
|
+
maxLength?: number;
|
|
301
|
+
}
|
|
298
302
|
) {
|
|
299
|
-
super(name, options, 36);
|
|
303
|
+
super(name, options, options?.maxLength ?? 36);
|
|
300
304
|
this.reverseCascadeDelete = !!options?.reverseCascadeDelete;
|
|
301
305
|
}
|
|
302
306
|
|
package/src/Db.ts
CHANGED
|
@@ -187,7 +187,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
187
187
|
await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
|
|
188
188
|
recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
|
|
189
189
|
await this.addColumnInsertHooks(table, recordCopy);
|
|
190
|
-
const
|
|
190
|
+
const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
|
|
191
|
+
const recordSerializer = new RecordSerializer(table, encryptionContext);
|
|
191
192
|
const serializedRecord = await recordSerializer.serialize(recordCopy);
|
|
192
193
|
const generateInsert = (config: DbDriverDmlStatementConfig) =>
|
|
193
194
|
new StatementFactory<T>().insert(
|
|
@@ -196,6 +197,17 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
196
197
|
this.statementConfigFactory.getStatementConfig(config)
|
|
197
198
|
);
|
|
198
199
|
await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
|
|
200
|
+
if (encryptionContext) {
|
|
201
|
+
// The same write that stored the ciphertext maintains its search tokens (rides the
|
|
202
|
+
// ambient transaction when there is one).
|
|
203
|
+
const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
|
|
204
|
+
await new EncryptionTokenMaintenance().afterInsert(
|
|
205
|
+
table,
|
|
206
|
+
recordCopy,
|
|
207
|
+
encryptionContext.keyOwner,
|
|
208
|
+
this.newSystemDb()
|
|
209
|
+
);
|
|
210
|
+
}
|
|
199
211
|
await this.runColumnAfterInsertHooks(table, recordCopy);
|
|
200
212
|
await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
|
|
201
213
|
return recordCopy as T;
|
|
@@ -229,17 +241,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
229
241
|
}
|
|
230
242
|
|
|
231
243
|
recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
new
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
let recordUpdateCount: number;
|
|
245
|
+
if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
|
|
246
|
+
recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb);
|
|
247
|
+
} else {
|
|
248
|
+
const recordSerializer = new RecordSerializer<T>(table);
|
|
249
|
+
const serializedRecord = await recordSerializer.serialize(recordCopy);
|
|
250
|
+
delete serializedRecord['id'];
|
|
251
|
+
const generateUpdate = (config: DbDriverDmlStatementConfig) =>
|
|
252
|
+
new StatementFactory<T>().update(
|
|
253
|
+
table.name,
|
|
254
|
+
serializedRecord as Partial<T>,
|
|
255
|
+
qb,
|
|
256
|
+
this.statementConfigFactory.getStatementConfig(config)
|
|
257
|
+
);
|
|
258
|
+
recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
|
|
259
|
+
}
|
|
243
260
|
if (!this.runAsSystem && recordUpdateCount === 0) {
|
|
244
261
|
const id = this.singleRowIdTarget(record, query);
|
|
245
262
|
if (id !== undefined) {
|
|
@@ -380,6 +397,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
380
397
|
const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
|
|
381
398
|
await this.runCascadeDeletions(table, recordsToDelete);
|
|
382
399
|
await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
|
|
400
|
+
await this.deleteEncryptionTokenRows(table, recordsToDeleteIds as string[]);
|
|
383
401
|
await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
|
|
384
402
|
return recordDeleteCount;
|
|
385
403
|
}
|
|
@@ -701,6 +719,21 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
701
719
|
await column.options.addToQuery(qb, this.runAsSystem, operation);
|
|
702
720
|
}
|
|
703
721
|
}
|
|
722
|
+
|
|
723
|
+
// The column-transform phase of the query model (`QueryBuilder.applyColumnTransforms`):
|
|
724
|
+
// each column's own query contract (`Column.queryTransform` — e.g. an encrypted
|
|
725
|
+
// column's condition/sort translation) is applied by the builder, with this
|
|
726
|
+
// operation's runtime — or rejected loudly at query-build time. Runs on every
|
|
727
|
+
// query/update/delete/count, so callers keep writing exactly what they write today.
|
|
728
|
+
// Imported at call time (module-graph discipline — see Db.init).
|
|
729
|
+
const { TableQueryTransformProvider } = await import('./TableQueryTransformProvider');
|
|
730
|
+
await qb.applyColumnTransforms(
|
|
731
|
+
new TableQueryTransformProvider(this.getTable, {
|
|
732
|
+
runAsSystem: this.runAsSystem,
|
|
733
|
+
query: (verifyTable, verifyQb) => this.query(verifyTable, verifyQb as any),
|
|
734
|
+
systemQuery: (derivedTable, derivedQb) => this.newSystemDb().query(derivedTable, derivedQb as any),
|
|
735
|
+
})
|
|
736
|
+
);
|
|
704
737
|
}
|
|
705
738
|
|
|
706
739
|
private async addColumnInsertHooks(table: Table<any>, record: any) {
|
|
@@ -852,6 +885,98 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
852
885
|
await hook();
|
|
853
886
|
}
|
|
854
887
|
|
|
888
|
+
/**
|
|
889
|
+
* The encryption context of a write, when the payload touches encrypted columns: the key
|
|
890
|
+
* owner (the row's permission-source scope owner) whose data key the values encrypt
|
|
891
|
+
* under. Undefined for writes that touch no encrypted column — the serializer then runs
|
|
892
|
+
* exactly as before.
|
|
893
|
+
*/
|
|
894
|
+
private async encryptionWriteContext(table: Table<any>, record: any): Promise<{ keyOwner: string } | undefined> {
|
|
895
|
+
const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
|
|
896
|
+
const encryptedColumns = new EncryptedColumns();
|
|
897
|
+
encryptedColumns.ensureSchema(table);
|
|
898
|
+
if (!encryptedColumns.recordTouchesEncryptedColumns(table, record)) {
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
|
|
903
|
+
return { keyOwner: await new EncryptionRecordHooks().resolveKeyOwnerForWrite(table, record) };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private async updateTouchesEncryptedColumns(table: Table<any>, record: any): Promise<boolean> {
|
|
907
|
+
const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
|
|
908
|
+
const encryptedColumns = new EncryptedColumns();
|
|
909
|
+
encryptedColumns.ensureSchema(table);
|
|
910
|
+
return encryptedColumns.recordTouchesEncryptedColumns(table, record);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Update path for payloads touching encrypted columns. Ciphertext is keyed per row OWNER
|
|
915
|
+
* (the row's scope owner), so one UPDATE statement cannot serve target rows of different
|
|
916
|
+
* owners: the target set is snapshotted (through the caller's own filtered query — the
|
|
917
|
+
* same query-then-act shape `delete` uses), grouped by owner, and updated one DML per
|
|
918
|
+
* owner group with that owner's ciphertext, companions, and search tokens.
|
|
919
|
+
*/
|
|
920
|
+
private async updateEncrypted<T extends R>(
|
|
921
|
+
table: Table<T>,
|
|
922
|
+
recordCopy: Partial<T>,
|
|
923
|
+
qb: QueryBuilder<T>
|
|
924
|
+
): Promise<number> {
|
|
925
|
+
const targetRows = await this._query(table, qb);
|
|
926
|
+
if (targetRows.length === 0) {
|
|
927
|
+
return 0;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
|
|
931
|
+
const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
|
|
932
|
+
const hooks = new EncryptionRecordHooks();
|
|
933
|
+
const idsByOwner = new Map<string, string[]>();
|
|
934
|
+
for (const row of targetRows) {
|
|
935
|
+
const owner = await hooks.resolveKeyOwnerForWrite(table, row);
|
|
936
|
+
const ids = idsByOwner.get(owner) ?? [];
|
|
937
|
+
ids.push(row.id);
|
|
938
|
+
idsByOwner.set(owner, ids);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let recordUpdateCount = 0;
|
|
942
|
+
const tokenMaintenance = new EncryptionTokenMaintenance();
|
|
943
|
+
for (const [owner, ids] of Array.from(idsByOwner.entries())) {
|
|
944
|
+
const recordSerializer = new RecordSerializer<T>(table, { keyOwner: owner });
|
|
945
|
+
const serializedRecord = await recordSerializer.serialize(recordCopy);
|
|
946
|
+
delete serializedRecord['id'];
|
|
947
|
+
const groupQb = new QueryBuilderFactory()
|
|
948
|
+
.getQueryBuilder(table)
|
|
949
|
+
.condition({ field: 'id', operator: 'IN', value: ids as T[keyof T][] });
|
|
950
|
+
const generateUpdate = (config: DbDriverDmlStatementConfig) =>
|
|
951
|
+
new StatementFactory<T>().update(
|
|
952
|
+
table.name,
|
|
953
|
+
serializedRecord as Partial<T>,
|
|
954
|
+
groupQb,
|
|
955
|
+
this.statementConfigFactory.getStatementConfig(config)
|
|
956
|
+
);
|
|
957
|
+
recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
|
|
958
|
+
await tokenMaintenance.afterUpdate(table, ids, recordCopy, owner, this.newSystemDb());
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return recordUpdateCount;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Deleted rows take their search-token rows with them (no-op for tables without contains columns). */
|
|
965
|
+
private async deleteEncryptionTokenRows(table: Table<any>, deletedIds: string[]): Promise<void> {
|
|
966
|
+
const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
|
|
967
|
+
await new EncryptionTokenMaintenance().afterDelete(table, deletedIds, this.newSystemDb());
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* A system instance riding THIS instance's driver and transaction context — for framework
|
|
972
|
+
* machinery beside a caller's operation (search-token upkeep, token-table reads): the
|
|
973
|
+
* derived tables are default-deny for callers, and the ops must ride the caller's ambient
|
|
974
|
+
* transaction.
|
|
975
|
+
*/
|
|
976
|
+
private newSystemDb(): Db<any> {
|
|
977
|
+
return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, true);
|
|
978
|
+
}
|
|
979
|
+
|
|
855
980
|
/**
|
|
856
981
|
* A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
|
|
857
982
|
* in a transaction. `runTransaction` carries the open transaction as INSTANCE state, and these
|
package/src/Record.ts
CHANGED
|
@@ -47,12 +47,18 @@ export function withRecordColumns<T extends Record>(
|
|
|
47
47
|
|
|
48
48
|
export type SerializedRecord = { [columnName: string]: any };
|
|
49
49
|
|
|
50
|
+
/** Who a row being written belongs to — the data key its encrypted columns encrypt under
|
|
51
|
+
* (see `EncryptionRecordHooks`). Resolved by `Db` for writes touching encrypted columns. */
|
|
52
|
+
export type RecordEncryptionContext = { keyOwner: string };
|
|
53
|
+
|
|
50
54
|
export class RecordSerializer<T extends Record> {
|
|
51
55
|
private logger = new Logger({ name: this.constructor.name });
|
|
52
56
|
private table: Table<T>;
|
|
57
|
+
private encryptionContext?: RecordEncryptionContext;
|
|
53
58
|
|
|
54
|
-
constructor(table: Table<T
|
|
59
|
+
constructor(table: Table<T>, encryptionContext?: RecordEncryptionContext) {
|
|
55
60
|
this.table = table;
|
|
61
|
+
this.encryptionContext = encryptionContext;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
async serialize(record: any): Promise<SerializedRecord> {
|
|
@@ -82,20 +88,31 @@ export class RecordSerializer<T extends Record> {
|
|
|
82
88
|
this.logger.warn({ message: `Fields were omitted during serialization`, obj: { omittedFields } });
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
// The transparent encryption seam (`ColumnOptions.encrypted`): encrypted columns'
|
|
92
|
+
// serialized values become ciphertext envelopes, search/sort companions are derived
|
|
93
|
+
// beside them. Imported at call time — this module sits below the encryption machinery
|
|
94
|
+
// in the package's module graph.
|
|
95
|
+
const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
|
|
96
|
+
await new EncryptionRecordHooks().onSerialize(this.table, serialized, this.encryptionContext);
|
|
85
97
|
return serialized;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
async deserialize(serializedRecord: SerializedRecord): Promise<T> {
|
|
101
|
+
// The transparent decryption seam: ciphertext envelopes decrypt (self-describing — the
|
|
102
|
+
// envelope names its key), framework companion columns drop out of the result.
|
|
103
|
+
const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
|
|
104
|
+
const prepared = await new EncryptionRecordHooks().onDeserialize(this.table, serializedRecord);
|
|
105
|
+
|
|
89
106
|
const deserialized: any = {};
|
|
90
107
|
const fieldSerializer = new FieldSerializer(this.table);
|
|
91
108
|
const omittedFields: string[] = [];
|
|
92
|
-
for (const columnName in
|
|
93
|
-
const serializedFieldValue =
|
|
109
|
+
for (const columnName in prepared) {
|
|
110
|
+
const serializedFieldValue = prepared[columnName];
|
|
94
111
|
try {
|
|
95
112
|
const { fieldPropertyName, fieldValue } = await fieldSerializer.deserialize(
|
|
96
113
|
columnName,
|
|
97
114
|
serializedFieldValue,
|
|
98
|
-
|
|
115
|
+
prepared
|
|
99
116
|
);
|
|
100
117
|
deserialized[fieldPropertyName] = fieldValue;
|
|
101
118
|
} catch (MissingFieldError) {
|
package/src/Table.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
|
2
2
|
import { CustomSerializableObject } from '@proteinjs/serializer';
|
|
3
3
|
import { isRecordColumn, Record } from './Record';
|
|
4
4
|
import { TableSerializerId } from './serializers/TableSerializer';
|
|
5
|
-
import { QueryBuilder } from '@proteinjs/db-query';
|
|
5
|
+
import { ColumnQueryTransform, QueryBuilder } from '@proteinjs/db-query';
|
|
6
6
|
import { Identity, TableOperationsAuth } from './auth/TableAuth';
|
|
7
7
|
import { Db } from './Db';
|
|
8
|
+
import { EncryptionDerivedTableRegistry } from './encryption/EncryptionDerivedTableRegistry';
|
|
8
9
|
|
|
9
10
|
export const isTable = (obj: any) => obj.__serializerId === TableSerializerId;
|
|
10
11
|
|
|
@@ -14,13 +15,38 @@ export const tableByName = (name: string) => {
|
|
|
14
15
|
const tables = getTables();
|
|
15
16
|
for (const table of tables) {
|
|
16
17
|
if (table.name == name) {
|
|
17
|
-
return table;
|
|
18
|
+
return ensureEncryptionSchema(table);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Framework-DERIVED tables (search-token tables beside encrypted columns) are synthesized
|
|
23
|
+
// from column config rather than declared in source, so the reflection registry never
|
|
24
|
+
// sees them (see EncryptionDerivedTableRegistry — a runtime-cycle-free, type-only import).
|
|
25
|
+
const derivedTable = EncryptionDerivedTableRegistry.get(name);
|
|
26
|
+
if (derivedTable) {
|
|
27
|
+
return derivedTable;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
throw new Error(`Unable to find table: ${name}`);
|
|
22
31
|
};
|
|
23
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Table instances are constructed ad hoc all over consumer code, and the encryption-derived
|
|
35
|
+
* physical schema (companion columns — see `EncryptedColumns.ensureSchema`) is injected
|
|
36
|
+
* per-instance at the seams that use an instance. Name-based resolution is one of those
|
|
37
|
+
* seams: statement generation and driver column-type lookups resolve the REGISTRY's
|
|
38
|
+
* instance through here, which must carry the same derived columns as the instance the
|
|
39
|
+
* caller handed the Db. Idempotent (marker-checked); a call-time require keeps the module
|
|
40
|
+
* graph acyclic at load time.
|
|
41
|
+
*/
|
|
42
|
+
const ensureEncryptionSchema = (table: Table<any>): Table<any> => {
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
44
|
+
const { EncryptedColumns } =
|
|
45
|
+
require('./encryption/EncryptedColumns') as typeof import('./encryption/EncryptedColumns');
|
|
46
|
+
new EncryptedColumns().ensureSchema(table);
|
|
47
|
+
return table;
|
|
48
|
+
};
|
|
49
|
+
|
|
24
50
|
export const getColumnPropertyName = (table: Table<any>, columnName: string) => {
|
|
25
51
|
for (const columnPropertyName in table.columns) {
|
|
26
52
|
const column = table.columns[columnPropertyName];
|
|
@@ -126,6 +152,20 @@ export type Columns<T> = {
|
|
|
126
152
|
[P in OptionalProps<T>]?: Column<T[P] | undefined, any>;
|
|
127
153
|
};
|
|
128
154
|
|
|
155
|
+
/**
|
|
156
|
+
* What a column's query transform runs WITH: the current operation's authority and its
|
|
157
|
+
* driver/transaction-riding query runners. Supplied by `Db` per operation
|
|
158
|
+
* (`Db.addColumnQueries`), so a transform's own reads behave like part of the query that
|
|
159
|
+
* carried them.
|
|
160
|
+
*/
|
|
161
|
+
export interface ColumnQueryRuntime {
|
|
162
|
+
runAsSystem: boolean;
|
|
163
|
+
/** Query with the CALLER's authority (auth + column queries apply). */
|
|
164
|
+
query: (table: Table<any>, qb: QueryBuilder<any>) => Promise<any[]>;
|
|
165
|
+
/** System-authority query — for framework-derived tables that are default-deny to callers. */
|
|
166
|
+
systemQuery: (table: Table<any>, qb: QueryBuilder<any>) => Promise<any[]>;
|
|
167
|
+
}
|
|
168
|
+
|
|
129
169
|
export type Column<T, Serialized> = {
|
|
130
170
|
name: string;
|
|
131
171
|
/**
|
|
@@ -137,6 +177,21 @@ export type Column<T, Serialized> = {
|
|
|
137
177
|
options?: ColumnOptions;
|
|
138
178
|
serialize?: (fieldValue: T | null | undefined) => Promise<Serialized | null | undefined>;
|
|
139
179
|
deserialize?: (serializedFieldValue: Serialized | null, serializedRecord: any) => Promise<T | null | void>;
|
|
180
|
+
/**
|
|
181
|
+
* The column's QUERY-side contract, paralleling `serialize`/`deserialize` on the storage
|
|
182
|
+
* side: how uses of this column in a query (conditions, ORDER BY, aggregation, GROUP BY)
|
|
183
|
+
* translate into uses of what the database actually stores and indexes for it — see
|
|
184
|
+
* `ColumnQueryTransform` (@proteinjs/db-query). Applied by
|
|
185
|
+
* `QueryBuilder.applyColumnTransforms` on every query, through
|
|
186
|
+
* `TableQueryTransformProvider`.
|
|
187
|
+
*
|
|
188
|
+
* A factory rather than a bare transform: translation may consult derived index state
|
|
189
|
+
* (e.g. an encrypted column's token table), so a transform is built per operation with
|
|
190
|
+
* the operation's `ColumnQueryRuntime` — its driver-riding query runners and authority.
|
|
191
|
+
* Derived by the framework for encrypted columns (`EncryptedColumns.ensureSchema`);
|
|
192
|
+
* any column may supply one.
|
|
193
|
+
*/
|
|
194
|
+
queryTransform?: (runtime: ColumnQueryRuntime) => ColumnQueryTransform;
|
|
140
195
|
beforeDelete?: (
|
|
141
196
|
table: Table<any>,
|
|
142
197
|
columnPropertyName: string,
|
|
@@ -146,8 +201,60 @@ export type Column<T, Serialized> = {
|
|
|
146
201
|
) => Promise<void>;
|
|
147
202
|
};
|
|
148
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Capabilities of an encrypted column. Nested INSIDE the `encrypted` declaration on purpose:
|
|
206
|
+
* `searchable` and `sortKey` only mean anything on an encrypted column (a plaintext column
|
|
207
|
+
* is inherently searchable and sortable through normal SQL), so the invalid state — a
|
|
208
|
+
* search/sort capability declared without encryption — is unrepresentable.
|
|
209
|
+
*/
|
|
210
|
+
export type EncryptedColumnConfig = {
|
|
211
|
+
/**
|
|
212
|
+
* Index the value for native querying over ciphertext (derived automatically at the
|
|
213
|
+
* database layer — see `EncryptedColumns`):
|
|
214
|
+
* - `'contains'` — word + trigram search tokens, keyed-fingerprinted per owner, serving
|
|
215
|
+
* the LIKE contains/prefix family with exact results (candidate cover + decrypt-verify).
|
|
216
|
+
* - `'equality'` — one whole-value fingerprint companion column serving `=` / `IN` /
|
|
217
|
+
* get-by-value as a single indexed lookup (and value uniqueness, per owner).
|
|
218
|
+
* Encrypted columns that are never queried by value declare neither and carry no
|
|
219
|
+
* derivatives — the default.
|
|
220
|
+
*/
|
|
221
|
+
searchable?: 'contains' | 'equality';
|
|
222
|
+
/**
|
|
223
|
+
* Native ORDER BY at any scale through a DECLARED bounded reveal: an ordered
|
|
224
|
+
* representation of the value's first `revealPrefix` characters (normalized) is stored
|
|
225
|
+
* beside the ciphertext and the database sorts on it. The leak, stated plainly: raw
|
|
226
|
+
* database access can see each value's first `revealPrefix` characters — nothing else.
|
|
227
|
+
* A conscious, schema-author-declared tradeoff; never the default. Rows sharing a prefix
|
|
228
|
+
* tie-break app-side within the returned page.
|
|
229
|
+
*/
|
|
230
|
+
sortKey?: { revealPrefix: number };
|
|
231
|
+
};
|
|
232
|
+
|
|
149
233
|
export type ColumnOptions = {
|
|
150
234
|
unique?: { unique: boolean; indexName?: string };
|
|
235
|
+
/**
|
|
236
|
+
* Whether this column's values are stored encrypted (AES-256-GCM under per-owner data
|
|
237
|
+
* keys wrapped by the deployment's master key — see `DbEncryptionConfig`). The value is
|
|
238
|
+
* either `false` (plaintext — said out loud) or a config object:
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* encrypted: false // plaintext (explicit)
|
|
242
|
+
* encrypted: {} // encrypted, never queried by value
|
|
243
|
+
* encrypted: { searchable: 'contains' } // + indexed contains/prefix search
|
|
244
|
+
* encrypted: { searchable: 'equality' } // + indexed exact-match lookup
|
|
245
|
+
* encrypted: { sortKey: { revealPrefix: 3 } } // + native ORDER BY via a declared bounded reveal
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* Everything downstream — the transparent encrypt/decrypt seam, companion index
|
|
249
|
+
* derivation, query translation, loud contract rejections, lifecycle backfills — is
|
|
250
|
+
* derived automatically at the database layer; callers never write an encrypt or decrypt
|
|
251
|
+
* call. Query shapes outside the contract are rejected at query-build time
|
|
252
|
+
* (`EncryptionQueryTranslator`).
|
|
253
|
+
*
|
|
254
|
+
* When `DbEncryptionConfig.requireEncryptedDeclarations` is on, every text-holding
|
|
255
|
+
* column MUST carry this declaration — registration fails loudly otherwise.
|
|
256
|
+
*/
|
|
257
|
+
encrypted?: false | EncryptedColumnConfig;
|
|
151
258
|
/**
|
|
152
259
|
* The column in the reference table `table` is the primary key of the table (`id` unless otherwise specified in the Table definition)
|
|
153
260
|
*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ColumnQueryTransform, ColumnQueryTransformProvider } from '@proteinjs/db-query';
|
|
2
|
+
import { Column, ColumnQueryRuntime, Table } from './Table';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Table-model side of db-query's column query-transform seam: resolves a
|
|
6
|
+
* (tableName, columnProperty) pair to the column's own `Column.queryTransform`, built with
|
|
7
|
+
* the current operation's `ColumnQueryRuntime`. `Db.addColumnQueries` hands one of these
|
|
8
|
+
* to `QueryBuilder.applyColumnTransforms` on every query, so a column's query contract is
|
|
9
|
+
* consulted wherever the column is used — including inside subquery values on other
|
|
10
|
+
* tables, resolved through the same table registry the statement layer uses.
|
|
11
|
+
*/
|
|
12
|
+
export class TableQueryTransformProvider implements ColumnQueryTransformProvider {
|
|
13
|
+
constructor(
|
|
14
|
+
private getTable: (tableName: string) => Table<any>,
|
|
15
|
+
private runtime: ColumnQueryRuntime
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
getTransform(tableName: string, columnPropertyName: string): ColumnQueryTransform | undefined {
|
|
19
|
+
let table: Table<any>;
|
|
20
|
+
try {
|
|
21
|
+
table = this.getTable(tableName);
|
|
22
|
+
} catch {
|
|
23
|
+
// Not a registered table (e.g. an aggregate '*' field, or a name outside the model) —
|
|
24
|
+
// nothing to consult.
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const column = (table.columns as any)[columnPropertyName] as Column<any, any> | undefined;
|
|
29
|
+
return column?.queryTransform?.(this.runtime);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64url (RFC 4648 §5) over Buffers, hand-rolled because the package's @types/node
|
|
3
|
+
* predates the 'base64url' BufferEncoding. URL/filename-safe alphabet, no padding — safe
|
|
4
|
+
* inside the colon-delimited ciphertext envelope and as index-column values.
|
|
5
|
+
*/
|
|
6
|
+
export class Base64Url {
|
|
7
|
+
static encode(bytes: Buffer): string {
|
|
8
|
+
return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static decode(encoded: string): Buffer {
|
|
12
|
+
return Buffer.from(encoded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Table } from '../Table';
|
|
2
|
+
import { Record, withRecordColumns } from '../Record';
|
|
3
|
+
import { IntegerColumn, StringColumn } from '../Columns';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One wrapped per-owner data key version (see `DataKeyStore`).
|
|
7
|
+
*
|
|
8
|
+
* - `active` — encrypts new writes (the highest active version) and fingerprints queries.
|
|
9
|
+
* - `retired` — decrypt-only: envelopes referencing it still unwrap, but no new writes or
|
|
10
|
+
* query fingerprints use it. A rotation walk moves every row off a version, then retires it.
|
|
11
|
+
*
|
|
12
|
+
* Crypto-shred = deleting an owner's rows here: every envelope naming that owner becomes
|
|
13
|
+
* permanently unreadable, including inside backups.
|
|
14
|
+
*/
|
|
15
|
+
export interface DataEncryptionKey extends Record {
|
|
16
|
+
/** The key owner (a user id — the row's permission-source scope owner). */
|
|
17
|
+
owner: string;
|
|
18
|
+
/** Monotonic per-owner version; envelopes name (owner, version). */
|
|
19
|
+
version: number;
|
|
20
|
+
/** The data-key material, wrapped by the master key (`MasterKeyProvider`). */
|
|
21
|
+
wrappedKey: string;
|
|
22
|
+
status: 'active' | 'retired';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class DataEncryptionKeyTable extends Table<DataEncryptionKey> {
|
|
26
|
+
public name = 'data_encryption_key';
|
|
27
|
+
public columns: Table<DataEncryptionKey>['columns'] = withRecordColumns<DataEncryptionKey>({
|
|
28
|
+
owner: new StringColumn('owner', { nullable: false }, 36),
|
|
29
|
+
version: new IntegerColumn('version', { nullable: false }),
|
|
30
|
+
wrappedKey: new StringColumn('wrapped_key', { nullable: false }, 'MAX'),
|
|
31
|
+
status: new StringColumn('status', { nullable: false }, 16),
|
|
32
|
+
});
|
|
33
|
+
/** (owner, version) is the identity an envelope names — the race loser on concurrent
|
|
34
|
+
* first-write key creation fails here and re-reads the winner's key. */
|
|
35
|
+
public indexes: { columns: (keyof DataEncryptionKey)[]; name?: string; unique?: boolean }[] = [
|
|
36
|
+
{ columns: ['owner', 'version'], name: 'data_encryption_key_owner_version_unique', unique: true },
|
|
37
|
+
];
|
|
38
|
+
// No auth block: default-deny for every non-system caller. Key rows are framework
|
|
39
|
+
// machinery, only ever touched through system paths (DataKeyStore).
|
|
40
|
+
}
|