@proteinjs/db-driver-spanner 1.23.5 → 1.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/CHANGELOG.md +27 -0
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +17 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
- package/dist/src/SpannerSchemaOperations.js +4 -1
- package/dist/src/SpannerSchemaOperations.js.map +1 -1
- package/dist/test/ColumnEncryption.test.d.ts +2 -0
- package/dist/test/ColumnEncryption.test.d.ts.map +1 -0
- package/dist/test/ColumnEncryption.test.js +482 -0
- package/dist/test/ColumnEncryption.test.js.map +1 -0
- package/dist/test/EncryptedSearch.test.d.ts +2 -0
- package/dist/test/EncryptedSearch.test.d.ts.map +1 -0
- package/dist/test/EncryptedSearch.test.js +455 -0
- package/dist/test/EncryptedSearch.test.js.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts +2 -0
- package/dist/test/EncryptionLifecycleWalker.test.d.ts.map +1 -0
- package/dist/test/EncryptionLifecycleWalker.test.js +408 -0
- package/dist/test/EncryptionLifecycleWalker.test.js.map +1 -0
- package/dist/test/EncryptionPerf.test.d.ts +2 -0
- package/dist/test/EncryptionPerf.test.d.ts.map +1 -0
- package/dist/test/EncryptionPerf.test.js +278 -0
- package/dist/test/EncryptionPerf.test.js.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts +2 -0
- package/dist/test/EncryptionSortAndUnique.test.d.ts.map +1 -0
- package/dist/test/EncryptionSortAndUnique.test.js +229 -0
- package/dist/test/EncryptionSortAndUnique.test.js.map +1 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.d.ts +2 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.d.ts.map +1 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.js +244 -0
- package/dist/test/ReferenceColumnAdoptWidth.test.js.map +1 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/columnEncryptionTestHarness.d.ts +23 -0
- package/dist/test/util/columnEncryptionTestHarness.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestHarness.js +136 -0
- package/dist/test/util/columnEncryptionTestHarness.js.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts +75 -0
- package/dist/test/util/columnEncryptionTestTables.d.ts.map +1 -0
- package/dist/test/util/columnEncryptionTestTables.js +124 -0
- package/dist/test/util/columnEncryptionTestTables.js.map +1 -0
- package/dist/test/util/referenceAdoptWidthTestTables.d.ts +24 -0
- package/dist/test/util/referenceAdoptWidthTestTables.d.ts.map +1 -0
- package/dist/test/util/referenceAdoptWidthTestTables.js +35 -0
- package/dist/test/util/referenceAdoptWidthTestTables.js.map +1 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +2 -2
- package/generated/test/index.ts +17 -1
- package/package.json +6 -6
- package/src/SpannerSchemaOperations.ts +4 -1
- package/test/ColumnEncryption.test.ts +252 -0
- package/test/EncryptedSearch.test.ts +247 -0
- package/test/EncryptionLifecycleWalker.test.ts +223 -0
- package/test/EncryptionPerf.test.ts +156 -0
- package/test/EncryptionSortAndUnique.test.ts +130 -0
- package/test/ReferenceColumnAdoptWidth.test.ts +155 -0
- package/test/index.ts +1 -0
- package/test/util/columnEncryptionTestHarness.ts +54 -0
- package/test/util/columnEncryptionTestTables.ts +115 -0
- package/test/util/referenceAdoptWidthTestTables.ts +23 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Record,
|
|
4
|
+
StringColumn,
|
|
5
|
+
Table,
|
|
6
|
+
withRecordColumns,
|
|
7
|
+
setDbEncryptionConfig,
|
|
8
|
+
InMemoryMasterKeyProvider,
|
|
9
|
+
DataKeyStore,
|
|
10
|
+
EncryptedColumns,
|
|
11
|
+
EncryptionEnvelope,
|
|
12
|
+
EncryptionLifecycleWalker,
|
|
13
|
+
QueryBuilder,
|
|
14
|
+
} from '@proteinjs/db';
|
|
15
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
16
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
17
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
18
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
19
|
+
import { EncWalkRow, EncWalkRowTable } from './util/columnEncryptionTestTables';
|
|
20
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
21
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
22
|
+
import '../generated/test/index';
|
|
23
|
+
|
|
24
|
+
const spannerDriver = new SpannerDriver({
|
|
25
|
+
projectId: 'proteinjs-test',
|
|
26
|
+
instanceName: 'proteinjs-test',
|
|
27
|
+
databaseName: 'test',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
31
|
+
|
|
32
|
+
const OWNER = 'enc-walk-owner-a';
|
|
33
|
+
|
|
34
|
+
const walkTable = new EncWalkRowTable() as Table<EncWalkRow>;
|
|
35
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
36
|
+
const systemDb = new Db(spannerDriver, undefined, new TransactionContext(), true);
|
|
37
|
+
const envelope = new EncryptionEnvelope();
|
|
38
|
+
const walker = new EncryptionLifecycleWalker();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The decrypt-out view of the walk table: the SAME physical table with the columns declared
|
|
42
|
+
* plaintext — how the schema looks after a deliberate reclassification flips the config.
|
|
43
|
+
* Deliberately NOT a registered loadable (the registry holds the encrypted declaration;
|
|
44
|
+
* name-based lookups only resolve column names/types, identical between the two).
|
|
45
|
+
*/
|
|
46
|
+
class EncWalkRowDecryptedView extends Table<EncWalkRow> {
|
|
47
|
+
name = 'db_test_enc_walk_row';
|
|
48
|
+
columns: Table<EncWalkRow>['columns'] = withRecordColumns<EncWalkRow>({
|
|
49
|
+
scope: new StringColumn('scope', {}, 36),
|
|
50
|
+
title: new StringColumn('title', { encrypted: false }, 'MAX'),
|
|
51
|
+
body: new StringColumn('body', { encrypted: false }, 'MAX'),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rawRows = async (): Promise<any[]> =>
|
|
56
|
+
await spannerDriver.runQuery(() => ({
|
|
57
|
+
sql: `SELECT \`id\`, \`title\`, \`body\` FROM \`${walkTable.name}\` ORDER BY \`id\``,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const seedPlaintextRow = async (id: string, title: string, body: string) => {
|
|
61
|
+
await spannerDriver.runDml(() => ({
|
|
62
|
+
sql:
|
|
63
|
+
`INSERT INTO \`${walkTable.name}\` (\`id\`, \`created\`, \`updated\`, \`scope\`, \`title\`, \`body\`) ` +
|
|
64
|
+
`VALUES ('${id}', CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP(), '${OWNER}', '${title}', '${body}')`,
|
|
65
|
+
}));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const titleLike = (pattern: string) =>
|
|
69
|
+
new QueryBuilder<EncWalkRow>(walkTable.name).condition({ field: 'title', operator: 'LIKE', value: pattern });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The one reusable ONLINE backfill behind every config transition (encrypt-in-place
|
|
73
|
+
* adoption, add-searchable tokenization, decrypt-out, key rotation) — idempotent,
|
|
74
|
+
* resumable, migration-runner-shaped.
|
|
75
|
+
*/
|
|
76
|
+
describe('Encryption lifecycle walker', () => {
|
|
77
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
78
|
+
const tableManager = spannerDriver.getTableManager();
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
82
|
+
projectId: 'proteinjs-test',
|
|
83
|
+
instanceName: 'proteinjs-test',
|
|
84
|
+
databaseName: 'test',
|
|
85
|
+
});
|
|
86
|
+
registerTestUser();
|
|
87
|
+
setDbEncryptionConfig({
|
|
88
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
89
|
+
getAccessibleKeyOwners: async () => [OWNER],
|
|
90
|
+
});
|
|
91
|
+
await loadColumnEncryptionTestSchema(tableManager, [walkTable]);
|
|
92
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [walkTable]);
|
|
93
|
+
}, 120000);
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
setDbEncryptionConfig(undefined);
|
|
97
|
+
clearTestUser();
|
|
98
|
+
await dropTable(new EncryptedColumns().tokenTableFor(walkTable)!);
|
|
99
|
+
await dropTable(walkTable);
|
|
100
|
+
await SpannerEmulatorProvisioner.release();
|
|
101
|
+
}, 120000);
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await spannerDriver.runDml(() => ({ sql: `DELETE FROM \`${walkTable.name}\` WHERE TRUE` }));
|
|
105
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(walkTable)!;
|
|
106
|
+
await spannerDriver.runDml(() => ({ sql: `DELETE FROM \`${tokenTable.name}\` WHERE TRUE` }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('encrypt-in-place adoption: plaintext rows become envelopes, search works, live rows are skipped (idempotence)', async () => {
|
|
110
|
+
// the pre-encryption estate
|
|
111
|
+
await seedPlaintextRow('walk-adopt-1', 'meeting notes alpha', 'body one');
|
|
112
|
+
await seedPlaintextRow('walk-adopt-2', 'meeting notes beta', 'body two');
|
|
113
|
+
await seedPlaintextRow('walk-adopt-3', 'unrelated title', 'body three');
|
|
114
|
+
// a row already written through the live seam (already encrypted)
|
|
115
|
+
const liveRow = await db.insert(walkTable, { scope: OWNER, title: 'live row gamma', body: 'live body' });
|
|
116
|
+
|
|
117
|
+
const summary = await walker.walkTable(walkTable, 'encrypt', {
|
|
118
|
+
dbDriver: spannerDriver,
|
|
119
|
+
db: systemDb,
|
|
120
|
+
windowSize: 2, // exercise multi-window cursor paging
|
|
121
|
+
});
|
|
122
|
+
expect(summary.scanned).toBe(4);
|
|
123
|
+
expect(summary.rewritten).toBe(3); // the three plaintext rows; the live row skipped
|
|
124
|
+
|
|
125
|
+
// all rows now ciphertext at rest
|
|
126
|
+
for (const raw of await rawRows()) {
|
|
127
|
+
expect(envelope.isEnvelope(raw.title)).toBe(true);
|
|
128
|
+
expect(envelope.isEnvelope(raw.body)).toBe(true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// the adopted rows are searchable (tokens were written by the rewrite)
|
|
132
|
+
const found = await db.query(walkTable, titleLike('%meeting notes%'));
|
|
133
|
+
expect(found.map((row) => row.title).sort()).toEqual(['meeting notes alpha', 'meeting notes beta']);
|
|
134
|
+
|
|
135
|
+
// and values still read back exactly
|
|
136
|
+
expect((await db.get(walkTable, { id: 'walk-adopt-1' })).body).toBe('body one');
|
|
137
|
+
|
|
138
|
+
// IDEMPOTENCE: a re-run rewrites nothing
|
|
139
|
+
const rerun = await walker.walkTable(walkTable, 'encrypt', { dbDriver: spannerDriver, db: systemDb });
|
|
140
|
+
expect(rerun.scanned).toBe(4);
|
|
141
|
+
expect(rerun.rewritten).toBe(0);
|
|
142
|
+
|
|
143
|
+
void liveRow;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('resumability: a walk resumed from a cursor completes the remainder; done rows stay done', async () => {
|
|
147
|
+
await seedPlaintextRow('walk-resume-1', 'resume one', 'b');
|
|
148
|
+
await seedPlaintextRow('walk-resume-2', 'resume two', 'b');
|
|
149
|
+
await seedPlaintextRow('walk-resume-3', 'resume three', 'b');
|
|
150
|
+
|
|
151
|
+
// "crash" after the first row: simulate by walking only rows after a cursor — then
|
|
152
|
+
// verify a fresh full run converges the rest without redoing done work
|
|
153
|
+
const partial = await walker.walkTable(walkTable, 'encrypt', {
|
|
154
|
+
dbDriver: spannerDriver,
|
|
155
|
+
db: systemDb,
|
|
156
|
+
startAfterId: 'walk-resume-1',
|
|
157
|
+
});
|
|
158
|
+
expect(partial.rewritten).toBe(2);
|
|
159
|
+
|
|
160
|
+
const finish = await walker.walkTable(walkTable, 'encrypt', { dbDriver: spannerDriver, db: systemDb });
|
|
161
|
+
expect(finish.rewritten).toBe(1); // only the row the partial walk never reached
|
|
162
|
+
|
|
163
|
+
for (const raw of await rawRows()) {
|
|
164
|
+
expect(envelope.isEnvelope(raw.title)).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('decrypt-out reclassification: envelopes become plaintext, token rows are swept', async () => {
|
|
169
|
+
await db.insert(walkTable, { scope: OWNER, title: 'soon public alpha', body: 'body a' });
|
|
170
|
+
await db.insert(walkTable, { scope: OWNER, title: 'soon public beta', body: 'body b' });
|
|
171
|
+
|
|
172
|
+
const decryptedView = new EncWalkRowDecryptedView() as Table<EncWalkRow>;
|
|
173
|
+
const summary = await walker.walkTable(decryptedView, 'decrypt', {
|
|
174
|
+
dbDriver: spannerDriver,
|
|
175
|
+
db: systemDb,
|
|
176
|
+
columns: ['title', 'body'],
|
|
177
|
+
});
|
|
178
|
+
expect(summary.rewritten).toBe(2);
|
|
179
|
+
|
|
180
|
+
for (const raw of await rawRows()) {
|
|
181
|
+
expect(envelope.isEnvelope(raw.title)).toBe(false);
|
|
182
|
+
expect(raw.title).toContain('soon public');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tokenTable = new EncryptedColumns().tokenTableFor(walkTable)!;
|
|
186
|
+
const tokenCount = await spannerDriver.runQuery(() => ({
|
|
187
|
+
sql: `SELECT COUNT(*) as tokenCount FROM \`${tokenTable.name}\``,
|
|
188
|
+
}));
|
|
189
|
+
expect(Number((tokenCount[0] as any).tokenCount)).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('key rotation: rotate mints a new version, the walk rewrites envelopes to it, search stays exact, old version retires', async () => {
|
|
193
|
+
const row1 = await db.insert(walkTable, { scope: OWNER, title: 'rotation subject one', body: 'rb1' });
|
|
194
|
+
const row2 = await db.insert(walkTable, { scope: OWNER, title: 'rotation subject two', body: 'rb2' });
|
|
195
|
+
|
|
196
|
+
const store = new DataKeyStore();
|
|
197
|
+
const versionBefore = envelope.parse((await rawRows())[0].title)!.version;
|
|
198
|
+
const newVersion = await store.rotateKey(OWNER);
|
|
199
|
+
expect(newVersion).toBe(versionBefore + 1);
|
|
200
|
+
|
|
201
|
+
// during the rotation window (before the walk), reads and search still work
|
|
202
|
+
expect((await db.get(walkTable, { id: row1.id })).title).toBe('rotation subject one');
|
|
203
|
+
expect((await db.query(walkTable, titleLike('%rotation subject%'))).length).toBe(2);
|
|
204
|
+
|
|
205
|
+
const summary = await walker.walkTable(walkTable, 'rotate-keys', { dbDriver: spannerDriver, db: systemDb });
|
|
206
|
+
expect(summary.rewritten).toBe(2);
|
|
207
|
+
|
|
208
|
+
for (const raw of await rawRows()) {
|
|
209
|
+
expect(envelope.parse(raw.title)!.version).toBe(newVersion);
|
|
210
|
+
expect(envelope.parse(raw.body)!.version).toBe(newVersion);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// idempotence: nothing left at the old version
|
|
214
|
+
const rerun = await walker.walkTable(walkTable, 'rotate-keys', { dbDriver: spannerDriver, db: systemDb });
|
|
215
|
+
expect(rerun.rewritten).toBe(0);
|
|
216
|
+
|
|
217
|
+
// retire the old version: reads and search remain exact on the new key
|
|
218
|
+
await store.retireKeyVersion(OWNER, versionBefore);
|
|
219
|
+
expect((await db.get(walkTable, { id: row2.id })).title).toBe('rotation subject two');
|
|
220
|
+
const found = await db.query(walkTable, titleLike('%rotation subject%'));
|
|
221
|
+
expect(found.length).toBe(2);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
DataKeyStore,
|
|
7
|
+
EncryptionEnvelope,
|
|
8
|
+
QueryBuilder,
|
|
9
|
+
} from '@proteinjs/db';
|
|
10
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
11
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
12
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
13
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
14
|
+
import { EncPerfRow, EncPerfRowTable, PlainPerfRowTable } from './util/columnEncryptionTestTables';
|
|
15
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
16
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
17
|
+
import '../generated/test/index';
|
|
18
|
+
|
|
19
|
+
const spannerDriver = new SpannerDriver({
|
|
20
|
+
projectId: 'proteinjs-test',
|
|
21
|
+
instanceName: 'proteinjs-test',
|
|
22
|
+
databaseName: 'test',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
26
|
+
|
|
27
|
+
const OWNER = 'enc-perf-owner-a';
|
|
28
|
+
const ROW_COUNT = 1000;
|
|
29
|
+
|
|
30
|
+
const encTable = new EncPerfRowTable() as Table<EncPerfRow>;
|
|
31
|
+
const plainTable = new PlainPerfRowTable() as Table<EncPerfRow>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Performance sanity for the doc's claims (TRUST_AND_COMPLIANCE §1.5): per-value AES-256-GCM
|
|
36
|
+
* decrypt ~1-3µs in Node; a 1k-row read's decrypt overhead an order of magnitude below the
|
|
37
|
+
* database/network latency the read already pays. Numbers are printed for the report.
|
|
38
|
+
*/
|
|
39
|
+
describe('Column encryption performance sanity', () => {
|
|
40
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
41
|
+
const tableManager = spannerDriver.getTableManager();
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
45
|
+
projectId: 'proteinjs-test',
|
|
46
|
+
instanceName: 'proteinjs-test',
|
|
47
|
+
databaseName: 'test',
|
|
48
|
+
});
|
|
49
|
+
registerTestUser();
|
|
50
|
+
setDbEncryptionConfig({
|
|
51
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
52
|
+
getAccessibleKeyOwners: async () => [OWNER],
|
|
53
|
+
});
|
|
54
|
+
await loadColumnEncryptionTestSchema(tableManager, [encTable, plainTable]);
|
|
55
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [encTable, plainTable]);
|
|
56
|
+
|
|
57
|
+
// Seed 1k rows in each table with identical ~120-char bodies (batched inserts via txns
|
|
58
|
+
// to keep seeding fast on the emulator).
|
|
59
|
+
const body = (index: number) =>
|
|
60
|
+
`note body ${index} — a sentence of ordinary length, the kind a document row actually stores in practice.`;
|
|
61
|
+
for (let batch = 0; batch < ROW_COUNT / 100; batch++) {
|
|
62
|
+
await db.runTransaction(async () => {
|
|
63
|
+
for (let i = 0; i < 100; i++) {
|
|
64
|
+
const index = batch * 100 + i;
|
|
65
|
+
await db.insert(encTable, { scope: OWNER, body: body(index) });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await db.runTransaction(async () => {
|
|
69
|
+
for (let i = 0; i < 100; i++) {
|
|
70
|
+
const index = batch * 100 + i;
|
|
71
|
+
await db.insert(plainTable, { scope: OWNER, body: body(index) });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}, 600000);
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
setDbEncryptionConfig(undefined);
|
|
79
|
+
clearTestUser();
|
|
80
|
+
await dropTable(encTable);
|
|
81
|
+
await dropTable(plainTable);
|
|
82
|
+
await SpannerEmulatorProvisioner.release();
|
|
83
|
+
}, 120000);
|
|
84
|
+
|
|
85
|
+
const timeQuery = async (table: Table<EncPerfRow>): Promise<{ ms: number; rows: number }> => {
|
|
86
|
+
const start = process.hrtime.bigint();
|
|
87
|
+
const rows = await db.query(
|
|
88
|
+
table,
|
|
89
|
+
new QueryBuilder<EncPerfRow>(table.name).condition({ field: 'scope', operator: '=', value: OWNER })
|
|
90
|
+
);
|
|
91
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
92
|
+
return { ms, rows: rows.length };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
test('1k-row read: decrypt overhead measured against the plaintext twin', async () => {
|
|
96
|
+
// warm the key cache + driver sessions
|
|
97
|
+
await timeQuery(encTable);
|
|
98
|
+
await timeQuery(plainTable);
|
|
99
|
+
|
|
100
|
+
const samples = 5;
|
|
101
|
+
let encTotal = 0;
|
|
102
|
+
let plainTotal = 0;
|
|
103
|
+
for (let i = 0; i < samples; i++) {
|
|
104
|
+
encTotal += (await timeQuery(encTable)).ms;
|
|
105
|
+
plainTotal += (await timeQuery(plainTable)).ms;
|
|
106
|
+
}
|
|
107
|
+
const encAvg = encTotal / samples;
|
|
108
|
+
const plainAvg = plainTotal / samples;
|
|
109
|
+
const overheadMs = encAvg - plainAvg;
|
|
110
|
+
const perValueUs = (overheadMs * 1000) / ROW_COUNT;
|
|
111
|
+
|
|
112
|
+
// eslint-disable-next-line no-console
|
|
113
|
+
console.info(
|
|
114
|
+
`[perf] 1k-row read: encrypted ${encAvg.toFixed(1)}ms vs plaintext ${plainAvg.toFixed(1)}ms — ` +
|
|
115
|
+
`decrypt overhead ${overheadMs.toFixed(1)}ms total, ~${perValueUs.toFixed(2)}µs/value (doc claim: ~1-3µs/value)`
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const encRows = await db.query(
|
|
119
|
+
encTable,
|
|
120
|
+
new QueryBuilder<EncPerfRow>(encTable.name).condition({ field: 'scope', operator: '=', value: OWNER })
|
|
121
|
+
);
|
|
122
|
+
expect(encRows.length).toBe(ROW_COUNT);
|
|
123
|
+
// Sanity bound, generous (emulator variance): the whole-read overhead stays well under
|
|
124
|
+
// the read itself — decrypt must not dominate.
|
|
125
|
+
expect(overheadMs).toBeLessThan(Math.max(plainAvg, 250));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('per-value decrypt microbenchmark: the in-process AES-256-GCM cost itself', async () => {
|
|
129
|
+
const key = await new DataKeyStore().getWriteKey(OWNER);
|
|
130
|
+
const envelope = new EncryptionEnvelope();
|
|
131
|
+
const value = 'note body — a sentence of ordinary length, the kind a document row actually stores in practice.';
|
|
132
|
+
const encrypted = envelope.encrypt(value, key);
|
|
133
|
+
|
|
134
|
+
const iterations = 10000;
|
|
135
|
+
// warm
|
|
136
|
+
for (let i = 0; i < 1000; i++) {
|
|
137
|
+
envelope.decrypt(encrypted, key);
|
|
138
|
+
}
|
|
139
|
+
const start = process.hrtime.bigint();
|
|
140
|
+
for (let i = 0; i < iterations; i++) {
|
|
141
|
+
envelope.decrypt(encrypted, key);
|
|
142
|
+
}
|
|
143
|
+
const totalUs = Number(process.hrtime.bigint() - start) / 1e3;
|
|
144
|
+
const perValueUs = totalUs / iterations;
|
|
145
|
+
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.info(
|
|
148
|
+
`[perf] in-process decrypt: ${perValueUs.toFixed(2)}µs/value over ${iterations} iterations ` +
|
|
149
|
+
`(doc claim: ~1-3µs/value)`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(envelope.decrypt(encrypted, key)).toBe(value);
|
|
153
|
+
// The claim's order of magnitude, with generous headroom for slow machines
|
|
154
|
+
expect(perValueUs).toBeLessThan(30);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Db,
|
|
3
|
+
Table,
|
|
4
|
+
setDbEncryptionConfig,
|
|
5
|
+
InMemoryMasterKeyProvider,
|
|
6
|
+
EncryptedColumns,
|
|
7
|
+
EncryptedColumnQueryError,
|
|
8
|
+
QueryBuilder,
|
|
9
|
+
} from '@proteinjs/db';
|
|
10
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
11
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
12
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
13
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
14
|
+
import { EncSortedItem, EncSortedItemTable, EncUniqueTag, EncUniqueTagTable } from './util/columnEncryptionTestTables';
|
|
15
|
+
import { loadColumnEncryptionTestSchema, purgeColumnEncryptionTestRows } from './util/columnEncryptionTestHarness';
|
|
16
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
17
|
+
import '../generated/test/index';
|
|
18
|
+
|
|
19
|
+
const spannerDriver = new SpannerDriver({
|
|
20
|
+
projectId: 'proteinjs-test',
|
|
21
|
+
instanceName: 'proteinjs-test',
|
|
22
|
+
databaseName: 'test',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
(globalThis as any)['__proteinjs_db_defaultDbDriver'] = spannerDriver;
|
|
26
|
+
|
|
27
|
+
const OWNER_A = 'enc-sort-owner-a';
|
|
28
|
+
const OWNER_B = 'enc-sort-owner-b';
|
|
29
|
+
|
|
30
|
+
const sortedTable = new EncSortedItemTable() as Table<EncSortedItem>;
|
|
31
|
+
const uniqueTable = new EncUniqueTagTable() as Table<EncUniqueTag>;
|
|
32
|
+
const db = new Db(spannerDriver, undefined, new TransactionContext());
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The sortKey tier of the sorting story (a DECLARED bounded reveal → native ORDER BY at any
|
|
36
|
+
* scale), the loud live rejection without one, and value uniqueness through the equality
|
|
37
|
+
* fingerprint (per owner).
|
|
38
|
+
*/
|
|
39
|
+
describe('Encrypted sortKey ORDER BY + fingerprint uniqueness', () => {
|
|
40
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
41
|
+
const tableManager = spannerDriver.getTableManager();
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
45
|
+
projectId: 'proteinjs-test',
|
|
46
|
+
instanceName: 'proteinjs-test',
|
|
47
|
+
databaseName: 'test',
|
|
48
|
+
});
|
|
49
|
+
registerTestUser();
|
|
50
|
+
setDbEncryptionConfig({
|
|
51
|
+
masterKeyProvider: new InMemoryMasterKeyProvider('column-encryption-test'),
|
|
52
|
+
getAccessibleKeyOwners: async () => [OWNER_A],
|
|
53
|
+
});
|
|
54
|
+
await loadColumnEncryptionTestSchema(tableManager, [sortedTable, uniqueTable]);
|
|
55
|
+
await purgeColumnEncryptionTestRows(spannerDriver, [sortedTable, uniqueTable]);
|
|
56
|
+
}, 120000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
setDbEncryptionConfig(undefined);
|
|
60
|
+
clearTestUser();
|
|
61
|
+
await dropTable(new EncryptedColumns().tokenTableFor(sortedTable)!);
|
|
62
|
+
await dropTable(sortedTable);
|
|
63
|
+
await dropTable(uniqueTable);
|
|
64
|
+
await SpannerEmulatorProvisioner.release();
|
|
65
|
+
}, 120000);
|
|
66
|
+
|
|
67
|
+
test('sortKey: native ORDER BY + sorted pagination over encrypted values; only the declared prefix is revealed at rest', async () => {
|
|
68
|
+
const titles = ['banana bread', 'Apple pie', 'cherry tart', 'apricot jam', 'Blueberry scone'];
|
|
69
|
+
const ids: string[] = [];
|
|
70
|
+
for (const title of titles) {
|
|
71
|
+
ids.push((await db.insert(sortedTable, { scope: OWNER_A, title })).id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// native DB-side ORDER BY on the encrypted column (revealPrefix=3 buckets), ascending
|
|
75
|
+
const sorted = await db.query(
|
|
76
|
+
sortedTable,
|
|
77
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
78
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
79
|
+
.sort([{ field: 'title' }])
|
|
80
|
+
);
|
|
81
|
+
const sortedTitles = sorted.map((row) => row.title);
|
|
82
|
+
// prefix buckets (first 3 chars, normalized): app=Apple/apricot tie-break within bucket,
|
|
83
|
+
// then ban, blu, che — assert bucket ORDER, the declared guarantee
|
|
84
|
+
const buckets = sortedTitles.map((title) => (title ?? '').toLowerCase().slice(0, 3));
|
|
85
|
+
expect(buckets).toEqual([...buckets].sort());
|
|
86
|
+
|
|
87
|
+
// sorted PAGINATION: the first page of the prefix order
|
|
88
|
+
const firstPage = await db.query(
|
|
89
|
+
sortedTable,
|
|
90
|
+
new QueryBuilder<EncSortedItem>(sortedTable.name)
|
|
91
|
+
.condition({ field: 'scope', operator: '=', value: OWNER_A })
|
|
92
|
+
.sort([{ field: 'title' }])
|
|
93
|
+
.paginate({ start: 0, end: 2 })
|
|
94
|
+
);
|
|
95
|
+
expect(firstPage.length).toBe(2);
|
|
96
|
+
for (const row of firstPage) {
|
|
97
|
+
expect((row.title ?? '').toLowerCase().slice(0, 2)).toBe('ap');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// at rest: the sort companion holds EXACTLY the declared 3-character reveal
|
|
101
|
+
const raw = await spannerDriver.runQuery(() => ({
|
|
102
|
+
sql: `SELECT \`title\`, \`title_enc_srt\` FROM \`${sortedTable.name}\` WHERE \`id\` = '${ids[0]}'`,
|
|
103
|
+
}));
|
|
104
|
+
expect((raw[0] as any).title_enc_srt).toBe('ban');
|
|
105
|
+
expect((raw[0] as any).title).not.toContain('banana');
|
|
106
|
+
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
await db.delete(sortedTable, { id });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the live loud rejection: ORDER BY an encrypted column with no sortKey throws at query-build time', async () => {
|
|
113
|
+
const tagQb = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
114
|
+
await expect(db.query(uniqueTable, tagQb)).rejects.toThrow(EncryptedColumnQueryError);
|
|
115
|
+
|
|
116
|
+
const tagQb2 = new QueryBuilder<EncUniqueTag>(uniqueTable.name).sort([{ field: 'name' }]);
|
|
117
|
+
await expect(db.query(uniqueTable, tagQb2)).rejects.toThrow(/no sortKey declared/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('uniqueness on the value rides the fingerprint: duplicate value for the same owner rejected; other owners unaffected', async () => {
|
|
121
|
+
const first = await db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' });
|
|
122
|
+
await expect(db.insert(uniqueTable, { scope: OWNER_A, name: 'oncologist' })).rejects.toThrow();
|
|
123
|
+
|
|
124
|
+
// per-owner semantics: another owner may hold the same value (fingerprints are keyed per owner)
|
|
125
|
+
const otherOwner = await db.insert(uniqueTable, { scope: OWNER_B, name: 'oncologist' });
|
|
126
|
+
|
|
127
|
+
await db.delete(uniqueTable, { id: first.id });
|
|
128
|
+
await db.delete(uniqueTable, { id: otherOwner.id });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
2
|
+
import { Db, Record, Reference, ReferenceColumn, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
3
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
4
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
5
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
6
|
+
import {
|
|
7
|
+
AdoptWidthRecord,
|
|
8
|
+
ADOPT_WIDTH_TABLE_NAME,
|
|
9
|
+
ADOPT_WIDTH_TARGET_TABLE_NAME,
|
|
10
|
+
ReferenceAdoptWidthTestTable,
|
|
11
|
+
} from './util/referenceAdoptWidthTestTables';
|
|
12
|
+
import '../generated/test/index';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `ReferenceColumn`'s width-adoption option (`maxLength`), which exists so an EXISTING string-uuid
|
|
16
|
+
* column can be retyped to a reference IN PLACE — same storage type, same bytes, zero DDL.
|
|
17
|
+
*
|
|
18
|
+
* The default reference width is STRING(36), but a column that predates the reference type was
|
|
19
|
+
* created at StringColumn's default STRING(255); Spanner cannot narrow a STRING in place, so
|
|
20
|
+
* without adopting the existing width the schema sync doesn't just emit DDL — it throws and
|
|
21
|
+
* bricks boot. Contracts, as outcomes against the live emulator schema:
|
|
22
|
+
* 1. Adopting the existing width means the sync sees NO changes for the retype (no DDL at all).
|
|
23
|
+
* 2. Storage is byte-identical both directions: rows written by the string era read back as
|
|
24
|
+
* references to the same id, and reference writes store the raw id string.
|
|
25
|
+
* 3. The stock (36) shape against a 255-width column is a narrowing: reported as a type change
|
|
26
|
+
* and refused by the alter pass — the documented reason the adoption option must be passed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const spannerDriver = new SpannerDriver({
|
|
30
|
+
projectId: 'proteinjs-test',
|
|
31
|
+
instanceName: 'proteinjs-test',
|
|
32
|
+
databaseName: 'test',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const TABLE_NAME = ADOPT_WIDTH_TABLE_NAME;
|
|
36
|
+
const TARGET_TABLE_NAME = ADOPT_WIDTH_TARGET_TABLE_NAME;
|
|
37
|
+
|
|
38
|
+
interface LegacyRecord extends Record {
|
|
39
|
+
invitedBy?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The string era: how the column was originally declared (and lives in deployed schemas). */
|
|
43
|
+
const legacyTable = (): Table<LegacyRecord> => {
|
|
44
|
+
return new (class extends Table<LegacyRecord> {
|
|
45
|
+
name = TABLE_NAME;
|
|
46
|
+
columns = withRecordColumns<LegacyRecord>({
|
|
47
|
+
invitedBy: new StringColumn('invited_by'),
|
|
48
|
+
});
|
|
49
|
+
})();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The retype under test: same physical column, adopted at its existing width (registered — Db
|
|
53
|
+
* statement generation resolves tables by name through the reflection registry). */
|
|
54
|
+
const adoptedTable = (): Table<AdoptWidthRecord> => new ReferenceAdoptWidthTestTable();
|
|
55
|
+
|
|
56
|
+
/** The stock shape (36): what the retype would be WITHOUT width adoption. */
|
|
57
|
+
const stockWidthTable = (): Table<AdoptWidthRecord> => {
|
|
58
|
+
return new (class extends Table<AdoptWidthRecord> {
|
|
59
|
+
name = TABLE_NAME;
|
|
60
|
+
columns = withRecordColumns<AdoptWidthRecord>({
|
|
61
|
+
invitedBy: new ReferenceColumn<Record>('invited_by', TARGET_TABLE_NAME, false),
|
|
62
|
+
});
|
|
63
|
+
})();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type TableManagerInternals = {
|
|
67
|
+
getTableChanges(table: Table<any>): Promise<{
|
|
68
|
+
columnsToCreate: string[];
|
|
69
|
+
columnsToAlter: string[];
|
|
70
|
+
columnTypeChanges: { name: string; newType: string; oldType: string }[];
|
|
71
|
+
}>;
|
|
72
|
+
shouldAlterTable(tableChanges: unknown): boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe('ReferenceColumn width adoption (string uuid column retyped in place)', () => {
|
|
76
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
77
|
+
const tableManager = spannerDriver.getTableManager();
|
|
78
|
+
const tableManagerInternals = tableManager as unknown as TableManagerInternals;
|
|
79
|
+
|
|
80
|
+
const STRING_ERA_UUID = '3f2a9c04-6b1d-4e8a-9c37-5d20f4a81b6e';
|
|
81
|
+
const REFERENCE_ERA_UUID = '9b7c1e52-0d4f-4a63-8e91-2c85d6f03a47';
|
|
82
|
+
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
85
|
+
projectId: 'proteinjs-test',
|
|
86
|
+
instanceName: 'proteinjs-test',
|
|
87
|
+
databaseName: 'test',
|
|
88
|
+
});
|
|
89
|
+
await dropTable(legacyTable());
|
|
90
|
+
}, 30000);
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
await dropTable(legacyTable());
|
|
94
|
+
await SpannerEmulatorProvisioner.release();
|
|
95
|
+
}, 30000);
|
|
96
|
+
|
|
97
|
+
test('retype with the adopted width is invisible to the schema sync — zero DDL', async () => {
|
|
98
|
+
// The string era: the column exists as STRING(255) and holds a raw uuid.
|
|
99
|
+
const legacy = legacyTable();
|
|
100
|
+
await tableManager.loadTable(legacy);
|
|
101
|
+
expect((await tableManager.schemaMetadata.getColumnMetadata(legacy))['invited_by'].type).toBe('STRING(255)');
|
|
102
|
+
await spannerDriver.runDml(() => ({
|
|
103
|
+
sql: `INSERT INTO ${TABLE_NAME} (id, invited_by) VALUES (@id, @invitedBy)`,
|
|
104
|
+
namedParams: {
|
|
105
|
+
params: { id: 'string-era-row', invitedBy: STRING_ERA_UUID },
|
|
106
|
+
types: { id: 'string', invitedBy: 'string' },
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
// The retype: the sync must see NOTHING to do.
|
|
111
|
+
const adopted = adoptedTable();
|
|
112
|
+
const tableChanges = await tableManagerInternals.getTableChanges(adopted);
|
|
113
|
+
expect(tableChanges.columnTypeChanges).toEqual([]);
|
|
114
|
+
expect(tableChanges.columnsToAlter).toEqual([]);
|
|
115
|
+
expect(tableChanges.columnsToCreate).toEqual([]);
|
|
116
|
+
expect(tableManagerInternals.shouldAlterTable(tableChanges)).toBe(false);
|
|
117
|
+
|
|
118
|
+
// And a full load pass leaves the live column untouched.
|
|
119
|
+
await tableManager.loadTable(adopted);
|
|
120
|
+
expect((await tableManager.schemaMetadata.getColumnMetadata(adopted))['invited_by'].type).toBe('STRING(255)');
|
|
121
|
+
}, 60000);
|
|
122
|
+
|
|
123
|
+
test('storage stays byte-identical: string-era rows read as references; reference writes store the raw id', async () => {
|
|
124
|
+
const adopted = adoptedTable();
|
|
125
|
+
// As-system, like the server-side writers of adopted columns (TableAuth is not under test).
|
|
126
|
+
const db = new Db(spannerDriver, () => adopted, new TransactionContext(), true);
|
|
127
|
+
|
|
128
|
+
// The row written by the string era reads back through the retyped column as a reference
|
|
129
|
+
// to the exact same id.
|
|
130
|
+
const stringEraRow = await db.get(adopted, { id: 'string-era-row' });
|
|
131
|
+
expect(stringEraRow.invitedBy).toBeInstanceOf(Reference);
|
|
132
|
+
expect(stringEraRow.invitedBy?._id).toBe(STRING_ERA_UUID);
|
|
133
|
+
expect(stringEraRow.invitedBy?._table).toBe(TARGET_TABLE_NAME);
|
|
134
|
+
|
|
135
|
+
// A reference written through the retyped column stores the raw id string — the same bytes
|
|
136
|
+
// the string era would have written.
|
|
137
|
+
const inserted = await db.insert(adopted, {
|
|
138
|
+
invitedBy: new Reference<Record>(TARGET_TABLE_NAME, REFERENCE_ERA_UUID),
|
|
139
|
+
});
|
|
140
|
+
const rows = await spannerDriver.runQuery(() => ({
|
|
141
|
+
sql: `SELECT invited_by FROM ${TABLE_NAME} WHERE id = @id`,
|
|
142
|
+
namedParams: { params: { id: inserted.id }, types: { id: 'string' } },
|
|
143
|
+
}));
|
|
144
|
+
expect(rows[0].invited_by).toBe(REFERENCE_ERA_UUID);
|
|
145
|
+
}, 60000);
|
|
146
|
+
|
|
147
|
+
test('the stock 36 width against a 255 column is a narrowing: reported as a type change and refused', async () => {
|
|
148
|
+
const stock = stockWidthTable();
|
|
149
|
+
const tableChanges = await tableManagerInternals.getTableChanges(stock);
|
|
150
|
+
expect(tableChanges.columnTypeChanges).toEqual([
|
|
151
|
+
{ name: 'invited_by', oldType: 'STRING(255)', newType: 'STRING(36)' },
|
|
152
|
+
]);
|
|
153
|
+
await expect(tableManager.loadTable(stock)).rejects.toThrow(/Unable to change column types in Spanner/);
|
|
154
|
+
}, 60000);
|
|
155
|
+
});
|
package/test/index.ts
CHANGED