@pikku/kysely 0.13.21 → 0.13.23

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.
@@ -0,0 +1,249 @@
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
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,20 @@ export {
32
32
  type CoercionMap,
33
33
  type CreateCoercionPluginOptions,
34
34
  } from './coercion-plugin.js'
35
+ export {
36
+ createClassificationPlugin,
37
+ type CreateClassificationPluginOptions,
38
+ } from './classification-plugin.js'
39
+ export {
40
+ ClassificationCrypto,
41
+ DEFAULT_KEY_ID,
42
+ isColumnEnvelope,
43
+ parseColumnEnvelope,
44
+ type ClassificationCryptoOptions,
45
+ type ColumnEnvelope,
46
+ type KEKResolver,
47
+ type ResolvedKEK,
48
+ } from './classification-crypto.js'
35
49
  export {
36
50
  agentSchema,
37
51
  auditSchema,
@@ -121,7 +121,7 @@ export class KyselySecretService implements SecretService {
121
121
  .where('key', '=', key)
122
122
  .executeTakeFirst()
123
123
 
124
- if (!row) throw new Error('Requested secret not found')
124
+ if (!row) throw new Error(`Requested secret not found: ${key}`)
125
125
 
126
126
  const kek = await this.getKEK(row.keyVersion)
127
127
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)