@pikku/kysely 0.12.5 → 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 +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-credential-service.d.ts +30 -0
- package/dist/src/kysely-credential-service.js +188 -0
- package/dist/src/kysely-tables.d.ts +18 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/kysely-credential-service.ts +252 -0
- package/src/kysely-services.test.ts +58 -0
- package/src/kysely-tables.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.12.5
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
package/dist/src/index.d.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
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
|
@@ -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', () => {
|
package/src/kysely-tables.ts
CHANGED
|
@@ -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
|
}
|