@pikku/kysely 0.13.23 → 0.13.27
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 +248 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/kysely-webhook-service.d.ts +1 -1
- package/dist/src/kysely-webhook-service.js +1 -1
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -124
- package/src/kysely-webhook-service.ts +0 -137
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import { describe, test, before } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {
|
|
4
|
-
CamelCasePlugin,
|
|
5
|
-
DummyDriver,
|
|
6
|
-
Kysely,
|
|
7
|
-
PostgresAdapter,
|
|
8
|
-
PostgresIntrospector,
|
|
9
|
-
PostgresQueryCompiler,
|
|
10
|
-
type QueryResult,
|
|
11
|
-
type UnknownRow,
|
|
12
|
-
} from 'kysely'
|
|
13
|
-
import type { ClassificationManifest } from '@pikku/core/classification'
|
|
14
|
-
import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
|
|
15
|
-
import {
|
|
16
|
-
ClassificationCrypto,
|
|
17
|
-
type KEKResolver,
|
|
18
|
-
} from './classification-crypto.js'
|
|
19
|
-
import { createClassificationPlugin } from './classification-plugin.js'
|
|
20
|
-
|
|
21
|
-
interface TestDB {
|
|
22
|
-
users: {
|
|
23
|
-
id: string
|
|
24
|
-
email: string
|
|
25
|
-
ssn: string
|
|
26
|
-
tokenHash: string
|
|
27
|
-
recovery: string
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const manifest: ClassificationManifest = {
|
|
32
|
-
version: 1,
|
|
33
|
-
tables: {
|
|
34
|
-
users: {
|
|
35
|
-
id: { classification: 'public', anonymize_strategy: null },
|
|
36
|
-
email: { classification: 'pii', anonymize_strategy: 'fake:email' },
|
|
37
|
-
ssn: {
|
|
38
|
-
classification: 'secret',
|
|
39
|
-
anonymize_strategy: null,
|
|
40
|
-
form: 'wrapped',
|
|
41
|
-
},
|
|
42
|
-
token_hash: {
|
|
43
|
-
classification: 'secret',
|
|
44
|
-
anonymize_strategy: null,
|
|
45
|
-
form: 'hashed',
|
|
46
|
-
},
|
|
47
|
-
recovery: {
|
|
48
|
-
classification: 'secret',
|
|
49
|
-
anonymize_strategy: null,
|
|
50
|
-
form: 'wrapped',
|
|
51
|
-
keyId: 'recovery-codes',
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let crypto: ClassificationCrypto
|
|
58
|
-
|
|
59
|
-
/** A key source with nothing to give, the way an unconfigured one behaves. */
|
|
60
|
-
class NoKeyError extends Error {}
|
|
61
|
-
const noKey: KEKResolver = async () => {
|
|
62
|
-
throw new NoKeyError('no key for this column')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
before(async () => {
|
|
66
|
-
const keks = new Map<string, Promise<CryptoKey>>()
|
|
67
|
-
const resolveKEK: KEKResolver = async (keyId) => {
|
|
68
|
-
let kek = keks.get(keyId)
|
|
69
|
-
if (!kek) {
|
|
70
|
-
kek = deriveKEK(
|
|
71
|
-
'a-passphrase-long-enough-to-be-real-key-material',
|
|
72
|
-
generateKEKSalt()
|
|
73
|
-
)
|
|
74
|
-
keks.set(keyId, kek)
|
|
75
|
-
}
|
|
76
|
-
return { kek: await kek, keyVersion: 1 }
|
|
77
|
-
}
|
|
78
|
-
crypto = new ClassificationCrypto({ resolveKEK })
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const plugin = () => createClassificationPlugin({ manifest, crypto })
|
|
82
|
-
|
|
83
|
-
const selectFrom = (tables: string[]) => ({
|
|
84
|
-
kind: 'SelectQueryNode',
|
|
85
|
-
from: {
|
|
86
|
-
kind: 'FromNode',
|
|
87
|
-
froms: tables.map((table) => ({
|
|
88
|
-
kind: 'TableNode',
|
|
89
|
-
table: {
|
|
90
|
-
kind: 'SchemableIdentifierNode',
|
|
91
|
-
identifier: { kind: 'IdentifierNode', name: table },
|
|
92
|
-
},
|
|
93
|
-
})),
|
|
94
|
-
},
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
const readRows = async (
|
|
98
|
-
rows: UnknownRow[],
|
|
99
|
-
tables: string[] = ['users']
|
|
100
|
-
): Promise<UnknownRow[]> => {
|
|
101
|
-
const p = plugin()
|
|
102
|
-
const queryId = {} as { queryId: string }
|
|
103
|
-
p.transformQuery({ queryId, node: selectFrom(tables) } as any)
|
|
104
|
-
const out = await p.transformResult({
|
|
105
|
-
queryId,
|
|
106
|
-
result: { rows } as QueryResult<UnknownRow>,
|
|
107
|
-
} as any)
|
|
108
|
-
return out.rows as UnknownRow[]
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const writeDb = () =>
|
|
112
|
-
new Kysely<TestDB>({
|
|
113
|
-
dialect: {
|
|
114
|
-
createAdapter: () => new PostgresAdapter(),
|
|
115
|
-
createDriver: () => new DummyDriver(),
|
|
116
|
-
createIntrospector: (db) => new PostgresIntrospector(db),
|
|
117
|
-
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
118
|
-
},
|
|
119
|
-
plugins: [new CamelCasePlugin(), plugin()],
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
describe('reading classified columns', () => {
|
|
123
|
-
test('a wrapped column decrypts transparently', async () => {
|
|
124
|
-
const ssn = await crypto.encryptColumn('default', '123-45-6789')
|
|
125
|
-
const [row] = await readRows([{ id: 'u1', ssn }])
|
|
126
|
-
assert.equal(row!.ssn, '123-45-6789')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test('a column with its own keyId decrypts under that key', async () => {
|
|
130
|
-
const recovery = await crypto.encryptColumn('recovery-codes', 'abc-def')
|
|
131
|
-
const [row] = await readRows([{ id: 'u1', recovery }])
|
|
132
|
-
assert.equal(row!.recovery, 'abc-def')
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test('plain and pii columns are left alone', async () => {
|
|
136
|
-
const [row] = await readRows([{ id: 'u1', email: 'a@b.c' }])
|
|
137
|
-
assert.equal(row!.email, 'a@b.c')
|
|
138
|
-
assert.equal(row!.id, 'u1')
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test('a hashed column is never decrypted — the hash is the lookup key', async () => {
|
|
142
|
-
const tokenHash = 'sha256:deadbeef'
|
|
143
|
-
const [row] = await readRows([{ id: 'u1', tokenHash }])
|
|
144
|
-
assert.equal(row!.tokenHash, tokenHash)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
test('a null wrapped column stays null', async () => {
|
|
148
|
-
const [row] = await readRows([{ id: 'u1', ssn: null }])
|
|
149
|
-
assert.equal(row!.ssn, null)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
test('columns of a table the query never touched are left alone', async () => {
|
|
153
|
-
const [row] = await readRows([{ ssn: 'not-an-envelope' }], ['audit'])
|
|
154
|
-
assert.equal(row!.ssn, 'not-an-envelope')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('plain and hashed columns still read without any key at all', async () => {
|
|
158
|
-
const p = createClassificationPlugin({
|
|
159
|
-
manifest,
|
|
160
|
-
crypto: new ClassificationCrypto({ resolveKEK: noKey }),
|
|
161
|
-
})
|
|
162
|
-
const queryId = {} as { queryId: string }
|
|
163
|
-
p.transformQuery({ queryId, node: selectFrom(['users']) } as any)
|
|
164
|
-
|
|
165
|
-
// A sign-in that checks a token hash has to work on a deployment whose key
|
|
166
|
-
// source is unavailable, so a query touching no wrapped column must never
|
|
167
|
-
// reach the resolver at all.
|
|
168
|
-
const out = await p.transformResult({
|
|
169
|
-
queryId,
|
|
170
|
-
result: {
|
|
171
|
-
rows: [{ id: 'u1', email: 'a@b.c', tokenHash: 'sha256:deadbeef' }],
|
|
172
|
-
} as QueryResult<UnknownRow>,
|
|
173
|
-
} as any)
|
|
174
|
-
|
|
175
|
-
assert.deepEqual(out.rows[0], {
|
|
176
|
-
id: 'u1',
|
|
177
|
-
email: 'a@b.c',
|
|
178
|
-
tokenHash: 'sha256:deadbeef',
|
|
179
|
-
})
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
test('a wrapped column read without its key fails loudly', async () => {
|
|
183
|
-
const stored = await crypto.encryptColumn('default', 'ssn')
|
|
184
|
-
|
|
185
|
-
const p = createClassificationPlugin({
|
|
186
|
-
manifest,
|
|
187
|
-
crypto: new ClassificationCrypto({ resolveKEK: noKey }),
|
|
188
|
-
})
|
|
189
|
-
const queryId = {} as { queryId: string }
|
|
190
|
-
p.transformQuery({ queryId, node: selectFrom(['users']) } as any)
|
|
191
|
-
|
|
192
|
-
await assert.rejects(
|
|
193
|
-
() =>
|
|
194
|
-
p.transformResult({
|
|
195
|
-
queryId,
|
|
196
|
-
result: {
|
|
197
|
-
rows: [{ id: 'u1', ssn: stored }],
|
|
198
|
-
} as QueryResult<UnknownRow>,
|
|
199
|
-
} as any),
|
|
200
|
-
(error: unknown) => error instanceof NoKeyError,
|
|
201
|
-
'handing back an unopened envelope as if it were the value would be worse'
|
|
202
|
-
)
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
test('decrypts under CamelCasePlugin, which renames the column first', async () => {
|
|
206
|
-
const value = await crypto.encryptColumn('default', 'x')
|
|
207
|
-
const [snake] = await readRows([{ token_hash: 'h', ssn: value }])
|
|
208
|
-
assert.equal(snake!.ssn, 'x')
|
|
209
|
-
})
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
describe('writing classified columns', () => {
|
|
213
|
-
test('plaintext into a wrapped column is refused', async () => {
|
|
214
|
-
await assert.rejects(
|
|
215
|
-
() =>
|
|
216
|
-
writeDb()
|
|
217
|
-
.insertInto('users')
|
|
218
|
-
.values({ id: 'u1', ssn: '123-45-6789' as never })
|
|
219
|
-
.execute(),
|
|
220
|
-
/ssn/,
|
|
221
|
-
'a forgotten encryption must not reach the database'
|
|
222
|
-
)
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
test('a real envelope is accepted', async () => {
|
|
226
|
-
const ssn = await crypto.encryptColumn('default', '123-45-6789')
|
|
227
|
-
await writeDb()
|
|
228
|
-
.insertInto('users')
|
|
229
|
-
.values({ id: 'u1', ssn: ssn as never })
|
|
230
|
-
.execute()
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
test('an update with plaintext is refused too', async () => {
|
|
234
|
-
await assert.rejects(
|
|
235
|
-
() =>
|
|
236
|
-
writeDb()
|
|
237
|
-
.updateTable('users')
|
|
238
|
-
.set({ ssn: 'oops' as never })
|
|
239
|
-
.where('id', '=', 'u1')
|
|
240
|
-
.execute(),
|
|
241
|
-
/ssn/
|
|
242
|
-
)
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
test('unclassified columns are written without complaint', async () => {
|
|
246
|
-
await writeDb()
|
|
247
|
-
.insertInto('users')
|
|
248
|
-
.values({ id: 'u1', email: 'a@b.c' })
|
|
249
|
-
.execute()
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
test('a hashed column takes its hash as-is', async () => {
|
|
253
|
-
await writeDb()
|
|
254
|
-
.insertInto('users')
|
|
255
|
-
.values({ id: 'u1', tokenHash: 'sha256:deadbeef' as never })
|
|
256
|
-
.execute()
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('a null wrapped column is allowed', async () => {
|
|
260
|
-
await writeDb()
|
|
261
|
-
.insertInto('users')
|
|
262
|
-
.values({ id: 'u1', ssn: null as never })
|
|
263
|
-
.execute()
|
|
264
|
-
})
|
|
265
|
-
})
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import type { KyselyPlugin, RootOperationNode, UnknownRow } from 'kysely'
|
|
2
|
-
import type {
|
|
3
|
-
ClassificationManifest,
|
|
4
|
-
ColumnForm,
|
|
5
|
-
} from '@pikku/core/classification'
|
|
6
|
-
import {
|
|
7
|
-
DEFAULT_KEY_ID,
|
|
8
|
-
isColumnEnvelope,
|
|
9
|
-
type ClassificationCrypto,
|
|
10
|
-
} from './classification-crypto.js'
|
|
11
|
-
|
|
12
|
-
/** What the manifest says about one column, reduced to what the query path needs. */
|
|
13
|
-
interface EncryptedColumn {
|
|
14
|
-
form: Extract<ColumnForm, 'wrapped' | 'sealed'>
|
|
15
|
-
keyId: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface ResolvedManifest {
|
|
19
|
-
/** `table.column` → column, for the exact per-query resolution. */
|
|
20
|
-
qualified: Map<string, EncryptedColumn>
|
|
21
|
-
/** `column` → column, the fallback for when the query's tables say nothing. */
|
|
22
|
-
flat: Map<string, EncryptedColumn>
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function snakeToCamel(name: string): string {
|
|
26
|
-
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Flatten the manifest into a table-qualified lookup plus a bare column-name
|
|
31
|
-
* fallback, both indexed under snake_case and camelCase so the plugin works on
|
|
32
|
-
* either side of `CamelCasePlugin` in the array.
|
|
33
|
-
*
|
|
34
|
-
* A bare name two tables disagree about is dropped rather than letting the last
|
|
35
|
-
* one win: on a joined query it could carry either, and guessing wrong means
|
|
36
|
-
* either handing back ciphertext or refusing a legitimate write. The qualified
|
|
37
|
-
* map still resolves it whenever the query names its tables.
|
|
38
|
-
*/
|
|
39
|
-
function buildManifest(manifest: ClassificationManifest): ResolvedManifest {
|
|
40
|
-
const qualified = new Map<string, EncryptedColumn>()
|
|
41
|
-
const flat = new Map<string, EncryptedColumn>()
|
|
42
|
-
const ambiguous = new Set<string>()
|
|
43
|
-
|
|
44
|
-
const assignFlat = (key: string, column: EncryptedColumn) => {
|
|
45
|
-
const existing = flat.get(key)
|
|
46
|
-
if (existing && existing.keyId !== column.keyId) {
|
|
47
|
-
ambiguous.add(key)
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
flat.set(key, column)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
for (const [table, columns] of Object.entries(manifest.tables)) {
|
|
54
|
-
for (const [name, classification] of Object.entries(columns)) {
|
|
55
|
-
const form = classification.form
|
|
56
|
-
if (form !== 'wrapped' && form !== 'sealed') continue
|
|
57
|
-
|
|
58
|
-
const column: EncryptedColumn = {
|
|
59
|
-
form,
|
|
60
|
-
keyId: classification.keyId ?? DEFAULT_KEY_ID,
|
|
61
|
-
}
|
|
62
|
-
const camel = snakeToCamel(name)
|
|
63
|
-
qualified.set(`${table}.${name}`, column)
|
|
64
|
-
qualified.set(`${table}.${camel}`, column)
|
|
65
|
-
assignFlat(name, column)
|
|
66
|
-
assignFlat(camel, column)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const key of ambiguous) flat.delete(key)
|
|
71
|
-
return { qualified, flat }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function collectQueryTables(node: unknown, out: Set<string>): void {
|
|
75
|
-
if (!node || typeof node !== 'object') return
|
|
76
|
-
|
|
77
|
-
const op = node as {
|
|
78
|
-
kind?: string
|
|
79
|
-
table?: { identifier?: { name?: string } }
|
|
80
|
-
}
|
|
81
|
-
if (op.kind === 'TableNode') {
|
|
82
|
-
const name = op.table?.identifier?.name
|
|
83
|
-
if (typeof name === 'string' && name.length > 0) out.add(name)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const value of Object.values(node)) {
|
|
87
|
-
if (Array.isArray(value)) {
|
|
88
|
-
for (const item of value) collectQueryTables(item, out)
|
|
89
|
-
} else {
|
|
90
|
-
collectQueryTables(value, out)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function lookupColumn(
|
|
96
|
-
manifest: ResolvedManifest,
|
|
97
|
-
tables: readonly string[],
|
|
98
|
-
column: string
|
|
99
|
-
): EncryptedColumn | undefined {
|
|
100
|
-
let matched: EncryptedColumn | undefined
|
|
101
|
-
for (const table of tables) {
|
|
102
|
-
const found = manifest.qualified.get(`${table}.${column}`)
|
|
103
|
-
if (!found) continue
|
|
104
|
-
if (matched && matched.keyId !== found.keyId) {
|
|
105
|
-
return manifest.flat.get(column)
|
|
106
|
-
}
|
|
107
|
-
matched = found
|
|
108
|
-
}
|
|
109
|
-
return matched ?? manifest.flat.get(column)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The column names an insert or update writes, paired with the values going
|
|
114
|
-
* into them.
|
|
115
|
-
*
|
|
116
|
-
* Only literal values are collected. A raw expression or a sub-select is
|
|
117
|
-
* something this plugin cannot read and must not guess at, so it is skipped —
|
|
118
|
-
* the guard's job is to catch a plaintext string a call site forgot to encrypt,
|
|
119
|
-
* not to prove every write correct.
|
|
120
|
-
*/
|
|
121
|
-
function collectWrittenValues(node: unknown): Array<[string, unknown]> {
|
|
122
|
-
const written: Array<[string, unknown]> = []
|
|
123
|
-
|
|
124
|
-
const visit = (current: unknown): void => {
|
|
125
|
-
if (!current || typeof current !== 'object') return
|
|
126
|
-
const op = current as {
|
|
127
|
-
kind?: string
|
|
128
|
-
column?: { column?: { name?: string } }
|
|
129
|
-
value?: { kind?: string; value?: unknown }
|
|
130
|
-
columns?: Array<{ column?: { name?: string } }>
|
|
131
|
-
values?: unknown
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (op.kind === 'ColumnUpdateNode') {
|
|
135
|
-
const name = op.column?.column?.name
|
|
136
|
-
if (typeof name === 'string' && op.value?.kind === 'ValueNode') {
|
|
137
|
-
written.push([name, op.value.value])
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (op.kind === 'InsertQueryNode' && Array.isArray(op.columns)) {
|
|
142
|
-
const names = op.columns.map((c) => c.column?.name)
|
|
143
|
-
const rows = (op.values as { kind?: string; values?: unknown[] })?.values
|
|
144
|
-
if (Array.isArray(rows)) {
|
|
145
|
-
for (const row of rows) {
|
|
146
|
-
const list = (row as { kind?: string; values?: unknown[] })?.values
|
|
147
|
-
if (!Array.isArray(list)) continue
|
|
148
|
-
list.forEach((value, index) => {
|
|
149
|
-
const name = names[index]
|
|
150
|
-
if (typeof name !== 'string') return
|
|
151
|
-
const unwrapped =
|
|
152
|
-
value && typeof value === 'object' && 'kind' in value
|
|
153
|
-
? (value as { kind?: string; value?: unknown }).kind ===
|
|
154
|
-
'ValueNode'
|
|
155
|
-
? (value as { value?: unknown }).value
|
|
156
|
-
: undefined
|
|
157
|
-
: value
|
|
158
|
-
if (unwrapped !== undefined) written.push([name, unwrapped])
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
for (const value of Object.values(current)) {
|
|
165
|
-
if (Array.isArray(value)) {
|
|
166
|
-
for (const item of value) visit(item)
|
|
167
|
-
} else {
|
|
168
|
-
visit(value)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
visit(node)
|
|
174
|
-
return written
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export interface CreateClassificationPluginOptions {
|
|
178
|
-
manifest: ClassificationManifest
|
|
179
|
-
crypto: ClassificationCrypto
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Make a column's at-rest form a property of the column rather than of the
|
|
184
|
-
* developer's memory.
|
|
185
|
-
*
|
|
186
|
-
* Reads are transparent: a `wrapped` column comes back decrypted, so
|
|
187
|
-
* application code never sees an envelope. `hashed` and `plain` columns are
|
|
188
|
-
* untouched, which is what keeps the rest of the schema queryable.
|
|
189
|
-
*
|
|
190
|
-
* Writes are *guarded* rather than transformed, and the reason is Kysely's
|
|
191
|
-
* shape, not a preference: `transformQuery` is synchronous and WebCrypto is
|
|
192
|
-
* not, so a plugin cannot encrypt on the way in. Encrypting at the call site
|
|
193
|
-
* and letting a forgotten call slip through silently is the failure this
|
|
194
|
-
* design exists to prevent, so the guard refuses any plaintext heading for a
|
|
195
|
-
* `wrapped` or `sealed` column. Values are produced with
|
|
196
|
-
* `ClassificationCrypto.encryptColumn`.
|
|
197
|
-
*
|
|
198
|
-
* A `sealed` column is never decrypted on the way out — the application holds
|
|
199
|
-
* only the public half by definition, so returning it as stored is correct
|
|
200
|
-
* rather than a gap.
|
|
201
|
-
*/
|
|
202
|
-
export function createClassificationPlugin(
|
|
203
|
-
options: CreateClassificationPluginOptions
|
|
204
|
-
): KyselyPlugin {
|
|
205
|
-
const manifest = buildManifest(options.manifest)
|
|
206
|
-
const queryTables = new WeakMap<object, readonly string[]>()
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
transformQuery(args) {
|
|
210
|
-
const tables = new Set<string>()
|
|
211
|
-
collectQueryTables(args.node as RootOperationNode, tables)
|
|
212
|
-
queryTables.set(args.queryId, [...tables])
|
|
213
|
-
|
|
214
|
-
for (const [name, value] of collectWrittenValues(args.node)) {
|
|
215
|
-
if (value === null || value === undefined) continue
|
|
216
|
-
const column = lookupColumn(manifest, [...tables], name)
|
|
217
|
-
if (!column) continue
|
|
218
|
-
if (!isColumnEnvelope(value)) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
`Refusing to write plaintext into "${name}", which is classified ${column.form}. ` +
|
|
221
|
-
`Produce the value with ClassificationCrypto.encryptColumn() before writing it.`
|
|
222
|
-
)
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return args.node
|
|
227
|
-
},
|
|
228
|
-
|
|
229
|
-
async transformResult(args) {
|
|
230
|
-
const tables = queryTables.get(args.queryId) ?? []
|
|
231
|
-
const rows: UnknownRow[] = []
|
|
232
|
-
|
|
233
|
-
for (const row of args.result.rows as UnknownRow[]) {
|
|
234
|
-
let next: UnknownRow | undefined
|
|
235
|
-
for (const [name, value] of Object.entries(row)) {
|
|
236
|
-
if (value === null || value === undefined) continue
|
|
237
|
-
const column = lookupColumn(manifest, tables, name)
|
|
238
|
-
if (!column || column.form !== 'wrapped') continue
|
|
239
|
-
if (!isColumnEnvelope(value)) continue
|
|
240
|
-
next ??= { ...row }
|
|
241
|
-
next[name] = await options.crypto.decryptColumn(value)
|
|
242
|
-
}
|
|
243
|
-
rows.push(next ?? row)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return { ...args.result, rows }
|
|
247
|
-
},
|
|
248
|
-
}
|
|
249
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { describe, test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import type { QueryResult, UnknownRow } from 'kysely'
|
|
4
|
-
import { createCoercionPlugin, type CoercionMap } from './coercion-plugin.js'
|
|
5
|
-
|
|
6
|
-
function selectFrom(...tables: string[]) {
|
|
7
|
-
return {
|
|
8
|
-
kind: 'SelectQueryNode',
|
|
9
|
-
from: {
|
|
10
|
-
kind: 'FromNode',
|
|
11
|
-
froms: tables.map((table) => ({
|
|
12
|
-
kind: 'TableNode',
|
|
13
|
-
table: {
|
|
14
|
-
kind: 'SchemableIdentifierNode',
|
|
15
|
-
identifier: { kind: 'IdentifierNode', name: table },
|
|
16
|
-
},
|
|
17
|
-
})),
|
|
18
|
-
},
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const runTransform = async (
|
|
23
|
-
map: CoercionMap,
|
|
24
|
-
rows: UnknownRow[],
|
|
25
|
-
node?: unknown
|
|
26
|
-
): Promise<UnknownRow[]> => {
|
|
27
|
-
const plugin = createCoercionPlugin({ map })
|
|
28
|
-
const queryId = {} as { queryId: string }
|
|
29
|
-
if (node !== undefined) {
|
|
30
|
-
plugin.transformQuery({ node, queryId } as any)
|
|
31
|
-
}
|
|
32
|
-
const result = { rows } as QueryResult<UnknownRow>
|
|
33
|
-
const out = await plugin.transformResult({ result, queryId } as any)
|
|
34
|
-
return out.rows as UnknownRow[]
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('createCoercionPlugin', () => {
|
|
38
|
-
test('transformQuery passes the node through untouched', () => {
|
|
39
|
-
const plugin = createCoercionPlugin({ map: {} })
|
|
40
|
-
const node = selectFrom('users') as any
|
|
41
|
-
assert.equal(
|
|
42
|
-
plugin.transformQuery({ node, queryId: { queryId: 'x' } } as any),
|
|
43
|
-
node
|
|
44
|
-
)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('coerces date columns from ISO strings to Date', async () => {
|
|
48
|
-
const [row] = await runTransform({ users: { created_at: 'date' } }, [
|
|
49
|
-
{ created_at: '2026-06-26T00:00:00.000Z' },
|
|
50
|
-
])
|
|
51
|
-
assert.ok(row!.created_at instanceof Date)
|
|
52
|
-
assert.equal(
|
|
53
|
-
(row!.created_at as Date).toISOString(),
|
|
54
|
-
'2026-06-26T00:00:00.000Z'
|
|
55
|
-
)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('leaves unparseable date strings untouched', async () => {
|
|
59
|
-
const [row] = await runTransform({ users: { created_at: 'date' } }, [
|
|
60
|
-
{ created_at: 'not-a-date' },
|
|
61
|
-
])
|
|
62
|
-
assert.equal(row!.created_at, 'not-a-date')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('coerces bool columns from 0/1 numbers and bigints', async () => {
|
|
66
|
-
const rows = await runTransform(
|
|
67
|
-
{ users: { is_active: 'bool', has_pets: 'bool' } },
|
|
68
|
-
[{ is_active: 1, has_pets: 0n }]
|
|
69
|
-
)
|
|
70
|
-
assert.equal(rows[0]!.is_active, true)
|
|
71
|
-
assert.equal(rows[0]!.has_pets, false)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('parses json columns and tolerates invalid json', async () => {
|
|
75
|
-
const rows = await runTransform(
|
|
76
|
-
{ users: { meta: 'json', broken: 'json' } },
|
|
77
|
-
[{ meta: '{"a":1}', broken: '{bad' }]
|
|
78
|
-
)
|
|
79
|
-
assert.deepEqual(rows[0]!.meta, { a: 1 })
|
|
80
|
-
assert.equal(rows[0]!.broken, '{bad')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('passes through null values and unmapped columns', async () => {
|
|
84
|
-
const rows = await runTransform({ users: { created_at: 'date' } }, [
|
|
85
|
-
{ created_at: null, name: 'leave me' },
|
|
86
|
-
])
|
|
87
|
-
assert.equal(rows[0]!.created_at, null)
|
|
88
|
-
assert.equal(rows[0]!.name, 'leave me')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('matches both snake_case and camelCase column names', async () => {
|
|
92
|
-
const rows = await runTransform({ users: { created_at: 'date' } }, [
|
|
93
|
-
{ createdAt: '2026-06-26T00:00:00.000Z' },
|
|
94
|
-
])
|
|
95
|
-
assert.ok(rows[0]!.createdAt instanceof Date)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
test('leaves columns ambiguous across tables uncoerced', async () => {
|
|
99
|
-
const rows = await runTransform(
|
|
100
|
-
{ users: { value: 'json' }, events: { value: 'bool' } },
|
|
101
|
-
[{ value: '{"a":1}' }]
|
|
102
|
-
)
|
|
103
|
-
assert.equal(rows[0]!.value, '{"a":1}')
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
test('still coerces columns that agree across tables', async () => {
|
|
107
|
-
const rows = await runTransform(
|
|
108
|
-
{ users: { active: 'bool' }, events: { active: 'bool' } },
|
|
109
|
-
[{ active: 1 }]
|
|
110
|
-
)
|
|
111
|
-
assert.equal(rows[0]!.active, true)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('uses table-qualified coercions when names collide', async () => {
|
|
115
|
-
const map: CoercionMap = {
|
|
116
|
-
users: { settings: 'json' },
|
|
117
|
-
posts: { settings: 'bool' },
|
|
118
|
-
}
|
|
119
|
-
const fromUsers = await runTransform(
|
|
120
|
-
map,
|
|
121
|
-
[{ settings: '{"theme":"dark"}' }],
|
|
122
|
-
selectFrom('users')
|
|
123
|
-
)
|
|
124
|
-
assert.deepEqual(fromUsers[0]!.settings, { theme: 'dark' })
|
|
125
|
-
|
|
126
|
-
const fromPosts = await runTransform(
|
|
127
|
-
map,
|
|
128
|
-
[{ settings: 1 }],
|
|
129
|
-
selectFrom('posts')
|
|
130
|
-
)
|
|
131
|
-
assert.equal(fromPosts[0]!.settings, true)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
test('table-qualified lookup matches camelCase result columns too', async () => {
|
|
135
|
-
const rows = await runTransform(
|
|
136
|
-
{ users: { created_at: 'date' }, jobs: { created_at: 'json' } },
|
|
137
|
-
[{ createdAt: '2026-06-26T00:00:00.000Z' }],
|
|
138
|
-
selectFrom('users')
|
|
139
|
-
)
|
|
140
|
-
assert.ok(rows[0]!.createdAt instanceof Date)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
test('a join whose tables disagree leaves the column uncoerced', async () => {
|
|
144
|
-
const rows = await runTransform(
|
|
145
|
-
{ users: { value: 'json' }, events: { value: 'bool' } },
|
|
146
|
-
[{ value: '{"a":1}' }],
|
|
147
|
-
selectFrom('users', 'events')
|
|
148
|
-
)
|
|
149
|
-
assert.equal(rows[0]!.value, '{"a":1}')
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
test('falls back to flat keys when the query names no known table', async () => {
|
|
153
|
-
const rows = await runTransform(
|
|
154
|
-
{ users: { created_at: 'date' } },
|
|
155
|
-
[{ createdAt: '2026-01-02T03:04:05.000Z' }],
|
|
156
|
-
selectFrom('unrelated')
|
|
157
|
-
)
|
|
158
|
-
assert.ok(rows[0]!.createdAt instanceof Date)
|
|
159
|
-
})
|
|
160
|
-
})
|