@pikku/kysely 0.13.6 → 0.13.10
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 +505 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-audit-service.d.ts +34 -2
- package/dist/src/kysely-audit-service.js +163 -3
- 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 +7 -3
- package/dist/src/kysely-secret-service.js +55 -13
- package/dist/src/kysely-tables.d.ts +49 -2
- package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
- package/dist/src/kysely-virtual-user-run-store.js +131 -0
- package/dist/src/kysely-workflow-service.d.ts +0 -1
- package/dist/src/kysely-workflow-service.js +0 -12
- package/dist/src/schema/audit.schema.d.ts +16 -0
- package/dist/src/schema/audit.schema.js +47 -0
- package/dist/src/schema/credential.schema.d.ts +3 -2
- package/dist/src/schema/credential.schema.js +9 -3
- package/dist/src/schema/index.d.ts +17 -4
- package/dist/src/schema/index.js +40 -6
- package/dist/src/schema/pikku-schema.types.d.ts +41 -5
- package/dist/src/schema/pikku-schema.types.js +27 -12
- package/dist/src/schema/scope.schema.js +12 -0
- package/dist/src/schema/secret.schema.js +5 -0
- package/dist/src/schema/virtual-user.schema.d.ts +19 -0
- package/dist/src/schema/virtual-user.schema.js +54 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/create-audited-kysely.test.ts +10 -10
- package/src/index.ts +7 -0
- package/src/kysely-audit-service.test.ts +360 -0
- package/src/kysely-audit-service.ts +181 -4
- package/src/kysely-credential-service.ts +57 -12
- package/src/kysely-scope-service.ts +151 -2
- package/src/kysely-secret-service.ts +70 -21
- package/src/kysely-services.test.ts +123 -1
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +53 -2
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/kysely-workflow-service.ts +0 -17
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +14 -3
- package/src/schema/index.ts +59 -10
- package/src/schema/pikku-schema.types.ts +65 -10
- package/src/schema/schema.test.ts +82 -4
- package/src/schema/scope.schema.ts +14 -0
- package/src/schema/secret.schema.ts +9 -0
- package/src/schema/virtual-user.schema.ts +65 -0
|
@@ -1,4 +1,64 @@
|
|
|
1
|
+
import { ensurePikkuSchema } from './schema/index.js';
|
|
2
|
+
import { auditSchema } from './schema/audit.schema.js';
|
|
1
3
|
const jsonOrNull = (v) => v != null ? JSON.stringify(v) : null;
|
|
4
|
+
const parseJson = (v) => {
|
|
5
|
+
if (typeof v !== 'string')
|
|
6
|
+
return v ?? undefined;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(v);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// A row written by something other than this service, or truncated in
|
|
12
|
+
// transit. Surfacing the raw text beats dropping the event: the row is
|
|
13
|
+
// still evidence that something happened, and swallowing it would make the
|
|
14
|
+
// trail quietly incomplete.
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A column by its physical name, whatever the connection renamed it to.
|
|
20
|
+
*
|
|
21
|
+
* `CamelCasePlugin` is on most pikku Kysely instances and rewrites result keys
|
|
22
|
+
* on the way out, so the same row arrives keyed `audit_id` or `auditId`
|
|
23
|
+
* depending on a plugin this service is never told about. Reading both is
|
|
24
|
+
* cheaper than demanding a particular connection — and quieter to get wrong,
|
|
25
|
+
* since the mismatch does not throw, it just returns a page of undefined.
|
|
26
|
+
*/
|
|
27
|
+
const column = (row, name) => {
|
|
28
|
+
const value = row[name];
|
|
29
|
+
if (value !== undefined)
|
|
30
|
+
return value;
|
|
31
|
+
return row[name.replace(/_(\w)/g, (_, char) => char.toUpperCase())];
|
|
32
|
+
};
|
|
33
|
+
/** Rows are TEXT on every engine, so the shape is whatever the driver returned. */
|
|
34
|
+
const rowToEvent = (row) => {
|
|
35
|
+
const userId = column(row, 'user_id');
|
|
36
|
+
const orgId = column(row, 'org_id');
|
|
37
|
+
const pikkuUserId = column(row, 'pikku_user_id');
|
|
38
|
+
return {
|
|
39
|
+
eventId: column(row, 'audit_id') ?? undefined,
|
|
40
|
+
occurredAt: column(row, 'occurred_at'),
|
|
41
|
+
type: row.type,
|
|
42
|
+
source: row.source ?? 'auto',
|
|
43
|
+
outcome: row.outcome ?? undefined,
|
|
44
|
+
functionId: column(row, 'function_id') ?? undefined,
|
|
45
|
+
wireType: column(row, 'wire_type') ?? undefined,
|
|
46
|
+
traceId: column(row, 'trace_id') ?? undefined,
|
|
47
|
+
transactionId: column(row, 'transaction_id') ?? undefined,
|
|
48
|
+
queryId: column(row, 'query_id') ?? undefined,
|
|
49
|
+
userIdentity: userId || orgId || pikkuUserId
|
|
50
|
+
? {
|
|
51
|
+
userId: userId ?? undefined,
|
|
52
|
+
orgId: orgId ?? undefined,
|
|
53
|
+
pikkuUserId: pikkuUserId ?? undefined,
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
metadata: parseJson(row.data),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** The most a single page may return, whatever the caller asked for. */
|
|
60
|
+
const MAX_LIMIT = 200;
|
|
61
|
+
const DEFAULT_LIMIT = 50;
|
|
2
62
|
// No global `crypto` is guaranteed across every runtime, and audit_id is the
|
|
3
63
|
// PK — any collision is dropped by ON CONFLICT DO NOTHING.
|
|
4
64
|
const fallbackId = () => `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -8,16 +68,31 @@ const fallbackId = () => `evt_${Date.now().toString(36)}${Math.random().toString
|
|
|
8
68
|
*
|
|
9
69
|
* The column mapping matches a platform audit-queue consumer, so a locally-run
|
|
10
70
|
* project and a deployed stage write identical rows (the read side relies only
|
|
11
|
-
* on `type`, `occurred_at`, `
|
|
71
|
+
* on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
|
|
12
72
|
* `metadata`). The `audit` table is an optional per-project migration (not in
|
|
13
73
|
* the generated schema), so rows are inserted untyped; all columns are TEXT on
|
|
14
74
|
* every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
|
|
15
75
|
*/
|
|
16
76
|
export class KyselyAuditService {
|
|
17
77
|
db;
|
|
78
|
+
initialized = false;
|
|
18
79
|
constructor(db) {
|
|
19
80
|
this.db = db;
|
|
20
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Creates the `audit` table if the database has none.
|
|
84
|
+
*
|
|
85
|
+
* Optional — a project that migrates the table itself can skip it, and the
|
|
86
|
+
* declaration is written to match the documented shape either way. Calling it
|
|
87
|
+
* on a database that already has the table is a no-op, so two instances
|
|
88
|
+
* booting cold do not race each other into a half-applied schema.
|
|
89
|
+
*/
|
|
90
|
+
async init() {
|
|
91
|
+
if (this.initialized)
|
|
92
|
+
return;
|
|
93
|
+
await ensurePikkuSchema(this.db, auditSchema);
|
|
94
|
+
this.initialized = true;
|
|
95
|
+
}
|
|
21
96
|
async audit(event) {
|
|
22
97
|
await this.write([event]);
|
|
23
98
|
}
|
|
@@ -37,8 +112,9 @@ export class KyselyAuditService {
|
|
|
37
112
|
trace_id: e.traceId ?? null,
|
|
38
113
|
transaction_id: e.transactionId ?? null,
|
|
39
114
|
query_id: e.queryId ?? null,
|
|
40
|
-
|
|
41
|
-
|
|
115
|
+
user_id: e.userIdentity?.userId ?? null,
|
|
116
|
+
org_id: e.userIdentity?.orgId ?? null,
|
|
117
|
+
pikku_user_id: e.userIdentity?.pikkuUserId ?? null,
|
|
42
118
|
tables: jsonOrNull(metadata?.tables),
|
|
43
119
|
changed_cols: jsonOrNull(metadata?.changedColumns),
|
|
44
120
|
event: e.eventId ?? null,
|
|
@@ -52,4 +128,88 @@ export class KyselyAuditService {
|
|
|
52
128
|
.onConflict((oc) => oc.doNothing())
|
|
53
129
|
.execute();
|
|
54
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* A page of the trail, newest first.
|
|
133
|
+
*
|
|
134
|
+
* Offset paging, matching the rest of the console. The trail is append-only
|
|
135
|
+
* and ordered by `occurred_at DESC`, so events written *while* a reader is
|
|
136
|
+
* paging shift the window and can repeat a row across pages. That is visible
|
|
137
|
+
* but harmless for browsing; anything that must not miss a row should page by
|
|
138
|
+
* a bounded `from`/`to` window instead of scrolling.
|
|
139
|
+
*
|
|
140
|
+
* Ties on `occurred_at` are broken by `audit_id` so the order is total —
|
|
141
|
+
* without it a batch flushed in one call shares a timestamp and the engine is
|
|
142
|
+
* free to order it differently per query, which drops or repeats rows at a
|
|
143
|
+
* page boundary.
|
|
144
|
+
*/
|
|
145
|
+
async query(query = {}) {
|
|
146
|
+
const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
147
|
+
const offset = Math.max(query.offset ?? 0, 0);
|
|
148
|
+
let builder = this.db.selectFrom('audit').selectAll();
|
|
149
|
+
builder = this.applyFilters(builder, query);
|
|
150
|
+
const rows = await builder
|
|
151
|
+
.orderBy('occurred_at', 'desc')
|
|
152
|
+
.orderBy('audit_id', 'desc')
|
|
153
|
+
// One extra row is the cheapest honest way to know whether another page
|
|
154
|
+
// exists — a COUNT would be a second scan of the same filter.
|
|
155
|
+
.limit(limit + 1)
|
|
156
|
+
.offset(offset)
|
|
157
|
+
.execute();
|
|
158
|
+
const hasMore = rows.length > limit;
|
|
159
|
+
return {
|
|
160
|
+
events: rows.slice(0, limit).map(rowToEvent),
|
|
161
|
+
nextCursor: hasMore ? offset + limit : null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async facets() {
|
|
165
|
+
const [users, types] = await Promise.all([
|
|
166
|
+
this.db
|
|
167
|
+
.selectFrom('audit')
|
|
168
|
+
.select('user_id')
|
|
169
|
+
.distinct()
|
|
170
|
+
.where('user_id', 'is not', null)
|
|
171
|
+
.orderBy('user_id', 'asc')
|
|
172
|
+
.execute(),
|
|
173
|
+
this.db
|
|
174
|
+
.selectFrom('audit')
|
|
175
|
+
.select('type')
|
|
176
|
+
.distinct()
|
|
177
|
+
.orderBy('type', 'asc')
|
|
178
|
+
.execute(),
|
|
179
|
+
]);
|
|
180
|
+
return {
|
|
181
|
+
userIds: users.map((r) => column(r, 'user_id')),
|
|
182
|
+
types: types.map((r) => r.type).filter(Boolean),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* An empty array means "match nothing" and is applied as such. Treating it as
|
|
187
|
+
* "no filter" would turn a reader's deselect-everything into a full-trail
|
|
188
|
+
* read, which is the opposite of what they asked for.
|
|
189
|
+
*/
|
|
190
|
+
applyFilters(builder, query) {
|
|
191
|
+
// `in ()` is a syntax error on Postgres and SQLite alike, so an empty
|
|
192
|
+
// selection is expressed as a predicate that cannot hold.
|
|
193
|
+
const matchNothing = (b) => b.where('audit_id', 'is', null);
|
|
194
|
+
if (query.userIds) {
|
|
195
|
+
builder = query.userIds.length
|
|
196
|
+
? builder.where('user_id', 'in', query.userIds)
|
|
197
|
+
: matchNothing(builder);
|
|
198
|
+
}
|
|
199
|
+
if (query.types) {
|
|
200
|
+
builder = query.types.length
|
|
201
|
+
? builder.where('type', 'in', query.types)
|
|
202
|
+
: matchNothing(builder);
|
|
203
|
+
}
|
|
204
|
+
if (query.orgId) {
|
|
205
|
+
builder = builder.where('org_id', '=', query.orgId);
|
|
206
|
+
}
|
|
207
|
+
if (query.from) {
|
|
208
|
+
builder = builder.where('occurred_at', '>=', query.from);
|
|
209
|
+
}
|
|
210
|
+
if (query.to) {
|
|
211
|
+
builder = builder.where('occurred_at', '<', query.to);
|
|
212
|
+
}
|
|
213
|
+
return builder;
|
|
214
|
+
}
|
|
55
215
|
}
|
|
@@ -11,6 +11,8 @@ export interface KyselyCredentialServiceConfig {
|
|
|
11
11
|
export declare class KyselyCredentialService implements CredentialService {
|
|
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 KyselyCredentialService implements CredentialService {
|
|
|
19
21
|
constructor(db: Kysely<KyselyPikkuDB>, config: KyselyCredentialServiceConfig);
|
|
20
22
|
init(): Promise<void>;
|
|
21
23
|
private logAudit;
|
|
24
|
+
private getKEKSalt;
|
|
22
25
|
private getKEK;
|
|
23
26
|
private whereUserId;
|
|
24
27
|
get<T = unknown>(name: string, userId?: string): Promise<T | null>;
|
|
@@ -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 { credentialSchema } from './schema/credential.schema.js';
|
|
4
4
|
export class KyselyCredentialService {
|
|
5
5
|
db;
|
|
6
6
|
initialized = false;
|
|
7
|
+
kekSalts = new Map();
|
|
8
|
+
keks = new Map();
|
|
7
9
|
key;
|
|
8
10
|
keyVersion;
|
|
9
11
|
previousKey;
|
|
@@ -39,12 +41,47 @@ export class KyselyCredentialService {
|
|
|
39
41
|
})
|
|
40
42
|
.execute();
|
|
41
43
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
async getKEKSalt(version) {
|
|
45
|
+
const cached = this.kekSalts.get(version);
|
|
46
|
+
if (cached)
|
|
47
|
+
return cached;
|
|
48
|
+
const existing = await this.db
|
|
49
|
+
.selectFrom('credentialKekSalts')
|
|
50
|
+
.select('salt')
|
|
51
|
+
.where('keyVersion', '=', version)
|
|
52
|
+
.executeTakeFirst();
|
|
53
|
+
let salt = existing?.salt;
|
|
54
|
+
if (!salt) {
|
|
55
|
+
await this.db
|
|
56
|
+
.insertInto('credentialKekSalts')
|
|
57
|
+
.values({
|
|
58
|
+
keyVersion: version,
|
|
59
|
+
salt: generateKEKSalt(),
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
})
|
|
62
|
+
.onConflict((oc) => oc.column('keyVersion').doNothing())
|
|
63
|
+
.execute();
|
|
64
|
+
const row = await this.db
|
|
65
|
+
.selectFrom('credentialKekSalts')
|
|
66
|
+
.select('salt')
|
|
67
|
+
.where('keyVersion', '=', version)
|
|
68
|
+
.executeTakeFirstOrThrow();
|
|
69
|
+
salt = row.salt;
|
|
70
|
+
}
|
|
71
|
+
this.kekSalts.set(version, salt);
|
|
72
|
+
return salt;
|
|
73
|
+
}
|
|
74
|
+
async getKEK(version) {
|
|
75
|
+
const cached = this.keks.get(version);
|
|
76
|
+
if (cached)
|
|
77
|
+
return cached;
|
|
78
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey;
|
|
79
|
+
if (!passphrase) {
|
|
80
|
+
throw new Error(`No KEK available for key_version ${version}`);
|
|
81
|
+
}
|
|
82
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version));
|
|
83
|
+
this.keks.set(version, kek);
|
|
84
|
+
return kek;
|
|
48
85
|
}
|
|
49
86
|
whereUserId(qb, userId) {
|
|
50
87
|
return userId
|
|
@@ -60,7 +97,7 @@ export class KyselyCredentialService {
|
|
|
60
97
|
const row = await qb.executeTakeFirst();
|
|
61
98
|
if (!row)
|
|
62
99
|
return null;
|
|
63
|
-
const kek = this.getKEK(row.keyVersion);
|
|
100
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
64
101
|
const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
65
102
|
await this.logAudit(name, userId, 'read');
|
|
66
103
|
try {
|
|
@@ -72,7 +109,7 @@ export class KyselyCredentialService {
|
|
|
72
109
|
}
|
|
73
110
|
async set(name, value, userId) {
|
|
74
111
|
const plaintext = JSON.stringify(value);
|
|
75
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.
|
|
112
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), plaintext);
|
|
76
113
|
const now = new Date().toISOString();
|
|
77
114
|
const exists = await this.has(name, userId);
|
|
78
115
|
if (exists) {
|
|
@@ -127,7 +164,7 @@ export class KyselyCredentialService {
|
|
|
127
164
|
.execute();
|
|
128
165
|
const result = {};
|
|
129
166
|
for (const row of rows) {
|
|
130
|
-
const kek = this.getKEK(row.keyVersion);
|
|
167
|
+
const kek = await this.getKEK(row.keyVersion);
|
|
131
168
|
const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
|
|
132
169
|
try {
|
|
133
170
|
result[row.name] = JSON.parse(plaintext);
|
|
@@ -166,8 +203,10 @@ export class KyselyCredentialService {
|
|
|
166
203
|
.select(['name', 'userId', 'wrappedDek'])
|
|
167
204
|
.where('keyVersion', '<', this.keyVersion)
|
|
168
205
|
.execute();
|
|
206
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1);
|
|
207
|
+
const newKEK = await this.getKEK(this.keyVersion);
|
|
169
208
|
for (const row of rows) {
|
|
170
|
-
const newWrappedDEK = await envelopeRewrap(
|
|
209
|
+
const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek);
|
|
171
210
|
let qb = this.db
|
|
172
211
|
.updateTable('credentials')
|
|
173
212
|
.set({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Role, ScopeService } from '@pikku/core/services';
|
|
2
2
|
import type { FlatScope } from '@pikku/core/scope';
|
|
3
|
+
import type { SystemRole } from '@pikku/core/role';
|
|
3
4
|
import type { Kysely } from 'kysely';
|
|
4
5
|
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
5
6
|
/**
|
|
@@ -33,9 +34,28 @@ export declare class KyselyScopeService implements ScopeService {
|
|
|
33
34
|
listScopes(): Promise<Array<FlatScope & {
|
|
34
35
|
declared: boolean;
|
|
35
36
|
}>>;
|
|
37
|
+
/**
|
|
38
|
+
* Registers the roles declared with `defineSystemRole`.
|
|
39
|
+
*
|
|
40
|
+
* Additive on the same terms as `syncScopes` — a role whose declaration has
|
|
41
|
+
* gone is marked `declared = false` rather than deleted, so a rollback or a
|
|
42
|
+
* rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
|
|
43
|
+
* outright, because that is the declaration's entire content: editing
|
|
44
|
+
* `defineSystemRole` is how you change what a role means, and the deploy is
|
|
45
|
+
* when it takes effect.
|
|
46
|
+
*/
|
|
47
|
+
syncSystemRoles(roles: SystemRole[]): Promise<void>;
|
|
36
48
|
createRole(role: Role): Promise<void>;
|
|
37
49
|
deleteRole(name: string): Promise<void>;
|
|
38
50
|
setRoleScopes(name: string, scopes: string[]): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Whether the store holds this name as a system role.
|
|
53
|
+
*
|
|
54
|
+
* Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
|
|
55
|
+
* store is where a shadow would actually collide — and because a role whose
|
|
56
|
+
* declaration was deleted is still immutable until someone prunes it.
|
|
57
|
+
*/
|
|
58
|
+
private isSystemRole;
|
|
39
59
|
listRoles(): Promise<Role[]>;
|
|
40
60
|
addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>;
|
|
41
61
|
removeUserFromRole(userId: string, role: string): Promise<void>;
|
|
@@ -56,4 +76,22 @@ export declare class KyselyScopeService implements ScopeService {
|
|
|
56
76
|
* them. This revokes access, so it is never run implicitly.
|
|
57
77
|
*/
|
|
58
78
|
pruneScopes(): Promise<string[]>;
|
|
79
|
+
/**
|
|
80
|
+
* System roles the last sync found no declaration for, with how many people
|
|
81
|
+
* still hold each. Powers `pikku roles audit`.
|
|
82
|
+
*
|
|
83
|
+
* The count is the whole point: "this role is gone from the code" and "this
|
|
84
|
+
* role is gone from the code and 40 people are standing on it" call for
|
|
85
|
+
* different decisions, and the number is what makes pruning a choice rather
|
|
86
|
+
* than a formality.
|
|
87
|
+
*/
|
|
88
|
+
findStaleSystemRoles(): Promise<Array<{
|
|
89
|
+
role: string;
|
|
90
|
+
users: number;
|
|
91
|
+
}>>;
|
|
92
|
+
/**
|
|
93
|
+
* Removes undeclared system roles, cascading them out of every user grant
|
|
94
|
+
* that holds them. This revokes access, so it is never run implicitly.
|
|
95
|
+
*/
|
|
96
|
+
pruneSystemRoles(): Promise<string[]>;
|
|
59
97
|
}
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type SecretValue } from '@pikku/core';
|
|
2
|
+
import type { SecretService, SecretValues } from '@pikku/core/services';
|
|
2
3
|
import type { Kysely } from 'kysely';
|
|
3
4
|
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
4
5
|
export interface KyselySecretServiceConfig {
|
|
@@ -11,6 +12,8 @@ export interface KyselySecretServiceConfig {
|
|
|
11
12
|
export declare class KyselySecretService implements SecretService {
|
|
12
13
|
private db;
|
|
13
14
|
private initialized;
|
|
15
|
+
private kekSalts;
|
|
16
|
+
private keks;
|
|
14
17
|
private key;
|
|
15
18
|
private keyVersion;
|
|
16
19
|
private previousKey?;
|
|
@@ -19,11 +22,12 @@ export declare class KyselySecretService implements SecretService {
|
|
|
19
22
|
constructor(db: Kysely<KyselyPikkuDB>, config: KyselySecretServiceConfig);
|
|
20
23
|
init(): Promise<void>;
|
|
21
24
|
private logAudit;
|
|
25
|
+
private getKEKSalt;
|
|
22
26
|
private getKEK;
|
|
23
|
-
getSecret<T = string>(key: string): Promise<T
|
|
27
|
+
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
24
28
|
hasSecret(key: string): Promise<boolean>;
|
|
25
29
|
setSecret(key: string, value: unknown): Promise<void>;
|
|
26
30
|
deleteSecret(key: string): Promise<void>;
|
|
27
|
-
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T
|
|
31
|
+
getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
|
|
28
32
|
rotateKEK(): Promise<number>;
|
|
29
33
|
}
|