@pikku/kysely 0.13.23 → 0.13.27
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 +248 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/kysely-webhook-service.d.ts +1 -1
- package/dist/src/kysely-webhook-service.js +1 -1
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -124
- package/src/kysely-webhook-service.ts +0 -137
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -1,425 +0,0 @@
|
|
|
1
|
-
import type { ScopeService } from '@pikku/core/services'
|
|
2
|
-
import type { Role } from '@pikku/core/services'
|
|
3
|
-
import type { FlatScope } from '@pikku/core/scope'
|
|
4
|
-
import type { SystemRole } from '@pikku/core/role'
|
|
5
|
-
import {
|
|
6
|
-
SystemRoleImmutableError,
|
|
7
|
-
SystemRoleShadowedError,
|
|
8
|
-
} from '@pikku/core/errors'
|
|
9
|
-
import type { Kysely } from 'kysely'
|
|
10
|
-
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
11
|
-
import { requirePikkuSchema } from './schema/index.js'
|
|
12
|
-
import { scopeSchema } from './schema/scope.schema.js'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Resolves and administers user scopes against four self-created tables.
|
|
16
|
-
*
|
|
17
|
-
* Requires better-auth: `pikku_user_role.user_id` references its `user` table
|
|
18
|
-
* with ON DELETE CASCADE, so deleting a user takes their grants with it. That
|
|
19
|
-
* table is created by better-auth's own migrations, which `pikku db migrate`
|
|
20
|
-
* hard-fails without, so it exists before `init()` runs.
|
|
21
|
-
*
|
|
22
|
-
* Scopes are declared in code and synced here; roles are data, composed by
|
|
23
|
-
* admins at runtime. `pikku_role_scopes` FKs into `pikku_scopes`, so the
|
|
24
|
-
* database itself refuses to grant a scope that was never declared.
|
|
25
|
-
*/
|
|
26
|
-
export class KyselyScopeService implements ScopeService {
|
|
27
|
-
private initialized = false
|
|
28
|
-
|
|
29
|
-
constructor(private db: Kysely<KyselyPikkuDB>) {}
|
|
30
|
-
|
|
31
|
-
public async init(): Promise<void> {
|
|
32
|
-
if (this.initialized) return
|
|
33
|
-
await requirePikkuSchema(this.db, scopeSchema)
|
|
34
|
-
this.initialized = true
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Registers the declared scope set.
|
|
39
|
-
*
|
|
40
|
-
* Additive: rows are upserted and anything no longer declared is *marked*
|
|
41
|
-
* (`declared = false`), never deleted. Marking is non-destructive, so a
|
|
42
|
-
* rename, a rollback, or a rolling deploy where an older replica is still
|
|
43
|
-
* serving cannot silently strip a grant. `pruneScopes` is the deliberate
|
|
44
|
-
* removal path.
|
|
45
|
-
*/
|
|
46
|
-
async syncScopes(scopes: FlatScope[]): Promise<void> {
|
|
47
|
-
await this.db.transaction().execute(async (trx) => {
|
|
48
|
-
if (scopes.length > 0) {
|
|
49
|
-
await trx
|
|
50
|
-
.insertInto('pikkuScopes')
|
|
51
|
-
.values(
|
|
52
|
-
scopes.map((scope) => ({
|
|
53
|
-
name: scope.id,
|
|
54
|
-
description: scope.description ?? null,
|
|
55
|
-
declared: true,
|
|
56
|
-
}))
|
|
57
|
-
)
|
|
58
|
-
.onConflict((oc) =>
|
|
59
|
-
oc.column('name').doUpdateSet((eb) => ({
|
|
60
|
-
description: eb.ref('excluded.description'),
|
|
61
|
-
declared: true,
|
|
62
|
-
}))
|
|
63
|
-
)
|
|
64
|
-
.execute()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const markStale = trx.updateTable('pikkuScopes').set({ declared: false })
|
|
68
|
-
await (
|
|
69
|
-
scopes.length > 0
|
|
70
|
-
? markStale.where(
|
|
71
|
-
'name',
|
|
72
|
-
'not in',
|
|
73
|
-
scopes.map((s) => s.id)
|
|
74
|
-
)
|
|
75
|
-
: markStale
|
|
76
|
-
).execute()
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async resolveScopes(userId: string): Promise<string[]> {
|
|
81
|
-
const roleScopes = this.db
|
|
82
|
-
.selectFrom('pikkuUserRole')
|
|
83
|
-
.innerJoin(
|
|
84
|
-
'pikkuRoleScopes',
|
|
85
|
-
'pikkuRoleScopes.role',
|
|
86
|
-
'pikkuUserRole.role'
|
|
87
|
-
)
|
|
88
|
-
.select('pikkuRoleScopes.scope')
|
|
89
|
-
.where('pikkuUserRole.userId', '=', userId)
|
|
90
|
-
|
|
91
|
-
const directScopes = this.db
|
|
92
|
-
.selectFrom('pikkuUserScope')
|
|
93
|
-
.select('pikkuUserScope.scope')
|
|
94
|
-
.where('pikkuUserScope.userId', '=', userId)
|
|
95
|
-
|
|
96
|
-
const rows = await roleScopes.union(directScopes).execute()
|
|
97
|
-
|
|
98
|
-
return rows.map((row) => row.scope)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async listScopes(): Promise<Array<FlatScope & { declared: boolean }>> {
|
|
102
|
-
const rows = await this.db
|
|
103
|
-
.selectFrom('pikkuScopes')
|
|
104
|
-
.select(['name', 'description', 'declared'])
|
|
105
|
-
.execute()
|
|
106
|
-
|
|
107
|
-
return rows.map((row) => ({
|
|
108
|
-
id: row.name,
|
|
109
|
-
description: row.description ?? undefined,
|
|
110
|
-
declared: !!row.declared,
|
|
111
|
-
}))
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Registers the roles declared with `defineSystemRole`.
|
|
116
|
-
*
|
|
117
|
-
* Additive on the same terms as `syncScopes` — a role whose declaration has
|
|
118
|
-
* gone is marked `declared = false` rather than deleted, so a rollback or a
|
|
119
|
-
* rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
|
|
120
|
-
* outright, because that is the declaration's entire content: editing
|
|
121
|
-
* `defineSystemRole` is how you change what a role means, and the deploy is
|
|
122
|
-
* when it takes effect.
|
|
123
|
-
*/
|
|
124
|
-
async syncSystemRoles(roles: SystemRole[]): Promise<void> {
|
|
125
|
-
await this.db.transaction().execute(async (trx) => {
|
|
126
|
-
for (const role of roles) {
|
|
127
|
-
await trx
|
|
128
|
-
.insertInto('pikkuRoles')
|
|
129
|
-
.values({
|
|
130
|
-
name: role.name,
|
|
131
|
-
description: role.description ?? null,
|
|
132
|
-
system: true,
|
|
133
|
-
declared: true,
|
|
134
|
-
})
|
|
135
|
-
.onConflict((oc) =>
|
|
136
|
-
oc.column('name').doUpdateSet((eb) => ({
|
|
137
|
-
description: eb.ref('excluded.description'),
|
|
138
|
-
system: true,
|
|
139
|
-
declared: true,
|
|
140
|
-
}))
|
|
141
|
-
)
|
|
142
|
-
.execute()
|
|
143
|
-
|
|
144
|
-
await trx
|
|
145
|
-
.deleteFrom('pikkuRoleScopes')
|
|
146
|
-
.where('role', '=', role.name)
|
|
147
|
-
.execute()
|
|
148
|
-
if (role.scopes.length > 0) {
|
|
149
|
-
await trx
|
|
150
|
-
.insertInto('pikkuRoleScopes')
|
|
151
|
-
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
152
|
-
.execute()
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Only system rows are in scope here: an admin's own role is not
|
|
157
|
-
// undeclared, it was never declared, and marking it would take it out of
|
|
158
|
-
// the console for a reason nobody could act on.
|
|
159
|
-
const markStale = trx
|
|
160
|
-
.updateTable('pikkuRoles')
|
|
161
|
-
.set({ declared: false })
|
|
162
|
-
.where('system', '=', true)
|
|
163
|
-
await (
|
|
164
|
-
roles.length > 0
|
|
165
|
-
? markStale.where(
|
|
166
|
-
'name',
|
|
167
|
-
'not in',
|
|
168
|
-
roles.map((role) => role.name)
|
|
169
|
-
)
|
|
170
|
-
: markStale
|
|
171
|
-
).execute()
|
|
172
|
-
})
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async createRole(role: Role): Promise<void> {
|
|
176
|
-
if (await this.isSystemRole(role.name)) {
|
|
177
|
-
throw new SystemRoleShadowedError(role.name)
|
|
178
|
-
}
|
|
179
|
-
await this.db.transaction().execute(async (trx) => {
|
|
180
|
-
await trx
|
|
181
|
-
.insertInto('pikkuRoles')
|
|
182
|
-
.values({
|
|
183
|
-
name: role.name,
|
|
184
|
-
description: role.description ?? null,
|
|
185
|
-
system: false,
|
|
186
|
-
declared: true,
|
|
187
|
-
})
|
|
188
|
-
.execute()
|
|
189
|
-
|
|
190
|
-
if (role.scopes.length > 0) {
|
|
191
|
-
await trx
|
|
192
|
-
.insertInto('pikkuRoleScopes')
|
|
193
|
-
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
194
|
-
.execute()
|
|
195
|
-
}
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async deleteRole(name: string): Promise<void> {
|
|
200
|
-
if (await this.isSystemRole(name)) {
|
|
201
|
-
throw new SystemRoleImmutableError(name, 'delete')
|
|
202
|
-
}
|
|
203
|
-
await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute()
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async setRoleScopes(name: string, scopes: string[]): Promise<void> {
|
|
207
|
-
if (await this.isSystemRole(name)) {
|
|
208
|
-
throw new SystemRoleImmutableError(name, 're-scope')
|
|
209
|
-
}
|
|
210
|
-
await this.db.transaction().execute(async (trx) => {
|
|
211
|
-
await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute()
|
|
212
|
-
|
|
213
|
-
if (scopes.length > 0) {
|
|
214
|
-
await trx
|
|
215
|
-
.insertInto('pikkuRoleScopes')
|
|
216
|
-
.values(scopes.map((scope) => ({ role: name, scope })))
|
|
217
|
-
.execute()
|
|
218
|
-
}
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Whether the store holds this name as a system role.
|
|
224
|
-
*
|
|
225
|
-
* Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
|
|
226
|
-
* store is where a shadow would actually collide — and because a role whose
|
|
227
|
-
* declaration was deleted is still immutable until someone prunes it.
|
|
228
|
-
*/
|
|
229
|
-
private async isSystemRole(name: string): Promise<boolean> {
|
|
230
|
-
const row = await this.db
|
|
231
|
-
.selectFrom('pikkuRoles')
|
|
232
|
-
.select('system')
|
|
233
|
-
.where('name', '=', name)
|
|
234
|
-
.executeTakeFirst()
|
|
235
|
-
return !!row?.system
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async listRoles(): Promise<Role[]> {
|
|
239
|
-
const roles = await this.db
|
|
240
|
-
.selectFrom('pikkuRoles')
|
|
241
|
-
.select(['name', 'description', 'system', 'declared'])
|
|
242
|
-
.execute()
|
|
243
|
-
|
|
244
|
-
const scopeRows = await this.db
|
|
245
|
-
.selectFrom('pikkuRoleScopes')
|
|
246
|
-
.select(['role', 'scope'])
|
|
247
|
-
.execute()
|
|
248
|
-
|
|
249
|
-
const byRole = new Map<string, string[]>()
|
|
250
|
-
for (const row of scopeRows) {
|
|
251
|
-
const existing = byRole.get(row.role)
|
|
252
|
-
if (existing) {
|
|
253
|
-
existing.push(row.scope)
|
|
254
|
-
} else {
|
|
255
|
-
byRole.set(row.role, [row.scope])
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return roles.map((role) => ({
|
|
260
|
-
name: role.name,
|
|
261
|
-
description: role.description ?? undefined,
|
|
262
|
-
scopes: byRole.get(role.name) ?? [],
|
|
263
|
-
system: !!role.system,
|
|
264
|
-
declared: !!role.declared,
|
|
265
|
-
}))
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async addUserToRole(
|
|
269
|
-
userId: string,
|
|
270
|
-
role: string,
|
|
271
|
-
grantedBy?: string
|
|
272
|
-
): Promise<void> {
|
|
273
|
-
await this.db
|
|
274
|
-
.insertInto('pikkuUserRole')
|
|
275
|
-
.values({ userId, role, grantedBy: grantedBy ?? null })
|
|
276
|
-
.onConflict((oc) => oc.columns(['userId', 'role']).doNothing())
|
|
277
|
-
.execute()
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async removeUserFromRole(userId: string, role: string): Promise<void> {
|
|
281
|
-
await this.db
|
|
282
|
-
.deleteFrom('pikkuUserRole')
|
|
283
|
-
.where('userId', '=', userId)
|
|
284
|
-
.where('role', '=', role)
|
|
285
|
-
.execute()
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async listUserRoles(userId: string): Promise<string[]> {
|
|
289
|
-
const rows = await this.db
|
|
290
|
-
.selectFrom('pikkuUserRole')
|
|
291
|
-
.select('role')
|
|
292
|
-
.where('userId', '=', userId)
|
|
293
|
-
.execute()
|
|
294
|
-
|
|
295
|
-
return rows.map((row) => row.role)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async addScopeToUser(
|
|
299
|
-
userId: string,
|
|
300
|
-
scope: string,
|
|
301
|
-
grantedBy?: string
|
|
302
|
-
): Promise<void> {
|
|
303
|
-
await this.db
|
|
304
|
-
.insertInto('pikkuUserScope')
|
|
305
|
-
.values({ userId, scope, grantedBy: grantedBy ?? null })
|
|
306
|
-
.onConflict((oc) => oc.columns(['userId', 'scope']).doNothing())
|
|
307
|
-
.execute()
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async removeScopeFromUser(userId: string, scope: string): Promise<void> {
|
|
311
|
-
await this.db
|
|
312
|
-
.deleteFrom('pikkuUserScope')
|
|
313
|
-
.where('userId', '=', userId)
|
|
314
|
-
.where('scope', '=', scope)
|
|
315
|
-
.execute()
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async listUserScopes(userId: string): Promise<string[]> {
|
|
319
|
-
const rows = await this.db
|
|
320
|
-
.selectFrom('pikkuUserScope')
|
|
321
|
-
.select('scope')
|
|
322
|
-
.where('userId', '=', userId)
|
|
323
|
-
.execute()
|
|
324
|
-
|
|
325
|
-
return rows.map((row) => row.scope)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Scopes marked undeclared by the last sync, with the roles that would lose
|
|
330
|
-
* them. Powers `pikku scopes audit`.
|
|
331
|
-
*/
|
|
332
|
-
async findStaleScopes(): Promise<Array<{ scope: string; roles: string[] }>> {
|
|
333
|
-
const stale = await this.db
|
|
334
|
-
.selectFrom('pikkuScopes')
|
|
335
|
-
.leftJoin('pikkuRoleScopes', 'pikkuRoleScopes.scope', 'pikkuScopes.name')
|
|
336
|
-
.select(['pikkuScopes.name as scope', 'pikkuRoleScopes.role as role'])
|
|
337
|
-
.where('pikkuScopes.declared', '=', false)
|
|
338
|
-
.execute()
|
|
339
|
-
|
|
340
|
-
const byScope = new Map<string, string[]>()
|
|
341
|
-
for (const row of stale) {
|
|
342
|
-
const roles = byScope.get(row.scope) ?? []
|
|
343
|
-
if (row.role) {
|
|
344
|
-
roles.push(row.role)
|
|
345
|
-
}
|
|
346
|
-
byScope.set(row.scope, roles)
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return [...byScope.entries()].map(([scope, roles]) => ({ scope, roles }))
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Removes undeclared scopes, cascading them out of every role that holds
|
|
354
|
-
* them. This revokes access, so it is never run implicitly.
|
|
355
|
-
*/
|
|
356
|
-
async pruneScopes(): Promise<string[]> {
|
|
357
|
-
const stale = await this.findStaleScopes()
|
|
358
|
-
if (stale.length === 0) {
|
|
359
|
-
return []
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const names = stale.map((s) => s.scope)
|
|
363
|
-
const deleted = await this.db
|
|
364
|
-
.deleteFrom('pikkuScopes')
|
|
365
|
-
.where('name', 'in', names)
|
|
366
|
-
.where('declared', '=', false)
|
|
367
|
-
.returning('name')
|
|
368
|
-
.execute()
|
|
369
|
-
|
|
370
|
-
return deleted.map((r) => r.name)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* System roles the last sync found no declaration for, with how many people
|
|
375
|
-
* still hold each. Powers `pikku roles audit`.
|
|
376
|
-
*
|
|
377
|
-
* The count is the whole point: "this role is gone from the code" and "this
|
|
378
|
-
* role is gone from the code and 40 people are standing on it" call for
|
|
379
|
-
* different decisions, and the number is what makes pruning a choice rather
|
|
380
|
-
* than a formality.
|
|
381
|
-
*/
|
|
382
|
-
async findStaleSystemRoles(): Promise<
|
|
383
|
-
Array<{ role: string; users: number }>
|
|
384
|
-
> {
|
|
385
|
-
const stale = await this.db
|
|
386
|
-
.selectFrom('pikkuRoles')
|
|
387
|
-
.leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
|
|
388
|
-
.select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
|
|
389
|
-
.where('pikkuRoles.system', '=', true)
|
|
390
|
-
.where('pikkuRoles.declared', '=', false)
|
|
391
|
-
.execute()
|
|
392
|
-
|
|
393
|
-
const byRole = new Map<string, number>()
|
|
394
|
-
for (const row of stale) {
|
|
395
|
-
byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0))
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return [...byRole.entries()].map(([role, users]) => ({ role, users }))
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Removes undeclared system roles, cascading them out of every user grant
|
|
403
|
-
* that holds them. This revokes access, so it is never run implicitly.
|
|
404
|
-
*/
|
|
405
|
-
async pruneSystemRoles(): Promise<string[]> {
|
|
406
|
-
const stale = await this.findStaleSystemRoles()
|
|
407
|
-
if (stale.length === 0) {
|
|
408
|
-
return []
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const deleted = await this.db
|
|
412
|
-
.deleteFrom('pikkuRoles')
|
|
413
|
-
.where(
|
|
414
|
-
'name',
|
|
415
|
-
'in',
|
|
416
|
-
stale.map((s) => s.role)
|
|
417
|
-
)
|
|
418
|
-
.where('system', '=', true)
|
|
419
|
-
.where('declared', '=', false)
|
|
420
|
-
.returning('name')
|
|
421
|
-
.execute()
|
|
422
|
-
|
|
423
|
-
return deleted.map((r) => r.name)
|
|
424
|
-
}
|
|
425
|
-
}
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
deriveKEK,
|
|
3
|
-
envelopeDecrypt,
|
|
4
|
-
envelopeEncrypt,
|
|
5
|
-
envelopeRewrap,
|
|
6
|
-
generateKEKSalt,
|
|
7
|
-
} from '@pikku/core/crypto-utils'
|
|
8
|
-
import { createSecretValue, isSecretValue } from '@pikku/core/classification'
|
|
9
|
-
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
10
|
-
import type { SecretValue } from '@pikku/core/classification'
|
|
11
|
-
import type { Kysely } from 'kysely'
|
|
12
|
-
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
13
|
-
import { requirePikkuSchema } from './schema/index.js'
|
|
14
|
-
import { secretSchema } from './schema/secret.schema.js'
|
|
15
|
-
|
|
16
|
-
export interface KyselySecretServiceConfig {
|
|
17
|
-
key: string
|
|
18
|
-
keyVersion?: number
|
|
19
|
-
previousKey?: string
|
|
20
|
-
audit?: boolean
|
|
21
|
-
auditReads?: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export class KyselySecretService implements SecretService {
|
|
25
|
-
private initialized = false
|
|
26
|
-
private kekSalts = new Map<number, string>()
|
|
27
|
-
private keks = new Map<number, CryptoKey>()
|
|
28
|
-
private key: string
|
|
29
|
-
private keyVersion: number
|
|
30
|
-
private previousKey?: string
|
|
31
|
-
private audit: boolean
|
|
32
|
-
private auditReads: boolean
|
|
33
|
-
|
|
34
|
-
constructor(
|
|
35
|
-
private db: Kysely<KyselyPikkuDB>,
|
|
36
|
-
config: KyselySecretServiceConfig
|
|
37
|
-
) {
|
|
38
|
-
this.key = config.key
|
|
39
|
-
this.keyVersion = config.keyVersion ?? 1
|
|
40
|
-
this.previousKey = config.previousKey
|
|
41
|
-
this.audit = config.audit ?? false
|
|
42
|
-
this.auditReads = config.auditReads ?? false
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
public async init(): Promise<void> {
|
|
46
|
-
if (this.initialized) return
|
|
47
|
-
await requirePikkuSchema(this.db, secretSchema)
|
|
48
|
-
this.initialized = true
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private async logAudit(
|
|
52
|
-
secretKey: string,
|
|
53
|
-
action: 'read' | 'write' | 'delete' | 'rotate'
|
|
54
|
-
): Promise<void> {
|
|
55
|
-
if (!this.audit) return
|
|
56
|
-
if (action === 'read' && !this.auditReads) return
|
|
57
|
-
|
|
58
|
-
await this.db
|
|
59
|
-
.insertInto('secretsAudit')
|
|
60
|
-
.values({
|
|
61
|
-
id: crypto.randomUUID(),
|
|
62
|
-
secretKey: secretKey,
|
|
63
|
-
action,
|
|
64
|
-
performedAt: new Date().toISOString() as unknown as Date,
|
|
65
|
-
})
|
|
66
|
-
.execute()
|
|
67
|
-
}
|
|
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('secretKekSalts')
|
|
75
|
-
.select('salt')
|
|
76
|
-
.where('keyVersion', '=', version)
|
|
77
|
-
.executeTakeFirst()
|
|
78
|
-
|
|
79
|
-
let salt = existing?.salt
|
|
80
|
-
if (!salt) {
|
|
81
|
-
await this.db
|
|
82
|
-
.insertInto('secretKekSalts')
|
|
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('secretKekSalts')
|
|
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
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
118
|
-
const row = await this.db
|
|
119
|
-
.selectFrom('secrets')
|
|
120
|
-
.select(['ciphertext', 'wrappedDek', 'keyVersion'])
|
|
121
|
-
.where('key', '=', key)
|
|
122
|
-
.executeTakeFirst()
|
|
123
|
-
|
|
124
|
-
if (!row) throw new Error(`Requested secret not found: ${key}`)
|
|
125
|
-
|
|
126
|
-
const kek = await this.getKEK(row.keyVersion)
|
|
127
|
-
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
128
|
-
await this.logAudit(key, 'read')
|
|
129
|
-
return createSecretValue(result)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async hasSecret(key: string): Promise<boolean> {
|
|
133
|
-
const row = await this.db
|
|
134
|
-
.selectFrom('secrets')
|
|
135
|
-
.select('key')
|
|
136
|
-
.where('key', '=', key)
|
|
137
|
-
.executeTakeFirst()
|
|
138
|
-
return !!row
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async setSecret(key: string, value: unknown): Promise<void> {
|
|
142
|
-
// Encrypting the wrapper would store its redaction, not the secret —
|
|
143
|
-
// writing a secret back to the vault is exactly what this method is for.
|
|
144
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
145
|
-
await this.getKEK(this.keyVersion),
|
|
146
|
-
isSecretValue(value) ? value.reveal() : value
|
|
147
|
-
)
|
|
148
|
-
const now = new Date().toISOString()
|
|
149
|
-
|
|
150
|
-
await this.db
|
|
151
|
-
.insertInto('secrets')
|
|
152
|
-
.values({
|
|
153
|
-
key,
|
|
154
|
-
ciphertext,
|
|
155
|
-
wrappedDek: wrappedDEK,
|
|
156
|
-
keyVersion: this.keyVersion,
|
|
157
|
-
createdAt: now as unknown as Date,
|
|
158
|
-
updatedAt: now as unknown as Date,
|
|
159
|
-
})
|
|
160
|
-
.onConflict((oc) =>
|
|
161
|
-
oc.column('key').doUpdateSet({
|
|
162
|
-
ciphertext,
|
|
163
|
-
wrappedDek: wrappedDEK,
|
|
164
|
-
keyVersion: this.keyVersion,
|
|
165
|
-
updatedAt: now as unknown as Date,
|
|
166
|
-
})
|
|
167
|
-
)
|
|
168
|
-
.execute()
|
|
169
|
-
|
|
170
|
-
await this.logAudit(key, 'write')
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async deleteSecret(key: string): Promise<void> {
|
|
174
|
-
await this.db.deleteFrom('secrets').where('key', '=', key).execute()
|
|
175
|
-
await this.logAudit(key, 'delete')
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
179
|
-
keys: (keyof T & string)[]
|
|
180
|
-
): Promise<Partial<SecretValues<T>>> {
|
|
181
|
-
const rows = await this.db
|
|
182
|
-
.selectFrom('secrets')
|
|
183
|
-
.select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
|
|
184
|
-
.where('key', 'in', keys)
|
|
185
|
-
.execute()
|
|
186
|
-
|
|
187
|
-
const out: Record<string, unknown> = {}
|
|
188
|
-
for (const row of rows) {
|
|
189
|
-
try {
|
|
190
|
-
const kek = await this.getKEK(row.keyVersion)
|
|
191
|
-
out[row.key] = createSecretValue(
|
|
192
|
-
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
193
|
-
)
|
|
194
|
-
} catch (cause) {
|
|
195
|
-
// A stored secret that fails to decrypt is never expected — it means the
|
|
196
|
-
// KEK does not match what the value was wrapped under (mismatched
|
|
197
|
-
// PIKKU_SECRET_KEK, or a missing previousKey for an older key_version).
|
|
198
|
-
// Swallowing it produces an undefined secret and an opaque downstream
|
|
199
|
-
// failure (e.g. an auth "server configuration" 500). Fail loud, naming
|
|
200
|
-
// the key and key_version so the misconfiguration is diagnosable.
|
|
201
|
-
throw new Error(
|
|
202
|
-
`Failed to decrypt secret "${row.key}" (key_version ${row.keyVersion}): ` +
|
|
203
|
-
`the configured KEK does not match the key it was wrapped under`,
|
|
204
|
-
{ cause }
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return out as Partial<SecretValues<T>>
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async rotateKEK(): Promise<number> {
|
|
212
|
-
if (!this.previousKey) {
|
|
213
|
-
throw new Error('No previousKey configured — nothing to rotate from')
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const rows = await this.db
|
|
217
|
-
.selectFrom('secrets')
|
|
218
|
-
.select(['key', 'wrappedDek'])
|
|
219
|
-
.where('keyVersion', '<', this.keyVersion)
|
|
220
|
-
.execute()
|
|
221
|
-
|
|
222
|
-
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
223
|
-
const newKEK = await this.getKEK(this.keyVersion)
|
|
224
|
-
|
|
225
|
-
for (const row of rows) {
|
|
226
|
-
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
227
|
-
await this.db
|
|
228
|
-
.updateTable('secrets')
|
|
229
|
-
.set({
|
|
230
|
-
wrappedDek: newWrappedDEK,
|
|
231
|
-
keyVersion: this.keyVersion,
|
|
232
|
-
updatedAt: new Date().toISOString() as unknown as Date,
|
|
233
|
-
})
|
|
234
|
-
.where('key', '=', row.key)
|
|
235
|
-
.execute()
|
|
236
|
-
|
|
237
|
-
await this.logAudit(row.key, 'rotate')
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return rows.length
|
|
241
|
-
}
|
|
242
|
-
}
|