@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-audit-service.d.ts +34 -2
  5. package/dist/src/kysely-audit-service.js +163 -3
  6. package/dist/src/kysely-credential-service.d.ts +3 -0
  7. package/dist/src/kysely-credential-service.js +50 -11
  8. package/dist/src/kysely-scope-service.d.ts +38 -0
  9. package/dist/src/kysely-scope-service.js +125 -2
  10. package/dist/src/kysely-secret-service.d.ts +7 -3
  11. package/dist/src/kysely-secret-service.js +55 -13
  12. package/dist/src/kysely-tables.d.ts +49 -2
  13. package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
  14. package/dist/src/kysely-virtual-user-run-store.js +131 -0
  15. package/dist/src/kysely-workflow-service.d.ts +0 -1
  16. package/dist/src/kysely-workflow-service.js +0 -12
  17. package/dist/src/schema/audit.schema.d.ts +16 -0
  18. package/dist/src/schema/audit.schema.js +47 -0
  19. package/dist/src/schema/credential.schema.d.ts +3 -2
  20. package/dist/src/schema/credential.schema.js +9 -3
  21. package/dist/src/schema/index.d.ts +17 -4
  22. package/dist/src/schema/index.js +40 -6
  23. package/dist/src/schema/pikku-schema.types.d.ts +41 -5
  24. package/dist/src/schema/pikku-schema.types.js +27 -12
  25. package/dist/src/schema/scope.schema.js +12 -0
  26. package/dist/src/schema/secret.schema.js +5 -0
  27. package/dist/src/schema/virtual-user.schema.d.ts +19 -0
  28. package/dist/src/schema/virtual-user.schema.js +54 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/src/create-audited-kysely.test.ts +10 -10
  32. package/src/index.ts +7 -0
  33. package/src/kysely-audit-service.test.ts +360 -0
  34. package/src/kysely-audit-service.ts +181 -4
  35. package/src/kysely-credential-service.ts +57 -12
  36. package/src/kysely-scope-service.ts +151 -2
  37. package/src/kysely-secret-service.ts +70 -21
  38. package/src/kysely-services.test.ts +123 -1
  39. package/src/kysely-tables.test.ts +12 -1
  40. package/src/kysely-tables.ts +53 -2
  41. package/src/kysely-virtual-user-run-store.test.ts +212 -0
  42. package/src/kysely-virtual-user-run-store.ts +149 -0
  43. package/src/kysely-workflow-service.ts +0 -17
  44. package/src/schema/audit.schema.ts +55 -0
  45. package/src/schema/credential.schema.ts +14 -3
  46. package/src/schema/index.ts +59 -10
  47. package/src/schema/pikku-schema.types.ts +65 -10
  48. package/src/schema/schema.test.ts +82 -4
  49. package/src/schema/scope.schema.ts +14 -0
  50. package/src/schema/secret.schema.ts +9 -0
  51. 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`, `actor_user_id`, and the `data` JSON that holds
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
- actor_user_id: e.actor?.userId ?? null,
41
- actor_org_id: e.actor?.orgId ?? null,
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
- getKEK(version) {
43
- if (version === this.keyVersion)
44
- return this.key;
45
- if (this.previousKey)
46
- return this.previousKey;
47
- throw new Error(`No KEK available for key_version ${version}`);
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.key, plaintext);
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(this.previousKey, this.key, row.wrappedDek);
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({ name: role.name, description: role.description ?? null })
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 { SecretService } from '@pikku/core/services';
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
  }