@pikku/kysely 0.13.6 → 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 +325 -0
- 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 +18 -2
- package/dist/src/kysely-workflow-service.d.ts +0 -1
- package/dist/src/kysely-workflow-service.js +0 -12
- 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/package.json +2 -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 +21 -2
- package/src/kysely-workflow-service.ts +0 -17
- package/src/schema/credential.schema.ts +9 -0
- package/src/schema/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -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
|
}
|
|
@@ -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
|
@@ -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
|
|
|
@@ -284,8 +301,10 @@ export interface KyselyPikkuDB {
|
|
|
284
301
|
pikkuDeployments: PikkuDeploymentsTable
|
|
285
302
|
pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
|
|
286
303
|
secrets: SecretsTable
|
|
304
|
+
secretKekSalts: KekSaltsTable
|
|
287
305
|
secretsAudit: SecretsAuditTable
|
|
288
306
|
credentials: CredentialsTable
|
|
307
|
+
credentialKekSalts: KekSaltsTable
|
|
289
308
|
credentialsAudit: CredentialsAuditTable
|
|
290
309
|
pikkuUserSessions: UserSessionsTable
|
|
291
310
|
webhookDelivery: WebhookDeliveryTable
|
|
@@ -505,23 +505,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
505
505
|
return { completedNodeIds, failedNodeIds, branchKeys }
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
-
async getNodesWithoutSteps(
|
|
509
|
-
runId: string,
|
|
510
|
-
nodeIds: string[]
|
|
511
|
-
): Promise<string[]> {
|
|
512
|
-
if (nodeIds.length === 0) return []
|
|
513
|
-
|
|
514
|
-
const result = await this.db
|
|
515
|
-
.selectFrom('workflowStep')
|
|
516
|
-
.select('stepName')
|
|
517
|
-
.where('workflowRunId', '=', runId)
|
|
518
|
-
.where('stepName', 'in', nodeIds)
|
|
519
|
-
.execute()
|
|
520
|
-
|
|
521
|
-
const existingStepNames = new Set(result.map((r) => r.stepName))
|
|
522
|
-
return nodeIds.filter((id) => !existingStepNames.has(id))
|
|
523
|
-
}
|
|
524
|
-
|
|
525
508
|
async getStepInstances(
|
|
526
509
|
runId: string
|
|
527
510
|
): Promise<
|
|
@@ -32,6 +32,15 @@ export const credentialSchema: PikkuSchema = {
|
|
|
32
32
|
sql`CREATE UNIQUE INDEX credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`
|
|
33
33
|
),
|
|
34
34
|
|
|
35
|
+
(db) =>
|
|
36
|
+
db.schema
|
|
37
|
+
.createTable('credentialKekSalts')
|
|
38
|
+
.addColumn('keyVersion', 'integer', (col) => col.primaryKey())
|
|
39
|
+
.addColumn('salt', 'varchar(64)', (col) => col.notNull())
|
|
40
|
+
.addColumn('createdAt', 'timestamp', (col) =>
|
|
41
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
42
|
+
),
|
|
43
|
+
|
|
35
44
|
(db) =>
|
|
36
45
|
db.schema
|
|
37
46
|
.createTable('credentialsAudit')
|
|
@@ -35,6 +35,20 @@ export const scopeSchema: PikkuSchema = {
|
|
|
35
35
|
.createTable('pikkuRoles')
|
|
36
36
|
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
37
37
|
.addColumn('description', 'text')
|
|
38
|
+
// Declared in code with `defineSystemRole` rather than composed by an
|
|
39
|
+
// admin. What it buys is the refusals: a system role cannot be renamed,
|
|
40
|
+
// re-scoped or deleted from the console, and a console role cannot be
|
|
41
|
+
// created with its name — two rows answering to one name would make
|
|
42
|
+
// "does Susan hold `buyer`?" depend on which one the store returned.
|
|
43
|
+
.addColumn('system', 'boolean', (col) => col.defaultTo(false).notNull())
|
|
44
|
+
// A system role whose declaration has gone: still held by whoever holds
|
|
45
|
+
// it, no longer offered for new grants, awaiting `pikku roles prune`.
|
|
46
|
+
// The same additive contract as `pikkuScopes.declared`, and for the same
|
|
47
|
+
// reason — a mid-deploy revocation is not something a code edit should
|
|
48
|
+
// be able to cause.
|
|
49
|
+
.addColumn('declared', 'boolean', (col) =>
|
|
50
|
+
col.defaultTo(true).notNull()
|
|
51
|
+
)
|
|
38
52
|
.addColumn('createdAt', 'timestamp', (col) =>
|
|
39
53
|
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
40
54
|
),
|
|
@@ -27,6 +27,15 @@ export const secretSchema: PikkuSchema = {
|
|
|
27
27
|
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
28
28
|
),
|
|
29
29
|
|
|
30
|
+
(db) =>
|
|
31
|
+
db.schema
|
|
32
|
+
.createTable('secretKekSalts')
|
|
33
|
+
.addColumn('keyVersion', 'integer', (col) => col.primaryKey())
|
|
34
|
+
.addColumn('salt', 'varchar(64)', (col) => col.notNull())
|
|
35
|
+
.addColumn('createdAt', 'timestamp', (col) =>
|
|
36
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
37
|
+
),
|
|
38
|
+
|
|
30
39
|
(db) =>
|
|
31
40
|
db.schema
|
|
32
41
|
.createTable('secretsAudit')
|