@proteinjs/db 1.35.1 → 1.36.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 +11 -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/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/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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { isInstanceOf } from '@proteinjs/util';
|
|
2
|
+
import { Column, EncryptedColumnConfig, Table } from '../Table';
|
|
3
|
+
import { Record, withRecordColumns } from '../Record';
|
|
4
|
+
import {
|
|
5
|
+
DynamicReferenceColumn,
|
|
6
|
+
DynamicReferenceTableNameColumn,
|
|
7
|
+
ObjectColumn,
|
|
8
|
+
ReferenceArrayColumn,
|
|
9
|
+
ReferenceColumn,
|
|
10
|
+
StringColumn,
|
|
11
|
+
UuidColumn,
|
|
12
|
+
} from '../Columns';
|
|
13
|
+
import { EncryptedColumnConfigError } from './DbEncryptionConfig';
|
|
14
|
+
import { EncryptionDerivedTableRegistry } from './EncryptionDerivedTableRegistry';
|
|
15
|
+
|
|
16
|
+
/** One search-token row: a keyed fingerprint of one token of one encrypted column value. */
|
|
17
|
+
export interface EncryptionSearchToken extends Record {
|
|
18
|
+
recordId: string;
|
|
19
|
+
columnName: string;
|
|
20
|
+
token: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The schema side of column encryption (`ColumnOptions.encrypted`): validation of the
|
|
25
|
+
* declaration, and derivation of the physical schema an encrypted column needs —
|
|
26
|
+
* automatically, at the framework seam, never by callers:
|
|
27
|
+
*
|
|
28
|
+
* - the ciphertext column itself widens to STRING(MAX) (envelopes outgrow declared widths);
|
|
29
|
+
* - `searchable: 'equality'` derives an indexed whole-value fingerprint companion column
|
|
30
|
+
* (`<column>_enc_eq`); a `unique` declaration on the base column moves onto it (per-owner
|
|
31
|
+
* value uniqueness — fingerprints are keyed per owner);
|
|
32
|
+
* - `searchable: 'contains'` derives a companion search-token TABLE (`<table>_enc_tok`) —
|
|
33
|
+
* a plain indexed inverted index, portable across drivers (Spanner's native search
|
|
34
|
+
* indexes are a named later optimization at the driver layer);
|
|
35
|
+
* - `sortKey: { revealPrefix: N }` derives an ordered-prefix companion column
|
|
36
|
+
* (`<column>_enc_srt`) for native ORDER BY — the declared bounded reveal.
|
|
37
|
+
*
|
|
38
|
+
* `ensureSchema` is idempotent and runs wherever the physical schema is consulted
|
|
39
|
+
* (TableManager, the record serializer, the query translator).
|
|
40
|
+
*/
|
|
41
|
+
export class EncryptedColumns {
|
|
42
|
+
private static readonly SCHEMA_APPLIED_MARKER = '__encSchemaApplied';
|
|
43
|
+
private static readonly INTERNAL_COLUMN_MARKER = '__encInternalColumn';
|
|
44
|
+
|
|
45
|
+
/** Validate declarations and inject the derived physical schema (idempotent). */
|
|
46
|
+
ensureSchema(table: Table<any>): void {
|
|
47
|
+
if ((table as any)[EncryptedColumns.SCHEMA_APPLIED_MARKER]) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let hasContainsColumns = false;
|
|
52
|
+
for (const prop of Object.keys(table.columns)) {
|
|
53
|
+
const column = (table.columns as any)[prop] as Column<any, any>;
|
|
54
|
+
const config = this.validateDeclaration(table, prop, column);
|
|
55
|
+
if (!config) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof (column as any).maxLength !== 'undefined') {
|
|
60
|
+
// Envelopes outgrow the declared plaintext width; the physical column is MAX.
|
|
61
|
+
(column as any).maxLength = 'MAX';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (config.searchable === 'equality') {
|
|
65
|
+
this.injectEqualityCompanion(table, prop, column);
|
|
66
|
+
}
|
|
67
|
+
if (config.searchable === 'contains') {
|
|
68
|
+
hasContainsColumns = true;
|
|
69
|
+
}
|
|
70
|
+
if (config.sortKey) {
|
|
71
|
+
this.injectSortCompanion(table, prop, column, config.sortKey.revealPrefix);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The column's QUERY-side contract (`Column.queryTransform` — the parallel of the
|
|
75
|
+
// storage-side serialize/deserialize seam), DERIVED from the declaration at this
|
|
76
|
+
// framework seam, never caller-declared. `QueryBuilder.applyColumnTransforms`
|
|
77
|
+
// consults it on every query. Call-time require: the transform pulls the key-store
|
|
78
|
+
// stack, which sits beside Db in the module graph.
|
|
79
|
+
column.queryTransform = (runtime) => {
|
|
80
|
+
const { EncryptedColumnQueryTransform } =
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
82
|
+
require('./EncryptedColumnQueryTransform') as typeof import('./EncryptedColumnQueryTransform');
|
|
83
|
+
return new EncryptedColumnQueryTransform(table, prop, column, config, runtime);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (hasContainsColumns) {
|
|
88
|
+
EncryptionDerivedTableRegistry.register(this.tokenTableShape(table));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Marked only after full success: a validation throw above must re-validate (and
|
|
92
|
+
// re-throw, loudly) on every subsequent use, never half-apply.
|
|
93
|
+
(table as any)[EncryptedColumns.SCHEMA_APPLIED_MARKER] = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The mandatory-declaration gate (`DbEncryptionConfig.requireEncryptedDeclarations`):
|
|
98
|
+
* every text-holding column must declare `encrypted` (`false` or a config object), so no
|
|
99
|
+
* column can be added without deciding, and the encrypted set can never silently drift.
|
|
100
|
+
* Identifier-bearing column classes (uuids, references) are metadata by construction and
|
|
101
|
+
* are exempt.
|
|
102
|
+
*/
|
|
103
|
+
validateDeclarations(table: Table<any>): void {
|
|
104
|
+
const undeclared: string[] = [];
|
|
105
|
+
for (const prop of Object.keys(table.columns)) {
|
|
106
|
+
const column = (table.columns as any)[prop] as Column<any, any>;
|
|
107
|
+
if (!this.isTextColumn(column) || this.isInternalColumn(column)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof column.options?.encrypted === 'undefined') {
|
|
112
|
+
undeclared.push(`${prop} ('${column.name}')`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (undeclared.length > 0) {
|
|
117
|
+
throw new EncryptedColumnConfigError(
|
|
118
|
+
`(${table.name}) Every text-holding column must declare 'encrypted' (false, or a config ` +
|
|
119
|
+
`object such as {} or { searchable: 'contains' }). Missing declarations: ${undeclared.join(', ')}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The `encrypted` config object for a column property, or undefined when not encrypted. */
|
|
125
|
+
configFor(table: Table<any>, prop: string): EncryptedColumnConfig | undefined {
|
|
126
|
+
const column = (table.columns as any)[prop] as Column<any, any> | undefined;
|
|
127
|
+
const encrypted = column?.options?.encrypted;
|
|
128
|
+
return encrypted && typeof encrypted === 'object' ? encrypted : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Property names of this table's encrypted columns. */
|
|
132
|
+
encryptedProps(table: Table<any>): string[] {
|
|
133
|
+
return Object.keys(table.columns).filter((prop) => !!this.configFor(table, prop));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Property names declared `searchable: 'contains'`. */
|
|
137
|
+
containsProps(table: Table<any>): string[] {
|
|
138
|
+
return this.encryptedProps(table).filter((prop) => this.configFor(table, prop)!.searchable === 'contains');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Does this write payload touch any encrypted column? */
|
|
142
|
+
recordTouchesEncryptedColumns(table: Table<any>, record: any): boolean {
|
|
143
|
+
return this.encryptedProps(table).some((prop) => typeof record[prop] !== 'undefined');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
hasEncryptedColumns(table: Table<any>): boolean {
|
|
147
|
+
return this.encryptedProps(table).length > 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The derived search-token table for `table`, or undefined when nothing is `contains`-searchable. */
|
|
151
|
+
tokenTableFor(table: Table<any>): Table<EncryptionSearchToken> | undefined {
|
|
152
|
+
this.ensureSchema(table);
|
|
153
|
+
return EncryptionDerivedTableRegistry.get(this.tokenTableName(table)) as Table<EncryptionSearchToken> | undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
tokenTableName(table: Table<any>): string {
|
|
157
|
+
return `${table.name}_enc_tok`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Companion property names EQUAL their column names (several seams assume prop == name). */
|
|
161
|
+
eqCompanionProp(table: Table<any>, prop: string): string {
|
|
162
|
+
return `${((table.columns as any)[prop] as { name: string }).name}_enc_eq`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
sortCompanionProp(table: Table<any>, prop: string): string {
|
|
166
|
+
return `${((table.columns as any)[prop] as { name: string }).name}_enc_srt`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Framework-derived companion columns are dropped from deserialized records and exempt from declaration checks. */
|
|
170
|
+
isInternalColumn(column: Column<any, any>): boolean {
|
|
171
|
+
return !!(column as any)[EncryptedColumns.INTERNAL_COLUMN_MARKER];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private validateDeclaration(
|
|
175
|
+
table: Table<any>,
|
|
176
|
+
prop: string,
|
|
177
|
+
column: Column<any, any>
|
|
178
|
+
): EncryptedColumnConfig | undefined {
|
|
179
|
+
const encrypted = column.options?.encrypted;
|
|
180
|
+
if (typeof encrypted === 'undefined' || encrypted === false) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (typeof encrypted !== 'object') {
|
|
185
|
+
throw new EncryptedColumnConfigError(
|
|
186
|
+
`(${table.name}.${prop}) 'encrypted' must be false or a config object ` +
|
|
187
|
+
`(e.g. {}, { searchable: 'contains' }); got: ${JSON.stringify(encrypted)}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!this.isTextColumn(column)) {
|
|
192
|
+
throw new EncryptedColumnConfigError(
|
|
193
|
+
`(${table.name}.${prop}) 'encrypted' is only supported on text columns (StringColumn family). ` +
|
|
194
|
+
`Identifier and reference columns are metadata by construction; JSON-typed columns must use a ` +
|
|
195
|
+
`string-serialized column type to encrypt.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (encrypted.searchable && encrypted.searchable !== 'contains' && encrypted.searchable !== 'equality') {
|
|
200
|
+
throw new EncryptedColumnConfigError(
|
|
201
|
+
`(${table.name}.${prop}) encrypted.searchable must be 'contains' or 'equality'; ` +
|
|
202
|
+
`got: ${JSON.stringify(encrypted.searchable)}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ((encrypted.searchable || encrypted.sortKey) && isInstanceOf(column, ObjectColumn)) {
|
|
207
|
+
throw new EncryptedColumnConfigError(
|
|
208
|
+
`(${table.name}.${prop}) encrypted.searchable / encrypted.sortKey require a plain string column ` +
|
|
209
|
+
`(the search and sort derivatives cover the value's text, not its serialization). ` +
|
|
210
|
+
`Object/array columns may declare encrypted: {} (encrypted, never queried by value).`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (encrypted.sortKey) {
|
|
215
|
+
const revealPrefix = encrypted.sortKey.revealPrefix;
|
|
216
|
+
if (!Number.isInteger(revealPrefix) || revealPrefix < 1) {
|
|
217
|
+
throw new EncryptedColumnConfigError(
|
|
218
|
+
`(${table.name}.${prop}) encrypted.sortKey.revealPrefix must be a positive integer ` +
|
|
219
|
+
`(the number of leading characters whose order the sort column reveals); ` +
|
|
220
|
+
`got: ${JSON.stringify(revealPrefix)}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (column.options?.unique?.unique && encrypted.searchable !== 'equality') {
|
|
226
|
+
throw new EncryptedColumnConfigError(
|
|
227
|
+
`(${table.name}.${prop}) a unique constraint on an encrypted column requires ` +
|
|
228
|
+
`encrypted: { searchable: 'equality' } — uniqueness is enforced through a unique index on ` +
|
|
229
|
+
`the whole-value fingerprint (ciphertext is never equal, so a unique index on it cannot bite).`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return encrypted;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private injectEqualityCompanion(table: Table<any>, prop: string, column: Column<any, any>): void {
|
|
237
|
+
const companionProp = `${column.name}_enc_eq`;
|
|
238
|
+
const baseUnique = column.options?.unique?.unique === true;
|
|
239
|
+
const companion = new StringColumn(
|
|
240
|
+
`${column.name}_enc_eq`,
|
|
241
|
+
{
|
|
242
|
+
nullable: true,
|
|
243
|
+
...(baseUnique ? { unique: { unique: true, indexName: `${table.name}_${column.name}_enc_eq_unique` } } : {}),
|
|
244
|
+
ui: { hidden: true },
|
|
245
|
+
},
|
|
246
|
+
64
|
|
247
|
+
);
|
|
248
|
+
this.injectCompanionColumn(table, companionProp, companion);
|
|
249
|
+
if (baseUnique) {
|
|
250
|
+
// Uniqueness moves to the fingerprint column (see validateDeclaration).
|
|
251
|
+
delete column.options!.unique;
|
|
252
|
+
} else {
|
|
253
|
+
table.indexes.push({
|
|
254
|
+
columns: [companionProp],
|
|
255
|
+
name: `${table.name}_${column.name}_enc_eq_idx`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private injectSortCompanion(table: Table<any>, prop: string, column: Column<any, any>, revealPrefix: number): void {
|
|
261
|
+
const companion = new StringColumn(
|
|
262
|
+
`${column.name}_enc_srt`,
|
|
263
|
+
{ nullable: true, ui: { hidden: true } },
|
|
264
|
+
Math.max(revealPrefix, 1)
|
|
265
|
+
);
|
|
266
|
+
this.injectCompanionColumn(table, `${column.name}_enc_srt`, companion);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private injectCompanionColumn(table: Table<any>, companionProp: string, companion: Column<any, any>): void {
|
|
270
|
+
if ((table.columns as any)[companionProp] && !this.isInternalColumn((table.columns as any)[companionProp])) {
|
|
271
|
+
throw new EncryptedColumnConfigError(
|
|
272
|
+
`(${table.name}) Column property '${companionProp}' collides with a framework-derived ` +
|
|
273
|
+
`encryption companion column. Rename the declared column.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
(companion as any)[EncryptedColumns.INTERNAL_COLUMN_MARKER] = true;
|
|
278
|
+
(table.columns as any)[companionProp] = companion;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The token table's SHAPE for `table`, independent of registration — the lifecycle
|
|
283
|
+
* walker's decrypt-out sweep needs it after the config stopped declaring any
|
|
284
|
+
* `searchable: 'contains'` column (when `tokenTableFor` no longer resolves).
|
|
285
|
+
*/
|
|
286
|
+
tokenTableShape(table: Table<any>): Table<EncryptionSearchToken> {
|
|
287
|
+
const tokenTableName = this.tokenTableName(table);
|
|
288
|
+
const tokenTable = new (class extends Table<EncryptionSearchToken> {
|
|
289
|
+
public name = tokenTableName;
|
|
290
|
+
public columns: Table<EncryptionSearchToken>['columns'] = withRecordColumns<EncryptionSearchToken>({
|
|
291
|
+
recordId: new StringColumn('record_id', { nullable: false }, 36),
|
|
292
|
+
columnName: new StringColumn('column_name', { nullable: false }, 128),
|
|
293
|
+
token: new StringColumn('token', { nullable: false }, 64),
|
|
294
|
+
});
|
|
295
|
+
public indexes: { columns: (keyof EncryptionSearchToken)[]; name?: string; unique?: boolean }[] = [
|
|
296
|
+
{ columns: ['token', 'columnName'], name: `${tokenTableName}_cover_idx` },
|
|
297
|
+
{ columns: ['recordId'], name: `${tokenTableName}_record_idx` },
|
|
298
|
+
];
|
|
299
|
+
// No auth block: default-deny for non-system callers; only framework system paths
|
|
300
|
+
// (EncryptionTokenMaintenance, the query translator's subqueries) touch token rows.
|
|
301
|
+
})();
|
|
302
|
+
return tokenTable;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private isTextColumn(column: Column<any, any>): boolean {
|
|
306
|
+
if (!isInstanceOf(column, StringColumn)) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return !(
|
|
311
|
+
isInstanceOf(column, UuidColumn) ||
|
|
312
|
+
isInstanceOf(column, ReferenceColumn) ||
|
|
313
|
+
isInstanceOf(column, ReferenceArrayColumn) ||
|
|
314
|
+
isInstanceOf(column, DynamicReferenceColumn) ||
|
|
315
|
+
isInstanceOf(column, DynamicReferenceTableNameColumn)
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Table } from '../Table';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registry of framework-DERIVED tables (search-token tables beside encrypted-searchable
|
|
5
|
+
* columns — see `EncryptedColumns`). Derived tables are synthesized from column config, not
|
|
6
|
+
* declared in source, so the reflection-backed table registry never sees them; `tableByName`
|
|
7
|
+
* consults this registry after the source registry misses.
|
|
8
|
+
*
|
|
9
|
+
* Lives on the global object so every live copy of this package (per-package installs)
|
|
10
|
+
* shares one registry — same pattern as `Db.getDefaultDbDriver`.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately import-cycle-free: `Table` is a type-only import (erased at runtime), so
|
|
13
|
+
* `Table.ts` can import this module without closing a load-order loop.
|
|
14
|
+
*/
|
|
15
|
+
export class EncryptionDerivedTableRegistry {
|
|
16
|
+
private static readonly GLOBAL_KEY = '__proteinjs_db_encryptionDerivedTables';
|
|
17
|
+
|
|
18
|
+
static get(tableName: string): Table<any> | undefined {
|
|
19
|
+
return EncryptionDerivedTableRegistry.tables().get(tableName);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static register(table: Table<any>): void {
|
|
23
|
+
EncryptionDerivedTableRegistry.tables().set(table.name, table);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private static tables(): Map<string, Table<any>> {
|
|
27
|
+
const globalObject = globalThis as any;
|
|
28
|
+
if (!globalObject[EncryptionDerivedTableRegistry.GLOBAL_KEY]) {
|
|
29
|
+
globalObject[EncryptionDerivedTableRegistry.GLOBAL_KEY] = new Map<string, Table<any>>();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return globalObject[EncryptionDerivedTableRegistry.GLOBAL_KEY];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
2
|
+
import { Base64Url } from './Base64Url';
|
|
3
|
+
import type { DataKeyMaterial } from './DataKeyStore';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The self-describing ciphertext envelope an encrypted column stores:
|
|
7
|
+
*
|
|
8
|
+
* `pjenc:1:<ownerId>:<keyVersion>:<base64url(iv || authTag || ciphertext)>`
|
|
9
|
+
*
|
|
10
|
+
* Decrypt resolves the key FROM the envelope (owner + version), never from who is asking —
|
|
11
|
+
* a shared row decrypts for whoever the permission layer admits, and key rotation is a
|
|
12
|
+
* background rewrite. Cipher: AES-256-GCM (12-byte IV, 16-byte tag), the data key's cipher
|
|
13
|
+
* half (`DataKeyMaterial.cipherKey`).
|
|
14
|
+
*/
|
|
15
|
+
export class EncryptionEnvelope {
|
|
16
|
+
static readonly PREFIX = 'pjenc:1:';
|
|
17
|
+
private static readonly ENVELOPE_PATTERN = /^pjenc:1:([^:]+):(\d+):([A-Za-z0-9_-]+)$/;
|
|
18
|
+
|
|
19
|
+
encrypt(plaintext: string, key: DataKeyMaterial): string {
|
|
20
|
+
const iv = randomBytes(12);
|
|
21
|
+
const cipher = createCipheriv('aes-256-gcm', key.cipherKey, iv);
|
|
22
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
23
|
+
const authTag = cipher.getAuthTag();
|
|
24
|
+
const payload = Base64Url.encode(Buffer.concat([iv, authTag, ciphertext]));
|
|
25
|
+
return `${EncryptionEnvelope.PREFIX}${key.owner}:${key.version}:${payload}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
decrypt(envelope: string, key: DataKeyMaterial): string {
|
|
29
|
+
const parsed = this.parse(envelope);
|
|
30
|
+
if (!parsed) {
|
|
31
|
+
throw new Error(`Value is not a ciphertext envelope`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bytes = Base64Url.decode(parsed.payload);
|
|
35
|
+
const iv = bytes.subarray(0, 12);
|
|
36
|
+
const authTag = bytes.subarray(12, 28);
|
|
37
|
+
const ciphertext = bytes.subarray(28);
|
|
38
|
+
const decipher = createDecipheriv('aes-256-gcm', key.cipherKey, iv);
|
|
39
|
+
decipher.setAuthTag(authTag);
|
|
40
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Owner + version + payload when `value` is an envelope, else undefined. */
|
|
44
|
+
parse(value: unknown): { owner: string; version: number; payload: string } | undefined {
|
|
45
|
+
if (typeof value !== 'string') {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const match = EncryptionEnvelope.ENVELOPE_PATTERN.exec(value);
|
|
50
|
+
if (!match) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { owner: match[1], version: parseInt(match[2], 10), payload: match[3] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isEnvelope(value: unknown): boolean {
|
|
58
|
+
return this.parse(value) !== undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { QueryBuilder } from '@proteinjs/db-query';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import type { Table } from '../Table';
|
|
4
|
+
import { EncryptedColumns } from './EncryptedColumns';
|
|
5
|
+
import { EncryptionEnvelope } from './EncryptionEnvelope';
|
|
6
|
+
import { DataKeyStore } from './DataKeyStore';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The config-lifecycle transitions, all riding this one walker:
|
|
10
|
+
* - `encrypt` — plaintext → `encrypted: {...}` adoption: rewrite rows whose stored value is
|
|
11
|
+
* not yet an envelope (also how the initial adoption wave lands).
|
|
12
|
+
* - `retokenize` — a `searchable`/`sortKey` capability added later: rewrite every non-null
|
|
13
|
+
* value so the write seam derives the new companions/tokens.
|
|
14
|
+
* - `decrypt` — `encrypted` → `false` (a deliberate, reviewed reclassification): rewrite
|
|
15
|
+
* rows whose stored value is still an envelope; pass the affected columns explicitly
|
|
16
|
+
* (the config no longer marks them). Stale token rows are swept.
|
|
17
|
+
* - `rotate-keys` — after `DataKeyStore.rotateKey`: rewrite rows whose envelope names an
|
|
18
|
+
* older version (plaintext stragglers are encrypted too), then the caller retires the old
|
|
19
|
+
* version.
|
|
20
|
+
*/
|
|
21
|
+
export type EncryptionWalkMode = 'encrypt' | 'retokenize' | 'decrypt' | 'rotate-keys';
|
|
22
|
+
|
|
23
|
+
export interface EncryptionWalkSummary {
|
|
24
|
+
scanned: number;
|
|
25
|
+
rewritten: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EncryptionWalkOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Column property names to walk. Required for mode 'decrypt' (the config no longer marks
|
|
31
|
+
* the columns); defaults to every encrypted-declared column otherwise.
|
|
32
|
+
*/
|
|
33
|
+
columns?: string[];
|
|
34
|
+
/** Resume after this row id (the walk is id-ordered; re-running from the start is also safe — done rows are skipped). */
|
|
35
|
+
startAfterId?: string;
|
|
36
|
+
windowSize?: number;
|
|
37
|
+
/** The driver to scan with; defaults to the process's default driver. */
|
|
38
|
+
dbDriver?: import('../Db').DbDriver;
|
|
39
|
+
/** The Db to rewrite through (must be a SYSTEM db); defaults to `getDbAsSystem()`. */
|
|
40
|
+
db?: import('../Db').Db<any>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reusable ONLINE backfill behind every encrypted-column config transition (and key
|
|
45
|
+
* rotation) — the migration-runner pattern: a server-side, id-ordered, windowed table walk
|
|
46
|
+
* that rewrites pending rows THROUGH the normal write seam (so encryption, companions, and
|
|
47
|
+
* search tokens all derive exactly as a live write would).
|
|
48
|
+
*
|
|
49
|
+
* Idempotent and resumable by construction: pending-ness is detected from the stored value
|
|
50
|
+
* itself (envelope or not, version current or not), so a re-run — after a crash, or just to
|
|
51
|
+
* be sure — skips completed rows and continues. Rows written by live traffic mid-walk are
|
|
52
|
+
* already in the new shape and skip too. Run it from a `Migration.run()` for deploy-coupled
|
|
53
|
+
* transitions.
|
|
54
|
+
*/
|
|
55
|
+
export class EncryptionLifecycleWalker {
|
|
56
|
+
private static readonly DEFAULT_WINDOW_SIZE = 200;
|
|
57
|
+
private logger = new Logger({ name: this.constructor.name });
|
|
58
|
+
private encryptedColumns = new EncryptedColumns();
|
|
59
|
+
private envelope = new EncryptionEnvelope();
|
|
60
|
+
|
|
61
|
+
async walkTable(
|
|
62
|
+
table: Table<any>,
|
|
63
|
+
mode: EncryptionWalkMode,
|
|
64
|
+
options: EncryptionWalkOptions = {}
|
|
65
|
+
): Promise<EncryptionWalkSummary> {
|
|
66
|
+
this.encryptedColumns.ensureSchema(table);
|
|
67
|
+
const props = this.resolveProps(table, mode, options);
|
|
68
|
+
const { Db, getDbAsSystem } = await import('../Db');
|
|
69
|
+
const dbDriver = options.dbDriver ?? Db.getDefaultDbDriver();
|
|
70
|
+
const db = options.db ?? getDbAsSystem();
|
|
71
|
+
const windowSize = options.windowSize ?? EncryptionLifecycleWalker.DEFAULT_WINDOW_SIZE;
|
|
72
|
+
const columnNames = props.map((prop) => ({
|
|
73
|
+
prop,
|
|
74
|
+
columnName: ((table.columns as any)[prop] as { name: string }).name,
|
|
75
|
+
}));
|
|
76
|
+
const activeVersionByOwner = new Map<string, number>();
|
|
77
|
+
|
|
78
|
+
const summary: EncryptionWalkSummary = { scanned: 0, rewritten: 0 };
|
|
79
|
+
let cursor = options.startAfterId;
|
|
80
|
+
// eslint-disable-next-line no-constant-condition
|
|
81
|
+
while (true) {
|
|
82
|
+
const rawRows = await this.rawWindow(table, dbDriver, columnNames, cursor, windowSize);
|
|
83
|
+
if (rawRows.length === 0) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const rawRow of rawRows) {
|
|
88
|
+
summary.scanned++;
|
|
89
|
+
const pendingProps: string[] = [];
|
|
90
|
+
for (const { prop, columnName } of columnNames) {
|
|
91
|
+
if (await this.isPending(mode, rawRow[columnName], activeVersionByOwner)) {
|
|
92
|
+
pendingProps.push(prop);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (pendingProps.length === 0) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await this.rewriteRow(table, db, rawRow['id'], pendingProps);
|
|
101
|
+
summary.rewritten++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
cursor = rawRows[rawRows.length - 1]['id'];
|
|
105
|
+
if (rawRows.length < windowSize) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (mode === 'decrypt') {
|
|
111
|
+
await this.sweepTokenRows(
|
|
112
|
+
table,
|
|
113
|
+
db,
|
|
114
|
+
columnNames.map(({ columnName }) => columnName)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.logger.info({
|
|
119
|
+
message: `Encryption lifecycle walk finished`,
|
|
120
|
+
obj: { table: table.name, mode, ...summary },
|
|
121
|
+
});
|
|
122
|
+
return summary;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async isPending(
|
|
126
|
+
mode: EncryptionWalkMode,
|
|
127
|
+
rawValue: any,
|
|
128
|
+
activeVersionByOwner: Map<string, number>
|
|
129
|
+
): Promise<boolean> {
|
|
130
|
+
if (rawValue === null || typeof rawValue === 'undefined') {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const parsed = this.envelope.parse(rawValue);
|
|
135
|
+
switch (mode) {
|
|
136
|
+
case 'encrypt':
|
|
137
|
+
return !parsed;
|
|
138
|
+
case 'retokenize':
|
|
139
|
+
return true;
|
|
140
|
+
case 'decrypt':
|
|
141
|
+
return !!parsed;
|
|
142
|
+
case 'rotate-keys': {
|
|
143
|
+
if (!parsed) {
|
|
144
|
+
return true; // plaintext straggler — encrypt under the current key
|
|
145
|
+
}
|
|
146
|
+
let activeVersion = activeVersionByOwner.get(parsed.owner);
|
|
147
|
+
if (typeof activeVersion === 'undefined') {
|
|
148
|
+
activeVersion = (await new DataKeyStore().getWriteKey(parsed.owner)).version;
|
|
149
|
+
activeVersionByOwner.set(parsed.owner, activeVersion);
|
|
150
|
+
}
|
|
151
|
+
return parsed.version < activeVersion;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Rewrite one row through the normal seam: read it (the deserialize hook decrypts — or
|
|
158
|
+
* passes plaintext through, the adoption path), write the pending columns back (the
|
|
159
|
+
* serialize hook re-derives ciphertext, companions, and tokens under the current config).
|
|
160
|
+
*/
|
|
161
|
+
private async rewriteRow(
|
|
162
|
+
table: Table<any>,
|
|
163
|
+
db: import('../Db').Db<any>,
|
|
164
|
+
id: string,
|
|
165
|
+
pendingProps: string[]
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const row = await db.get(table, { id });
|
|
168
|
+
if (!row) {
|
|
169
|
+
return; // deleted mid-walk
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const payload: any = { id };
|
|
173
|
+
for (const prop of pendingProps) {
|
|
174
|
+
if (typeof row[prop] !== 'undefined') {
|
|
175
|
+
payload[prop] = row[prop];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Object.keys(payload).length === 1) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await db.update(table, payload);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Raw (un-deserialized) id-ordered window — pending-ness must see stored bytes, not decrypted values. */
|
|
187
|
+
private async rawWindow(
|
|
188
|
+
table: Table<any>,
|
|
189
|
+
dbDriver: import('../Db').DbDriver,
|
|
190
|
+
columnNames: { prop: string; columnName: string }[],
|
|
191
|
+
cursor: string | undefined,
|
|
192
|
+
windowSize: number
|
|
193
|
+
): Promise<any[]> {
|
|
194
|
+
const { StatementConfigFactory } = await import('../StatementConfigFactory');
|
|
195
|
+
const statementConfigFactory = new StatementConfigFactory(dbDriver.getDbName());
|
|
196
|
+
const qb = new QueryBuilder(table.name).select({ fields: ['id', ...columnNames.map(({ prop }) => prop)] });
|
|
197
|
+
if (cursor) {
|
|
198
|
+
qb.condition({ field: 'id', operator: '>', value: cursor });
|
|
199
|
+
}
|
|
200
|
+
qb.sort([{ field: 'id' }]).paginate({ start: 0, end: windowSize });
|
|
201
|
+
return await dbDriver.runQuery((config) => qb.toSql(statementConfigFactory.getStatementConfig(config)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** After decrypt-out, the decrypted columns' token rows are stale — sweep them. */
|
|
205
|
+
private async sweepTokenRows(table: Table<any>, db: import('../Db').Db<any>, columnNames: string[]): Promise<void> {
|
|
206
|
+
const tokenTable = this.encryptedColumns.tokenTableFor(table) ?? this.encryptedColumns.tokenTableShape(table);
|
|
207
|
+
if (!(await db.tableExists(tokenTable))) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const deleteQb = new QueryBuilder(tokenTable.name).condition({
|
|
212
|
+
field: 'columnName',
|
|
213
|
+
operator: 'IN',
|
|
214
|
+
value: columnNames,
|
|
215
|
+
});
|
|
216
|
+
await db.delete(tokenTable, deleteQb);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private resolveProps(table: Table<any>, mode: EncryptionWalkMode, options: EncryptionWalkOptions): string[] {
|
|
220
|
+
if (options.columns && options.columns.length > 0) {
|
|
221
|
+
for (const prop of options.columns) {
|
|
222
|
+
if (!(table.columns as any)[prop]) {
|
|
223
|
+
throw new Error(`(${table.name}) Unknown column property to walk: ${prop}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return options.columns;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (mode === 'decrypt') {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Mode 'decrypt' requires options.columns: after the config flips to encrypted: false, the ` +
|
|
232
|
+
`declaration no longer names the columns to decrypt.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const props = this.encryptedColumns.encryptedProps(table);
|
|
237
|
+
if (props.length === 0) {
|
|
238
|
+
throw new Error(`(${table.name}) No encrypted columns declared — nothing to walk.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return props;
|
|
242
|
+
}
|
|
243
|
+
}
|