@awsmbiz/inbis-access-control 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,25 @@
1
+ import { _ as SystemRoleDefinition, a as createAccessControl, c as DeleteRoleInput, d as UserRole, f as RoleFields, g as RoleDeclaration, h as PermissionDefinition, i as RoleAssignmentInput, l as Role, m as AccessControlInput, n as AccessControlOptions, o as SeedResult, p as AccessControlDefinition, r as AuthorizeInput, s as CreateRoleInput, t as AccessControl, u as UpdateRoleInput, v as defineAccessControl } from "./access-control-eE6LZrpn.js";
2
+ /**
3
+ * Audit actions recorded by `@awsmbiz/inbis-access-control`; register them with the
4
+ * app's audit trail (`createAuditActions({ platform: [ACCESS_CONTROL_AUDIT_ACTIONS] })`).
5
+ * - `access.role.assigned` / `access.role.unassigned`: subject `user:<id>`, metadata `roleId`.
6
+ * - `access.role.created`: subject `role:<id>`, metadata `name`, `description`, `permissions`, `inherits`.
7
+ * - `access.role.updated`: subject `role:<id>`, metadata `changes` (`name`/`description` as
8
+ * `{ from, to }`, `permissions`/`inherits` as `{ added, removed }`; changed fields only).
9
+ * - `access.role.deleted`: subject `role:<id>`, metadata `name`, `permissions`, `inherits`,
10
+ * `unassignedUserIds` (the assignments deleted with it).
11
+ */
12
+ export declare const ACCESS_CONTROL_AUDIT_ACTIONS: {
13
+ readonly roleAssigned: "access.role.assigned";
14
+ readonly roleUnassigned: "access.role.unassigned";
15
+ readonly roleCreated: "access.role.created";
16
+ readonly roleUpdated: "access.role.updated";
17
+ readonly roleDeleted: "access.role.deleted";
18
+ };
19
+ /**
20
+ * The `migrations_table` of the app's access-control D1 binding (ADR-4).
21
+ * Declare that binding after auth's and before audit's.
22
+ */
23
+ export declare const ACCESS_CONTROL_MIGRATIONS_TABLE = "_inbis_access_control_migrations";
24
+ export { type AccessControl, type AccessControlDefinition, type AccessControlInput, type AccessControlOptions, type AuthorizeInput, type CreateRoleInput, type DeleteRoleInput, type PermissionDefinition, type Role, type RoleAssignmentInput, type RoleDeclaration, type RoleFields, type SeedResult, type SystemRoleDefinition, type UpdateRoleInput, type UserRole, createAccessControl, defineAccessControl };
25
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,438 @@
1
+ import { n as cleanList, r as cleanName, t as cleanDescription } from "./role-fields-ClqODoYn.js";
2
+ import { AppError, createLogger, systemClock } from "@awsmbiz/inbis-core";
3
+ // src/actions.ts
4
+ /**
5
+ * Audit actions recorded by `@awsmbiz/inbis-access-control`; register them with the
6
+ * app's audit trail (`createAuditActions({ platform: [ACCESS_CONTROL_AUDIT_ACTIONS] })`).
7
+ * - `access.role.assigned` / `access.role.unassigned`: subject `user:<id>`, metadata `roleId`.
8
+ * - `access.role.created`: subject `role:<id>`, metadata `name`, `description`, `permissions`, `inherits`.
9
+ * - `access.role.updated`: subject `role:<id>`, metadata `changes` (`name`/`description` as
10
+ * `{ from, to }`, `permissions`/`inherits` as `{ added, removed }`; changed fields only).
11
+ * - `access.role.deleted`: subject `role:<id>`, metadata `name`, `permissions`, `inherits`,
12
+ * `unassignedUserIds` (the assignments deleted with it).
13
+ */
14
+ const ACCESS_CONTROL_AUDIT_ACTIONS = {
15
+ roleAssigned: "access.role.assigned",
16
+ roleUnassigned: "access.role.unassigned",
17
+ roleCreated: "access.role.created",
18
+ roleUpdated: "access.role.updated",
19
+ roleDeleted: "access.role.deleted"
20
+ };
21
+ // src/roles.ts
22
+ const ROLES = `
23
+ SELECT r.id, r.name, r.description, r.system, r.created_at, r.updated_at,
24
+ (SELECT json_group_array(permission) FROM (SELECT permission FROM role_permission WHERE role_id = r.id ORDER BY permission)) AS permissions,
25
+ (SELECT json_group_array(inherits_role_id) FROM (SELECT inherits_role_id FROM role_inheritance WHERE role_id = r.id ORDER BY inherits_role_id)) AS inherits,
26
+ (SELECT count(*) FROM user_role WHERE role_id = r.id) AS user_count
27
+ FROM role r`;
28
+ const roleNotFound = () => new AppError({
29
+ code: "ROLE_NOT_FOUND",
30
+ status: 404,
31
+ message: "No such role."
32
+ });
33
+ const userNotFound = () => new AppError({
34
+ code: "USER_NOT_FOUND",
35
+ status: 404,
36
+ message: "No such user."
37
+ });
38
+ /** Lists `added` and `removed` between two sorted lists. */
39
+ function listDiff(from, to) {
40
+ return {
41
+ added: to.filter((item) => !from.includes(item)),
42
+ removed: from.filter((item) => !to.includes(item))
43
+ };
44
+ }
45
+ /** Role administration over the access-control tables (plan Q40: custom roles; system roles read-only). */
46
+ function roleAdmin(db, definition, record, now) {
47
+ const toRole = (row) => ({
48
+ id: row.id,
49
+ name: row.name,
50
+ description: row.description,
51
+ system: row.system === 1,
52
+ permissions: JSON.parse(row.permissions).filter((name) => definition.hasPermission(name)),
53
+ inherits: JSON.parse(row.inherits),
54
+ userCount: row.user_count,
55
+ createdAt: new Date(row.created_at),
56
+ updatedAt: new Date(row.updated_at)
57
+ });
58
+ async function findRole(roleId) {
59
+ const row = await db.prepare(`${ROLES} WHERE r.id = ?`).bind(roleId).first();
60
+ return row === null ? null : toRole(row);
61
+ }
62
+ async function getRole(roleId) {
63
+ const role = await findRole(roleId);
64
+ if (role === null) throw roleNotFound();
65
+ return role;
66
+ }
67
+ async function assertNameFree(name, roleId) {
68
+ if (await db.prepare("SELECT id FROM role WHERE lower(name) = lower(?) AND id <> ?").bind(name, roleId).first("id") !== null) throw nameTaken(name);
69
+ }
70
+ function assertPermissions(permissions) {
71
+ const unknown = permissions.filter((name) => !definition.hasPermission(name));
72
+ if (unknown.length > 0) throw new AppError({
73
+ code: "UNKNOWN_PERMISSION",
74
+ status: 422,
75
+ message: "Some permissions are not declared.",
76
+ details: { permissions: unknown }
77
+ });
78
+ }
79
+ /** Unknown roles are `422 UNKNOWN_ROLE`; an edge that would close a loop is `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`). */
80
+ async function assertInheritance(roleId, inherits) {
81
+ const { results: edges } = await db.prepare("SELECT id AS role_id, NULL AS inherits_role_id FROM role UNION ALL SELECT role_id, inherits_role_id FROM role_inheritance").all();
82
+ const parents = /* @__PURE__ */ new Map();
83
+ for (const edge of edges) {
84
+ const list = parents.get(edge.role_id) ?? [];
85
+ if (edge.inherits_role_id !== null) list.push(edge.inherits_role_id);
86
+ parents.set(edge.role_id, list);
87
+ }
88
+ const unknown = inherits.filter((id) => !parents.has(id) && id !== roleId);
89
+ if (unknown.length > 0) throw new AppError({
90
+ code: "UNKNOWN_ROLE",
91
+ status: 422,
92
+ message: "Some inherited roles do not exist.",
93
+ details: { roleIds: unknown }
94
+ });
95
+ parents.set(roleId, [...inherits]);
96
+ const seen = /* @__PURE__ */ new Set();
97
+ const find = (id, path) => {
98
+ for (const parent of parents.get(id) ?? []) {
99
+ if (parent === roleId) return [...path, parent];
100
+ if (seen.has(parent)) continue;
101
+ seen.add(parent);
102
+ const found = find(parent, [...path, parent]);
103
+ if (found !== null) return found;
104
+ }
105
+ return null;
106
+ };
107
+ const cycle = find(roleId, [roleId]);
108
+ if (cycle !== null) throw new AppError({
109
+ code: "ROLE_INHERITANCE_CYCLE",
110
+ status: 422,
111
+ message: "A role cannot inherit itself, directly or through other roles.",
112
+ details: { cycle }
113
+ });
114
+ }
115
+ function assertCustom(role) {
116
+ if (role.system) throw new AppError({
117
+ code: "SYSTEM_ROLE_IMMUTABLE",
118
+ status: 409,
119
+ message: "System roles are declared in code and cannot be changed."
120
+ });
121
+ }
122
+ const replaceGrants = (roleId, permissions) => [db.prepare("DELETE FROM role_permission WHERE role_id = ?").bind(roleId), db.prepare("INSERT INTO role_permission (role_id, permission) SELECT ?, value FROM json_each(?)").bind(roleId, JSON.stringify(permissions))];
123
+ const replaceInheritance = (roleId, inherits) => [db.prepare("DELETE FROM role_inheritance WHERE role_id = ?").bind(roleId), db.prepare("INSERT INTO role_inheritance (role_id, inherits_role_id) SELECT ?, value FROM json_each(?)").bind(roleId, JSON.stringify(inherits))];
124
+ /** Runs the batch, turning the unique-name index's refusal (a concurrent create) into `409 ROLE_NAME_TAKEN`. */
125
+ async function write(statements, name) {
126
+ try {
127
+ await db.batch(statements);
128
+ } catch (error) {
129
+ if (String(error).includes("UNIQUE constraint failed: role.name")) throw nameTaken(name);
130
+ throw error;
131
+ }
132
+ }
133
+ return {
134
+ listPermissions: () => definition.permissions.map(({ name, description }) => ({
135
+ name,
136
+ description
137
+ })),
138
+ async listRoles() {
139
+ const { results } = await db.prepare(`${ROLES} ORDER BY r.system DESC, r.name COLLATE NOCASE, r.id`).all();
140
+ return results.map(toRole);
141
+ },
142
+ getRole,
143
+ async createRole(input) {
144
+ const id = crypto.randomUUID();
145
+ const name = cleanName(input.name);
146
+ const description = cleanDescription(input.description);
147
+ const permissions = cleanList(input.permissions);
148
+ const inherits = cleanList(input.inherits);
149
+ assertPermissions(permissions);
150
+ await assertInheritance(id, inherits);
151
+ await assertNameFree(name, id);
152
+ const at = now().getTime();
153
+ await write([
154
+ db.prepare("INSERT INTO role (id, name, description, system, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)").bind(id, name, description, at, at),
155
+ ...replaceGrants(id, permissions),
156
+ ...replaceInheritance(id, inherits)
157
+ ], name);
158
+ await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleCreated, `role:${id}`, input.actorId, {
159
+ name,
160
+ description,
161
+ permissions,
162
+ inherits
163
+ });
164
+ return getRole(id);
165
+ },
166
+ async updateRole({ roleId, changes, actorId }) {
167
+ const role = await getRole(roleId);
168
+ assertCustom(role);
169
+ const name = changes.name === void 0 ? role.name : cleanName(changes.name);
170
+ const description = changes.description === void 0 ? role.description : cleanDescription(changes.description);
171
+ const permissions = changes.permissions === void 0 ? role.permissions : cleanList(changes.permissions);
172
+ const inherits = changes.inherits === void 0 ? role.inherits : cleanList(changes.inherits);
173
+ assertPermissions(permissions);
174
+ await assertInheritance(roleId, inherits);
175
+ if (name !== role.name) await assertNameFree(name, roleId);
176
+ const diff = {};
177
+ if (name !== role.name) diff["name"] = {
178
+ from: role.name,
179
+ to: name
180
+ };
181
+ if (description !== role.description) diff["description"] = {
182
+ from: role.description,
183
+ to: description
184
+ };
185
+ const permissionDiff = listDiff(role.permissions, permissions);
186
+ if (permissionDiff.added.length + permissionDiff.removed.length > 0) diff["permissions"] = permissionDiff;
187
+ const inheritsDiff = listDiff(role.inherits, inherits);
188
+ if (inheritsDiff.added.length + inheritsDiff.removed.length > 0) diff["inherits"] = inheritsDiff;
189
+ if (Object.keys(diff).length === 0) return role;
190
+ await write([
191
+ db.prepare("UPDATE role SET name = ?, description = ?, updated_at = ? WHERE id = ?").bind(name, description, now().getTime(), roleId),
192
+ ..."permissions" in diff ? replaceGrants(roleId, permissions) : [],
193
+ ..."inherits" in diff ? replaceInheritance(roleId, inherits) : []
194
+ ], name);
195
+ await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleUpdated, `role:${roleId}`, actorId, { changes: diff });
196
+ return getRole(roleId);
197
+ },
198
+ async deleteRole({ roleId, actorId }) {
199
+ const role = await getRole(roleId);
200
+ assertCustom(role);
201
+ const { results: heirs } = await db.prepare("SELECT role_id FROM role_inheritance WHERE inherits_role_id = ? ORDER BY role_id").bind(roleId).all();
202
+ if (heirs.length > 0) throw new AppError({
203
+ code: "ROLE_INHERITED",
204
+ status: 409,
205
+ message: "Other roles inherit this role; remove it from them first.",
206
+ details: { roleIds: heirs.map((row) => row.role_id) }
207
+ });
208
+ const [users] = await db.batch([db.prepare("SELECT user_id FROM user_role WHERE role_id = ? ORDER BY user_id").bind(roleId), db.prepare("DELETE FROM role WHERE id = ? AND system = 0").bind(roleId)]);
209
+ await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleDeleted, `role:${roleId}`, actorId, {
210
+ name: role.name,
211
+ permissions: role.permissions,
212
+ inherits: role.inherits,
213
+ unassignedUserIds: (users?.results ?? []).map((row) => row.user_id)
214
+ });
215
+ },
216
+ async userRoles(userId) {
217
+ if (await db.prepare("SELECT id FROM user WHERE id = ?").bind(userId).first("id") === null) throw userNotFound();
218
+ const { results } = await db.prepare(`SELECT r.id, r.name, r.system, ur.assigned_at, ur.assigned_by FROM user_role ur JOIN role r ON r.id = ur.role_id
219
+ WHERE ur.user_id = ? ORDER BY r.system DESC, r.name COLLATE NOCASE, r.id`).bind(userId).all();
220
+ return results.map((row) => ({
221
+ id: row.id,
222
+ name: row.name,
223
+ system: row.system === 1,
224
+ assignedAt: new Date(row.assigned_at),
225
+ assignedBy: row.assigned_by
226
+ }));
227
+ }
228
+ };
229
+ }
230
+ function nameTaken(name) {
231
+ return new AppError({
232
+ code: "ROLE_NAME_TAKEN",
233
+ status: 409,
234
+ message: `A role named "${name}" already exists.`,
235
+ details: { name }
236
+ });
237
+ }
238
+ // src/seed.ts
239
+ async function fingerprintOf(definition) {
240
+ const canonical = JSON.stringify({
241
+ permissions: definition.permissions,
242
+ roles: definition.roles
243
+ });
244
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
245
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
246
+ }
247
+ /**
248
+ * Makes the database hold exactly the declared catalogue: upserts permissions
249
+ * and system roles, replaces system roles' grants and inheritance, and removes
250
+ * permissions and system roles no longer declared (with their grants and
251
+ * assignments). Custom roles keep their rows; grants of a removed permission go.
252
+ * One D1 batch (a transaction). Idempotent; skipped when the stored
253
+ * fingerprint matches, so running it per isolate costs one read.
254
+ */
255
+ async function seedCatalogue(db, definition, now) {
256
+ const fingerprint = await fingerprintOf(definition);
257
+ if (await db.prepare("SELECT fingerprint FROM access_control_seed WHERE id = 1").first("fingerprint") === fingerprint) return { changed: false };
258
+ const at = now.getTime();
259
+ const permissionNames = JSON.stringify(definition.permissions.map((permission) => permission.name));
260
+ const roleIds = JSON.stringify(definition.roles.map((role) => role.id));
261
+ const statements = [
262
+ ...definition.permissions.map((permission) => db.prepare("INSERT INTO permission (name, description) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET description = excluded.description").bind(permission.name, permission.description)),
263
+ db.prepare("DELETE FROM permission WHERE name NOT IN (SELECT value FROM json_each(?))").bind(permissionNames),
264
+ db.prepare("DELETE FROM role WHERE system = 1 AND id NOT IN (SELECT value FROM json_each(?))").bind(roleIds),
265
+ ...definition.roles.map((role) => db.prepare(`INSERT INTO role (id, name, description, system, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)
266
+ ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, system = 1, updated_at = excluded.updated_at`).bind(role.id, role.name, role.description, at, at)),
267
+ ...definition.roles.flatMap((role) => [
268
+ db.prepare("DELETE FROM role_permission WHERE role_id = ?").bind(role.id),
269
+ db.prepare("INSERT INTO role_permission (role_id, permission) SELECT ?, value FROM json_each(?)").bind(role.id, JSON.stringify(role.permissions)),
270
+ db.prepare("DELETE FROM role_inheritance WHERE role_id = ?").bind(role.id),
271
+ db.prepare("INSERT INTO role_inheritance (role_id, inherits_role_id) SELECT ?, value FROM json_each(?)").bind(role.id, JSON.stringify(role.inherits))
272
+ ]),
273
+ db.prepare("INSERT INTO access_control_seed (id, fingerprint, seeded_at) VALUES (1, ?, ?) ON CONFLICT (id) DO UPDATE SET fingerprint = excluded.fingerprint, seeded_at = excluded.seeded_at").bind(fingerprint, at)
274
+ ];
275
+ await db.batch(statements);
276
+ return { changed: true };
277
+ }
278
+ // src/access-control.ts
279
+ /** Every permission the user holds through their roles and the roles those explicitly inherit. */
280
+ const PERMISSIONS_OF_USER = `
281
+ WITH RECURSIVE granted(role_id) AS (
282
+ SELECT role_id FROM user_role WHERE user_id = ?
283
+ UNION
284
+ SELECT ri.inherits_role_id FROM role_inheritance ri JOIN granted g ON ri.role_id = g.role_id
285
+ )
286
+ SELECT DISTINCT rp.permission AS permission FROM role_permission rp JOIN granted g ON rp.role_id = g.role_id`;
287
+ /**
288
+ * Access control over the database holding the access-control tables (the
289
+ * app's access-control binding), for one request.
290
+ */
291
+ function createAccessControl(db, definition, options = {}) {
292
+ const clock = options.clock ?? systemClock;
293
+ const memo = /* @__PURE__ */ new Map();
294
+ function permissionsOf(userId) {
295
+ let pending = memo.get(userId);
296
+ if (pending === void 0) {
297
+ pending = db.prepare(PERMISSIONS_OF_USER).bind(userId).all().then(({ results }) => new Set(results.map((row) => row.permission)));
298
+ pending.catch(() => memo.delete(userId));
299
+ memo.set(userId, pending);
300
+ }
301
+ return pending;
302
+ }
303
+ /**
304
+ * Runs a change, then forgets every memoised answer (even if it failed part
305
+ * way): later checks on this instance read the new state from D1.
306
+ */
307
+ async function changing(change) {
308
+ try {
309
+ return await change();
310
+ } finally {
311
+ memo.clear();
312
+ }
313
+ }
314
+ const admin = roleAdmin(db, definition, record, () => clock.now());
315
+ const recordAssignment = (action, { userId, roleId, actorId }) => record(action, `user:${userId}`, actorId, { roleId });
316
+ async function record(action, subjectId, actorId, metadata) {
317
+ if (options.audit === void 0) return;
318
+ try {
319
+ await options.audit.record({
320
+ action,
321
+ occurredAt: clock.now(),
322
+ actorId,
323
+ subjectId,
324
+ ...options.requestId === void 0 ? {} : { requestId: options.requestId },
325
+ metadata
326
+ });
327
+ } catch (error) {
328
+ (options.logger ?? createLogger()).error("access.audit.record_failed", {
329
+ action,
330
+ error
331
+ });
332
+ }
333
+ }
334
+ return {
335
+ async authorize({ userId, permission }) {
336
+ if (!definition.hasPermission(permission)) return false;
337
+ return (await permissionsOf(userId)).has(permission);
338
+ },
339
+ async rolesOf(userId) {
340
+ const { results } = await db.prepare("SELECT role_id FROM user_role WHERE user_id = ? ORDER BY role_id").bind(userId).all();
341
+ return results.map((row) => row.role_id);
342
+ },
343
+ assignRole: (input) => changing(async () => {
344
+ if (await db.prepare("SELECT id FROM role WHERE id = ?").bind(input.roleId).first("id") === null) throw new AppError({
345
+ code: "ROLE_NOT_FOUND",
346
+ status: 404,
347
+ message: "No such role."
348
+ });
349
+ if (await db.prepare("SELECT id FROM user WHERE id = ?").bind(input.userId).first("id") === null) throw new AppError({
350
+ code: "USER_NOT_FOUND",
351
+ status: 404,
352
+ message: "No such user."
353
+ });
354
+ const { meta } = await db.prepare("INSERT INTO user_role (user_id, role_id, assigned_at, assigned_by) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").bind(input.userId, input.roleId, clock.now().getTime(), input.actorId).run();
355
+ if (meta.changes > 0) await recordAssignment(ACCESS_CONTROL_AUDIT_ACTIONS.roleAssigned, input);
356
+ }),
357
+ unassignRole: (input) => changing(async () => {
358
+ const { meta } = await db.prepare("DELETE FROM user_role WHERE user_id = ? AND role_id = ?").bind(input.userId, input.roleId).run();
359
+ if (meta.changes > 0) await recordAssignment(ACCESS_CONTROL_AUDIT_ACTIONS.roleUnassigned, input);
360
+ }),
361
+ seed: () => seedCatalogue(db, definition, clock.now()),
362
+ ...admin,
363
+ updateRole: (input) => changing(() => admin.updateRole(input)),
364
+ deleteRole: (input) => changing(() => admin.deleteRole(input))
365
+ };
366
+ }
367
+ // src/define.ts
368
+ /** `namespace.thing[.more]`: lowercase segments of letters, digits and `_`, at least two. */
369
+ const PERMISSION_NAME = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
370
+ /** System role IDs: lowercase letters, digits, `_` and `-`. */
371
+ const ROLE_ID = /^[a-z][a-z0-9_-]*$/;
372
+ function assertNoCycle(roles) {
373
+ const byId = new Map(roles.map((role) => [role.id, role]));
374
+ const done = /* @__PURE__ */ new Set();
375
+ const visit = (id, path) => {
376
+ if (path.includes(id)) throw new Error(`Role inheritance cycle: ${[...path, id].join(" -> ")}.`);
377
+ if (done.has(id)) return;
378
+ for (const parent of byId.get(id)?.inherits ?? []) visit(parent, [...path, id]);
379
+ done.add(id);
380
+ };
381
+ for (const role of roles) visit(role.id, []);
382
+ }
383
+ /**
384
+ * Declares the app's permissions and system roles. Throws on a malformed name,
385
+ * an undeclared permission or role, a duplicate role name or an inheritance
386
+ * cycle: all programming errors, found at startup.
387
+ *
388
+ * ```ts
389
+ * export const access = defineAccessControl({
390
+ * permissions: { "notes.read": "Read notes", "notes.write": "Write notes" },
391
+ * roles: {
392
+ * reader: { name: "Reader", permissions: ["notes.read"] },
393
+ * editor: { name: "Editor", permissions: ["notes.write"], inherits: ["reader"] },
394
+ * },
395
+ * });
396
+ * ```
397
+ */
398
+ function defineAccessControl(input) {
399
+ const permissions = Object.entries(input.permissions).map(([name, description]) => ({
400
+ name,
401
+ description
402
+ })).sort((a, b) => a.name.localeCompare(b.name));
403
+ const names = new Set(permissions.map((permission) => permission.name));
404
+ for (const { name } of permissions) if (!PERMISSION_NAME.test(name)) throw new Error(`Permission "${name}" is not a dotted lowercase name such as "notes.read".`);
405
+ const declared = input.roles;
406
+ const roles = Object.entries(declared).map(([id, role]) => ({
407
+ id,
408
+ name: role.name,
409
+ description: role.description ?? null,
410
+ permissions: [...new Set(role.permissions)].sort(),
411
+ inherits: [...new Set(role.inherits ?? [])].sort()
412
+ })).sort((a, b) => a.id.localeCompare(b.id));
413
+ const roleIds = new Set(roles.map((role) => role.id));
414
+ const roleNames = /* @__PURE__ */ new Set();
415
+ for (const role of roles) {
416
+ if (!ROLE_ID.test(role.id)) throw new Error(`Role ID "${role.id}" must be lowercase letters, digits, "_" or "-".`);
417
+ if (role.name.trim() === "") throw new Error(`Role "${role.id}" needs a name.`);
418
+ if (roleNames.has(role.name)) throw new Error(`Two roles are named "${role.name}".`);
419
+ roleNames.add(role.name);
420
+ for (const permission of role.permissions) if (!names.has(permission)) throw new Error(`Role "${role.id}" grants undeclared permission "${permission}".`);
421
+ for (const parent of role.inherits) if (!roleIds.has(parent)) throw new Error(`Role "${role.id}" inherits undeclared role "${parent}".`);
422
+ }
423
+ assertNoCycle(roles);
424
+ return {
425
+ permissions,
426
+ roles,
427
+ hasPermission: (name) => names.has(name)
428
+ };
429
+ }
430
+ // src/index.ts
431
+ /**
432
+ * The `migrations_table` of the app's access-control D1 binding (ADR-4).
433
+ * Declare that binding after auth's and before audit's.
434
+ */
435
+ const ACCESS_CONTROL_MIGRATIONS_TABLE = "_inbis_access_control_migrations";
436
+ export { ACCESS_CONTROL_AUDIT_ACTIONS, ACCESS_CONTROL_MIGRATIONS_TABLE, createAccessControl, defineAccessControl };
437
+
438
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/actions.ts","../src/roles.ts","../src/seed.ts","../src/access-control.ts","../src/define.ts","../src/index.ts"],"sourcesContent":["/**\n * Audit actions recorded by `@awsmbiz/inbis-access-control`; register them with the\n * app's audit trail (`createAuditActions({ platform: [ACCESS_CONTROL_AUDIT_ACTIONS] })`).\n * - `access.role.assigned` / `access.role.unassigned`: subject `user:<id>`, metadata `roleId`.\n * - `access.role.created`: subject `role:<id>`, metadata `name`, `description`, `permissions`, `inherits`.\n * - `access.role.updated`: subject `role:<id>`, metadata `changes` (`name`/`description` as\n * `{ from, to }`, `permissions`/`inherits` as `{ added, removed }`; changed fields only).\n * - `access.role.deleted`: subject `role:<id>`, metadata `name`, `permissions`, `inherits`,\n * `unassignedUserIds` (the assignments deleted with it).\n */\nexport const ACCESS_CONTROL_AUDIT_ACTIONS = {\n roleAssigned: \"access.role.assigned\",\n roleUnassigned: \"access.role.unassigned\",\n roleCreated: \"access.role.created\",\n roleUpdated: \"access.role.updated\",\n roleDeleted: \"access.role.deleted\",\n} as const;\n","import { AppError } from \"@awsmbiz/inbis-core\";\nimport { ACCESS_CONTROL_AUDIT_ACTIONS } from \"./actions.js\";\nimport type { AccessControlDefinition, PermissionDefinition } from \"./define.js\";\nimport { cleanDescription, cleanList, cleanName, type RoleFields } from \"./role-fields.js\";\n\n/** A role as the admin API shows it. */\nexport interface Role {\n id: string;\n name: string;\n description: string | null;\n /** Declared in code: read-only through the API. */\n system: boolean;\n /** Permissions the role grants itself (declared ones only), sorted. */\n permissions: string[];\n /** IDs of the roles it inherits directly, sorted. */\n inherits: string[];\n /** Users the role is assigned to directly. */\n userCount: number;\n createdAt: Date;\n updatedAt: Date;\n}\n\n/** A role assigned to a user directly. */\nexport interface UserRole {\n /** The role's ID. */\n id: string;\n name: string;\n system: boolean;\n assignedAt: Date;\n /** The admin who assigned it; `null` for the system. */\n assignedBy: string | null;\n}\n\nexport interface CreateRoleInput extends RoleFields {\n actorId: string | null;\n}\n\nexport interface UpdateRoleInput {\n roleId: string;\n /** The fields to change; others keep their value. */\n changes: Partial<RoleFields>;\n actorId: string | null;\n}\n\nexport interface DeleteRoleInput {\n roleId: string;\n actorId: string | null;\n}\n\n/** Records an audit event (failures are the recorder's to handle). */\nexport type Recorder = (action: string, subjectId: string, actorId: string | null, metadata: Record<string, unknown>) => Promise<void>;\n\ninterface RoleRow {\n id: string;\n name: string;\n description: string | null;\n system: number;\n created_at: number;\n updated_at: number;\n permissions: string;\n inherits: string;\n user_count: number;\n}\n\nconst ROLES = `\nSELECT r.id, r.name, r.description, r.system, r.created_at, r.updated_at,\n (SELECT json_group_array(permission) FROM (SELECT permission FROM role_permission WHERE role_id = r.id ORDER BY permission)) AS permissions,\n (SELECT json_group_array(inherits_role_id) FROM (SELECT inherits_role_id FROM role_inheritance WHERE role_id = r.id ORDER BY inherits_role_id)) AS inherits,\n (SELECT count(*) FROM user_role WHERE role_id = r.id) AS user_count\nFROM role r`;\n\nconst roleNotFound = () => new AppError({ code: \"ROLE_NOT_FOUND\", status: 404, message: \"No such role.\" });\nconst userNotFound = () => new AppError({ code: \"USER_NOT_FOUND\", status: 404, message: \"No such user.\" });\n\n/** Lists `added` and `removed` between two sorted lists. */\nfunction listDiff(from: readonly string[], to: readonly string[]) {\n return { added: to.filter((item) => !from.includes(item)), removed: from.filter((item) => !to.includes(item)) };\n}\n\n/** Role administration over the access-control tables (plan Q40: custom roles; system roles read-only). */\nexport function roleAdmin<P extends string>(db: D1Database, definition: AccessControlDefinition<P>, record: Recorder, now: () => Date) {\n const toRole = (row: RoleRow): Role => ({\n id: row.id,\n name: row.name,\n description: row.description,\n system: row.system === 1,\n permissions: (JSON.parse(row.permissions) as string[]).filter((name) => definition.hasPermission(name)),\n inherits: JSON.parse(row.inherits) as string[],\n userCount: row.user_count,\n createdAt: new Date(row.created_at),\n updatedAt: new Date(row.updated_at),\n });\n\n async function findRole(roleId: string): Promise<Role | null> {\n const row = await db.prepare(`${ROLES} WHERE r.id = ?`).bind(roleId).first<RoleRow>();\n return row === null ? null : toRole(row);\n }\n\n async function getRole(roleId: string): Promise<Role> {\n const role = await findRole(roleId);\n if (role === null) throw roleNotFound();\n return role;\n }\n\n async function assertNameFree(name: string, roleId: string): Promise<void> {\n const other = await db.prepare(\"SELECT id FROM role WHERE lower(name) = lower(?) AND id <> ?\").bind(name, roleId).first(\"id\");\n if (other !== null) throw nameTaken(name);\n }\n\n function assertPermissions(permissions: readonly string[]): void {\n const unknown = permissions.filter((name) => !definition.hasPermission(name));\n if (unknown.length > 0) {\n throw new AppError({ code: \"UNKNOWN_PERMISSION\", status: 422, message: \"Some permissions are not declared.\", details: { permissions: unknown } });\n }\n }\n\n /** Unknown roles are `422 UNKNOWN_ROLE`; an edge that would close a loop is `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`). */\n async function assertInheritance(roleId: string, inherits: readonly string[]): Promise<void> {\n const { results: edges } = await db\n .prepare(\"SELECT id AS role_id, NULL AS inherits_role_id FROM role UNION ALL SELECT role_id, inherits_role_id FROM role_inheritance\")\n .all<{ role_id: string; inherits_role_id: string | null }>();\n const parents = new Map<string, string[]>();\n for (const edge of edges) {\n const list = parents.get(edge.role_id) ?? [];\n if (edge.inherits_role_id !== null) list.push(edge.inherits_role_id);\n parents.set(edge.role_id, list);\n }\n const unknown = inherits.filter((id) => !parents.has(id) && id !== roleId);\n if (unknown.length > 0) {\n throw new AppError({ code: \"UNKNOWN_ROLE\", status: 422, message: \"Some inherited roles do not exist.\", details: { roleIds: unknown } });\n }\n parents.set(roleId, [...inherits]);\n // A cycle through this role: a path from it back to itself.\n const seen = new Set<string>();\n const find = (id: string, path: string[]): string[] | null => {\n for (const parent of parents.get(id) ?? []) {\n if (parent === roleId) return [...path, parent];\n if (seen.has(parent)) continue;\n seen.add(parent);\n const found = find(parent, [...path, parent]);\n if (found !== null) return found;\n }\n return null;\n };\n const cycle = find(roleId, [roleId]);\n if (cycle !== null) {\n throw new AppError({\n code: \"ROLE_INHERITANCE_CYCLE\",\n status: 422,\n message: \"A role cannot inherit itself, directly or through other roles.\",\n details: { cycle },\n });\n }\n }\n\n function assertCustom(role: Role): void {\n if (role.system) {\n throw new AppError({ code: \"SYSTEM_ROLE_IMMUTABLE\", status: 409, message: \"System roles are declared in code and cannot be changed.\" });\n }\n }\n\n const replaceGrants = (roleId: string, permissions: readonly string[]) => [\n db.prepare(\"DELETE FROM role_permission WHERE role_id = ?\").bind(roleId),\n db.prepare(\"INSERT INTO role_permission (role_id, permission) SELECT ?, value FROM json_each(?)\").bind(roleId, JSON.stringify(permissions)),\n ];\n const replaceInheritance = (roleId: string, inherits: readonly string[]) => [\n db.prepare(\"DELETE FROM role_inheritance WHERE role_id = ?\").bind(roleId),\n db.prepare(\"INSERT INTO role_inheritance (role_id, inherits_role_id) SELECT ?, value FROM json_each(?)\").bind(roleId, JSON.stringify(inherits)),\n ];\n\n /** Runs the batch, turning the unique-name index's refusal (a concurrent create) into `409 ROLE_NAME_TAKEN`. */\n async function write(statements: D1PreparedStatement[], name: string): Promise<void> {\n try {\n await db.batch(statements);\n } catch (error) {\n if (String(error).includes(\"UNIQUE constraint failed: role.name\")) throw nameTaken(name);\n throw error;\n }\n }\n\n return {\n listPermissions: (): PermissionDefinition<P>[] => definition.permissions.map(({ name, description }) => ({ name, description })),\n\n async listRoles(): Promise<Role[]> {\n const { results } = await db.prepare(`${ROLES} ORDER BY r.system DESC, r.name COLLATE NOCASE, r.id`).all<RoleRow>();\n return results.map(toRole);\n },\n\n getRole,\n\n async createRole(input: CreateRoleInput): Promise<Role> {\n const id = crypto.randomUUID();\n const name = cleanName(input.name);\n const description = cleanDescription(input.description);\n const permissions = cleanList(input.permissions);\n const inherits = cleanList(input.inherits);\n assertPermissions(permissions);\n await assertInheritance(id, inherits);\n await assertNameFree(name, id);\n const at = now().getTime();\n await write(\n [\n db\n .prepare(\"INSERT INTO role (id, name, description, system, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)\")\n .bind(id, name, description, at, at),\n ...replaceGrants(id, permissions),\n ...replaceInheritance(id, inherits),\n ],\n name,\n );\n await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleCreated, `role:${id}`, input.actorId, { name, description, permissions, inherits });\n return getRole(id);\n },\n\n async updateRole({ roleId, changes, actorId }: UpdateRoleInput): Promise<Role> {\n const role = await getRole(roleId);\n assertCustom(role);\n const name = changes.name === undefined ? role.name : cleanName(changes.name);\n const description = changes.description === undefined ? role.description : cleanDescription(changes.description);\n const permissions = changes.permissions === undefined ? role.permissions : cleanList(changes.permissions);\n const inherits = changes.inherits === undefined ? role.inherits : cleanList(changes.inherits);\n assertPermissions(permissions);\n await assertInheritance(roleId, inherits);\n if (name !== role.name) await assertNameFree(name, roleId);\n\n // A secret-free diff: changed scalars as { from, to }, lists as { added, removed }.\n const diff: Record<string, unknown> = {};\n if (name !== role.name) diff[\"name\"] = { from: role.name, to: name };\n if (description !== role.description) diff[\"description\"] = { from: role.description, to: description };\n const permissionDiff = listDiff(role.permissions, permissions);\n if (permissionDiff.added.length + permissionDiff.removed.length > 0) diff[\"permissions\"] = permissionDiff;\n const inheritsDiff = listDiff(role.inherits, inherits);\n if (inheritsDiff.added.length + inheritsDiff.removed.length > 0) diff[\"inherits\"] = inheritsDiff;\n if (Object.keys(diff).length === 0) return role;\n\n await write(\n [\n db.prepare(\"UPDATE role SET name = ?, description = ?, updated_at = ? WHERE id = ?\").bind(name, description, now().getTime(), roleId),\n ...(\"permissions\" in diff ? replaceGrants(roleId, permissions) : []),\n ...(\"inherits\" in diff ? replaceInheritance(roleId, inherits) : []),\n ],\n name,\n );\n await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleUpdated, `role:${roleId}`, actorId, { changes: diff });\n return getRole(roleId);\n },\n\n async deleteRole({ roleId, actorId }: DeleteRoleInput): Promise<void> {\n const role = await getRole(roleId);\n assertCustom(role);\n const { results: heirs } = await db\n .prepare(\"SELECT role_id FROM role_inheritance WHERE inherits_role_id = ? ORDER BY role_id\")\n .bind(roleId)\n .all<{ role_id: string }>();\n if (heirs.length > 0) {\n throw new AppError({\n code: \"ROLE_INHERITED\",\n status: 409,\n message: \"Other roles inherit this role; remove it from them first.\",\n details: { roleIds: heirs.map((row) => row.role_id) },\n });\n }\n const [users] = await db.batch<{ user_id: string }>([\n db.prepare(\"SELECT user_id FROM user_role WHERE role_id = ? ORDER BY user_id\").bind(roleId),\n // Assignments and grants go with it (ON DELETE CASCADE).\n db.prepare(\"DELETE FROM role WHERE id = ? AND system = 0\").bind(roleId),\n ]);\n await record(ACCESS_CONTROL_AUDIT_ACTIONS.roleDeleted, `role:${roleId}`, actorId, {\n name: role.name,\n permissions: role.permissions,\n inherits: role.inherits,\n unassignedUserIds: (users?.results ?? []).map((row) => row.user_id),\n });\n },\n\n async userRoles(userId: string): Promise<UserRole[]> {\n const user = await db.prepare(\"SELECT id FROM user WHERE id = ?\").bind(userId).first(\"id\");\n if (user === null) throw userNotFound();\n const { results } = await db\n .prepare(\n `SELECT r.id, r.name, r.system, ur.assigned_at, ur.assigned_by FROM user_role ur JOIN role r ON r.id = ur.role_id\n WHERE ur.user_id = ? ORDER BY r.system DESC, r.name COLLATE NOCASE, r.id`,\n )\n .bind(userId)\n .all<{ id: string; name: string; system: number; assigned_at: number; assigned_by: string | null }>();\n return results.map((row) => ({\n id: row.id,\n name: row.name,\n system: row.system === 1,\n assignedAt: new Date(row.assigned_at),\n assignedBy: row.assigned_by,\n }));\n },\n };\n}\n\nfunction nameTaken(name: string): AppError {\n return new AppError({ code: \"ROLE_NAME_TAKEN\", status: 409, message: `A role named \"${name}\" already exists.`, details: { name } });\n}\n\n\n","import type { AccessControlDefinition } from \"./define.js\";\n\n/** What {@link seedCatalogue} did. */\nexport interface SeedResult {\n /** `false` when the database already held this exact catalogue (nothing written). */\n changed: boolean;\n}\n\nasync function fingerprintOf(definition: AccessControlDefinition): Promise<string> {\n const canonical = JSON.stringify({ permissions: definition.permissions, roles: definition.roles });\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(canonical));\n return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/**\n * Makes the database hold exactly the declared catalogue: upserts permissions\n * and system roles, replaces system roles' grants and inheritance, and removes\n * permissions and system roles no longer declared (with their grants and\n * assignments). Custom roles keep their rows; grants of a removed permission go.\n * One D1 batch (a transaction). Idempotent; skipped when the stored\n * fingerprint matches, so running it per isolate costs one read.\n */\nexport async function seedCatalogue(db: D1Database, definition: AccessControlDefinition, now: Date): Promise<SeedResult> {\n const fingerprint = await fingerprintOf(definition);\n const stored = await db.prepare(\"SELECT fingerprint FROM access_control_seed WHERE id = 1\").first<string>(\"fingerprint\");\n if (stored === fingerprint) return { changed: false };\n\n const at = now.getTime();\n const permissionNames = JSON.stringify(definition.permissions.map((permission) => permission.name));\n const roleIds = JSON.stringify(definition.roles.map((role) => role.id));\n const statements: D1PreparedStatement[] = [\n ...definition.permissions.map((permission) =>\n db\n .prepare(\n \"INSERT INTO permission (name, description) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET description = excluded.description\",\n )\n .bind(permission.name, permission.description),\n ),\n db.prepare(\"DELETE FROM permission WHERE name NOT IN (SELECT value FROM json_each(?))\").bind(permissionNames),\n db.prepare(\"DELETE FROM role WHERE system = 1 AND id NOT IN (SELECT value FROM json_each(?))\").bind(roleIds),\n ...definition.roles.map((role) =>\n db\n .prepare(\n `INSERT INTO role (id, name, description, system, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)\n ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, system = 1, updated_at = excluded.updated_at`,\n )\n .bind(role.id, role.name, role.description, at, at),\n ),\n ...definition.roles.flatMap((role) => [\n db.prepare(\"DELETE FROM role_permission WHERE role_id = ?\").bind(role.id),\n db\n .prepare(\"INSERT INTO role_permission (role_id, permission) SELECT ?, value FROM json_each(?)\")\n .bind(role.id, JSON.stringify(role.permissions)),\n db.prepare(\"DELETE FROM role_inheritance WHERE role_id = ?\").bind(role.id),\n db\n .prepare(\"INSERT INTO role_inheritance (role_id, inherits_role_id) SELECT ?, value FROM json_each(?)\")\n .bind(role.id, JSON.stringify(role.inherits)),\n ]),\n db\n .prepare(\n \"INSERT INTO access_control_seed (id, fingerprint, seeded_at) VALUES (1, ?, ?) ON CONFLICT (id) DO UPDATE SET fingerprint = excluded.fingerprint, seeded_at = excluded.seeded_at\",\n )\n .bind(fingerprint, at),\n ];\n await db.batch(statements);\n return { changed: true };\n}\n","import { AppError, createLogger, systemClock, type AuditSink, type Clock, type Logger } from \"@awsmbiz/inbis-core\";\nimport { ACCESS_CONTROL_AUDIT_ACTIONS } from \"./actions.js\";\nimport type { AccessControlDefinition, PermissionDefinition } from \"./define.js\";\nimport { roleAdmin, type CreateRoleInput, type DeleteRoleInput, type Role, type UpdateRoleInput, type UserRole } from \"./roles.js\";\nimport { seedCatalogue, type SeedResult } from \"./seed.js\";\n\nexport interface AuthorizeInput<P extends string = string> {\n userId: string;\n permission: P;\n /**\n * The record the action applies to, e.g. `note:<id>`. Reserved: ignored\n * today, so apps can add resource scoping later without a breaking change.\n */\n resource?: string;\n}\n\nexport interface RoleAssignmentInput {\n userId: string;\n roleId: string;\n /** Who made the change (audited); `null` for the system. */\n actorId: string | null;\n}\n\n/**\n * Access control for one request (create one per request: answers are\n * memoised for its life only, and forgotten when it changes roles or assignments).\n */\nexport interface AccessControl<P extends string = string> {\n /**\n * `true` when one of the user's roles, or a role they explicitly inherit,\n * grants the permission. Reads D1 (once per user per instance, never a\n * cache), so revocation applies on the next request. Unknown permissions\n * deny. A platform admin (`role` `admin`) gets nothing implicitly.\n */\n authorize(input: AuthorizeInput<P>): Promise<boolean>;\n /** IDs of the roles assigned to the user directly (not inherited), sorted. */\n rolesOf(userId: string): Promise<string[]>;\n /**\n * Assigns a role (idempotent). Audited as `access.role.assigned` when it\n * changes anything. `404 ROLE_NOT_FOUND` / `404 USER_NOT_FOUND`.\n */\n assignRole(input: RoleAssignmentInput): Promise<void>;\n /** Removes a role assignment (idempotent). Audited as `access.role.unassigned` when it changes anything. */\n unassignRole(input: RoleAssignmentInput): Promise<void>;\n /** Seeds the catalogue and system roles into D1 (see `seedCatalogue`); run once per isolate before use. */\n seed(): Promise<SeedResult>;\n\n // Role administration (the admin API, `mountRoleAdmin`). Refusals are `AppError`s.\n\n /** The declared permission catalogue, sorted by name. */\n listPermissions(): PermissionDefinition<P>[];\n /** Every role: system roles first, then by name. */\n listRoles(): Promise<Role[]>;\n /** `404 ROLE_NOT_FOUND` for an unknown ID. */\n getRole(roleId: string): Promise<Role>;\n /**\n * Creates a custom role. Audited as `access.role.created`. `422 VALIDATION_FAILED`\n * (name, description), `422 UNKNOWN_PERMISSION` (`details.permissions`),\n * `422 UNKNOWN_ROLE` (`details.roleIds`), `409 ROLE_NAME_TAKEN`.\n */\n createRole(input: CreateRoleInput): Promise<Role>;\n /**\n * Changes a custom role's fields. Audited as `access.role.updated` with a diff\n * (nothing is written or audited when nothing changes). As {@link createRole},\n * plus `404 ROLE_NOT_FOUND`, `409 SYSTEM_ROLE_IMMUTABLE` and\n * `422 ROLE_INHERITANCE_CYCLE` (`details.cycle`, role IDs from this role back to it).\n */\n updateRole(input: UpdateRoleInput): Promise<Role>;\n /**\n * Deletes a custom role and its assignments. Audited as `access.role.deleted`.\n * `404 ROLE_NOT_FOUND`, `409 SYSTEM_ROLE_IMMUTABLE`, `409 ROLE_INHERITED`\n * (`details.roleIds`: roles that inherit it).\n */\n deleteRole(input: DeleteRoleInput): Promise<void>;\n /** The user's directly assigned roles. `404 USER_NOT_FOUND`. */\n userRoles(userId: string): Promise<UserRole[]>;\n}\n\nexport interface AccessControlOptions {\n /** Receives role-assignment events (e.g. `@awsmbiz/inbis-audit`'s sink). Without it, nothing is audited. */\n audit?: AuditSink;\n /** Stamped on audit events. */\n requestId?: string;\n /** Receives `access.audit.record_failed`. Defaults to a console JSON logger. */\n logger?: Logger;\n clock?: Clock;\n}\n\n/** Every permission the user holds through their roles and the roles those explicitly inherit. */\nconst PERMISSIONS_OF_USER = `\nWITH RECURSIVE granted(role_id) AS (\n SELECT role_id FROM user_role WHERE user_id = ?\n UNION\n SELECT ri.inherits_role_id FROM role_inheritance ri JOIN granted g ON ri.role_id = g.role_id\n)\nSELECT DISTINCT rp.permission AS permission FROM role_permission rp JOIN granted g ON rp.role_id = g.role_id`;\n\n/**\n * Access control over the database holding the access-control tables (the\n * app's access-control binding), for one request.\n */\nexport function createAccessControl<P extends string>(\n db: D1Database,\n definition: AccessControlDefinition<P>,\n options: AccessControlOptions = {},\n): AccessControl<P> {\n const clock = options.clock ?? systemClock;\n const memo = new Map<string, Promise<ReadonlySet<string>>>();\n\n function permissionsOf(userId: string): Promise<ReadonlySet<string>> {\n let pending = memo.get(userId);\n if (pending === undefined) {\n pending = db\n .prepare(PERMISSIONS_OF_USER)\n .bind(userId)\n .all<{ permission: string }>()\n .then(({ results }) => new Set(results.map((row) => row.permission)));\n // A failed read is not remembered: the next call tries again.\n pending.catch(() => memo.delete(userId));\n memo.set(userId, pending);\n }\n return pending;\n }\n\n /**\n * Runs a change, then forgets every memoised answer (even if it failed part\n * way): later checks on this instance read the new state from D1.\n */\n async function changing<T>(change: () => Promise<T>): Promise<T> {\n try {\n return await change();\n } finally {\n memo.clear();\n }\n }\n\n const admin = roleAdmin(db, definition, record, () => clock.now());\n\n const recordAssignment = (action: string, { userId, roleId, actorId }: RoleAssignmentInput) =>\n record(action, `user:${userId}`, actorId, { roleId });\n\n async function record(action: string, subjectId: string, actorId: string | null, metadata: Record<string, unknown>): Promise<void> {\n if (options.audit === undefined) return;\n try {\n await options.audit.record({\n action,\n occurredAt: clock.now(),\n actorId,\n subjectId,\n ...(options.requestId === undefined ? {} : { requestId: options.requestId }),\n metadata,\n });\n } catch (error) {\n // The change is made; a failing sink is logged, never the request's failure.\n (options.logger ?? createLogger()).error(\"access.audit.record_failed\", { action, error });\n }\n }\n\n return {\n async authorize({ userId, permission }) {\n if (!definition.hasPermission(permission)) return false;\n return (await permissionsOf(userId)).has(permission);\n },\n async rolesOf(userId) {\n const { results } = await db\n .prepare(\"SELECT role_id FROM user_role WHERE user_id = ? ORDER BY role_id\")\n .bind(userId)\n .all<{ role_id: string }>();\n return results.map((row) => row.role_id);\n },\n assignRole: (input) => changing(async () => {\n const role = await db.prepare(\"SELECT id FROM role WHERE id = ?\").bind(input.roleId).first(\"id\");\n if (role === null) throw new AppError({ code: \"ROLE_NOT_FOUND\", status: 404, message: \"No such role.\" });\n const user = await db.prepare(\"SELECT id FROM user WHERE id = ?\").bind(input.userId).first(\"id\");\n if (user === null) throw new AppError({ code: \"USER_NOT_FOUND\", status: 404, message: \"No such user.\" });\n const { meta } = await db\n .prepare(\"INSERT INTO user_role (user_id, role_id, assigned_at, assigned_by) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING\")\n .bind(input.userId, input.roleId, clock.now().getTime(), input.actorId)\n .run();\n if (meta.changes > 0) await recordAssignment(ACCESS_CONTROL_AUDIT_ACTIONS.roleAssigned, input);\n }),\n unassignRole: (input) => changing(async () => {\n const { meta } = await db\n .prepare(\"DELETE FROM user_role WHERE user_id = ? AND role_id = ?\")\n .bind(input.userId, input.roleId)\n .run();\n if (meta.changes > 0) await recordAssignment(ACCESS_CONTROL_AUDIT_ACTIONS.roleUnassigned, input);\n }),\n seed: () => seedCatalogue(db, definition, clock.now()),\n ...admin,\n updateRole: (input) => changing(() => admin.updateRole(input)),\n deleteRole: (input) => changing(() => admin.deleteRole(input)),\n };\n}\n","/**\n * The permission catalogue and system roles, declared in the app's code\n * (plan Q40). Permission names are typed: a typo is a compile error.\n */\n\n/** A system role as the app declares it. */\nexport interface RoleDeclaration<P extends string, R extends string> {\n /** Display name, unique among all roles (custom ones included). */\n name: string;\n description?: string;\n /** Permissions the role grants itself. */\n permissions: readonly P[];\n /**\n * Roles whose permissions this role also grants (transitively). Explicit\n * inheritance is the only inheritance: nothing is implied by names or order.\n */\n inherits?: readonly R[];\n}\n\nexport interface AccessControlInput<P extends string, R extends string> {\n /** Every permission the app checks, with a description for the admin UI: `{ \"notes.read\": \"Read notes\" }`. */\n permissions: Readonly<Record<P, string>>;\n /** System roles by ID: seeded into D1 and immutable through the API. */\n roles: Readonly<Record<R, RoleDeclaration<NoInfer<P>, NoInfer<R>>>>;\n}\n\nexport interface PermissionDefinition<P extends string = string> {\n name: P;\n description: string;\n}\n\nexport interface SystemRoleDefinition<P extends string = string, R extends string = string> {\n id: R;\n name: string;\n description: string | null;\n permissions: readonly P[];\n inherits: readonly R[];\n}\n\n/** A validated catalogue, from {@link defineAccessControl}. */\nexport interface AccessControlDefinition<P extends string = string, R extends string = string> {\n /** Sorted by name. */\n readonly permissions: readonly PermissionDefinition<P>[];\n /** Sorted by ID. */\n readonly roles: readonly SystemRoleDefinition<P, R>[];\n /** True for a declared permission name. */\n hasPermission(name: string): name is P;\n}\n\n/** `namespace.thing[.more]`: lowercase segments of letters, digits and `_`, at least two. */\nconst PERMISSION_NAME = /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$/;\n/** System role IDs: lowercase letters, digits, `_` and `-`. */\nconst ROLE_ID = /^[a-z][a-z0-9_-]*$/;\n\nfunction assertNoCycle(roles: readonly SystemRoleDefinition[]): void {\n const byId = new Map(roles.map((role) => [role.id, role]));\n const done = new Set<string>();\n const visit = (id: string, path: readonly string[]) => {\n if (path.includes(id)) throw new Error(`Role inheritance cycle: ${[...path, id].join(\" -> \")}.`);\n if (done.has(id)) return;\n for (const parent of byId.get(id)?.inherits ?? []) visit(parent, [...path, id]);\n done.add(id);\n };\n for (const role of roles) visit(role.id, []);\n}\n\n/**\n * Declares the app's permissions and system roles. Throws on a malformed name,\n * an undeclared permission or role, a duplicate role name or an inheritance\n * cycle: all programming errors, found at startup.\n *\n * ```ts\n * export const access = defineAccessControl({\n * permissions: { \"notes.read\": \"Read notes\", \"notes.write\": \"Write notes\" },\n * roles: {\n * reader: { name: \"Reader\", permissions: [\"notes.read\"] },\n * editor: { name: \"Editor\", permissions: [\"notes.write\"], inherits: [\"reader\"] },\n * },\n * });\n * ```\n */\nexport function defineAccessControl<const P extends string, const R extends string>(\n input: AccessControlInput<P, R>,\n): AccessControlDefinition<P, R> {\n const permissions = (Object.entries(input.permissions) as [P, string][])\n .map(([name, description]) => ({ name, description }))\n .sort((a, b) => a.name.localeCompare(b.name));\n const names = new Set<string>(permissions.map((permission) => permission.name));\n for (const { name } of permissions) {\n if (!PERMISSION_NAME.test(name)) throw new Error(`Permission \"${name}\" is not a dotted lowercase name such as \"notes.read\".`);\n }\n\n const declared = input.roles as Readonly<Record<R, RoleDeclaration<P, R>>>;\n const roles = (Object.entries(declared) as [R, RoleDeclaration<P, R>][])\n .map(([id, role]) => ({\n id,\n name: role.name,\n description: role.description ?? null,\n permissions: [...new Set(role.permissions)].sort(),\n inherits: [...new Set(role.inherits ?? [])].sort(),\n }))\n .sort((a, b) => a.id.localeCompare(b.id));\n const roleIds = new Set<string>(roles.map((role) => role.id));\n const roleNames = new Set<string>();\n for (const role of roles) {\n if (!ROLE_ID.test(role.id)) throw new Error(`Role ID \"${role.id}\" must be lowercase letters, digits, \"_\" or \"-\".`);\n if (role.name.trim() === \"\") throw new Error(`Role \"${role.id}\" needs a name.`);\n if (roleNames.has(role.name)) throw new Error(`Two roles are named \"${role.name}\".`);\n roleNames.add(role.name);\n for (const permission of role.permissions) {\n if (!names.has(permission)) throw new Error(`Role \"${role.id}\" grants undeclared permission \"${permission}\".`);\n }\n for (const parent of role.inherits) {\n if (!roleIds.has(parent)) throw new Error(`Role \"${role.id}\" inherits undeclared role \"${parent}\".`);\n }\n }\n assertNoCycle(roles);\n\n return {\n permissions,\n roles,\n hasPermission: (name): name is P => names.has(name),\n };\n}\n","export {\n createAccessControl,\n type AccessControl,\n type AccessControlOptions,\n type AuthorizeInput,\n type RoleAssignmentInput,\n} from \"./access-control.js\";\nexport { ACCESS_CONTROL_AUDIT_ACTIONS } from \"./actions.js\";\nexport {\n defineAccessControl,\n type AccessControlDefinition,\n type AccessControlInput,\n type PermissionDefinition,\n type RoleDeclaration,\n type SystemRoleDefinition,\n} from \"./define.js\";\nexport type { RoleFields } from \"./role-fields.js\";\nexport type { CreateRoleInput, DeleteRoleInput, Role, UpdateRoleInput, UserRole } from \"./roles.js\";\nexport type { SeedResult } from \"./seed.js\";\n\n/**\n * The `migrations_table` of the app's access-control D1 binding (ADR-4).\n * Declare that binding after auth's and before audit's.\n */\nexport const ACCESS_CONTROL_MIGRATIONS_TABLE = \"_inbis_access_control_migrations\";\n"],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import { validationFailed } from "@awsmbiz/inbis-core";
2
+ function stringList(value, field, issues) {
3
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
4
+ issues.push({
5
+ path: [field],
6
+ message: "Must be an array of strings."
7
+ });
8
+ return;
9
+ }
10
+ return value;
11
+ }
12
+ function parseRoleFields(body, options = {}) {
13
+ const issues = [];
14
+ const fields = {};
15
+ if (typeof body !== "object" || body === null || Array.isArray(body)) issues.push({
16
+ path: [],
17
+ message: "Must be a JSON object."
18
+ });
19
+ else {
20
+ const input = body;
21
+ const partial = options.partial === true;
22
+ if (input["name"] !== void 0 || !partial) {
23
+ if (typeof input["name"] === "string") fields.name = input["name"];
24
+ else issues.push({
25
+ path: ["name"],
26
+ message: "Must be a string."
27
+ });
28
+ }
29
+ if (input["description"] !== void 0) {
30
+ if (typeof input["description"] === "string" || input["description"] === null) fields.description = input["description"];
31
+ else issues.push({
32
+ path: ["description"],
33
+ message: "Must be a string or null."
34
+ });
35
+ }
36
+ if (input["permissions"] !== void 0 || !partial) {
37
+ const permissions = stringList(input["permissions"], "permissions", issues);
38
+ if (permissions !== void 0) fields.permissions = permissions;
39
+ }
40
+ if (input["inherits"] !== void 0) {
41
+ const inherits = stringList(input["inherits"], "inherits", issues);
42
+ if (inherits !== void 0) fields.inherits = inherits;
43
+ }
44
+ }
45
+ if (issues.length > 0) throw validationFailed(issues);
46
+ return fields;
47
+ }
48
+ /** The trimmed name, or `422 VALIDATION_FAILED`. */
49
+ function cleanName(name) {
50
+ const trimmed = name.trim();
51
+ if (trimmed === "" || trimmed.length > 64) throw validationFailed([{
52
+ path: ["name"],
53
+ message: `Must be 1 to ${String(64)} characters.`
54
+ }]);
55
+ return trimmed;
56
+ }
57
+ /** The trimmed description (`null` when empty), or `422 VALIDATION_FAILED`. */
58
+ function cleanDescription(description) {
59
+ const trimmed = description?.trim() ?? "";
60
+ if (trimmed.length > 500) throw validationFailed([{
61
+ path: ["description"],
62
+ message: `Must be at most ${String(500)} characters.`
63
+ }]);
64
+ return trimmed === "" ? null : trimmed;
65
+ }
66
+ /** Deduplicated and sorted. */
67
+ function cleanList(values) {
68
+ return [...new Set(values ?? [])].sort();
69
+ }
70
+ export { parseRoleFields as i, cleanList as n, cleanName as r, cleanDescription as t };
71
+
72
+ //# sourceMappingURL=role-fields-ClqODoYn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"role-fields-ClqODoYn.js","names":[],"sources":["../src/role-fields.ts"],"sourcesContent":["import { validationFailed, type ValidationIssue } from \"@awsmbiz/inbis-core\";\n\n/** A custom role's editable fields. */\nexport interface RoleFields {\n /** Display name, 1–64 characters, unique among all roles (case-insensitive). */\n name: string;\n /** Up to 500 characters; `null` or `\"\"` for none. */\n description?: string | null;\n /** Declared permission names the role grants itself. */\n permissions: readonly string[];\n /** IDs of roles (system or custom) whose permissions this role also grants. */\n inherits?: readonly string[];\n}\n\nexport const NAME_MAX = 64;\nexport const DESCRIPTION_MAX = 500;\n\nfunction stringList(value: unknown, field: string, issues: ValidationIssue[]): string[] | undefined {\n if (!Array.isArray(value) || value.some((item) => typeof item !== \"string\")) {\n issues.push({ path: [field], message: \"Must be an array of strings.\" });\n return undefined;\n }\n return value as string[];\n}\n\n/**\n * Reads a request body into {@link RoleFields}: all fields for a new role,\n * any subset (`partial`) for a change. Fields it does not know are ignored.\n * A malformed field is `422 VALIDATION_FAILED` (`details.issues[{ path, message }]`).\n */\nexport function parseRoleFields(body: unknown, options: { partial: true }): Partial<RoleFields>;\nexport function parseRoleFields(body: unknown, options?: { partial?: false }): RoleFields;\nexport function parseRoleFields(body: unknown, options: { partial?: boolean } = {}): Partial<RoleFields> {\n const issues: ValidationIssue[] = [];\n const fields: Partial<RoleFields> = {};\n if (typeof body !== \"object\" || body === null || Array.isArray(body)) {\n issues.push({ path: [], message: \"Must be a JSON object.\" });\n } else {\n const input = body as Record<string, unknown>;\n const partial = options.partial === true;\n if (input[\"name\"] !== undefined || !partial) {\n if (typeof input[\"name\"] === \"string\") fields.name = input[\"name\"];\n else issues.push({ path: [\"name\"], message: \"Must be a string.\" });\n }\n if (input[\"description\"] !== undefined) {\n if (typeof input[\"description\"] === \"string\" || input[\"description\"] === null) fields.description = input[\"description\"];\n else issues.push({ path: [\"description\"], message: \"Must be a string or null.\" });\n }\n if (input[\"permissions\"] !== undefined || !partial) {\n const permissions = stringList(input[\"permissions\"], \"permissions\", issues);\n if (permissions !== undefined) fields.permissions = permissions;\n }\n if (input[\"inherits\"] !== undefined) {\n const inherits = stringList(input[\"inherits\"], \"inherits\", issues);\n if (inherits !== undefined) fields.inherits = inherits;\n }\n }\n if (issues.length > 0) throw validationFailed(issues);\n return fields;\n}\n\n/** The trimmed name, or `422 VALIDATION_FAILED`. */\nexport function cleanName(name: string): string {\n const trimmed = name.trim();\n if (trimmed === \"\" || trimmed.length > NAME_MAX) {\n throw validationFailed([{ path: [\"name\"], message: `Must be 1 to ${String(NAME_MAX)} characters.` }]);\n }\n return trimmed;\n}\n\n/** The trimmed description (`null` when empty), or `422 VALIDATION_FAILED`. */\nexport function cleanDescription(description: string | null | undefined): string | null {\n const trimmed = description?.trim() ?? \"\";\n if (trimmed.length > DESCRIPTION_MAX) {\n throw validationFailed([{ path: [\"description\"], message: `Must be at most ${String(DESCRIPTION_MAX)} characters.` }]);\n }\n return trimmed === \"\" ? null : trimmed;\n}\n\n/** Deduplicated and sorted. */\nexport function cleanList(values: readonly string[] | undefined): string[] {\n return [...new Set(values ?? [])].sort();\n}\n"],"mappings":";AAiBA,SAAS,WAAW,OAAgB,OAAe,QAAiD;CAClG,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,MAAM,SAAS,OAAO,SAAS,QAAQ,GAAG;EAC3E,OAAO,KAAK;GAAE,MAAM,CAAC,KAAK;GAAG,SAAS;EAA+B,CAAC;EACtE;CACF;CACA,OAAO;AACT;AASA,SAAgB,gBAAgB,MAAe,UAAiC,CAAC,GAAwB;CACvG,MAAM,SAA4B,CAAC;CACnC,MAAM,SAA8B,CAAC;CACrC,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,MAAM,QAAQ,IAAI,GACjE,OAAO,KAAK;EAAE,MAAM,CAAC;EAAG,SAAS;CAAyB,CAAC;MACtD;EACL,MAAM,QAAQ;EACd,MAAM,UAAU,QAAQ,YAAY;EACpC,IAAI,MAAM,YAAY,KAAA,KAAa,CAAC,SAAS;GAC3C,IAAI,OAAO,MAAM,YAAY,UAAU,OAAO,OAAO,MAAM;QACtD,OAAO,KAAK;IAAE,MAAM,CAAC,MAAM;IAAG,SAAS;GAAoB,CAAC;EACnE;EACA,IAAI,MAAM,mBAAmB,KAAA,GAAW;GACtC,IAAI,OAAO,MAAM,mBAAmB,YAAY,MAAM,mBAAmB,MAAM,OAAO,cAAc,MAAM;QACrG,OAAO,KAAK;IAAE,MAAM,CAAC,aAAa;IAAG,SAAS;GAA4B,CAAC;EAClF;EACA,IAAI,MAAM,mBAAmB,KAAA,KAAa,CAAC,SAAS;GAClD,MAAM,cAAc,WAAW,MAAM,gBAAgB,eAAe,MAAM;GAC1E,IAAI,gBAAgB,KAAA,GAAW,OAAO,cAAc;EACtD;EACA,IAAI,MAAM,gBAAgB,KAAA,GAAW;GACnC,MAAM,WAAW,WAAW,MAAM,aAAa,YAAY,MAAM;GACjE,IAAI,aAAa,KAAA,GAAW,OAAO,WAAW;EAChD;CACF;CACA,IAAI,OAAO,SAAS,GAAG,MAAM,iBAAiB,MAAM;CACpD,OAAO;AACT;;AAGA,SAAgB,UAAU,MAAsB;CAC9C,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,YAAY,MAAM,QAAQ,SAAA,IAC5B,MAAM,iBAAiB,CAAC;EAAE,MAAM,CAAC,MAAM;EAAG,SAAS,gBAAgB,OAAA,EAAe,EAAE;CAAc,CAAC,CAAC;CAEtG,OAAO;AACT;;AAGA,SAAgB,iBAAiB,aAAuD;CACtF,MAAM,UAAU,aAAa,KAAK,KAAK;CACvC,IAAI,QAAQ,SAAA,KACV,MAAM,iBAAiB,CAAC;EAAE,MAAM,CAAC,aAAa;EAAG,SAAS,mBAAmB,OAAA,GAAsB,EAAE;CAAc,CAAC,CAAC;CAEvH,OAAO,YAAY,KAAK,OAAO;AACjC;;AAGA,SAAgB,UAAU,QAAiD;CACzE,OAAO,CAAC,GAAG,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK;AACzC"}