@pikku/kysely 0.12.4 → 0.12.6

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  ## 0.12.0
2
2
 
3
+ ## 0.12.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 0f59432: Add per-user credential system with CredentialService, OAuth2 route handlers, and KyselyCredentialService with envelope encryption
8
+ - Updated dependencies [0f59432]
9
+ - Updated dependencies [52b64d1]
10
+ - @pikku/core@0.12.10
11
+
12
+ ## 0.12.5
13
+
14
+ ### Patch Changes
15
+
16
+ - 87433f0: Remove secret key names from error messages to prevent information disclosure.
17
+ - Updated dependencies [e412b4d]
18
+ - Updated dependencies [53dc8c8]
19
+ - Updated dependencies [0a1cc51]
20
+ - Updated dependencies [0a1cc51]
21
+ - Updated dependencies [0a1cc51]
22
+ - Updated dependencies [0a1cc51]
23
+ - Updated dependencies [0a1cc51]
24
+ - Updated dependencies [0a1cc51]
25
+ - Updated dependencies [0a1cc51]
26
+ - Updated dependencies [0a1cc51]
27
+ - Updated dependencies [0a1cc51]
28
+ - Updated dependencies [8b9b2e9]
29
+ - Updated dependencies [8b9b2e9]
30
+ - Updated dependencies [b973d44]
31
+ - Updated dependencies [8b9b2e9]
32
+ - Updated dependencies [8b9b2e9]
33
+ - @pikku/core@0.12.9
34
+
3
35
  ## 0.12.4
4
36
 
5
37
  ### Patch Changes
@@ -7,6 +7,8 @@ export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
7
7
  export { KyselyAgentRunService } from './kysely-agent-run-service.js';
8
8
  export { KyselySecretService } from './kysely-secret-service.js';
9
9
  export type { KyselySecretServiceConfig } from './kysely-secret-service.js';
10
+ export { KyselyCredentialService } from './kysely-credential-service.js';
11
+ export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js';
10
12
  export type { KyselyPikkuDB } from './kysely-tables.js';
11
13
  export type { WorkflowRunService } from '@pikku/core/workflow';
12
14
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
package/dist/src/index.js CHANGED
@@ -6,3 +6,4 @@ export { KyselyDeploymentService } from './kysely-deployment-service.js';
6
6
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
7
7
  export { KyselyAgentRunService } from './kysely-agent-run-service.js';
8
8
  export { KyselySecretService } from './kysely-secret-service.js';
