@pikku/kysely 0.13.6 → 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 +505 -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-credential-service.d.ts +3 -0
- package/dist/src/kysely-credential-service.js +50 -11
- package/dist/src/kysely-scope-service.d.ts +38 -0
- package/dist/src/kysely-scope-service.js +125 -2
- package/dist/src/kysely-secret-service.d.ts +7 -3
- package/dist/src/kysely-secret-service.js +55 -13
- package/dist/src/kysely-tables.d.ts +49 -2
- 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/kysely-workflow-service.d.ts +0 -1
- package/dist/src/kysely-workflow-service.js +0 -12
- 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 +9 -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/scope.schema.js +12 -0
- package/dist/src/schema/secret.schema.js +5 -0
- 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-credential-service.ts +57 -12
- package/src/kysely-scope-service.ts +151 -2
- package/src/kysely-secret-service.ts +70 -21
- package/src/kysely-services.test.ts +123 -1
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +53 -2
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/kysely-workflow-service.ts +0 -17
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +14 -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/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -0
- package/src/schema/virtual-user.schema.ts +65 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Role, ScopeService } from '@pikku/core/services'
|
|
2
2
|
import type { FlatScope } from '@pikku/core/scope'
|
|
3
|
+
import type { SystemRole } from '@pikku/core/role'
|
|
4
|
+
import {
|
|
5
|
+
SystemRoleImmutableError,
|
|
6
|
+
SystemRoleShadowedError,
|
|
7
|
+
} from '@pikku/core/errors'
|
|
3
8
|
import type { Kysely } from 'kysely'
|
|
4
9
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
5
10
|
import { ensurePikkuSchema } from './schema/index.js'
|
|
@@ -105,11 +110,80 @@ export class KyselyScopeService implements ScopeService {
|
|
|
105
110
|
}))
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Registers the roles declared with `defineSystemRole`.
|
|
115
|
+
*
|
|
116
|
+
* Additive on the same terms as `syncScopes` — a role whose declaration has
|
|
117
|
+
* gone is marked `declared = false` rather than deleted, so a rollback or a
|
|
118
|
+
* rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
|
|
119
|
+
* outright, because that is the declaration's entire content: editing
|
|
120
|
+
* `defineSystemRole` is how you change what a role means, and the deploy is
|
|
121
|
+
* when it takes effect.
|
|
122
|
+
*/
|
|
123
|
+
async syncSystemRoles(roles: SystemRole[]): Promise<void> {
|
|
124
|
+
await this.db.transaction().execute(async (trx) => {
|
|
125
|
+
for (const role of roles) {
|
|
126
|
+
await trx
|
|
127
|
+
.insertInto('pikkuRoles')
|
|
128
|
+
.values({
|
|
129
|
+
name: role.name,
|
|
130
|
+
description: role.description ?? null,
|
|
131
|
+
system: true,
|
|
132
|
+
declared: true,
|
|
133
|
+
})
|
|
134
|
+
.onConflict((oc) =>
|
|
135
|
+
oc.column('name').doUpdateSet((eb) => ({
|
|
136
|
+
description: eb.ref('excluded.description'),
|
|
137
|
+
system: true,
|
|
138
|
+
declared: true,
|
|
139
|
+
}))
|
|
140
|
+
)
|
|
141
|
+
.execute()
|
|
142
|
+
|
|
143
|
+
await trx
|
|
144
|
+
.deleteFrom('pikkuRoleScopes')
|
|
145
|
+
.where('role', '=', role.name)
|
|
146
|
+
.execute()
|
|
147
|
+
if (role.scopes.length > 0) {
|
|
148
|
+
await trx
|
|
149
|
+
.insertInto('pikkuRoleScopes')
|
|
150
|
+
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
151
|
+
.execute()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Only system rows are in scope here: an admin's own role is not
|
|
156
|
+
// undeclared, it was never declared, and marking it would take it out of
|
|
157
|
+
// the console for a reason nobody could act on.
|
|
158
|
+
const markStale = trx
|
|
159
|
+
.updateTable('pikkuRoles')
|
|
160
|
+
.set({ declared: false })
|
|
161
|
+
.where('system', '=', true)
|
|
162
|
+
await (
|
|
163
|
+
roles.length > 0
|
|
164
|
+
? markStale.where(
|
|
165
|
+
'name',
|
|
166
|
+
'not in',
|
|
167
|
+
roles.map((role) => role.name)
|
|
168
|
+
)
|
|
169
|
+
: markStale
|
|
170
|
+
).execute()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
108
174
|
async createRole(role: Role): Promise<void> {
|
|
175
|
+
if (await this.isSystemRole(role.name)) {
|
|
176
|
+
throw new SystemRoleShadowedError(role.name)
|
|
177
|
+
}
|
|
109
178
|
await this.db.transaction().execute(async (trx) => {
|
|
110
179
|
await trx
|
|
111
180
|
.insertInto('pikkuRoles')
|
|
112
|
-
.values({
|
|
181
|
+
.values({
|
|
182
|
+
name: role.name,
|
|
183
|
+
description: role.description ?? null,
|
|
184
|
+
system: false,
|
|
185
|
+
declared: true,
|
|
186
|
+
})
|
|
113
187
|
.execute()
|
|
114
188
|
|
|
115
189
|
if (role.scopes.length > 0) {
|
|
@@ -122,10 +196,16 @@ export class KyselyScopeService implements ScopeService {
|
|
|
122
196
|
}
|
|
123
197
|
|
|
124
198
|
async deleteRole(name: string): Promise<void> {
|
|
199
|
+
if (await this.isSystemRole(name)) {
|
|
200
|
+
throw new SystemRoleImmutableError(name, 'delete')
|
|
201
|
+
}
|
|
125
202
|
await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute()
|
|
126
203
|
}
|
|
127
204
|
|
|
128
205
|
async setRoleScopes(name: string, scopes: string[]): Promise<void> {
|
|
206
|
+
if (await this.isSystemRole(name)) {
|
|
207
|
+
throw new SystemRoleImmutableError(name, 're-scope')
|
|
208
|
+
}
|
|
129
209
|
await this.db.transaction().execute(async (trx) => {
|
|
130
210
|
await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute()
|
|
131
211
|
|
|
@@ -138,10 +218,26 @@ export class KyselyScopeService implements ScopeService {
|
|
|
138
218
|
})
|
|
139
219
|
}
|
|
140
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Whether the store holds this name as a system role.
|
|
223
|
+
*
|
|
224
|
+
* Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
|
|
225
|
+
* store is where a shadow would actually collide — and because a role whose
|
|
226
|
+
* declaration was deleted is still immutable until someone prunes it.
|
|
227
|
+
*/
|
|
228
|
+
private async isSystemRole(name: string): Promise<boolean> {
|
|
229
|
+
const row = await this.db
|
|
230
|
+
.selectFrom('pikkuRoles')
|
|
231
|
+
.select('system')
|
|
232
|
+
.where('name', '=', name)
|
|
233
|
+
.executeTakeFirst()
|
|
234
|
+
return !!row?.system
|
|
235
|
+
}
|
|
236
|
+
|
|
141
237
|
async listRoles(): Promise<Role[]> {
|
|
142
238
|
const roles = await this.db
|
|
143
239
|
.selectFrom('pikkuRoles')
|
|
144
|
-
.select(['name', 'description'])
|
|
240
|
+
.select(['name', 'description', 'system', 'declared'])
|
|
145
241
|
.execute()
|
|
146
242
|
|
|
147
243
|
const scopeRows = await this.db
|
|
@@ -163,6 +259,8 @@ export class KyselyScopeService implements ScopeService {
|
|
|
163
259
|
name: role.name,
|
|
164
260
|
description: role.description ?? undefined,
|
|
165
261
|
scopes: byRole.get(role.name) ?? [],
|
|
262
|
+
system: !!role.system,
|
|
263
|
+
declared: !!role.declared,
|
|
166
264
|
}))
|
|
167
265
|
}
|
|
168
266
|
|
|
@@ -270,4 +368,55 @@ export class KyselyScopeService implements ScopeService {
|
|
|
270
368
|
|
|
271
369
|
return deleted.map((r) => r.name)
|
|
272
370
|
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* System roles the last sync found no declaration for, with how many people
|
|
374
|
+
* still hold each. Powers `pikku roles audit`.
|
|
375
|
+
*
|
|
376
|
+
* The count is the whole point: "this role is gone from the code" and "this
|
|
377
|
+
* role is gone from the code and 40 people are standing on it" call for
|
|
378
|
+
* different decisions, and the number is what makes pruning a choice rather
|
|
379
|
+
* than a formality.
|
|
380
|
+
*/
|
|
381
|
+
async findStaleSystemRoles(): Promise<Array<{ role: string; users: number }>> {
|
|
382
|
+
const stale = await this.db
|
|
383
|
+
.selectFrom('pikkuRoles')
|
|
384
|
+
.leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
|
|
385
|
+
.select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
|
|
386
|
+
.where('pikkuRoles.system', '=', true)
|
|
387
|
+
.where('pikkuRoles.declared', '=', false)
|
|
388
|
+
.execute()
|
|
389
|
+
|
|
390
|
+
const byRole = new Map<string, number>()
|
|
391
|
+
for (const row of stale) {
|
|
392
|
+
byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return [...byRole.entries()].map(([role, users]) => ({ role, users }))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Removes undeclared system roles, cascading them out of every user grant
|
|
400
|
+
* that holds them. This revokes access, so it is never run implicitly.
|
|
401
|
+
*/
|
|
402
|
+
async pruneSystemRoles(): Promise<string[]> {
|
|
403
|
+
const stale = await this.findStaleSystemRoles()
|
|
404
|
+
if (stale.length === 0) {
|
|
405
|
+
return []
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const deleted = await this.db
|
|
409
|
+
.deleteFrom('pikkuRoles')
|
|
410
|
+
.where(
|
|
411
|
+
'name',
|
|
412
|
+
'in',
|
|
413
|
+
stale.map((s) => s.role)
|
|
414
|
+
)
|
|
415
|
+
.where('system', '=', true)
|
|
416
|
+
.where('declared', '=', false)
|
|
417
|
+
.returning('name')
|
|
418
|
+
.execute()
|
|
419
|
+
|
|
420
|
+
return deleted.map((r) => r.name)
|
|
421
|
+
}
|
|
273
422
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
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 {
|
|
6
|
+
deriveKEK,
|
|
7
|
+
generateKEKSalt,
|
|
5
8
|
envelopeEncrypt,
|
|
6
9
|
envelopeDecrypt,
|
|
7
10
|
envelopeRewrap,
|
|
@@ -19,6 +22,8 @@ export interface KyselySecretServiceConfig {
|
|
|
19
22
|
|
|
20
23
|
export class KyselySecretService implements SecretService {
|
|
21
24
|
private initialized = false
|
|
25
|
+
private kekSalts = new Map<number, string>()
|
|
26
|
+
private keks = new Map<number, CryptoKey>()
|
|
22
27
|
private key: string
|
|
23
28
|
private keyVersion: number
|
|
24
29
|
private previousKey?: string
|
|
@@ -60,13 +65,55 @@ export class KyselySecretService implements SecretService {
|
|
|
60
65
|
.execute()
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
68
|
+
private async getKEKSalt(version: number): Promise<string> {
|
|
69
|
+
const cached = this.kekSalts.get(version)
|
|
70
|
+
if (cached) return cached
|
|
71
|
+
|
|
72
|
+
const existing = await this.db
|
|
73
|
+
.selectFrom('secretKekSalts')
|
|
74
|
+
.select('salt')
|
|
75
|
+
.where('keyVersion', '=', version)
|
|
76
|
+
.executeTakeFirst()
|
|
77
|
+
|
|
78
|
+
let salt = existing?.salt
|
|
79
|
+
if (!salt) {
|
|
80
|
+
await this.db
|
|
81
|
+
.insertInto('secretKekSalts')
|
|
82
|
+
.values({
|
|
83
|
+
keyVersion: version,
|
|
84
|
+
salt: generateKEKSalt(),
|
|
85
|
+
createdAt: new Date().toISOString() as unknown as Date,
|
|
86
|
+
})
|
|
87
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
88
|
+
.execute()
|
|
89
|
+
|
|
90
|
+
const row = await this.db
|
|
91
|
+
.selectFrom('secretKekSalts')
|
|
92
|
+
.select('salt')
|
|
93
|
+
.where('keyVersion', '=', version)
|
|
94
|
+
.executeTakeFirstOrThrow()
|
|
95
|
+
salt = row.salt
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.kekSalts.set(version, salt)
|
|
99
|
+
return salt
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async getKEK(version: number): Promise<CryptoKey> {
|
|
103
|
+
const cached = this.keks.get(version)
|
|
104
|
+
if (cached) return cached
|
|
105
|
+
|
|
106
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
107
|
+
if (!passphrase) {
|
|
108
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
112
|
+
this.keks.set(version, kek)
|
|
113
|
+
return kek
|
|
67
114
|
}
|
|
68
115
|
|
|
69
|
-
async getSecret<T = string>(key: string): Promise<T
|
|
116
|
+
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
70
117
|
const row = await this.db
|
|
71
118
|
.selectFrom('secrets')
|
|
72
119
|
.select(['ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -75,10 +122,10 @@ export class KyselySecretService implements SecretService {
|
|
|
75
122
|
|
|
76
123
|
if (!row) throw new Error('Requested secret not found')
|
|
77
124
|
|
|
78
|
-
const kek = this.getKEK(row.keyVersion)
|
|
125
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
79
126
|
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
80
127
|
await this.logAudit(key, 'read')
|
|
81
|
-
return result
|
|
128
|
+
return createSecretValue(result)
|
|
82
129
|
}
|
|
83
130
|
|
|
84
131
|
async hasSecret(key: string): Promise<boolean> {
|
|
@@ -91,7 +138,12 @@ export class KyselySecretService implements SecretService {
|
|
|
91
138
|
}
|
|
92
139
|
|
|
93
140
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
94
|
-
|
|
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.
|
|
143
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
144
|
+
await this.getKEK(this.keyVersion),
|
|
145
|
+
isSecretValue(value) ? value.reveal() : value
|
|
146
|
+
)
|
|
95
147
|
const now = new Date().toISOString()
|
|
96
148
|
|
|
97
149
|
await this.db
|
|
@@ -124,7 +176,7 @@ export class KyselySecretService implements SecretService {
|
|
|
124
176
|
|
|
125
177
|
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
126
178
|
keys: (keyof T & string)[]
|
|
127
|
-
): Promise<T
|
|
179
|
+
): Promise<Partial<SecretValues<T>>> {
|
|
128
180
|
const rows = await this.db
|
|
129
181
|
.selectFrom('secrets')
|
|
130
182
|
.select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -134,11 +186,9 @@ export class KyselySecretService implements SecretService {
|
|
|
134
186
|
const out: Record<string, unknown> = {}
|
|
135
187
|
for (const row of rows) {
|
|
136
188
|
try {
|
|
137
|
-
const kek = this.getKEK(row.keyVersion)
|
|
138
|
-
out[row.key] =
|
|
139
|
-
kek,
|
|
140
|
-
row.ciphertext,
|
|
141
|
-
row.wrappedDek
|
|
189
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
190
|
+
out[row.key] = createSecretValue(
|
|
191
|
+
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
142
192
|
)
|
|
143
193
|
} catch (cause) {
|
|
144
194
|
// A stored secret that fails to decrypt is never expected — it means the
|
|
@@ -154,7 +204,7 @@ export class KyselySecretService implements SecretService {
|
|
|
154
204
|
)
|
|
155
205
|
}
|
|
156
206
|
}
|
|
157
|
-
return out as T
|
|
207
|
+
return out as Partial<SecretValues<T>>
|
|
158
208
|
}
|
|
159
209
|
|
|
160
210
|
async rotateKEK(): Promise<number> {
|
|
@@ -168,12 +218,11 @@ export class KyselySecretService implements SecretService {
|
|
|
168
218
|
.where('keyVersion', '<', this.keyVersion)
|
|
169
219
|
.execute()
|
|
170
220
|
|
|
221
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
222
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
223
|
+
|
|
171
224
|
for (const row of rows) {
|
|
172
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
173
|
-
this.previousKey,
|
|
174
|
-
this.key,
|
|
175
|
-
row.wrappedDek
|
|
176
|
-
)
|
|
225
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
177
226
|
await this.db
|
|
178
227
|
.updateTable('secrets')
|
|
179
228
|
.set({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test'
|
|
1
|
+
import { describe, test, before, beforeEach, after } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
|
|
4
4
|
import { SerializePlugin } from './serialize-plugin.js'
|
|
@@ -54,8 +54,10 @@ async function dropAllTables(db: Kysely<KyselyPikkuDB>): Promise<void> {
|
|
|
54
54
|
'workflow_runs',
|
|
55
55
|
'workflow_versions',
|
|
56
56
|
'secrets_audit',
|
|
57
|
+
'secret_kek_salts',
|
|
57
58
|
'secrets',
|
|
58
59
|
'credentials_audit',
|
|
60
|
+
'credential_kek_salts',
|
|
59
61
|
'credentials',
|
|
60
62
|
]
|
|
61
63
|
for (const table of tables) {
|
|
@@ -166,6 +168,126 @@ function registerTests(
|
|
|
166
168
|
})
|
|
167
169
|
})
|
|
168
170
|
|
|
171
|
+
describe(`KyselySecretService KEK derivation [${dialectName}]`, () => {
|
|
172
|
+
const kek = 'test-key-encryption-key-32chars!'
|
|
173
|
+
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
await getDb().deleteFrom('secrets').execute()
|
|
176
|
+
await getDb().deleteFrom('secretKekSalts').execute()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a KEK salt row is created once per key version', async () => {
|
|
180
|
+
const service = new KyselySecretService(getDb(), { key: kek })
|
|
181
|
+
await service.init()
|
|
182
|
+
await service.setSecret('salt-a', 'one')
|
|
183
|
+
await service.setSecret('salt-b', 'two')
|
|
184
|
+
|
|
185
|
+
const rows = await getDb()
|
|
186
|
+
.selectFrom('secretKekSalts')
|
|
187
|
+
.select(['keyVersion', 'salt'])
|
|
188
|
+
.execute()
|
|
189
|
+
|
|
190
|
+
assert.equal(rows.length, 1)
|
|
191
|
+
assert.equal(rows[0]!.keyVersion, 1)
|
|
192
|
+
assert.ok(rows[0]!.salt.length > 0)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a second instance reuses the stored salt rather than minting one', async () => {
|
|
196
|
+
const first = new KyselySecretService(getDb(), { key: kek })
|
|
197
|
+
await first.init()
|
|
198
|
+
await first.setSecret('shared-salt', 'value')
|
|
199
|
+
|
|
200
|
+
const second = new KyselySecretService(getDb(), { key: kek })
|
|
201
|
+
await second.init()
|
|
202
|
+
assert.equal((await second.getSecret('shared-salt')).reveal(), 'value')
|
|
203
|
+
|
|
204
|
+
const rows = await getDb()
|
|
205
|
+
.selectFrom('secretKekSalts')
|
|
206
|
+
.selectAll()
|
|
207
|
+
.execute()
|
|
208
|
+
assert.equal(rows.length, 1)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('getSecrets over 50 secrets costs one KEK derivation, not 50', async () => {
|
|
212
|
+
const service = new KyselySecretService(getDb(), { key: kek })
|
|
213
|
+
await service.init()
|
|
214
|
+
|
|
215
|
+
const keys: string[] = []
|
|
216
|
+
for (let i = 0; i < 50; i++) {
|
|
217
|
+
const key = `bulk-${i}`
|
|
218
|
+
keys.push(key)
|
|
219
|
+
await service.setSecret(key, `value-${i}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const reader = new KyselySecretService(getDb(), { key: kek })
|
|
223
|
+
await reader.init()
|
|
224
|
+
|
|
225
|
+
const start = performance.now()
|
|
226
|
+
const out = await reader.getSecrets(keys)
|
|
227
|
+
const elapsed = performance.now() - start
|
|
228
|
+
|
|
229
|
+
assert.equal(Object.keys(out).length, 50)
|
|
230
|
+
assert.equal(out['bulk-7']!.reveal(), 'value-7')
|
|
231
|
+
assert.ok(
|
|
232
|
+
elapsed < 400,
|
|
233
|
+
`getSecrets over 50 secrets took ${elapsed.toFixed(0)}ms; one derivation per secret would cost seconds`
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('rotateKEK over 50 secrets derives two KEKs, not 100', async () => {
|
|
238
|
+
const previousKey = 'previous-key-encryption-key-32c!'
|
|
239
|
+
const writer = new KyselySecretService(getDb(), {
|
|
240
|
+
key: previousKey,
|
|
241
|
+
keyVersion: 1,
|
|
242
|
+
})
|
|
243
|
+
await writer.init()
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < 50; i++) {
|
|
246
|
+
await writer.setSecret(`rotate-${i}`, `value-${i}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const rotator = new KyselySecretService(getDb(), {
|
|
250
|
+
key: kek,
|
|
251
|
+
keyVersion: 2,
|
|
252
|
+
previousKey,
|
|
253
|
+
})
|
|
254
|
+
await rotator.init()
|
|
255
|
+
|
|
256
|
+
const start = performance.now()
|
|
257
|
+
const rotated = await rotator.rotateKEK()
|
|
258
|
+
const elapsed = performance.now() - start
|
|
259
|
+
|
|
260
|
+
assert.equal(rotated, 50)
|
|
261
|
+
assert.equal((await rotator.getSecret('rotate-7')).reveal(), 'value-7')
|
|
262
|
+
assert.ok(
|
|
263
|
+
elapsed < 700,
|
|
264
|
+
`rotating 50 secrets took ${elapsed.toFixed(0)}ms; two derivations per secret would cost seconds`
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const salts = await getDb()
|
|
268
|
+
.selectFrom('secretKekSalts')
|
|
269
|
+
.select('keyVersion')
|
|
270
|
+
.orderBy('keyVersion', 'asc')
|
|
271
|
+
.execute()
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
salts.map((r) => r.keyVersion),
|
|
274
|
+
[1, 2]
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('a wrong KEK passphrase fails loudly rather than returning garbage', async () => {
|
|
279
|
+
const writer = new KyselySecretService(getDb(), { key: kek })
|
|
280
|
+
await writer.init()
|
|
281
|
+
await writer.setSecret('wrong-kek', 'value')
|
|
282
|
+
|
|
283
|
+
const reader = new KyselySecretService(getDb(), {
|
|
284
|
+
key: 'a-completely-different-kek-32ch!',
|
|
285
|
+
})
|
|
286
|
+
await reader.init()
|
|
287
|
+
await assert.rejects(() => reader.getSecret('wrong-kek'))
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
169
291
|
describe(`KyselyCredentialService audit [${dialectName}]`, () => {
|
|
170
292
|
const kek = 'test-key-encryption-key-32chars!'
|
|
171
293
|
|
|
@@ -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(
|
package/src/kysely-tables.ts
CHANGED
|
@@ -131,7 +131,14 @@ export interface AIRunTable {
|
|
|
131
131
|
agentName: string
|
|
132
132
|
threadId: string
|
|
133
133
|
resourceId: string
|
|
134
|
-
|
|
134
|
+
/**
|
|
135
|
+
* `interrupted` is distinct from `failed`: the run was stopped on purpose,
|
|
136
|
+
* usually because the user talked over it. Nothing went wrong, so it should
|
|
137
|
+
* not show up in error reporting.
|
|
138
|
+
*/
|
|
139
|
+
status: Generated<
|
|
140
|
+
'running' | 'suspended' | 'completed' | 'failed' | 'interrupted'
|
|
141
|
+
>
|
|
135
142
|
errorMessage: string | null
|
|
136
143
|
suspendReason: 'approval' | 'credential' | 'rpc-missing' | null
|
|
137
144
|
missingRpcs: string | null
|
|
@@ -165,6 +172,12 @@ export interface SecretsTable {
|
|
|
165
172
|
updatedAt: Generated<Date>
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
export interface KekSaltsTable {
|
|
176
|
+
keyVersion: number
|
|
177
|
+
salt: string
|
|
178
|
+
createdAt: Generated<Date>
|
|
179
|
+
}
|
|
180
|
+
|
|
168
181
|
export interface SecretsAuditTable {
|
|
169
182
|
id: string
|
|
170
183
|
secretKey: string
|
|
@@ -215,10 +228,14 @@ export interface PikkuScopesTable {
|
|
|
215
228
|
declared: Generated<boolean>
|
|
216
229
|
}
|
|
217
230
|
|
|
218
|
-
/** A role: an admin
|
|
231
|
+
/** A role: a bag of scopes, composed by an admin or declared in code. */
|
|
219
232
|
export interface PikkuRolesTable {
|
|
220
233
|
name: string
|
|
221
234
|
description: string | null
|
|
235
|
+
/** Declared with `defineSystemRole`, and therefore not editable here. */
|
|
236
|
+
system: Generated<boolean>
|
|
237
|
+
/** False for a system role whose declaration has gone: held, but inert. */
|
|
238
|
+
declared: Generated<boolean>
|
|
222
239
|
createdAt: Generated<Date>
|
|
223
240
|
}
|
|
224
241
|
|
|
@@ -264,6 +281,37 @@ export interface WebhookDeliveryAttemptTable {
|
|
|
264
281
|
createdAt: Generated<Date>
|
|
265
282
|
}
|
|
266
283
|
|
|
284
|
+
/**
|
|
285
|
+
* One virtual-user run. The JSON columns (`goals`, `memory`, `findings`,
|
|
286
|
+
* `tally`) are text the store serialises itself, so the row is byte-identical
|
|
287
|
+
* on every engine.
|
|
288
|
+
*/
|
|
289
|
+
export interface VirtualUserRunTable {
|
|
290
|
+
runId: string
|
|
291
|
+
persona: string
|
|
292
|
+
disposition: string
|
|
293
|
+
/**
|
|
294
|
+
* Read back as a string by some drivers — a BIGINT does not always fit a JS
|
|
295
|
+
* number — so the store narrows it rather than trusting the column type.
|
|
296
|
+
*/
|
|
297
|
+
seed: string | number | bigint
|
|
298
|
+
status: Generated<'running' | 'completed' | 'failed'>
|
|
299
|
+
goals: Generated<string>
|
|
300
|
+
memory: Generated<string>
|
|
301
|
+
findings: Generated<string>
|
|
302
|
+
tally: string | null
|
|
303
|
+
stoppedBy: string | null
|
|
304
|
+
error: string | null
|
|
305
|
+
startedBy: string | null
|
|
306
|
+
/**
|
|
307
|
+
* Written as an ISO string and read back as whatever the driver returns — a
|
|
308
|
+
* bare SQLite driver cannot bind a `Date` at all, and the store normalises
|
|
309
|
+
* both ends.
|
|
310
|
+
*/
|
|
311
|
+
createdAt: Generated<Date | string>
|
|
312
|
+
finishedAt: Date | string | null
|
|
313
|
+
}
|
|
314
|
+
|
|
267
315
|
export interface KyselyPikkuDB {
|
|
268
316
|
pikkuScopes: PikkuScopesTable
|
|
269
317
|
pikkuRoles: PikkuRolesTable
|
|
@@ -284,10 +332,13 @@ export interface KyselyPikkuDB {
|
|
|
284
332
|
pikkuDeployments: PikkuDeploymentsTable
|
|
285
333
|
pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
|
|
286
334
|
secrets: SecretsTable
|
|
335
|
+
secretKekSalts: KekSaltsTable
|
|
287
336
|
secretsAudit: SecretsAuditTable
|
|
288
337
|
credentials: CredentialsTable
|
|
338
|
+
credentialKekSalts: KekSaltsTable
|
|
289
339
|
credentialsAudit: CredentialsAuditTable
|
|
290
340
|
pikkuUserSessions: UserSessionsTable
|
|
291
341
|
webhookDelivery: WebhookDeliveryTable
|
|
292
342
|
webhookDeliveryAttempt: WebhookDeliveryAttemptTable
|
|
343
|
+
virtualUserRun: VirtualUserRunTable
|
|
293
344
|
}
|