@pikku/kysely 0.13.6 → 0.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +325 -0
- package/dist/src/kysely-credential-service.d.ts +3 -0
- package/dist/src/kysely-credential-service.js +50 -11
- package/dist/src/kysely-scope-service.d.ts +38 -0
- package/dist/src/kysely-scope-service.js +125 -2
- package/dist/src/kysely-secret-service.d.ts +3 -0
- package/dist/src/kysely-secret-service.js +50 -11
- package/dist/src/kysely-tables.d.ts +18 -2
- package/dist/src/kysely-workflow-service.d.ts +0 -1
- package/dist/src/kysely-workflow-service.js +0 -12
- package/dist/src/schema/credential.schema.js +5 -0
- package/dist/src/schema/scope.schema.js +12 -0
- package/dist/src/schema/secret.schema.js +5 -0
- package/package.json +2 -2
- package/src/kysely-credential-service.ts +57 -12
- package/src/kysely-scope-service.ts +151 -2
- package/src/kysely-secret-service.ts +60 -12
- package/src/kysely-services.test.ts +123 -1
- package/src/kysely-tables.ts +21 -2
- package/src/kysely-workflow-service.ts +0 -17
- package/src/schema/credential.schema.ts +9 -0
- package/src/schema/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SystemRoleImmutableError, SystemRoleShadowedError, } from '@pikku/core/errors';
|
|
1
2
|
import { ensurePikkuSchema } from './schema/index.js';
|
|
2
3
|
import { scopeSchema } from './schema/scope.schema.js';
|
|
3
4
|
/**
|
|
@@ -79,11 +80,69 @@ export class KyselyScopeService {
|
|
|
79
80
|
declared: !!row.declared,
|
|
80
81
|
}));
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Registers the roles declared with `defineSystemRole`.
|
|
85
|
+
*
|
|
86
|
+
* Additive on the same terms as `syncScopes` — a role whose declaration has
|
|
87
|
+
* gone is marked `declared = false` rather than deleted, so a rollback or a
|
|
88
|
+
* rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
|
|
89
|
+
* outright, because that is the declaration's entire content: editing
|
|
90
|
+
* `defineSystemRole` is how you change what a role means, and the deploy is
|
|
91
|
+
* when it takes effect.
|
|
92
|
+
*/
|
|
93
|
+
async syncSystemRoles(roles) {
|
|
94
|
+
await this.db.transaction().execute(async (trx) => {
|
|
95
|
+
for (const role of roles) {
|
|
96
|
+
await trx
|
|
97
|
+
.insertInto('pikkuRoles')
|
|
98
|
+
.values({
|
|
99
|
+
name: role.name,
|
|
100
|
+
description: role.description ?? null,
|
|
101
|
+
system: true,
|
|
102
|
+
declared: true,
|
|
103
|
+
})
|
|
104
|
+
.onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
|
|
105
|
+
description: eb.ref('excluded.description'),
|
|
106
|
+
system: true,
|
|
107
|
+
declared: true,
|
|
108
|
+
})))
|
|
109
|
+
.execute();
|
|
110
|
+
await trx
|
|
111
|
+
.deleteFrom('pikkuRoleScopes')
|
|
112
|
+
.where('role', '=', role.name)
|
|
113
|
+
.execute();
|
|
114
|
+
if (role.scopes.length > 0) {
|
|
115
|
+
await trx
|
|
116
|
+
.insertInto('pikkuRoleScopes')
|
|
117
|
+
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
118
|
+
.execute();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Only system rows are in scope here: an admin's own role is not
|
|
122
|
+
// undeclared, it was never declared, and marking it would take it out of
|
|
123
|
+
// the console for a reason nobody could act on.
|
|
124
|
+
const markStale = trx
|
|
125
|
+
.updateTable('pikkuRoles')
|
|
126
|
+
.set({ declared: false })
|
|
127
|
+
.where('system', '=', true);
|
|
128
|
+
await (roles.length > 0
|
|
129
|
+
? markStale.where('name', 'not in', roles.map((role) => role.name))
|
|
130
|
+
: markStale).execute();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
82
133
|
async createRole(role) {
|
|
134
|
+
if (await this.isSystemRole(role.name)) {
|
|
135
|
+
throw new SystemRoleShadowedError(role.name);
|
|
136
|
+
}
|
|
83
137
|
await this.db.transaction().execute(async (trx) => {
|
|
84
138
|
await trx
|
|
85
139
|
.insertInto('pikkuRoles')
|
|
86
|
-
.values({
|
|
140
|
+
.values({
|
|
141
|
+
name: role.name,
|
|
142
|
+
description: role.description ?? null,
|
|
143
|
+
system: false,
|
|
144
|
+
declared: true,
|
|
145
|
+
})
|
|
87
146
|
.execute();
|
|
88
147
|
if (role.scopes.length > 0) {
|
|
89
148
|
await trx
|
|
@@ -94,9 +153,15 @@ export class KyselyScopeService {
|
|
|
94
153
|
});
|
|
95
154
|
}
|
|
96
155
|
async deleteRole(name) {
|
|
156
|
+
if (await this.isSystemRole(name)) {
|
|
157
|
+
throw new SystemRoleImmutableError(name, 'delete');
|
|
158
|
+
}
|
|
97
159
|
await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute();
|
|
98
160
|
}
|
|
99
161
|
async setRoleScopes(name, scopes) {
|
|
162
|
+
if (await this.isSystemRole(name)) {
|
|
163
|
+
throw new SystemRoleImmutableError(name, 're-scope');
|
|
164
|
+
}
|
|
100
165
|
await this.db.transaction().execute(async (trx) => {
|
|
101
166
|
await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute();
|
|
102
167
|
if (scopes.length > 0) {
|
|
@@ -107,10 +172,25 @@ export class KyselyScopeService {
|
|
|
107
172
|
}
|
|
108
173
|
});
|
|
109
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether the store holds this name as a system role.
|
|
177
|
+
*
|
|
178
|
+
* Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
|
|
179
|
+
* store is where a shadow would actually collide — and because a role whose
|
|
180
|
+
* declaration was deleted is still immutable until someone prunes it.
|
|
181
|
+
*/
|
|
182
|
+
async isSystemRole(name) {
|
|
183
|
+
const row = await this.db
|
|
184
|
+
.selectFrom('pikkuRoles')
|
|
185
|
+
.select('system')
|
|
186
|
+
.where('name', '=', name)
|
|
187
|
+
.executeTakeFirst();
|
|
188
|
+
return !!row?.system;
|
|
189
|
+
}
|
|
110
190
|
async listRoles() {
|
|
111
191
|
const roles = await this.db
|
|
112
192
|
.selectFrom('pikkuRoles')
|
|
113
|
-
.select(['name', 'description'])
|
|
193
|
+
.select(['name', 'description', 'system', 'declared'])
|
|
114
194
|
.execute();
|
|
115
195
|
const scopeRows = await this.db
|
|
116
196
|
.selectFrom('pikkuRoleScopes')
|
|
@@ -130,6 +210,8 @@ export class KyselyScopeService {
|
|
|
130
210
|
name: role.name,
|
|
131
211
|
description: role.description ?? undefined,
|
|
132
212
|
scopes: byRole.get(role.name) ?? [],
|
|
213
|
+
system: !!role.system,
|
|
214
|
+
declared: !!role.declared,
|
|
133
215
|
}));
|
|
134
216
|
}
|
|
135
217
|
async addUserToRole(userId, role, grantedBy) {
|
|
@@ -215,4 +297,45 @@ export class KyselyScopeService {
|
|
|
215
297
|
.execute();
|
|
216
298
|
return deleted.map((r) => r.name);
|
|
217
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* System roles the last sync found no declaration for, with how many people
|
|
302
|
+
* still hold each. Powers `pikku roles audit`.
|
|
303
|
+
*
|
|
304
|
+
* The count is the whole point: "this role is gone from the code" and "this
|
|
305
|
+
* role is gone from the code and 40 people are standing on it" call for
|
|
306
|
+
* different decisions, and the number is what makes pruning a choice rather
|
|
307
|
+
* than a formality.
|
|
308
|
+
*/
|
|
309
|
+
async findStaleSystemRoles() {
|
|
310
|
+
const stale = await this.db
|
|
311
|
+
.selectFrom('pikkuRoles')
|
|
312
|
+
.leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
|
|
313
|
+
.select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
|
|
314
|
+
.where('pikkuRoles.system', '=', true)
|
|
315
|
+
.where('pikkuRoles.declared', '=', false)
|
|
316
|
+
.execute();
|
|
317
|
+
const byRole = new Map();
|
|
318
|
+
for (const row of stale) {
|
|
319
|
+
byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0));
|
|
320
|
+
}
|
|
321
|
+
return [...byRole.entries()].map(([role, users]) => ({ role, users }));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Removes undeclared system roles, cascading them out of every user grant
|
|
325
|
+
* that holds them. This revokes access, so it is never run implicitly.
|
|
326
|
+
*/
|
|
327
|
+
async pruneSystemRoles() {
|
|
328
|
+
const stale = await this.findStaleSystemRoles();
|
|
329
|
+
if (stale.length === 0) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const deleted = await this.db
|
|
333
|
+
.deleteFrom('pikkuRoles')
|
|
334
|
+
.where('name', 'in', stale.map((s) => s.role))
|
|
335
|
+
.where('system', '=', true)
|
|
336
|
+
.where('declared', '=', false)
|
|
337
|
+
.returning('name')
|
|
338
|
+
.execute();
|
|
339
|
+
return deleted.map((r) => r.name);
|
|
340
|
+
}
|
|
218
341
|
}
|
|
@@ -11,6 +11,8 @@ export interface KyselySecretServiceConfig {
|
|
|
11
11
|
export declare class KyselySecretService implements SecretService {
|
|
12
12
|
private db;
|
|
13
13
|
private initialized;
|
|
14
|
+
private kekSalts;
|
|
15
|
+
private keks;
|
|
14
16
|
private key;
|
|
15
17
|
private keyVersion;
|
|
16
18
|
private previousKey?;
|
|
@@ -19,6 +21,7 @@ export declare class KyselySecretService implements SecretService {
|
|
|
19
21
|
constructor(db: Kysely<KyselyPikkuDB>, config: KyselySecretServiceConfig);
|
|
20
22
|
init(): Promise<void>;
|
|
21
23
|
private logAudit;
|
|
24
|
+
private getKEKSalt;
|
|
22
25
|
private getKEK;
|
|
23
26
|
getSecret<T = string>(key: string): Promise<T>;
|
|
24
27
|
hasSecret(key: string): Promise<boolean>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
1
|
+
import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
|
|
2
2
|
import { ensurePikkuSchema } from './schema/index.js';
|
|
3
3
|
import { secretSchema } from './schema/secret.schema.js';
|
|
4
4
|
export class KyselySecretService {
|
|
5
5
|
db;
|
|
6
6
|
initialized = false;
|
|
7
|
+
kekSalts = new Map();
|
|
8
|
+
keks = new Map();
|
|
7
9
|
key;
|
|
8
10
|
keyVersion;
|
|
9
11
|
previousKey;
|
|
@@ -38,12 +40,47 @@ export class KyselySecretService {
|
|
|
38
40
|
})
|
|
39
41
|
.execute();
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
async getKEKSalt(version) {
|
|
44
|
+
const cached = this.kekSalts.get(version);
|
|
45
|
+
if (cached)
|
|
46
|
+
return cached;
|
|
47
|
+
const existing = await this.db
|
|
48
|
+
.selectFrom('secretKekSalts')
|
|
49
|
+
.select('salt')
|
|
50
|
+
.where('keyVersion', '=', version)
|
|
51
|
+
.executeTakeFirst();
|
|
52
|
+
let salt = existing?.salt;
|
|
53
|
+
if (!salt) {
|
|
54
|
+
await this.db
|
|
55
|
+
.insertInto('secretKekSalts')
|
|
56
|
+
.values({
|
|
57
|
+
keyVersion: version,
|
|
58
|
+
salt: generateKEKSalt(),
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
})
|
|
61
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
62
|
+
.execute();
|
|
63
|
+
const row = await this.db
|
|
64
|
+
.selectFrom('secretKekSalts')
|
|
65
|
+
.select('salt')
|
|
66
|
+
.where('keyVersion', '=', version)
|
|
67
|
+
.executeTakeFirstOrThrow();
|
|
68
|
+
salt = row.salt;
|
|
69
|
+
}
|
|
70
|
+
this.kekSalts.set(version, salt);
|
|
71
|
+
return salt;
|
|
72
|
+
}
|
|
73
|
+
async getKEK(version) {
|
|
74
|
+
const cached = this.keks.get(version);
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached;
|
|
77
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey;
|
|
78
|
+
if (!passphrase) {
|
|
79
|
+
throw new Error(`No KEK available for key_version ${version}`);
|
|
80
|
+
}
|
|
81
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
|
|
82
|
+
this.keks.set(version, kek);
|
|
83
|
+
return kek;
|
|
47
84
|
}
|
|
48
85
|
async getSecret(key) {
|
|
49
86
|
const row = await this.db
|
|
@@ -53,7 +90,7 @@ export class KyselySecretService {
|
|
|
53
90
|
.executeTakeFirst();
|
|
54
91
|
if (!row)
|
|
55
92
|
throw new Error('Requested secret not found');
|
|
56
|
-
const kek = this.getKEK(row.keyVersion);
|
|
93
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
57
94
|
const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
58
95
|
await this.logAudit(key, 'read');
|
|
59
96
|
return result;
|
|
@@ -67,7 +104,7 @@ export class KyselySecretService {
|
|
|
67
104
|
return !!row;
|
|
68
105
|
}
|
|
69
106
|
async setSecret(key, value) {
|
|
70
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.
|
|
107
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), value);
|
|
71
108
|
const now = new Date().toISOString();
|
|
72
109
|
await this.db
|
|
73
110
|
.insertInto('secrets')
|
|
@@ -101,7 +138,7 @@ export class KyselySecretService {
|
|
|
101
138
|
const out = {};
|
|
102
139
|
for (const row of rows) {
|
|
103
140
|
try {
|
|
104
|
-
const kek = this.getKEK(row.keyVersion);
|
|
141
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
105
142
|
out[row.key] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
106
143
|
}
|
|
107
144
|
catch (cause) {
|
|
@@ -126,8 +163,10 @@ export class KyselySecretService {
|
|
|
126
163
|
.select(['key', 'wrappedDek'])
|
|
127
164
|
.where('keyVersion', '<', this.keyVersion)
|
|
128
165
|
.execute();
|
|
166
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1);
|
|
167
|
+
const newKEK = await this.getKEK(this.keyVersion);
|
|
129
168
|
for (const row of rows) {
|
|
130
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
169
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
|
|
131
170
|
await this.db
|
|
132
171
|
.updateTable('secrets')
|
|
133
172
|
.set({
|
|
@@ -116,7 +116,12 @@ export interface AIRunTable {
|
|
|
116
116
|
agentName: string;
|
|
117
117
|
threadId: string;
|
|
118
118
|
resourceId: string;
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* `interrupted` is distinct from `failed`: the run was stopped on purpose,
|
|
121
|
+
* usually because the user talked over it. Nothing went wrong, so it should
|
|
122
|
+
* not show up in error reporting.
|
|
123
|
+
*/
|
|
124
|
+
status: Generated<'running' | 'suspended' | 'completed' | 'failed' | 'interrupted'>;
|
|
120
125
|
errorMessage: string | null;
|
|
121
126
|
suspendReason: 'approval' | 'credential' | 'rpc-missing' | null;
|
|
122
127
|
missingRpcs: string | null;
|
|
@@ -146,6 +151,11 @@ export interface SecretsTable {
|
|
|
146
151
|
createdAt: Generated<Date>;
|
|
147
152
|
updatedAt: Generated<Date>;
|
|
148
153
|
}
|
|
154
|
+
export interface KekSaltsTable {
|
|
155
|
+
keyVersion: number;
|
|
156
|
+
salt: string;
|
|
157
|
+
createdAt: Generated<Date>;
|
|
158
|
+
}
|
|
149
159
|
export interface SecretsAuditTable {
|
|
150
160
|
id: string;
|
|
151
161
|
secretKey: string;
|
|
@@ -191,10 +201,14 @@ export interface PikkuScopesTable {
|
|
|
191
201
|
*/
|
|
192
202
|
declared: Generated<boolean>;
|
|
193
203
|
}
|
|
194
|
-
/** A role: an admin
|
|
204
|
+
/** A role: a bag of scopes, composed by an admin or declared in code. */
|
|
195
205
|
export interface PikkuRolesTable {
|
|
196
206
|
name: string;
|
|
197
207
|
description: string | null;
|
|
208
|
+
/** Declared with `defineSystemRole`, and therefore not editable here. */
|
|
209
|
+
system: Generated<boolean>;
|
|
210
|
+
/** False for a system role whose declaration has gone: held, but inert. */
|
|
211
|
+
declared: Generated<boolean>;
|
|
198
212
|
createdAt: Generated<Date>;
|
|
199
213
|
}
|
|
200
214
|
export interface PikkuRoleScopesTable {
|
|
@@ -254,8 +268,10 @@ export interface KyselyPikkuDB {
|
|
|
254
268
|
pikkuDeployments: PikkuDeploymentsTable;
|
|
255
269
|
pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable;
|
|
256
270
|
secrets: SecretsTable;
|
|
271
|
+
secretKekSalts: KekSaltsTable;
|
|
257
272
|
secretsAudit: SecretsAuditTable;
|
|
258
273
|
credentials: CredentialsTable;
|
|
274
|
+
credentialKekSalts: KekSaltsTable;
|
|
259
275
|
credentialsAudit: CredentialsAuditTable;
|
|
260
276
|
pikkuUserSessions: UserSessionsTable;
|
|
261
277
|
webhookDelivery: WebhookDeliveryTable;
|
|
@@ -52,7 +52,6 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
52
52
|
failedNodeIds: string[];
|
|
53
53
|
branchKeys: Record<string, string>;
|
|
54
54
|
}>;
|
|
55
|
-
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
56
55
|
getStepInstances(runId: string): Promise<Array<{
|
|
57
56
|
stepName: string;
|
|
58
57
|
status: StepStatus;
|
|
@@ -393,18 +393,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
393
393
|
}
|
|
394
394
|
return { completedNodeIds, failedNodeIds, branchKeys };
|
|
395
395
|
}
|
|
396
|
-
async getNodesWithoutSteps(runId, nodeIds) {
|
|
397
|
-
if (nodeIds.length === 0)
|
|
398
|
-
return [];
|
|
399
|
-
const result = await this.db
|
|
400
|
-
.selectFrom('workflowStep')
|
|
401
|
-
.select('stepName')
|
|
402
|
-
.where('workflowRunId', '=', runId)
|
|
403
|
-
.where('stepName', 'in', nodeIds)
|
|
404
|
-
.execute();
|
|
405
|
-
const existingStepNames = new Set(result.map((r) => r.stepName));
|
|
406
|
-
return nodeIds.filter((id) => !existingStepNames.has(id));
|
|
407
|
-
}
|
|
408
396
|
async getStepInstances(runId) {
|
|
409
397
|
const rows = await this.db
|
|
410
398
|
.selectFrom('workflowStep')
|
|
@@ -22,6 +22,11 @@ export const credentialSchema = {
|
|
|
22
22
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
23
23
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
24
24
|
rawStatement(sql `CREATE UNIQUE INDEX credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`),
|
|
25
|
+
(db) => db.schema
|
|
26
|
+
.createTable('credentialKekSalts')
|
|
27
|
+
.addColumn('keyVersion', 'integer', (col) => col.primaryKey())
|
|
28
|
+
.addColumn('salt', 'varchar(64)', (col) => col.notNull())
|
|
29
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
25
30
|
(db) => db.schema
|
|
26
31
|
.createTable('credentialsAudit')
|
|
27
32
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
@@ -29,6 +29,18 @@ export const scopeSchema = {
|
|
|
29
29
|
.createTable('pikkuRoles')
|
|
30
30
|
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
31
31
|
.addColumn('description', 'text')
|
|
32
|
+
// Declared in code with `defineSystemRole` rather than composed by an
|
|
33
|
+
// admin. What it buys is the refusals: a system role cannot be renamed,
|
|
34
|
+
// re-scoped or deleted from the console, and a console role cannot be
|
|
35
|
+
// created with its name — two rows answering to one name would make
|
|
36
|
+
// "does Susan hold `buyer`?" depend on which one the store returned.
|
|
37
|
+
.addColumn('system', 'boolean', (col) => col.defaultTo(false).notNull())
|
|
38
|
+
// A system role whose declaration has gone: still held by whoever holds
|
|
39
|
+
// it, no longer offered for new grants, awaiting `pikku roles prune`.
|
|
40
|
+
// The same additive contract as `pikkuScopes.declared`, and for the same
|
|
41
|
+
// reason — a mid-deploy revocation is not something a code edit should
|
|
42
|
+
// be able to cause.
|
|
43
|
+
.addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
|
|
32
44
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
33
45
|
(db) => db.schema
|
|
34
46
|
.createTable('pikkuRoleScopes')
|
|
@@ -19,6 +19,11 @@ export const secretSchema = {
|
|
|
19
19
|
.addColumn('keyVersion', 'integer', (col) => col.notNull())
|
|
20
20
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
21
21
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
22
|
+
(db) => db.schema
|
|
23
|
+
.createTable('secretKekSalts')
|
|
24
|
+
.addColumn('keyVersion', 'integer', (col) => col.primaryKey())
|
|
25
|
+
.addColumn('salt', 'varchar(64)', (col) => col.notNull())
|
|
26
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
22
27
|
(db) => db.schema
|
|
23
28
|
.createTable('secretsAudit')
|
|
24
29
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.7",
|
|
4
4
|
"description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"prepublishOnly": "yarn build"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@pikku/core": "^0.12.
|
|
24
|
+
"@pikku/core": "^0.12.74"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"kysely": "^0.29.0"
|
|
@@ -2,6 +2,8 @@ import type { CredentialService } from '@pikku/core/services'
|
|
|
2
2
|
import type { Kysely } from 'kysely'
|
|
3
3
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
4
4
|
import {
|
|
5
|
+
deriveKEK,
|
|
6
|
+
generateKEKSalt,
|
|
5
7
|
envelopeEncrypt,
|
|
6
8
|
envelopeDecrypt,
|
|
7
9
|
envelopeRewrap,
|
|
@@ -19,6 +21,8 @@ export interface KyselyCredentialServiceConfig {
|
|
|
19
21
|
|
|
20
22
|
export class KyselyCredentialService implements CredentialService {
|
|
21
23
|
private initialized = false
|
|
24
|
+
private kekSalts = new Map<number, string>()
|
|
25
|
+
private keks = new Map<number, CryptoKey>()
|
|
22
26
|
private key: string
|
|
23
27
|
private keyVersion: number
|
|
24
28
|
private previousKey?: string
|
|
@@ -62,10 +66,52 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
62
66
|
.execute()
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
if (
|
|
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('credentialKekSalts')
|
|
75
|
+
.select('salt')
|
|
76
|
+
.where('keyVersion', '=', version)
|
|
77
|
+
.executeTakeFirst()
|
|
78
|
+
|
|
79
|
+
let salt = existing?.salt
|
|
80
|
+
if (!salt) {
|
|
81
|
+
await this.db
|
|
82
|
+
.insertInto('credentialKekSalts')
|
|
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('credentialKekSalts')
|
|
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
|
|
69
115
|
}
|
|
70
116
|
|
|
71
117
|
private whereUserId(qb: any, userId?: string) {
|
|
@@ -84,7 +130,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
84
130
|
const row = await qb.executeTakeFirst()
|
|
85
131
|
if (!row) return null
|
|
86
132
|
|
|
87
|
-
const kek = this.getKEK(row.keyVersion)
|
|
133
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
88
134
|
const plaintext = await envelopeDecrypt<string>(
|
|
89
135
|
kek,
|
|
90
136
|
row.ciphertext,
|
|
@@ -102,7 +148,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
102
148
|
async set(name: string, value: unknown, userId?: string): Promise<void> {
|
|
103
149
|
const plaintext = JSON.stringify(value)
|
|
104
150
|
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
105
|
-
this.
|
|
151
|
+
await this.getKEK(this.keyVersion),
|
|
106
152
|
plaintext
|
|
107
153
|
)
|
|
108
154
|
const now = new Date().toISOString()
|
|
@@ -166,7 +212,7 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
166
212
|
|
|
167
213
|
const result: Record<string, unknown> = {}
|
|
168
214
|
for (const row of rows) {
|
|
169
|
-
const kek = this.getKEK(row.keyVersion)
|
|
215
|
+
const kek = await this.getKEK(row.keyVersion)
|
|
170
216
|
const plaintext = await envelopeDecrypt<string>(
|
|
171
217
|
kek,
|
|
172
218
|
row.ciphertext,
|
|
@@ -216,12 +262,11 @@ export class KyselyCredentialService implements CredentialService {
|
|
|
216
262
|
.where('keyVersion', '<', this.keyVersion)
|
|
217
263
|
.execute()
|
|
218
264
|
|
|
265
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
266
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
267
|
+
|
|
219
268
|
for (const row of rows) {
|
|
220
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
221
|
-
this.previousKey,
|
|
222
|
-
this.key,
|
|
223
|
-
row.wrappedDek
|
|
224
|
-
)
|
|
269
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
|
|
225
270
|
let qb = this.db
|
|
226
271
|
.updateTable('credentials')
|
|
227
272
|
.set({
|