@pikku/kysely 0.13.5 → 0.13.7
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 +406 -0
- package/LICENSE +21 -0
- package/README.md +29 -2
- 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 +3 -0
- package/dist/src/kysely-secret-service.js +50 -11
- package/dist/src/kysely-tables.d.ts +26 -2
- package/dist/src/kysely-workflow-mirror.js +37 -25
- package/dist/src/kysely-workflow-run-service.d.ts +0 -5
- package/dist/src/kysely-workflow-run-service.js +4 -16
- package/dist/src/kysely-workflow-service.d.ts +35 -7
- package/dist/src/kysely-workflow-service.js +194 -146
- package/dist/src/schema/credential.schema.js +5 -0
- package/dist/src/schema/scope.schema.js +12 -0
- package/dist/src/schema/secret.schema.js +5 -0
- package/dist/src/schema/workflow.schema.d.ts +4 -2
- package/dist/src/schema/workflow.schema.js +28 -2
- package/package.json +3 -2
- package/src/kysely-credential-service.ts +57 -12
- package/src/kysely-scope-service.ts +151 -2
- package/src/kysely-secret-service.ts +60 -12
- package/src/kysely-services.test.ts +123 -1
- package/src/kysely-tables.ts +29 -2
- package/src/kysely-workflow-mirror.test.ts +1 -13
- package/src/kysely-workflow-mirror.ts +44 -20
- package/src/kysely-workflow-run-service.ts +7 -20
- package/src/kysely-workflow-service.test.ts +431 -0
- package/src/kysely-workflow-service.ts +212 -178
- package/src/schema/credential.schema.ts +9 -0
- package/src/schema/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -0
- package/src/schema/workflow.schema.ts +38 -2
|
@@ -2,6 +2,8 @@ import type { CredentialService } from '@pikku/core/services'
|
|
|
2
2
|
import type { Kysely } from 'kysely'
|
|
3
3
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
4
4
|
import {
|
|
5
|
+
deriveKEK,
|
|
6
|
+
generateKEKSalt,
|
|
5
7
|
envelopeEncrypt,
|
|
6
8
|
envelopeDecrypt,
|
|
7
9
|
envelopeRewrap,
|
|
@@ -19,6 +21,8 @@ export interface KyselyCredentialServiceConfig {
|
|
|
19
21
|
|
|
20
22
|
export class KyselyCredentialService implements CredentialService {
|
|
21
23
|
private initialized = false
|
|
24
|
+
private kekSalts = new Map<number, string>()
|
|
25
|
+
private keks = new Map<number, CryptoKey>()
|
|
22
26
|
private key: string
|
|
23
27
|
private keyVersion: number
|
|
24
28
|
private previousKey?: string
|
|
@@ -62,10 +66,52 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
62
66
|
.execute()
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
+
private async getKEKSalt(version: number): Promise<string> {
|
|
70
|
+
const cached = this.kekSalts.get(version)
|
|
71
|
+
if (cached) return cached
|
|
72
|
+
|
|
73
|
+
const existing = await this.db
|
|
74
|
+
.selectFrom('credentialKekSalts')
|
|
75
|
+
.select('salt')
|
|
76
|
+
.where('keyVersion', '=', version)
|
|
77
|
+
.executeTakeFirst()
|
|
78
|
+
|
|
79
|
+
let salt = existing?.salt
|
|
80
|
+
if (!salt) {
|
|
81
|
+
await this.db
|
|
82
|
+
.insertInto('credentialKekSalts')
|
|
83
|
+
.values({
|
|
84
|
+
keyVersion: version,
|
|
85
|
+
salt: generateKEKSalt(),
|
|
86
|
+
createdAt: new Date().toISOString() as unknown as Date,
|
|
87
|
+
})
|
|
88
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
89
|
+
.execute()
|
|
90
|
+
|
|
91
|
+
const row = await this.db
|
|
92
|
+
.selectFrom('credentialKekSalts')
|
|
93
|
+
.select('salt')
|
|
94
|
+
.where('keyVersion', '=', version)
|
|
95
|
+
.executeTakeFirstOrThrow()
|
|
96
|
+
salt = row.salt
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.kekSalts.set(version, salt)
|
|
100
|
+
return salt
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async getKEK(version: number): Promise<CryptoKey> {
|
|
104
|
+
const cached = this.keks.get(version)
|
|
105
|
+
if (cached) return cached
|
|
106
|
+
|
|
107
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
108
|
+
if (!passphrase) {
|
|
109
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
113
|
+
this.keks.set(version, kek)
|
|
114
|
+
return kek
|
|
69
115
|
}
|
|
70
116
|
|
|
71
117
|
private whereUserId(qb: any, userId?: string) {
|
|
@@ -84,7 +130,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
84
130
|
const row = await qb.executeTakeFirst()
|
|
85
131
|
if (!row) return null
|
|
86
132
|
|
|
87
|
-
const kek = this.getKEK(row.keyVersion)
|
|
133
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
88
134
|
const plaintext = await envelopeDecrypt<string>(
|
|
89
135
|
kek,
|
|
90
136
|
row.ciphertext,
|
|
@@ -102,7 +148,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
102
148
|
async set(name: string, value: unknown, userId?: string): Promise<void> {
|
|
103
149
|
const plaintext = JSON.stringify(value)
|
|
104
150
|
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
105
|
-
this.
|
|
151
|
+
await this.getKEK(this.keyVersion),
|
|
106
152
|
plaintext
|
|
107
153
|
)
|
|
108
154
|
const now = new Date().toISOString()
|
|
@@ -166,7 +212,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
166
212
|
|
|
167
213
|
const result: Record<string, unknown> = {}
|
|
168
214
|
for (const row of rows) {
|
|
169
|
-
const kek = this.getKEK(row.keyVersion)
|
|
215
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
170
216
|
const plaintext = await envelopeDecrypt<string>(
|
|
171
217
|
kek,
|
|
172
218
|
row.ciphertext,
|
|
@@ -216,12 +262,11 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
216
262
|
.where('keyVersion', '<', this.keyVersion)
|
|
217
263
|
.execute()
|
|
218
264
|
|
|
265
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
266
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
267
|
+
|
|
219
268
|
for (const row of rows) {
|
|
220
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
221
|
-
this.previousKey,
|
|
222
|
-
this.key,
|
|
223
|
-
row.wrappedDek
|
|
224
|
-
)
|
|
269
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
225
270
|
let qb = this.db
|
|
226
271
|
.updateTable('credentials')
|
|
227
272
|
.set({
|
|
@@ -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
|
}
|
|
@@ -2,6 +2,8 @@ import type { SecretService } from '@pikku/core/services'
|
|
|
2
2
|
import type { Kysely } from 'kysely'
|
|
3
3
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
4
4
|
import {
|
|
5
|
+
deriveKEK,
|
|
6
|
+
generateKEKSalt,
|
|
5
7
|
envelopeEncrypt,
|
|
6
8
|
envelopeDecrypt,
|
|
7
9
|
envelopeRewrap,
|
|
@@ -19,6 +21,8 @@ export interface KyselySecretServiceConfig {
|
|
|
19
21
|
|
|
20
22
|
export class KyselySecretService implements SecretService {
|
|
21
23
|
private initialized = false
|
|
24
|
+
private kekSalts = new Map<number, string>()
|
|
25
|
+
private keks = new Map<number, CryptoKey>()
|
|
22
26
|
private key: string
|
|
23
27
|
private keyVersion: number
|
|
24
28
|
private previousKey?: string
|
|
@@ -60,10 +64,52 @@ export class KyselySecretService implements SecretService {
|
|
|
60
64
|
.execute()
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
+
private async getKEKSalt(version: number): Promise<string> {
|
|
68
|
+
const cached = this.kekSalts.get(version)
|
|
69
|
+
if (cached) return cached
|
|
70
|
+
|
|
71
|
+
const existing = await this.db
|
|
72
|
+
.selectFrom('secretKekSalts')
|
|
73
|
+
.select('salt')
|
|
74
|
+
.where('keyVersion', '=', version)
|
|
75
|
+
.executeTakeFirst()
|
|
76
|
+
|
|
77
|
+
let salt = existing?.salt
|
|
78
|
+
if (!salt) {
|
|
79
|
+
await this.db
|
|
80
|
+
.insertInto('secretKekSalts')
|
|
81
|
+
.values({
|
|
82
|
+
keyVersion: version,
|
|
83
|
+
salt: generateKEKSalt(),
|
|
84
|
+
createdAt: new Date().toISOString() as unknown as Date,
|
|
85
|
+
})
|
|
86
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
87
|
+
.execute()
|
|
88
|
+
|
|
89
|
+
const row = await this.db
|
|
90
|
+
.selectFrom('secretKekSalts')
|
|
91
|
+
.select('salt')
|
|
92
|
+
.where('keyVersion', '=', version)
|
|
93
|
+
.executeTakeFirstOrThrow()
|
|
94
|
+
salt = row.salt
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.kekSalts.set(version, salt)
|
|
98
|
+
return salt
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async getKEK(version: number): Promise<CryptoKey> {
|
|
102
|
+
const cached = this.keks.get(version)
|
|
103
|
+
if (cached) return cached
|
|
104
|
+
|
|
105
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
106
|
+
if (!passphrase) {
|
|
107
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
111
|
+
this.keks.set(version, kek)
|
|
112
|
+
return kek
|
|
67
113
|
}
|
|
68
114
|
|
|
69
115
|
async getSecret<T = string>(key: string): Promise<T> {
|
|
@@ -75,7 +121,7 @@ export class KyselySecretService implements SecretService {
|
|
|
75
121
|
|
|
76
122
|
if (!row) throw new Error('Requested secret not found')
|
|
77
123
|
|
|
78
|
-
const kek = this.getKEK(row.keyVersion)
|
|
124
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
79
125
|
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
80
126
|
await this.logAudit(key, 'read')
|
|
81
127
|
return result
|
|
@@ -91,7 +137,10 @@ export class KyselySecretService implements SecretService {
|
|
|
91
137
|
}
|
|
92
138
|
|
|
93
139
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
94
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
140
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
141
|
+
await this.getKEK(this.keyVersion),
|
|
142
|
+
value
|
|
143
|
+
)
|
|
95
144
|
const now = new Date().toISOString()
|
|
96
145
|
|
|
97
146
|
await this.db
|
|
@@ -134,7 +183,7 @@ export class KyselySecretService implements SecretService {
|
|
|
134
183
|
const out: Record<string, unknown> = {}
|
|
135
184
|
for (const row of rows) {
|
|
136
185
|
try {
|
|
137
|
-
const kek = this.getKEK(row.keyVersion)
|
|
186
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
138
187
|
out[row.key] = await envelopeDecrypt(
|
|
139
188
|
kek,
|
|
140
189
|
row.ciphertext,
|
|
@@ -168,12 +217,11 @@ export class KyselySecretService implements SecretService {
|
|
|
168
217
|
.where('keyVersion', '<', this.keyVersion)
|
|
169
218
|
.execute()
|
|
170
219
|
|
|
220
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
221
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
222
|
+
|
|
171
223
|
for (const row of rows) {
|
|
172
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
173
|
-
this.previousKey,
|
|
174
|
-
this.key,
|
|
175
|
-
row.wrappedDek
|
|
176
|
-
)
|
|
224
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
177
225
|
await this.db
|
|
178
226
|
.updateTable('secrets')
|
|
179
227
|
.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'), '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'], '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'), '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
|
|
package/src/kysely-tables.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface WorkflowStepTable {
|
|
|
52
52
|
retries: number | null
|
|
53
53
|
retryDelay: string | null
|
|
54
54
|
fromStepName: string | null
|
|
55
|
+
/** The history attempt currently in flight; see WorkflowStepHistoryTable.attempt. */
|
|
56
|
+
currentAttempt: number | null
|
|
55
57
|
createdAt: Generated<Date>
|
|
56
58
|
updatedAt: Generated<Date>
|
|
57
59
|
}
|
|
@@ -62,6 +64,12 @@ export interface WorkflowStepHistoryTable {
|
|
|
62
64
|
status: StepStatus
|
|
63
65
|
result: string | null
|
|
64
66
|
error: string | null
|
|
67
|
+
/**
|
|
68
|
+
* Monotonic attempt number within the step, starting at 1. Orders attempts
|
|
69
|
+
* and identifies the live one without relying on `createdAt`, which cannot
|
|
70
|
+
* separate a retry from the attempt it replaces in the same millisecond.
|
|
71
|
+
*/
|
|
72
|
+
attempt: number | null
|
|
65
73
|
createdAt: Generated<Date>
|
|
66
74
|
runningAt: Date | null
|
|
67
75
|
scheduledAt: Date | null
|
|
@@ -123,7 +131,14 @@ export interface AIRunTable {
|
|
|
123
131
|
agentName: string
|
|
124
132
|
threadId: string
|
|
125
133
|
resourceId: string
|
|
126
|
-
|
|
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
|
+
>
|
|
127
142
|
errorMessage: string | null
|
|
128
143
|
suspendReason: 'approval' | 'credential' | 'rpc-missing' | null
|
|
129
144
|
missingRpcs: string | null
|
|
@@ -157,6 +172,12 @@ export interface SecretsTable {
|
|
|
157
172
|
updatedAt: Generated<Date>
|
|
158
173
|
}
|
|
159
174
|
|
|
175
|
+
export interface KekSaltsTable {
|
|
176
|
+
keyVersion: number
|
|
177
|
+
salt: string
|
|
178
|
+
createdAt: Generated<Date>
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
export interface SecretsAuditTable {
|
|
161
182
|
id: string
|
|
162
183
|
secretKey: string
|
|
@@ -207,10 +228,14 @@ export interface PikkuScopesTable {
|
|
|
207
228
|
declared: Generated<boolean>
|
|
208
229
|
}
|
|
209
230
|
|
|
210
|
-
/** A role: an admin
|
|
231
|
+
/** A role: a bag of scopes, composed by an admin or declared in code. */
|
|
211
232
|
export interface PikkuRolesTable {
|
|
212
233
|
name: string
|
|
213
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>
|
|
214
239
|
createdAt: Generated<Date>
|
|
215
240
|
}
|
|
216
241
|
|
|
@@ -276,8 +301,10 @@ export interface KyselyPikkuDB {
|
|
|
276
301
|
pikkuDeployments: PikkuDeploymentsTable
|
|
277
302
|
pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
|
|
278
303
|
secrets: SecretsTable
|
|
304
|
+
secretKekSalts: KekSaltsTable
|
|
279
305
|
secretsAudit: SecretsAuditTable
|
|
280
306
|
credentials: CredentialsTable
|
|
307
|
+
credentialKekSalts: KekSaltsTable
|
|
281
308
|
credentialsAudit: CredentialsAuditTable
|
|
282
309
|
pikkuUserSessions: UserSessionsTable
|
|
283
310
|
webhookDelivery: WebhookDeliveryTable
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
|
|
3
|
+
import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
|
|
4
4
|
import { SerializePlugin } from './serialize-plugin.js'
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
|
|
@@ -237,18 +237,6 @@ describe('KyselyWorkflowMirror — workflow versions', () => {
|
|
|
237
237
|
.executeTakeFirst()
|
|
238
238
|
assert.equal(row?.status, 'archived')
|
|
239
239
|
})
|
|
240
|
-
|
|
241
|
-
test('AI-generated workflows surface via getAIGeneratedWorkflows', async () => {
|
|
242
|
-
await mirror.upsertWorkflowVersion(
|
|
243
|
-
'ai:agent-x:foo',
|
|
244
|
-
'h1',
|
|
245
|
-
{ nodes: [] },
|
|
246
|
-
'dynamic-workflow'
|
|
247
|
-
)
|
|
248
|
-
const list = await runService.getAIGeneratedWorkflows('agent-x')
|
|
249
|
-
assert.equal(list.length, 1)
|
|
250
|
-
assert.equal(list[0]!.workflowName, 'ai:agent-x:foo')
|
|
251
|
-
})
|
|
252
240
|
})
|
|
253
241
|
|
|
254
242
|
describe('KyselyWorkflowMirror — init', () => {
|