@pikku/kysely 0.12.1 → 0.12.3
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 +26 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -1
- package/dist/src/kysely-agent-run-service.js +2 -0
- package/dist/src/kysely-ai-storage-service.d.ts +1 -0
- package/dist/src/kysely-ai-storage-service.js +75 -45
- package/dist/src/kysely-deployment-service.d.ts +2 -1
- package/dist/src/kysely-deployment-service.js +18 -6
- package/dist/src/kysely-secret-service.d.ts +29 -0
- package/dist/src/kysely-secret-service.js +142 -0
- package/dist/src/kysely-tables.d.ts +27 -8
- package/dist/src/kysely-workflow-run-service.d.ts +5 -0
- package/dist/src/kysely-workflow-run-service.js +16 -0
- package/dist/src/kysely-workflow-service.d.ts +11 -5
- package/dist/src/kysely-workflow-service.js +18 -22
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -6
- package/src/index.ts +2 -2
- package/src/kysely-agent-run-service.ts +2 -0
- package/src/kysely-ai-storage-service.ts +96 -55
- package/src/kysely-deployment-service.ts +28 -13
- package/src/kysely-secret-service.ts +197 -0
- package/src/kysely-services.test.ts +89 -698
- package/src/kysely-tables.ts +33 -8
- package/src/kysely-workflow-run-service.ts +20 -1
- package/src/kysely-workflow-service.ts +30 -24
- package/dist/src/pikku-kysely.d.ts +0 -13
- package/dist/src/pikku-kysely.js +0 -58
- package/src/pikku-kysely.ts +0 -67
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { SecretService } from '@pikku/core/services'
|
|
2
|
+
import type { Kysely } from 'kysely'
|
|
3
|
+
import { sql } from 'kysely'
|
|
4
|
+
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
5
|
+
import {
|
|
6
|
+
envelopeEncrypt,
|
|
7
|
+
envelopeDecrypt,
|
|
8
|
+
envelopeRewrap,
|
|
9
|
+
} from '@pikku/core/crypto-utils'
|
|
10
|
+
|
|
11
|
+
export interface KyselySecretServiceConfig {
|
|
12
|
+
key: string
|
|
13
|
+
keyVersion?: number
|
|
14
|
+
previousKey?: string
|
|
15
|
+
audit?: boolean
|
|
16
|
+
auditReads?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class KyselySecretService implements SecretService {
|
|
20
|
+
private initialized = false
|
|
21
|
+
private key: string
|
|
22
|
+
private keyVersion: number
|
|
23
|
+
private previousKey?: string
|
|
24
|
+
private audit: boolean
|
|
25
|
+
private auditReads: boolean
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private db: Kysely<KyselyPikkuDB>,
|
|
29
|
+
config: KyselySecretServiceConfig
|
|
30
|
+
) {
|
|
31
|
+
this.key = config.key
|
|
32
|
+
this.keyVersion = config.keyVersion ?? 1
|
|
33
|
+
this.previousKey = config.previousKey
|
|
34
|
+
this.audit = config.audit ?? false
|
|
35
|
+
this.auditReads = config.auditReads ?? false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async init(): Promise<void> {
|
|
39
|
+
if (this.initialized) return
|
|
40
|
+
|
|
41
|
+
await this.db.schema
|
|
42
|
+
.createTable('secrets')
|
|
43
|
+
.ifNotExists()
|
|
44
|
+
.addColumn('key', 'varchar(255)', (col) => col.primaryKey())
|
|
45
|
+
.addColumn('ciphertext', 'text', (col) => col.notNull())
|
|
46
|
+
.addColumn('wrapped_dek', 'text', (col) => col.notNull())
|
|
47
|
+
.addColumn('key_version', 'integer', (col) => col.notNull())
|
|
48
|
+
.addColumn('created_at', 'timestamp', (col) =>
|
|
49
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
50
|
+
)
|
|
51
|
+
.addColumn('updated_at', 'timestamp', (col) =>
|
|
52
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
53
|
+
)
|
|
54
|
+
.execute()
|
|
55
|
+
|
|
56
|
+
if (this.audit) {
|
|
57
|
+
await this.db.schema
|
|
58
|
+
.createTable('secrets_audit')
|
|
59
|
+
.ifNotExists()
|
|
60
|
+
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
61
|
+
.addColumn('secret_key', 'varchar(255)', (col) => col.notNull())
|
|
62
|
+
.addColumn('action', 'varchar(20)', (col) => col.notNull())
|
|
63
|
+
.addColumn('performed_at', 'timestamp', (col) =>
|
|
64
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
65
|
+
)
|
|
66
|
+
.execute()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.initialized = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async logAudit(
|
|
73
|
+
secretKey: string,
|
|
74
|
+
action: 'read' | 'write' | 'delete' | 'rotate'
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (!this.audit) return
|
|
77
|
+
if (action === 'read' && !this.auditReads) return
|
|
78
|
+
|
|
79
|
+
await this.db
|
|
80
|
+
.insertInto('secrets_audit')
|
|
81
|
+
.values({
|
|
82
|
+
id: crypto.randomUUID(),
|
|
83
|
+
secret_key: secretKey,
|
|
84
|
+
action,
|
|
85
|
+
performed_at: new Date().toISOString() as any,
|
|
86
|
+
})
|
|
87
|
+
.execute()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private getKEK(version: number): string {
|
|
91
|
+
if (version === this.keyVersion) return this.key
|
|
92
|
+
if (this.previousKey) return this.previousKey
|
|
93
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getSecret(key: string): Promise<string> {
|
|
97
|
+
const row = await this.db
|
|
98
|
+
.selectFrom('secrets')
|
|
99
|
+
.select(['ciphertext', 'wrapped_dek', 'key_version'])
|
|
100
|
+
.where('key', '=', key)
|
|
101
|
+
.executeTakeFirst()
|
|
102
|
+
|
|
103
|
+
if (!row) throw new Error(`Secret not found: ${key}`)
|
|
104
|
+
|
|
105
|
+
const kek = this.getKEK(row.key_version)
|
|
106
|
+
const result = await envelopeDecrypt<string>(
|
|
107
|
+
kek,
|
|
108
|
+
row.ciphertext,
|
|
109
|
+
row.wrapped_dek
|
|
110
|
+
)
|
|
111
|
+
await this.logAudit(key, 'read')
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getSecretJSON<R = {}>(key: string): Promise<R> {
|
|
116
|
+
const raw = await this.getSecret(key)
|
|
117
|
+
return JSON.parse(raw) as R
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async hasSecret(key: string): Promise<boolean> {
|
|
121
|
+
const row = await this.db
|
|
122
|
+
.selectFrom('secrets')
|
|
123
|
+
.select('key')
|
|
124
|
+
.where('key', '=', key)
|
|
125
|
+
.executeTakeFirst()
|
|
126
|
+
return !!row
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async setSecretJSON(key: string, value: unknown): Promise<void> {
|
|
130
|
+
const plaintext = JSON.stringify(value)
|
|
131
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
132
|
+
this.key,
|
|
133
|
+
plaintext
|
|
134
|
+
)
|
|
135
|
+
const now = new Date().toISOString()
|
|
136
|
+
|
|
137
|
+
await this.db
|
|
138
|
+
.insertInto('secrets')
|
|
139
|
+
.values({
|
|
140
|
+
key,
|
|
141
|
+
ciphertext,
|
|
142
|
+
wrapped_dek: wrappedDEK,
|
|
143
|
+
key_version: this.keyVersion,
|
|
144
|
+
created_at: now as any,
|
|
145
|
+
updated_at: now as any,
|
|
146
|
+
})
|
|
147
|
+
.onConflict((oc) =>
|
|
148
|
+
oc.column('key').doUpdateSet({
|
|
149
|
+
ciphertext,
|
|
150
|
+
wrapped_dek: wrappedDEK,
|
|
151
|
+
key_version: this.keyVersion,
|
|
152
|
+
updated_at: now as any,
|
|
153
|
+
})
|
|
154
|
+
)
|
|
155
|
+
.execute()
|
|
156
|
+
|
|
157
|
+
await this.logAudit(key, 'write')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async deleteSecret(key: string): Promise<void> {
|
|
161
|
+
await this.db.deleteFrom('secrets').where('key', '=', key).execute()
|
|
162
|
+
await this.logAudit(key, 'delete')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async rotateKEK(): Promise<number> {
|
|
166
|
+
if (!this.previousKey) {
|
|
167
|
+
throw new Error('No previousKey configured — nothing to rotate from')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rows = await this.db
|
|
171
|
+
.selectFrom('secrets')
|
|
172
|
+
.select(['key', 'wrapped_dek'])
|
|
173
|
+
.where('key_version', '<', this.keyVersion)
|
|
174
|
+
.execute()
|
|
175
|
+
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
const newWrappedDEK = await envelopeRewrap(
|
|
178
|
+
this.previousKey,
|
|
179
|
+
this.key,
|
|
180
|
+
row.wrapped_dek
|
|
181
|
+
)
|
|
182
|
+
await this.db
|
|
183
|
+
.updateTable('secrets')
|
|
184
|
+
.set({
|
|
185
|
+
wrapped_dek: newWrappedDEK,
|
|
186
|
+
key_version: this.keyVersion,
|
|
187
|
+
updated_at: new Date().toISOString() as any,
|
|
188
|
+
})
|
|
189
|
+
.where('key', '=', row.key)
|
|
190
|
+
.execute()
|
|
191
|
+
|
|
192
|
+
await this.logAudit(row.key, 'rotate')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return rows.length
|
|
196
|
+
}
|
|
197
|
+
}
|