9
+ export { KyselyCredentialService } from './kysely-credential-service.js';
@@ -0,0 +1,30 @@
1
+ import type { CredentialService } from '@pikku/core/services';
2
+ import type { Kysely } from 'kysely';
3
+ import type { KyselyPikkuDB } from './kysely-tables.js';
4
+ export interface KyselyCredentialServiceConfig {
5
+ key: string;
6
+ keyVersion?: number;
7
+ previousKey?: string;
8
+ audit?: boolean;
9
+ auditReads?: boolean;
10
+ }
11
+ export declare class KyselyCredentialService implements CredentialService {
12
+ private db;
13
+ private initialized;
14
+ private key;
15
+ private keyVersion;
16
+ private previousKey?;
17
+ private audit;
18
+ private auditReads;
19
+ constructor(db: Kysely<KyselyPikkuDB>, config: KyselyCredentialServiceConfig);
20
+ init(): Promise<void>;
21
+ private logAudit;
22
+ private getKEK;
23
+ private whereUserId;
24
+ get<T = unknown>(name: string, userId?: string): Promise<T | null>;
25
+ set(name: string, value: unknown, userId?: string): Promise<void>;
26
+ delete(name: string, userId?: string): Promise<void>;
27
+ has(name: string, userId?: string): Promise<boolean>;
28
+ getAll(userId: string): Promise<Record<string, unknown>>;
29
+ rotateKEK(): Promise<number>;
30
+ }
@@ -0,0 +1,188 @@
1
+ import { sql } from 'kysely';
2
+ import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
3
+ export class KyselyCredentialService {
4
+ db;
5
+ initialized = false;
6
+ key;
7
+ keyVersion;
8
+ previousKey;
9
+ audit;
10
+ auditReads;
11
+ constructor(db, config) {
12
+ this.db = db;
13
+ this.key = config.key;
14
+ this.keyVersion = config.keyVersion ?? 1;
15
+ this.previousKey = config.previousKey;
16
+ this.audit = config.audit ?? false;
17
+ this.auditReads = config.auditReads ?? false;
18
+ }
19
+ async init() {
20
+ if (this.initialized)
21
+ return;
22
+ await this.db.schema
23
+ .createTable('credentials')
24
+ .ifNotExists()
25
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
26
+ .addColumn('user_id', 'varchar(255)')
27
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
28
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
29
+ .addColumn('key_version', 'integer', (col) => col.notNull())
30
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
31
+ .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
32
+ .execute();
33
+ await sql `CREATE UNIQUE INDEX IF NOT EXISTS credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`.execute(this.db);
34
+ if (this.audit) {
35
+ await this.db.schema
36
+ .createTable('credentials_audit')
37
+ .ifNotExists()
38
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
39
+ .addColumn('credential_name', 'varchar(255)', (col) => col.notNull())
40
+ .addColumn('user_id', 'varchar(255)')
41
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
42
+ .addColumn('performed_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
43
+ .execute();
44
+ }
45
+ this.initialized = true;
46
+ }
47
+ async logAudit(name, userId, action) {
48
+ if (!this.audit)
49
+ return;
50
+ if (action === 'read' && !this.auditReads)
51
+ return;
52
+ await this.db
53
+ .insertInto('credentials_audit')
54
+ .values({
55
+ id: crypto.randomUUID(),
56
+ credential_name: name,
57
+ user_id: userId ?? null,
58
+ action,
59
+ performed_at: new Date().toISOString(),
60
+ })
61
+ .execute();
62
+ }
63
+ getKEK(version) {
64
+ if (version === this.keyVersion)
65
+ return this.key;
66
+ if (this.previousKey)
67
+ return this.previousKey;
68
+ throw new Error(`No KEK available for key_version ${version}`);
69
+ }
70
+ whereUserId(qb, userId) {
71
+ return userId
72
+ ? qb.where('user_id', '=', userId)
73
+ : qb.where('user_id', 'is', null);
74
+ }
75
+ async get(name, userId) {
76
+ let qb = this.db
77
+ .selectFrom('credentials')
78
+ .select(['ciphertext', 'wrapped_dek', 'key_version'])
79
+ .where('name', '=', name);
80
+ qb = this.whereUserId(qb, userId);
81
+ const row = await qb.executeTakeFirst();
82
+ if (!row)
83
+ return null;
84
+ const kek = this.getKEK(row.key_version);
85
+ const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrapped_dek);
86
+ await this.logAudit(name, userId, 'read');
87
+ try {
88
+ return JSON.parse(plaintext);
89
+ }
90
+ catch {
91
+ throw new Error(`Credential '${name}' contains invalid data`);
92
+ }
93
+ }
94
+ async set(name, value, userId) {
95
+ const plaintext = JSON.stringify(value);
96
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, plaintext);
97
+ const now = new Date().toISOString();
98
+ const exists = await this.has(name, userId);
99
+ if (exists) {
100
+ let qb = this.db
101
+ .updateTable('credentials')
102
+ .set({
103
+ ciphertext,
104
+ wrapped_dek: wrappedDEK,
105
+ key_version: this.keyVersion,
106
+ updated_at: now,
107
+ })
108
+ .where('name', '=', name);
109
+ qb = this.whereUserId(qb, userId);
110
+ await qb.execute();
111
+ }
112
+ else {
113
+ await this.db
114
+ .insertInto('credentials')
115
+ .values({
116
+ name,
117
+ user_id: userId ?? null,
118
+ ciphertext,
119
+ wrapped_dek: wrappedDEK,
120
+ key_version: this.keyVersion,
121
+ created_at: now,
122
+ updated_at: now,
123
+ })
124
+ .execute();
125
+ }
126
+ await this.logAudit(name, userId, 'write');
127
+ }
128
+ async delete(name, userId) {
129
+ let qb = this.db.deleteFrom('credentials').where('name', '=', name);
130
+ qb = this.whereUserId(qb, userId);
131
+ await qb.execute();
132
+ await this.logAudit(name, userId, 'delete');
133
+ }
134
+ async has(name, userId) {
135
+ let qb = this.db
136
+ .selectFrom('credentials')
137
+ .select('name')
138
+ .where('name', '=', name);
139
+ qb = this.whereUserId(qb, userId);
140
+ const row = await qb.executeTakeFirst();
141
+ return !!row;
142
+ }
143
+ async getAll(userId) {
144
+ const rows = await this.db
145
+ .selectFrom('credentials')
146
+ .select(['name', 'ciphertext', 'wrapped_dek', 'key_version'])
147
+ .where('user_id', '=', userId)
148
+ .execute();
149
+ const result = {};
150
+ for (const row of rows) {
151
+ const kek = this.getKEK(row.key_version);
152
+ const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrapped_dek);
153
+ try {
154
+ result[row.name] = JSON.parse(plaintext);
155
+ }
156
+ catch {
157
+ throw new Error(`Credential '${row.name}' contains invalid data`);
158
+ }
159
+ await this.logAudit(row.name, userId, 'read');
160
+ }
161
+ return result;
162
+ }
163
+ async rotateKEK() {
164
+ if (!this.previousKey) {
165
+ throw new Error('No previousKey configured — nothing to rotate from');
166
+ }
167
+ const rows = await this.db
168
+ .selectFrom('credentials')
169
+ .select(['name', 'user_id', 'wrapped_dek'])
170
+ .where('key_version', '<', this.keyVersion)
171
+ .execute();
172
+ for (const row of rows) {
173
+ const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrapped_dek);
174
+ let qb = this.db
175
+ .updateTable('credentials')
176
+ .set({
177
+ wrapped_dek: newWrappedDEK,
178
+ key_version: this.keyVersion,
179
+ updated_at: new Date().toISOString(),
180
+ })
181
+ .where('name', '=', row.name);
182
+ qb = this.whereUserId(qb, row.user_id ?? undefined);
183
+ await qb.execute();
184
+ await this.logAudit(row.name, row.user_id ?? undefined, 'rotate');
185
+ }
186
+ return rows.length;
187
+ }
188
+ }
@@ -70,7 +70,7 @@ export class KyselySecretService {
70
70
  .where('key', '=', key)
71
71
  .executeTakeFirst();
72
72
  if (!row)
73
- throw new Error(`Secret not found: ${key}`);
73
+ throw new Error('Requested secret not found');
74
74
  const kek = this.getKEK(row.key_version);
75
75
  const result = await envelopeDecrypt(kek, row.ciphertext, row.wrapped_dek);
76
76
  await this.logAudit(key, 'read');
@@ -137,6 +137,22 @@ export interface SecretsAuditTable {
137
137
  action: string;
138
138
  performed_at: Generated<Date>;
139
139
  }
140
+ export interface CredentialsTable {
141
+ name: string;
142
+ user_id: string | null;
143
+ ciphertext: string;
144
+ wrapped_dek: string;
145
+ key_version: number;
146
+ created_at: Generated<Date>;
147
+ updated_at: Generated<Date>;
148
+ }
149
+ export interface CredentialsAuditTable {
150
+ id: string;
151
+ credential_name: string;
152
+ user_id: string | null;
153
+ action: string;
154
+ performed_at: Generated<Date>;
155
+ }
140
156
  export interface KyselyPikkuDB {
141
157
  channels: ChannelsTable;
142
158
  channel_subscriptions: ChannelSubscriptionsTable;
@@ -153,4 +169,6 @@ export interface KyselyPikkuDB {
153
169
  pikku_deployment_functions: PikkuDeploymentFunctionsTable;
154
170
  secrets: SecretsTable;
155
171
  secrets_audit: SecretsAuditTable;
172
+ credentials: CredentialsTable;
173
+ credentials_audit: CredentialsAuditTable;
156
174
  }
@@ -67,6 +67,7 @@ export class KyselyWorkflowRunService {
67
67
  's.status',
68
68
  's.result',
69
69
  's.error',
70
+ 's.child_run_id',
70
71
  's.retries',
71
72
  's.retry_delay',
72
73
  's.created_at',
@@ -88,6 +89,7 @@ export class KyselyWorkflowRunService {
88
89
  status: row.status,
89
90
  result: parseJson(row.result),
90
91
  error: parseJson(row.error),
92
+ childRunId: row.child_run_id ?? undefined,
91
93
  attemptCount: Number(row.attempt_count || 1),
92
94
  retries: row.retries != null ? Number(row.retries) : undefined,
93
95
  retryDelay: row.retry_delay ?? undefined,
@@ -1 +1 @@
1
- {"root":["../src/index.ts","../src/kysely-agent-run-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-channel-store.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-secret-service.ts","../src/kysely-tables.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../bin/pikku-kysely-pure.ts"],"version":"5.9.3"}
1
+ {"root":["../src/index.ts","../src/kysely-agent-run-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-secret-service.ts","../src/kysely-tables.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../bin/pikku-kysely-pure.ts"],"version":"5.9.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.6"
23
+ "@pikku/core": "^0.12.10"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.28.12"
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export { KyselyAIStorageService } from './kysely-ai-storage-service.js'
7
7
  export { KyselyAgentRunService } from './kysely-agent-run-service.js'
8
8
  export { KyselySecretService } from './kysely-secret-service.js'
9
9
  export type { KyselySecretServiceConfig } from './kysely-secret-service.js'
10
+ export { KyselyCredentialService } from './kysely-credential-service.js'
11
+ export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js'
10
12
 
11
13
  export type { KyselyPikkuDB } from './kysely-tables.js'
12
14
  export type { WorkflowRunService } from '@pikku/core/workflow'
@@ -0,0 +1,252 @@
1
+ import type { CredentialService } 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 KyselyCredentialServiceConfig {
12
+ key: string
13
+ keyVersion?: number
14
+ previousKey?: string
15
+ audit?: boolean
16
+ auditReads?: boolean
17
+ }
18
+
19
+ export class KyselyCredentialService implements CredentialService {
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: KyselyCredentialServiceConfig
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('credentials')
43
+ .ifNotExists()
44
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
45
+ .addColumn('user_id', 'varchar(255)')
46
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
47
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
48
+ .addColumn('key_version', 'integer', (col) => col.notNull())
49
+ .addColumn('created_at', 'timestamp', (col) =>
50
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
51
+ )
52
+ .addColumn('updated_at', 'timestamp', (col) =>
53
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
54
+ )
55
+ .execute()
56
+
57
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`.execute(
58
+ this.db
59
+ )
60
+
61
+ if (this.audit) {
62
+ await this.db.schema
63
+ .createTable('credentials_audit')
64
+ .ifNotExists()
65
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
66
+ .addColumn('credential_name', 'varchar(255)', (col) => col.notNull())
67
+ .addColumn('user_id', 'varchar(255)')
68
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
69
+ .addColumn('performed_at', 'timestamp', (col) =>
70
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
71
+ )
72
+ .execute()
73
+ }
74
+
75
+ this.initialized = true
76
+ }
77
+
78
+ private async logAudit(
79
+ name: string,
80
+ userId: string | undefined,
81
+ action: 'read' | 'write' | 'delete' | 'rotate'
82
+ ): Promise<void> {
83
+ if (!this.audit) return
84
+ if (action === 'read' && !this.auditReads) return
85
+
86
+ await this.db
87
+ .insertInto('credentials_audit')
88
+ .values({
89
+ id: crypto.randomUUID(),
90
+ credential_name: name,
91
+ user_id: userId ?? null,
92
+ action,
93
+ performed_at: new Date().toISOString() as any,
94
+ })
95
+ .execute()
96
+ }
97
+
98
+ private getKEK(version: number): string {
99
+ if (version === this.keyVersion) return this.key
100
+ if (this.previousKey) return this.previousKey
101
+ throw new Error(`No KEK available for key_version ${version}`)
102
+ }
103
+
104
+ private whereUserId(qb: any, userId?: string) {
105
+ return userId
106
+ ? qb.where('user_id', '=', userId)
107
+ : qb.where('user_id', 'is', null)
108
+ }
109
+
110
+ async get<T = unknown>(name: string, userId?: string): Promise<T | null> {
111
+ let qb = this.db
112
+ .selectFrom('credentials')
113
+ .select(['ciphertext', 'wrapped_dek', 'key_version'])
114
+ .where('name', '=', name)
115
+ qb = this.whereUserId(qb, userId)
116
+
117
+ const row = await qb.executeTakeFirst()
118
+ if (!row) return null
119
+
120
+ const kek = this.getKEK(row.key_version)
121
+ const plaintext = await envelopeDecrypt<string>(
122
+ kek,
123
+ row.ciphertext,
124
+ row.wrapped_dek
125
+ )
126
+ await this.logAudit(name, userId, 'read')
127
+
128
+ try {
129
+ return JSON.parse(plaintext) as T
130
+ } catch {
131
+ throw new Error(`Credential '${name}' contains invalid data`)
132
+ }
133
+ }
134
+
135
+ async set(name: string, value: unknown, userId?: string): Promise<void> {
136
+ const plaintext = JSON.stringify(value)
137
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
138
+ this.key,
139
+ plaintext
140
+ )
141
+ const now = new Date().toISOString()
142
+ const exists = await this.has(name, userId)
143
+
144
+ if (exists) {
145
+ let qb = this.db
146
+ .updateTable('credentials')
147
+ .set({
148
+ ciphertext,
149
+ wrapped_dek: wrappedDEK,
150
+ key_version: this.keyVersion,
151
+ updated_at: now as any,
152
+ })
153
+ .where('name', '=', name)
154
+ qb = this.whereUserId(qb, userId)
155
+ await qb.execute()
156
+ } else {
157
+ await this.db
158
+ .insertInto('credentials')
159
+ .values({
160
+ name,
161
+ user_id: userId ?? null,
162
+ ciphertext,
163
+ wrapped_dek: wrappedDEK,
164
+ key_version: this.keyVersion,
165
+ created_at: now as any,
166
+ updated_at: now as any,
167
+ })
168
+ .execute()
169
+ }
170
+
171
+ await this.logAudit(name, userId, 'write')
172
+ }
173
+
174
+ async delete(name: string, userId?: string): Promise<void> {
175
+ let qb = this.db.deleteFrom('credentials').where('name', '=', name)
176
+ qb = this.whereUserId(qb, userId)
177
+ await qb.execute()
178
+
179
+ await this.logAudit(name, userId, 'delete')
180
+ }
181
+
182
+ async has(name: string, userId?: string): Promise<boolean> {
183
+ let qb = this.db
184
+ .selectFrom('credentials')
185
+ .select('name')
186
+ .where('name', '=', name)
187
+ qb = this.whereUserId(qb, userId)
188
+
189
+ const row = await qb.executeTakeFirst()
190
+ return !!row
191
+ }
192
+
193
+ async getAll(userId: string): Promise<Record<string, unknown>> {
194
+ const rows = await this.db
195
+ .selectFrom('credentials')
196
+ .select(['name', 'ciphertext', 'wrapped_dek', 'key_version'])
197
+ .where('user_id', '=', userId)
198
+ .execute()
199
+
200
+ const result: Record<string, unknown> = {}
201
+ for (const row of rows) {
202
+ const kek = this.getKEK(row.key_version)
203
+ const plaintext = await envelopeDecrypt<string>(
204
+ kek,
205
+ row.ciphertext,
206
+ row.wrapped_dek
207
+ )
208
+ try {
209
+ result[row.name] = JSON.parse(plaintext)
210
+ } catch {
211
+ throw new Error(`Credential '${row.name}' contains invalid data`)
212
+ }
213
+ await this.logAudit(row.name, userId, 'read')
214
+ }
215
+
216
+ return result
217
+ }
218
+
219
+ async rotateKEK(): Promise<number> {
220
+ if (!this.previousKey) {
221
+ throw new Error('No previousKey configured — nothing to rotate from')
222
+ }
223
+
224
+ const rows = await this.db
225
+ .selectFrom('credentials')
226
+ .select(['name', 'user_id', 'wrapped_dek'])
227
+ .where('key_version', '<', this.keyVersion)
228
+ .execute()
229
+
230
+ for (const row of rows) {
231
+ const newWrappedDEK = await envelopeRewrap(
232
+ this.previousKey,
233
+ this.key,
234
+ row.wrapped_dek
235
+ )
236
+ let qb = this.db
237
+ .updateTable('credentials')
238
+ .set({
239
+ wrapped_dek: newWrappedDEK,
240
+ key_version: this.keyVersion,
241
+ updated_at: new Date().toISOString() as any,
242
+ })
243
+ .where('name', '=', row.name)
244
+ qb = this.whereUserId(qb, row.user_id ?? undefined)
245
+ await qb.execute()
246
+
247
+ await this.logAudit(row.name, row.user_id ?? undefined, 'rotate')
248
+ }
249
+
250
+ return rows.length
251
+ }
252
+ }
@@ -100,7 +100,7 @@ export class KyselySecretService implements SecretService {
100
100
  .where('key', '=', key)
101
101
  .executeTakeFirst()
102
102
 
103
- if (!row) throw new Error(`Secret not found: ${key}`)
103
+ if (!row) throw new Error('Requested secret not found')
104
104
 
105
105
  const kek = this.getKEK(row.key_version)
106
106
  const result = await envelopeDecrypt<string>(
@@ -16,6 +16,7 @@ import { KyselyDeploymentService } from './kysely-deployment-service.js'
16
16
  import { KyselyAIStorageService } from './kysely-ai-storage-service.js'
17
17
  import { KyselyAgentRunService } from './kysely-agent-run-service.js'
18
18
  import { KyselySecretService } from './kysely-secret-service.js'
19
+ import { KyselyCredentialService } from './kysely-credential-service.js'
19
20
 
20
21
  function createSqliteDb(): Kysely<KyselyPikkuDB> {
21
22
  return new Kysely<KyselyPikkuDB>({
@@ -52,6 +53,8 @@ async function dropAllTables(db: Kysely<KyselyPikkuDB>): Promise<void> {
52
53
  'workflow_versions',
53
54
  'secrets_audit',
54
55
  'secrets',
56
+ 'credentials_audit',
57
+ 'credentials',
55
58
  ]
56
59
  for (const table of tables) {
57
60
  await db.schema.dropTable(table).ifExists().execute()
@@ -100,6 +103,11 @@ function registerTests(
100
103
  await s.init()
101
104
  return s
102
105
  },
106
+ credentialService: async (config) => {
107
+ const s = new KyselyCredentialService(getDb(), config)
108
+ await s.init()
109
+ return s
110
+ },
103
111
  },
104
112
  })
105
113
 
@@ -150,6 +158,56 @@ function registerTests(
150
158
  assert.equal(logs[0]!.action, 'write')
151
159
  })
152
160
  })
161
+
162
+ describe(`KyselyCredentialService audit [${dialectName}]`, () => {
163
+ const kek = 'test-key-encryption-key-32chars!'
164
+
165
+ test('audit logs writes, reads, and deletes', async () => {
166
+ const service = new KyselyCredentialService(getDb(), {
167
+ key: kek,
168
+ audit: true,
169
+ auditReads: true,
170
+ })
171
+ await service.init()
172
+ await service.set('audit-cred', { token: 'abc' }, 'user-1')
173
+ await service.get('audit-cred', 'user-1')
174
+ await service.delete('audit-cred', 'user-1')
175
+
176
+ const logs = await getDb()
177
+ .selectFrom('credentials_audit')
178
+ .select(['credential_name', 'user_id', 'action'])
179
+ .where('credential_name', '=', 'audit-cred')
180
+ .orderBy('performed_at', 'asc')
181
+ .execute()
182
+
183
+ assert.equal(logs.length, 3)
184
+ assert.equal(logs[0]!.action, 'write')
185
+ assert.equal(logs[0]!.user_id, 'user-1')
186
+ assert.equal(logs[1]!.action, 'read')
187
+ assert.equal(logs[2]!.action, 'delete')
188
+ })
189
+
190
+ test('audit logs global credential with null user_id', async () => {
191
+ const service = new KyselyCredentialService(getDb(), {
192
+ key: kek,
193
+ audit: true,
194
+ auditReads: true,
195
+ })
196
+ await service.init()
197
+ await service.set('global-cred', { key: 'val' })
198
+ await service.get('global-cred')
199
+
200
+ const logs = await getDb()
201
+ .selectFrom('credentials_audit')
202
+ .select(['credential_name', 'user_id', 'action'])
203
+ .where('credential_name', '=', 'global-cred')
204
+ .execute()
205
+
206
+ assert.equal(logs.length, 2)
207
+ assert.equal(logs[0]!.user_id, null)
208
+ assert.equal(logs[1]!.user_id, null)
209
+ })
210
+ })
153
211
  }
154
212
 
155
213
  describe('Kysely Services - SQLite', () => {
@@ -157,6 +157,24 @@ export interface SecretsAuditTable {
157
157
  performed_at: Generated<Date>
158
158
  }
159
159
 
160
+ export interface CredentialsTable {
161
+ name: string
162
+ user_id: string | null
163
+ ciphertext: string
164
+ wrapped_dek: string
165
+ key_version: number
166
+ created_at: Generated<Date>
167
+ updated_at: Generated<Date>
168
+ }
169
+
170
+ export interface CredentialsAuditTable {
171
+ id: string
172
+ credential_name: string
173
+ user_id: string | null
174
+ action: string
175
+ performed_at: Generated<Date>
176
+ }
177
+
160
178
  export interface KyselyPikkuDB {
161
179
  channels: ChannelsTable
162
180
  channel_subscriptions: ChannelSubscriptionsTable
@@ -173,4 +191,6 @@ export interface KyselyPikkuDB {
173
191
  pikku_deployment_functions: PikkuDeploymentFunctionsTable
174
192
  secrets: SecretsTable
175
193
  secrets_audit: SecretsAuditTable
194
+ credentials: CredentialsTable
195
+ credentials_audit: CredentialsAuditTable
176
196
  }
@@ -90,6 +90,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
90
90
  's.status',
91
91
  's.result',
92
92
  's.error',
93
+ 's.child_run_id',
93
94
  's.retries',
94
95
  's.retry_delay',
95
96
  's.created_at',
@@ -118,6 +119,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
118
119
  status: row.status as StepState['status'],
119
120
  result: parseJson(row.result),
120
121
  error: parseJson(row.error),
122
+ childRunId: row.child_run_id ?? undefined,
121
123
  attemptCount: Number(row.attempt_count || 1),
122
124
  retries: row.retries != null ? Number(row.retries) : undefined,
123
125
  retryDelay: row.retry_delay ?? undefined,