@pikku/kysely 0.13.7 → 0.13.10
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 +180 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-audit-service.d.ts +34 -2
- package/dist/src/kysely-audit-service.js +163 -3
- package/dist/src/kysely-secret-service.d.ts +4 -3
- package/dist/src/kysely-secret-service.js +6 -3
- package/dist/src/kysely-tables.d.ts +31 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
- package/dist/src/kysely-virtual-user-run-store.js +131 -0
- package/dist/src/schema/audit.schema.d.ts +16 -0
- package/dist/src/schema/audit.schema.js +47 -0
- package/dist/src/schema/credential.schema.d.ts +3 -2
- package/dist/src/schema/credential.schema.js +4 -3
- package/dist/src/schema/index.d.ts +17 -4
- package/dist/src/schema/index.js +40 -6
- package/dist/src/schema/pikku-schema.types.d.ts +41 -5
- package/dist/src/schema/pikku-schema.types.js +27 -12
- package/dist/src/schema/virtual-user.schema.d.ts +19 -0
- package/dist/src/schema/virtual-user.schema.js +54 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/create-audited-kysely.test.ts +10 -10
- package/src/index.ts +7 -0
- package/src/kysely-audit-service.test.ts +360 -0
- package/src/kysely-audit-service.ts +181 -4
- package/src/kysely-secret-service.ts +11 -10
- package/src/kysely-services.test.ts +3 -3
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +32 -0
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +5 -3
- package/src/schema/index.ts +59 -10
- package/src/schema/pikku-schema.types.ts +65 -10
- package/src/schema/schema.test.ts +82 -4
- package/src/schema/virtual-user.schema.ts +65 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import Database from 'better-sqlite3'
|
|
4
|
+
import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
|
|
5
|
+
import type { AuditEvent } from '../../../core/src/index.js'
|
|
6
|
+
|
|
7
|
+
import { KyselyAuditService } from './kysely-audit-service.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `audit` table as the docs specify it — all TEXT on every engine, so the
|
|
11
|
+
* service is exercised against the same shape a project would migrate to.
|
|
12
|
+
*/
|
|
13
|
+
const CREATE_AUDIT_TABLE = `
|
|
14
|
+
CREATE TABLE audit (
|
|
15
|
+
audit_id TEXT NOT NULL PRIMARY KEY,
|
|
16
|
+
occurred_at TEXT NOT NULL,
|
|
17
|
+
type TEXT NOT NULL,
|
|
18
|
+
source TEXT NOT NULL DEFAULT 'auto',
|
|
19
|
+
outcome TEXT,
|
|
20
|
+
function_id TEXT,
|
|
21
|
+
wire_type TEXT,
|
|
22
|
+
trace_id TEXT,
|
|
23
|
+
transaction_id TEXT,
|
|
24
|
+
query_id TEXT,
|
|
25
|
+
user_id TEXT,
|
|
26
|
+
org_id TEXT,
|
|
27
|
+
pikku_user_id TEXT,
|
|
28
|
+
tables TEXT,
|
|
29
|
+
changed_cols TEXT,
|
|
30
|
+
event TEXT,
|
|
31
|
+
old TEXT,
|
|
32
|
+
data TEXT
|
|
33
|
+
)
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
let db: Kysely<any>
|
|
37
|
+
let service: KyselyAuditService
|
|
38
|
+
|
|
39
|
+
const event = (
|
|
40
|
+
overrides: Partial<AuditEvent> & Pick<AuditEvent, 'type' | 'occurredAt'>
|
|
41
|
+
): AuditEvent => ({
|
|
42
|
+
source: 'explicit',
|
|
43
|
+
...overrides,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
db = new Kysely<any>({
|
|
48
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
49
|
+
})
|
|
50
|
+
await sql.raw(CREATE_AUDIT_TABLE).execute(db)
|
|
51
|
+
service = new KyselyAuditService(db)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const seed = () =>
|
|
55
|
+
service.write([
|
|
56
|
+
event({
|
|
57
|
+
eventId: 'e1',
|
|
58
|
+
type: 'invoice.update',
|
|
59
|
+
occurredAt: '2026-01-01T00:00:00.000Z',
|
|
60
|
+
userIdentity: { userId: 'alice', orgId: 'org-a' },
|
|
61
|
+
metadata: { entity: 'invoice', entityId: 'inv-1' },
|
|
62
|
+
}),
|
|
63
|
+
event({
|
|
64
|
+
eventId: 'e2',
|
|
65
|
+
type: 'invoice.delete',
|
|
66
|
+
occurredAt: '2026-01-02T00:00:00.000Z',
|
|
67
|
+
userIdentity: { userId: 'bob', orgId: 'org-a' },
|
|
68
|
+
}),
|
|
69
|
+
event({
|
|
70
|
+
eventId: 'e3',
|
|
71
|
+
type: 'invoice.update',
|
|
72
|
+
occurredAt: '2026-01-03T00:00:00.000Z',
|
|
73
|
+
userIdentity: { userId: 'alice', orgId: 'org-b' },
|
|
74
|
+
}),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
describe('KyselyAuditService.query', () => {
|
|
78
|
+
test('returns the trail newest first', async () => {
|
|
79
|
+
await seed()
|
|
80
|
+
const { events, nextCursor } = await service.query()
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
events.map((e) => e.eventId),
|
|
83
|
+
['e3', 'e2', 'e1']
|
|
84
|
+
)
|
|
85
|
+
assert.equal(nextCursor, null, 'a complete page has no next cursor')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('round-trips the event, including parsed metadata', async () => {
|
|
89
|
+
await seed()
|
|
90
|
+
const { events } = await service.query({ types: ['invoice.update'] })
|
|
91
|
+
const first = events.find((e) => e.eventId === 'e1')!
|
|
92
|
+
assert.equal(first.type, 'invoice.update')
|
|
93
|
+
assert.equal(first.source, 'explicit')
|
|
94
|
+
assert.deepEqual(first.userIdentity, {
|
|
95
|
+
userId: 'alice',
|
|
96
|
+
orgId: 'org-a',
|
|
97
|
+
pikkuUserId: undefined,
|
|
98
|
+
})
|
|
99
|
+
assert.deepEqual(first.metadata, { entity: 'invoice', entityId: 'inv-1' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('keeps the wire identity of a caller who never signed in', async () => {
|
|
103
|
+
await service.write([
|
|
104
|
+
event({
|
|
105
|
+
eventId: 'e4',
|
|
106
|
+
type: 'invoice.view',
|
|
107
|
+
occurredAt: '2026-01-04T00:00:00.000Z',
|
|
108
|
+
userIdentity: { pikkuUserId: 'pk_9' },
|
|
109
|
+
}),
|
|
110
|
+
])
|
|
111
|
+
const { events } = await service.query({ types: ['invoice.view'] })
|
|
112
|
+
assert.deepEqual(events[0]!.userIdentity, {
|
|
113
|
+
userId: undefined,
|
|
114
|
+
orgId: undefined,
|
|
115
|
+
pikkuUserId: 'pk_9',
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('filters by user', async () => {
|
|
120
|
+
await seed()
|
|
121
|
+
const { events } = await service.query({ userIds: ['alice'] })
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
events.map((e) => e.eventId),
|
|
124
|
+
['e3', 'e1']
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('filters by type', async () => {
|
|
129
|
+
await seed()
|
|
130
|
+
const { events } = await service.query({ types: ['invoice.delete'] })
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
events.map((e) => e.eventId),
|
|
133
|
+
['e2']
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('combines filters as a conjunction', async () => {
|
|
138
|
+
await seed()
|
|
139
|
+
const { events } = await service.query({
|
|
140
|
+
userIds: ['alice'],
|
|
141
|
+
types: ['invoice.update'],
|
|
142
|
+
orgId: 'org-b',
|
|
143
|
+
})
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
events.map((e) => e.eventId),
|
|
146
|
+
['e3']
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('an empty filter array matches nothing rather than everything', async () => {
|
|
151
|
+
await seed()
|
|
152
|
+
const byUser = await service.query({ userIds: [] })
|
|
153
|
+
assert.deepEqual(byUser.events, [])
|
|
154
|
+
const byType = await service.query({ types: [] })
|
|
155
|
+
assert.deepEqual(byType.events, [])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('bounds by occurredAt, `from` inclusive and `to` exclusive', async () => {
|
|
159
|
+
await seed()
|
|
160
|
+
const { events } = await service.query({
|
|
161
|
+
from: '2026-01-02T00:00:00.000Z',
|
|
162
|
+
to: '2026-01-03T00:00:00.000Z',
|
|
163
|
+
})
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
events.map((e) => e.eventId),
|
|
166
|
+
['e2']
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('pages, reporting the next offset only while more remain', async () => {
|
|
171
|
+
await seed()
|
|
172
|
+
const first = await service.query({ limit: 2 })
|
|
173
|
+
assert.deepEqual(
|
|
174
|
+
first.events.map((e) => e.eventId),
|
|
175
|
+
['e3', 'e2']
|
|
176
|
+
)
|
|
177
|
+
assert.equal(first.nextCursor, 2)
|
|
178
|
+
|
|
179
|
+
const second = await service.query({ limit: 2, offset: first.nextCursor! })
|
|
180
|
+
assert.deepEqual(
|
|
181
|
+
second.events.map((e) => e.eventId),
|
|
182
|
+
['e1']
|
|
183
|
+
)
|
|
184
|
+
assert.equal(second.nextCursor, null)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Without the audit_id tiebreak the engine may order a same-timestamp batch
|
|
188
|
+
// differently per query, which drops or repeats rows across a page boundary.
|
|
189
|
+
test('orders totally when timestamps tie', async () => {
|
|
190
|
+
const sameTime = '2026-02-01T00:00:00.000Z'
|
|
191
|
+
await service.write(
|
|
192
|
+
['a', 'b', 'c', 'd'].map((id) =>
|
|
193
|
+
event({ eventId: id, type: 't', occurredAt: sameTime })
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
const first = await service.query({ limit: 2 })
|
|
197
|
+
const second = await service.query({ limit: 2, offset: 2 })
|
|
198
|
+
const seen = [...first.events, ...second.events].map((e) => e.eventId)
|
|
199
|
+
assert.deepEqual(seen, ['d', 'c', 'b', 'a'])
|
|
200
|
+
assert.equal(new Set(seen).size, 4, 'no row may appear on two pages')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('caps an oversized limit', async () => {
|
|
204
|
+
await service.write(
|
|
205
|
+
Array.from({ length: 5 }, (_, i) =>
|
|
206
|
+
event({
|
|
207
|
+
eventId: `e${i}`,
|
|
208
|
+
type: 't',
|
|
209
|
+
occurredAt: `2026-03-0${i + 1}T00:00:00.000Z`,
|
|
210
|
+
})
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
const { events } = await service.query({ limit: 10_000 })
|
|
214
|
+
assert.equal(events.length, 5, 'the cap must not lose rows below it')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('a malformed data column surfaces rather than dropping the event', async () => {
|
|
218
|
+
await sql
|
|
219
|
+
.raw(
|
|
220
|
+
`INSERT INTO audit (audit_id, occurred_at, type, source, data)
|
|
221
|
+
VALUES ('bad', '2026-04-01T00:00:00.000Z', 'legacy.write', 'auto', 'not json')`
|
|
222
|
+
)
|
|
223
|
+
.execute(db)
|
|
224
|
+
const { events } = await service.query()
|
|
225
|
+
assert.equal(events.length, 1)
|
|
226
|
+
assert.equal(events[0]!.metadata as unknown, 'not json')
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The two connections a project actually hands this service. `CamelCasePlugin`
|
|
232
|
+
* is on almost every pikku Kysely instance, and it renames result columns on
|
|
233
|
+
* the way out — `audit_id` arrives as `auditId` — so a service that only reads
|
|
234
|
+
* the physical names returns a page of undefined-everything against the setup
|
|
235
|
+
* most projects have.
|
|
236
|
+
*/
|
|
237
|
+
describe('KyselyAuditService against a CamelCasePlugin connection', () => {
|
|
238
|
+
let camelDb: Kysely<any>
|
|
239
|
+
let camelService: KyselyAuditService
|
|
240
|
+
|
|
241
|
+
beforeEach(async () => {
|
|
242
|
+
camelDb = new Kysely<any>({
|
|
243
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
244
|
+
plugins: [new CamelCasePlugin()],
|
|
245
|
+
})
|
|
246
|
+
await sql.raw(CREATE_AUDIT_TABLE).execute(camelDb)
|
|
247
|
+
camelService = new KyselyAuditService(camelDb)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('reads back everything it wrote', async () => {
|
|
251
|
+
await camelService.write([
|
|
252
|
+
event({
|
|
253
|
+
eventId: 'e1',
|
|
254
|
+
type: 'invoice.update',
|
|
255
|
+
occurredAt: '2026-01-01T00:00:00.000Z',
|
|
256
|
+
outcome: 'success',
|
|
257
|
+
functionId: 'cancelInvoice',
|
|
258
|
+
wireType: 'http',
|
|
259
|
+
traceId: 'trace-1',
|
|
260
|
+
userIdentity: { userId: 'alice', orgId: 'org-a' },
|
|
261
|
+
metadata: { entity: 'invoice' },
|
|
262
|
+
}),
|
|
263
|
+
])
|
|
264
|
+
const { events } = await camelService.query()
|
|
265
|
+
assert.equal(events.length, 1)
|
|
266
|
+
assert.deepEqual(events[0], {
|
|
267
|
+
eventId: 'e1',
|
|
268
|
+
occurredAt: '2026-01-01T00:00:00.000Z',
|
|
269
|
+
type: 'invoice.update',
|
|
270
|
+
source: 'explicit',
|
|
271
|
+
outcome: 'success',
|
|
272
|
+
functionId: 'cancelInvoice',
|
|
273
|
+
wireType: 'http',
|
|
274
|
+
traceId: 'trace-1',
|
|
275
|
+
transactionId: undefined,
|
|
276
|
+
queryId: undefined,
|
|
277
|
+
userIdentity: {
|
|
278
|
+
userId: 'alice',
|
|
279
|
+
orgId: 'org-a',
|
|
280
|
+
pikkuUserId: undefined,
|
|
281
|
+
},
|
|
282
|
+
metadata: { entity: 'invoice' },
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('still filters and facets by user', async () => {
|
|
287
|
+
await camelService.write([
|
|
288
|
+
event({
|
|
289
|
+
eventId: 'e1',
|
|
290
|
+
type: 'a',
|
|
291
|
+
occurredAt: '2026-01-01T00:00:00.000Z',
|
|
292
|
+
userIdentity: { userId: 'alice' },
|
|
293
|
+
}),
|
|
294
|
+
event({
|
|
295
|
+
eventId: 'e2',
|
|
296
|
+
type: 'b',
|
|
297
|
+
occurredAt: '2026-01-02T00:00:00.000Z',
|
|
298
|
+
userIdentity: { userId: 'bob' },
|
|
299
|
+
}),
|
|
300
|
+
])
|
|
301
|
+
const { events } = await camelService.query({ userIds: ['bob'] })
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
events.map((e) => e.eventId),
|
|
304
|
+
['e2']
|
|
305
|
+
)
|
|
306
|
+
const facets = await camelService.facets()
|
|
307
|
+
assert.deepEqual(facets.userIds, ['alice', 'bob'])
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('KyselyAuditService.init', () => {
|
|
312
|
+
test('creates the audit table on a database that has none', async () => {
|
|
313
|
+
const fresh = new Kysely<any>({
|
|
314
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
315
|
+
plugins: [new CamelCasePlugin()],
|
|
316
|
+
})
|
|
317
|
+
const freshService = new KyselyAuditService(fresh)
|
|
318
|
+
await freshService.init()
|
|
319
|
+
await freshService.write([
|
|
320
|
+
event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
|
|
321
|
+
])
|
|
322
|
+
const { events } = await freshService.query()
|
|
323
|
+
assert.deepEqual(
|
|
324
|
+
events.map((e) => e.eventId),
|
|
325
|
+
['e1']
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// Two boots against one database, and the second must not wipe the first.
|
|
330
|
+
test('is idempotent and keeps what is already there', async () => {
|
|
331
|
+
await service.write([
|
|
332
|
+
event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
|
|
333
|
+
])
|
|
334
|
+
await service.init()
|
|
335
|
+
await service.init()
|
|
336
|
+
const { events } = await service.query()
|
|
337
|
+
assert.deepEqual(
|
|
338
|
+
events.map((e) => e.eventId),
|
|
339
|
+
['e1']
|
|
340
|
+
)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
describe('KyselyAuditService.facets', () => {
|
|
345
|
+
test('lists the distinct users and types across the whole trail', async () => {
|
|
346
|
+
await seed()
|
|
347
|
+
const facets = await service.facets()
|
|
348
|
+
assert.deepEqual(facets.userIds, ['alice', 'bob'])
|
|
349
|
+
assert.deepEqual(facets.types, ['invoice.delete', 'invoice.update'])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('omits userless events from the user list', async () => {
|
|
353
|
+
await service.write([
|
|
354
|
+
event({ eventId: 'cron', type: 'cleanup.ran', occurredAt: '2026-05-01' }),
|
|
355
|
+
])
|
|
356
|
+
const facets = await service.facets()
|
|
357
|
+
assert.deepEqual(facets.userIds, [])
|
|
358
|
+
assert.deepEqual(facets.types, ['cleanup.ran'])
|
|
359
|
+
})
|
|
360
|
+
})
|
|
@@ -1,9 +1,78 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AuditEvent,
|
|
3
|
+
AuditEventBatch,
|
|
4
|
+
AuditFacets,
|
|
5
|
+
AuditQuery,
|
|
6
|
+
AuditQueryResult,
|
|
7
|
+
AuditService,
|
|
8
|
+
} from '@pikku/core'
|
|
2
9
|
import type { Kysely } from 'kysely'
|
|
10
|
+
import { ensurePikkuSchema } from './schema/index.js'
|
|
11
|
+
import { auditSchema } from './schema/audit.schema.js'
|
|
3
12
|
|
|
4
13
|
const jsonOrNull = (v: unknown): string | null =>
|
|
5
14
|
v != null ? JSON.stringify(v) : null
|
|
6
15
|
|
|
16
|
+
const parseJson = (v: unknown): any => {
|
|
17
|
+
if (typeof v !== 'string') return v ?? undefined
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(v)
|
|
20
|
+
} catch {
|
|
21
|
+
// A row written by something other than this service, or truncated in
|
|
22
|
+
// transit. Surfacing the raw text beats dropping the event: the row is
|
|
23
|
+
// still evidence that something happened, and swallowing it would make the
|
|
24
|
+
// trail quietly incomplete.
|
|
25
|
+
return v
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A column by its physical name, whatever the connection renamed it to.
|
|
31
|
+
*
|
|
32
|
+
* `CamelCasePlugin` is on most pikku Kysely instances and rewrites result keys
|
|
33
|
+
* on the way out, so the same row arrives keyed `audit_id` or `auditId`
|
|
34
|
+
* depending on a plugin this service is never told about. Reading both is
|
|
35
|
+
* cheaper than demanding a particular connection — and quieter to get wrong,
|
|
36
|
+
* since the mismatch does not throw, it just returns a page of undefined.
|
|
37
|
+
*/
|
|
38
|
+
const column = (row: Record<string, any>, name: string): any => {
|
|
39
|
+
const value = row[name]
|
|
40
|
+
if (value !== undefined) return value
|
|
41
|
+
return row[name.replace(/_(\w)/g, (_, char: string) => char.toUpperCase())]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Rows are TEXT on every engine, so the shape is whatever the driver returned. */
|
|
45
|
+
const rowToEvent = (row: Record<string, any>): AuditEvent => {
|
|
46
|
+
const userId = column(row, 'user_id')
|
|
47
|
+
const orgId = column(row, 'org_id')
|
|
48
|
+
const pikkuUserId = column(row, 'pikku_user_id')
|
|
49
|
+
return {
|
|
50
|
+
eventId: column(row, 'audit_id') ?? undefined,
|
|
51
|
+
occurredAt: column(row, 'occurred_at'),
|
|
52
|
+
type: row.type,
|
|
53
|
+
source: row.source ?? 'auto',
|
|
54
|
+
outcome: row.outcome ?? undefined,
|
|
55
|
+
functionId: column(row, 'function_id') ?? undefined,
|
|
56
|
+
wireType: column(row, 'wire_type') ?? undefined,
|
|
57
|
+
traceId: column(row, 'trace_id') ?? undefined,
|
|
58
|
+
transactionId: column(row, 'transaction_id') ?? undefined,
|
|
59
|
+
queryId: column(row, 'query_id') ?? undefined,
|
|
60
|
+
userIdentity:
|
|
61
|
+
userId || orgId || pikkuUserId
|
|
62
|
+
? {
|
|
63
|
+
userId: userId ?? undefined,
|
|
64
|
+
orgId: orgId ?? undefined,
|
|
65
|
+
pikkuUserId: pikkuUserId ?? undefined,
|
|
66
|
+
}
|
|
67
|
+
: undefined,
|
|
68
|
+
metadata: parseJson(row.data),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The most a single page may return, whatever the caller asked for. */
|
|
73
|
+
const MAX_LIMIT = 200
|
|
74
|
+
const DEFAULT_LIMIT = 50
|
|
75
|
+
|
|
7
76
|
// No global `crypto` is guaranteed across every runtime, and audit_id is the
|
|
8
77
|
// PK — any collision is dropped by ON CONFLICT DO NOTHING.
|
|
9
78
|
const fallbackId = (): string =>
|
|
@@ -15,14 +84,30 @@ const fallbackId = (): string =>
|
|
|
15
84
|
*
|
|
16
85
|
* The column mapping matches a platform audit-queue consumer, so a locally-run
|
|
17
86
|
* project and a deployed stage write identical rows (the read side relies only
|
|
18
|
-
* on `type`, `occurred_at`, `
|
|
87
|
+
* on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
|
|
19
88
|
* `metadata`). The `audit` table is an optional per-project migration (not in
|
|
20
89
|
* the generated schema), so rows are inserted untyped; all columns are TEXT on
|
|
21
90
|
* every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
|
|
22
91
|
*/
|
|
23
92
|
export class KyselyAuditService implements AuditService {
|
|
93
|
+
private initialized = false
|
|
94
|
+
|
|
24
95
|
constructor(private db: Kysely<any>) {}
|
|
25
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Creates the `audit` table if the database has none.
|
|
99
|
+
*
|
|
100
|
+
* Optional — a project that migrates the table itself can skip it, and the
|
|
101
|
+
* declaration is written to match the documented shape either way. Calling it
|
|
102
|
+
* on a database that already has the table is a no-op, so two instances
|
|
103
|
+
* booting cold do not race each other into a half-applied schema.
|
|
104
|
+
*/
|
|
105
|
+
public async init(): Promise<void> {
|
|
106
|
+
if (this.initialized) return
|
|
107
|
+
await ensurePikkuSchema(this.db, auditSchema)
|
|
108
|
+
this.initialized = true
|
|
109
|
+
}
|
|
110
|
+
|
|
26
111
|
async audit(event: AuditEvent): Promise<void> {
|
|
27
112
|
await this.write([event])
|
|
28
113
|
}
|
|
@@ -44,8 +129,9 @@ export class KyselyAuditService implements AuditService {
|
|
|
44
129
|
trace_id: e.traceId ?? null,
|
|
45
130
|
transaction_id: e.transactionId ?? null,
|
|
46
131
|
query_id: e.queryId ?? null,
|
|
47
|
-
|
|
48
|
-
|
|
132
|
+
user_id: e.userIdentity?.userId ?? null,
|
|
133
|
+
org_id: e.userIdentity?.orgId ?? null,
|
|
134
|
+
pikku_user_id: e.userIdentity?.pikkuUserId ?? null,
|
|
49
135
|
tables: jsonOrNull(metadata?.tables),
|
|
50
136
|
changed_cols: jsonOrNull(metadata?.changedColumns),
|
|
51
137
|
event: e.eventId ?? null,
|
|
@@ -59,4 +145,95 @@ export class KyselyAuditService implements AuditService {
|
|
|
59
145
|
.onConflict((oc: any) => oc.doNothing())
|
|
60
146
|
.execute()
|
|
61
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A page of the trail, newest first.
|
|
151
|
+
*
|
|
152
|
+
* Offset paging, matching the rest of the console. The trail is append-only
|
|
153
|
+
* and ordered by `occurred_at DESC`, so events written *while* a reader is
|
|
154
|
+
* paging shift the window and can repeat a row across pages. That is visible
|
|
155
|
+
* but harmless for browsing; anything that must not miss a row should page by
|
|
156
|
+
* a bounded `from`/`to` window instead of scrolling.
|
|
157
|
+
*
|
|
158
|
+
* Ties on `occurred_at` are broken by `audit_id` so the order is total —
|
|
159
|
+
* without it a batch flushed in one call shares a timestamp and the engine is
|
|
160
|
+
* free to order it differently per query, which drops or repeats rows at a
|
|
161
|
+
* page boundary.
|
|
162
|
+
*/
|
|
163
|
+
async query(query: AuditQuery = {}): Promise<AuditQueryResult> {
|
|
164
|
+
const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
|
|
165
|
+
const offset = Math.max(query.offset ?? 0, 0)
|
|
166
|
+
|
|
167
|
+
let builder = (this.db as any).selectFrom('audit').selectAll()
|
|
168
|
+
builder = this.applyFilters(builder, query)
|
|
169
|
+
|
|
170
|
+
const rows = await builder
|
|
171
|
+
.orderBy('occurred_at', 'desc')
|
|
172
|
+
.orderBy('audit_id', 'desc')
|
|
173
|
+
// One extra row is the cheapest honest way to know whether another page
|
|
174
|
+
// exists — a COUNT would be a second scan of the same filter.
|
|
175
|
+
.limit(limit + 1)
|
|
176
|
+
.offset(offset)
|
|
177
|
+
.execute()
|
|
178
|
+
|
|
179
|
+
const hasMore = rows.length > limit
|
|
180
|
+
return {
|
|
181
|
+
events: rows.slice(0, limit).map(rowToEvent),
|
|
182
|
+
nextCursor: hasMore ? offset + limit : null,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async facets(): Promise<AuditFacets> {
|
|
187
|
+
const [users, types] = await Promise.all([
|
|
188
|
+
(this.db as any)
|
|
189
|
+
.selectFrom('audit')
|
|
190
|
+
.select('user_id')
|
|
191
|
+
.distinct()
|
|
192
|
+
.where('user_id', 'is not', null)
|
|
193
|
+
.orderBy('user_id', 'asc')
|
|
194
|
+
.execute(),
|
|
195
|
+
(this.db as any)
|
|
196
|
+
.selectFrom('audit')
|
|
197
|
+
.select('type')
|
|
198
|
+
.distinct()
|
|
199
|
+
.orderBy('type', 'asc')
|
|
200
|
+
.execute(),
|
|
201
|
+
])
|
|
202
|
+
return {
|
|
203
|
+
userIds: users.map((r: any) => column(r, 'user_id')),
|
|
204
|
+
types: types.map((r: any) => r.type).filter(Boolean),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* An empty array means "match nothing" and is applied as such. Treating it as
|
|
210
|
+
* "no filter" would turn a reader's deselect-everything into a full-trail
|
|
211
|
+
* read, which is the opposite of what they asked for.
|
|
212
|
+
*/
|
|
213
|
+
private applyFilters(builder: any, query: AuditQuery): any {
|
|
214
|
+
// `in ()` is a syntax error on Postgres and SQLite alike, so an empty
|
|
215
|
+
// selection is expressed as a predicate that cannot hold.
|
|
216
|
+
const matchNothing = (b: any) => b.where('audit_id', 'is', null)
|
|
217
|
+
|
|
218
|
+
if (query.userIds) {
|
|
219
|
+
builder = query.userIds.length
|
|
220
|
+
? builder.where('user_id', 'in', query.userIds)
|
|
221
|
+
: matchNothing(builder)
|
|
222
|
+
}
|
|
223
|
+
if (query.types) {
|
|
224
|
+
builder = query.types.length
|
|
225
|
+
? builder.where('type', 'in', query.types)
|
|
226
|
+
: matchNothing(builder)
|
|
227
|
+
}
|
|
228
|
+
if (query.orgId) {
|
|
229
|
+
builder = builder.where('org_id', '=', query.orgId)
|
|
230
|
+
}
|
|
231
|
+
if (query.from) {
|
|
232
|
+
builder = builder.where('occurred_at', '>=', query.from)
|
|
233
|
+
}
|
|
234
|
+
if (query.to) {
|
|
235
|
+
builder = builder.where('occurred_at', '<', query.to)
|
|
236
|
+
}
|
|
237
|
+
return builder
|
|
238
|
+
}
|
|
62
239
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
|
|
2
|
+
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
2
3
|
import type { Kysely } from 'kysely'
|
|
3
4
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
4
5
|
import {
|
|
@@ -112,7 +113,7 @@ export class KyselySecretService implements SecretService {
|
|
|
112
113
|
return kek
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
async getSecret<T = string>(key: string): Promise<T
|
|
116
|
+
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
116
117
|
const row = await this.db
|
|
117
118
|
.selectFrom('secrets')
|
|
118
119
|
.select(['ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -124,7 +125,7 @@ export class KyselySecretService implements SecretService {
|
|
|
124
125
|
const kek = await this.getKEK(row.keyVersion)
|
|
125
126
|
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
126
127
|
await this.logAudit(key, 'read')
|
|
127
|
-
return result
|
|
128
|
+
return createSecretValue(result)
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
async hasSecret(key: string): Promise<boolean> {
|
|
@@ -137,9 +138,11 @@ export class KyselySecretService implements SecretService {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
141
|
+
// Encrypting the wrapper would store its redaction, not the secret —
|
|
142
|
+
// writing a secret back to the vault is exactly what this method is for.
|
|
140
143
|
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
141
144
|
await this.getKEK(this.keyVersion),
|
|
142
|
-
value
|
|
145
|
+
isSecretValue(value) ? value.reveal() : value
|
|
143
146
|
)
|
|
144
147
|
const now = new Date().toISOString()
|
|
145
148
|
|
|
@@ -173,7 +176,7 @@ export class KyselySecretService implements SecretService {
|
|
|
173
176
|
|
|
174
177
|
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
175
178
|
keys: (keyof T & string)[]
|
|
176
|
-
): Promise<T
|
|
179
|
+
): Promise<Partial<SecretValues<T>>> {
|
|
177
180
|
const rows = await this.db
|
|
178
181
|
.selectFrom('secrets')
|
|
179
182
|
.select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -184,10 +187,8 @@ export class KyselySecretService implements SecretService {
|
|
|
184
187
|
for (const row of rows) {
|
|
185
188
|
try {
|
|
186
189
|
const kek = await this.getKEK(row.keyVersion)
|
|
187
|
-
out[row.key] =
|
|
188
|
-
kek,
|
|
189
|
-
row.ciphertext,
|
|
190
|
-
row.wrappedDek
|
|
190
|
+
out[row.key] = createSecretValue(
|
|
191
|
+
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
191
192
|
)
|
|
192
193
|
} catch (cause) {
|
|
193
194
|
// A stored secret that fails to decrypt is never expected — it means the
|
|
@@ -203,7 +204,7 @@ export class KyselySecretService implements SecretService {
|
|
|
203
204
|
)
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
|
-
return out as T
|
|
207
|
+
return out as Partial<SecretValues<T>>
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
async rotateKEK(): Promise<number> {
|
|
@@ -199,7 +199,7 @@ function registerTests(
|
|
|
199
199
|
|
|
200
200
|
const second = new KyselySecretService(getDb(), { key: kek })
|
|
201
201
|
await second.init()
|
|
202
|
-
assert.equal(await second.getSecret('shared-salt'), 'value')
|
|
202
|
+
assert.equal((await second.getSecret('shared-salt')).reveal(), 'value')
|
|
203
203
|
|
|
204
204
|
const rows = await getDb()
|
|
205
205
|
.selectFrom('secretKekSalts')
|
|
@@ -227,7 +227,7 @@ function registerTests(
|
|
|
227
227
|
const elapsed = performance.now() - start
|
|
228
228
|
|
|
229
229
|
assert.equal(Object.keys(out).length, 50)
|
|
230
|
-
assert.equal(out['bulk-7'], 'value-7')
|
|
230
|
+
assert.equal(out['bulk-7']!.reveal(), 'value-7')
|
|
231
231
|
assert.ok(
|
|
232
232
|
elapsed < 400,
|
|
233
233
|
`getSecrets over 50 secrets took ${elapsed.toFixed(0)}ms; one derivation per secret would cost seconds`
|
|
@@ -258,7 +258,7 @@ function registerTests(
|
|
|
258
258
|
const elapsed = performance.now() - start
|
|
259
259
|
|
|
260
260
|
assert.equal(rotated, 50)
|
|
261
|
-
assert.equal(await rotator.getSecret('rotate-7'), 'value-7')
|
|
261
|
+
assert.equal((await rotator.getSecret('rotate-7')).reveal(), 'value-7')
|
|
262
262
|
assert.ok(
|
|
263
263
|
elapsed < 700,
|
|
264
264
|
`rotating 50 secrets took ${elapsed.toFixed(0)}ms; two derivations per secret would cost seconds`
|
|
@@ -5,7 +5,15 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { Kysely, SqliteDialect } from 'kysely'
|
|
6
6
|
import Database from 'better-sqlite3'
|
|
7
7
|
|
|
8
|
-
import { applyPikkuSchemas } from './schema/index.js'
|
|
8
|
+
import { applyPikkuSchemas, ensurePikkuSchema } from './schema/index.js'
|
|
9
|
+
import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in schemas that still have an interface in `KyselyPikkuDB`, so the drift
|
|
13
|
+
* check covers them too. A table being opt-in says when it is created, not
|
|
14
|
+
* whether its type is allowed to disagree with its DDL.
|
|
15
|
+
*/
|
|
16
|
+
const OPTIONAL_SCHEMAS = [virtualUserSchema]
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* `KyselyPikkuDB` and the schema declaration describe the same tables, and
|
|
@@ -65,6 +73,9 @@ const declaredSchema = async (): Promise<Map<string, Set<string>>> => {
|
|
|
65
73
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
66
74
|
.execute()
|
|
67
75
|
await applyPikkuSchemas(db)
|
|
76
|
+
for (const schema of OPTIONAL_SCHEMAS) {
|
|
77
|
+
await ensurePikkuSchema(db, schema)
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
const tables = await db.introspection.getTables()
|
|
70
81
|
return new Map(
|