@aooth/arbac 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 aoothjs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ <p align="center">
2
+ <a href="https://aooth.moost.org">
3
+ <img src="https://aooth.moost.org/logo.svg" alt="aoothjs" width="120" />
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">@aooth/arbac</h1>
8
+
9
+ <p align="center">
10
+ Batteries-included RBAC — re-exports <code>@aooth/arbac-core</code> and adds a fluent role builder, reusable privilege factories, scope-merge utilities, and a codegen CLI for typed resource/action unions.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://aooth.moost.org/arbac/"><strong>Documentation →</strong></a>
15
+ </p>
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pnpm add @aooth/arbac
23
+ ```
24
+
25
+ ## Documentation
26
+
27
+ Full docs, API reference, and recipes: **https://aooth.moost.org/arbac/**
28
+
29
+ - [Concepts](https://aooth.moost.org/arbac/concepts)
30
+ - [Role builder](https://aooth.moost.org/arbac/builder)
31
+ - [Privilege factories](https://aooth.moost.org/arbac/privileges)
32
+ - [Scope merging](https://aooth.moost.org/arbac/scopes)
33
+ - [Codegen CLI](https://aooth.moost.org/arbac/codegen)
34
+ - [API reference](https://aooth.moost.org/api/arbac)
35
+
36
+ ## License
37
+
38
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,476 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/define-role.ts
3
+ var RoleBuilderImpl = class {
4
+ _id;
5
+ _name;
6
+ _description;
7
+ _rules = [];
8
+ id(id) {
9
+ this._id = id;
10
+ return this;
11
+ }
12
+ name(name) {
13
+ this._name = name;
14
+ return this;
15
+ }
16
+ describe(description) {
17
+ this._description = description;
18
+ return this;
19
+ }
20
+ allow(resource, action, scope) {
21
+ if (scope) this._rules.push({
22
+ resource,
23
+ action,
24
+ scope
25
+ });
26
+ else this._rules.push({
27
+ resource,
28
+ action
29
+ });
30
+ return this;
31
+ }
32
+ deny(resource, action) {
33
+ this._rules.push({
34
+ resource,
35
+ action,
36
+ effect: "deny"
37
+ });
38
+ return this;
39
+ }
40
+ use(...privileges) {
41
+ for (const priv of privileges) this._rules.push(...priv());
42
+ return this;
43
+ }
44
+ build() {
45
+ if (!this._id) throw new Error("Role id is required. Call .id() before .build().");
46
+ return {
47
+ id: this._id,
48
+ ...this._name !== void 0 && { name: this._name },
49
+ ...this._description !== void 0 && { description: this._description },
50
+ rules: [...this._rules]
51
+ };
52
+ }
53
+ };
54
+ /**
55
+ * Start building a new role.
56
+ *
57
+ * @example
58
+ * const editor = defineRole<MyAttrs, MyScope>()
59
+ * .id("editor")
60
+ * .name("Editor")
61
+ * .use(allowTableWrite("articles"))
62
+ * .deny("articles", "publish")
63
+ * .build();
64
+ */
65
+ function defineRole() {
66
+ return new RoleBuilderImpl();
67
+ }
68
+ //#endregion
69
+ //#region src/define-privilege.ts
70
+ /**
71
+ * Define a reusable privilege factory.
72
+ *
73
+ * Double-call pattern: first call pins TUserAttrs/TScope generics,
74
+ * second call infers TArgs from the factory function.
75
+ *
76
+ * @example
77
+ * const canManageUsers = definePrivilege<MyAttrs, MyScope>()(
78
+ * (scope: (attrs: MyAttrs, userId: string) => MyScope) => [
79
+ * { resource: "users", action: "read", scope },
80
+ * { resource: "users", action: "update", scope },
81
+ * ]
82
+ * );
83
+ *
84
+ * defineRole<MyAttrs, MyScope>()
85
+ * .id("manager")
86
+ * .use(canManageUsers((attrs) => ({ dept: attrs.dept })))
87
+ * .build();
88
+ */
89
+ function definePrivilege() {
90
+ return (factory) => {
91
+ return (...args) => () => factory(...args);
92
+ };
93
+ }
94
+ //#endregion
95
+ //#region src/db-privileges.ts
96
+ const TABLE_READ_ACTIONS = [
97
+ "query",
98
+ "pages",
99
+ "getOne",
100
+ "getOneComposite",
101
+ "meta",
102
+ "metaForm"
103
+ ];
104
+ const TABLE_WRITE_ACTIONS = [
105
+ "insert",
106
+ "update",
107
+ "replace",
108
+ "remove",
109
+ "removeComposite"
110
+ ];
111
+ function rulesFor(resource, actions, scope) {
112
+ return actions.map((action) => scope ? {
113
+ resource,
114
+ action,
115
+ scope
116
+ } : {
117
+ resource,
118
+ action
119
+ });
120
+ }
121
+ /** Read-side actions on an `AsDbController` table: `query`, `pages`, `getOne`, `getOneComposite`, `meta`, `metaForm`. */
122
+ function allowTableRead(resource, opts) {
123
+ return () => rulesFor(resource, TABLE_READ_ACTIONS, opts?.scope);
124
+ }
125
+ /** All actions on an `AsDbController` table: read + `insert`, `update`, `replace`, `remove`, `removeComposite`. */
126
+ function allowTableWrite(resource, opts) {
127
+ return () => rulesFor(resource, [...TABLE_READ_ACTIONS, ...TABLE_WRITE_ACTIONS], opts?.scope);
128
+ }
129
+ /** One or more declarative actions on the same table, sharing a scope. */
130
+ function allowTableAction(resource, name, opts) {
131
+ const actions = typeof name === "string" ? [name] : name;
132
+ return () => rulesFor(resource, actions, opts?.scope);
133
+ }
134
+ //#endregion
135
+ //#region src/scope/projection.ts
136
+ /**
137
+ * Determine whether a projection is in inclusion mode (all 1s),
138
+ * exclusion mode (all 0s), or empty (`{}`, no restriction).
139
+ *
140
+ * @throws when a single projection mixes 0 and 1 values
141
+ */
142
+ function getProjectionMode(proj) {
143
+ const values = Object.values(proj);
144
+ if (values.length === 0) return "empty";
145
+ const hasInclude = values.includes(1);
146
+ const hasExclude = values.includes(0);
147
+ if (hasInclude && hasExclude) throw new Error("Invalid projection: cannot mix include (1) and exclude (0) in a single projection");
148
+ return hasInclude ? "include" : "exclude";
149
+ }
150
+ /**
151
+ * Check whether a dot-path field is allowed by a projection.
152
+ *
153
+ * Inclusion mode: a field is allowed if it, any of its parents, or any of its children
154
+ * is explicitly listed.
155
+ * Exclusion mode: a field is allowed unless it or any of its parents is excluded.
156
+ * Empty projection: every field is allowed.
157
+ */
158
+ function isFieldAllowed(field, projection) {
159
+ const mode = getProjectionMode(projection);
160
+ if (mode === "empty") return true;
161
+ if (mode === "include") {
162
+ if (projection[field] === 1) return true;
163
+ const parts = field.split(".");
164
+ for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 1) return true;
165
+ for (const key of Object.keys(projection)) if (key.startsWith(`${field}.`)) return true;
166
+ return false;
167
+ }
168
+ if (projection[field] === 0) return false;
169
+ const parts = field.split(".");
170
+ for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 0) return false;
171
+ return true;
172
+ }
173
+ /**
174
+ * Combines projections from multiple RBAC role grants under additive semantics:
175
+ * more roles = broader access. Each projection represents a set of allowed fields:
176
+ * include-mode `{a:1}` allows `{a}`; exclude-mode `{a:0}` allows `universe \ {a}`;
177
+ * empty `{}` allows the universe.
178
+ *
179
+ * A field is effectively allowed if any input grants it. Equivalently, a field
180
+ * stays excluded only if (a) no include-mode role grants it explicitly, AND (b)
181
+ * every exclude-mode role excludes it.
182
+ *
183
+ * Output mode:
184
+ * - All-include input → include-mode result (union of include keys)
185
+ * - At least one exclude-mode → exclude-mode result (or `{}` if no fields excluded)
186
+ * - Empty input or any universal grant `{}` → `{}` (universe)
187
+ *
188
+ * Within a single projection, mixing 1 and 0 keys is an error (call sites should
189
+ * normalize first). Across projections, mixing modes is supported and resolves
190
+ * via the additive rule above.
191
+ *
192
+ * @throws when a single projection mixes 1 and 0 keys, or contains an invalid value
193
+ */
194
+ function unionProjections(...projections) {
195
+ if (projections.length === 0) return {};
196
+ const includeKeys = /* @__PURE__ */ new Set();
197
+ const excludeKeys = [];
198
+ let hasUniverseGrant = false;
199
+ for (const p of projections) {
200
+ const entries = Object.entries(p);
201
+ if (entries.length === 0) {
202
+ hasUniverseGrant = true;
203
+ continue;
204
+ }
205
+ let mode = null;
206
+ const localExcludes = /* @__PURE__ */ new Set();
207
+ for (const [k, v] of entries) if (v === 1) {
208
+ if (mode === "exclude") throw new Error(`unionProjections: projection mixes 1 and 0 within itself: ${JSON.stringify(p)}`);
209
+ mode = "include";
210
+ includeKeys.add(k);
211
+ } else if (v === 0) {
212
+ if (mode === "include") throw new Error(`unionProjections: projection mixes 1 and 0 within itself: ${JSON.stringify(p)}`);
213
+ mode = "exclude";
214
+ localExcludes.add(k);
215
+ } else throw new Error(`unionProjections: invalid projection value ${String(v)} for key ${k} (must be 0 or 1)`);
216
+ if (mode === "exclude") excludeKeys.push(localExcludes);
217
+ }
218
+ if (hasUniverseGrant) return {};
219
+ if (excludeKeys.length === 0) {
220
+ if (includeKeys.size === 0) return {};
221
+ return Object.fromEntries([...includeKeys].toSorted().map((k) => [k, 1]));
222
+ }
223
+ let denyAcc = new Set(excludeKeys[0]);
224
+ for (let i = 1; i < excludeKeys.length; i++) denyAcc = new Set([...denyAcc].filter((k) => excludeKeys[i].has(k)));
225
+ const effectivelyExcluded = [...denyAcc].filter((k) => !includeKeys.has(k)).toSorted();
226
+ if (effectivelyExcluded.length === 0) return {};
227
+ return Object.fromEntries(effectivelyExcluded.map((k) => [k, 0]));
228
+ }
229
+ /**
230
+ * Restrict a desired projection to only fields allowed by an access-control projection.
231
+ *
232
+ * The result is the intersection of the two projections: only fields that pass both
233
+ * `desired` and `accessControl` survive. Either side may be empty (unrestricted), in
234
+ * which case the other side is returned. Mixed include/exclude modes are normalized
235
+ * to a single result projection.
236
+ *
237
+ * @param desired - the projection the caller asked for
238
+ * @param accessControl - the projection allowed by RBAC
239
+ */
240
+ function restrictProjection(desired, accessControl) {
241
+ const desiredMode = getProjectionMode(desired);
242
+ const acMode = getProjectionMode(accessControl);
243
+ if (acMode === "empty") return { ...desired };
244
+ if (desiredMode === "empty") return { ...accessControl };
245
+ if (desiredMode === "include" && acMode === "include") {
246
+ const result = {};
247
+ for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
248
+ return result;
249
+ }
250
+ if (desiredMode === "exclude" && acMode === "exclude") {
251
+ const result = {};
252
+ for (const key of Object.keys(desired)) result[key] = 0;
253
+ for (const key of Object.keys(accessControl)) result[key] = 0;
254
+ return result;
255
+ }
256
+ if (desiredMode === "include" && acMode === "exclude") {
257
+ const result = {};
258
+ for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
259
+ return result;
260
+ }
261
+ const result = {};
262
+ for (const key of Object.keys(accessControl)) if (isFieldAllowed(key, desired)) result[key] = 1;
263
+ return result;
264
+ }
265
+ //#endregion
266
+ //#region src/scope/filter.ts
267
+ /**
268
+ * Merge multiple scope filters into a single filter using `$or` semantics.
269
+ *
270
+ * In RBAC, if multiple roles grant access with different filters,
271
+ * the user can see records matching ANY of them — hence `$or`.
272
+ *
273
+ * Behaviour:
274
+ * - Empty input → `undefined` (no filter)
275
+ * - Any empty filter → `undefined` (one role grants unrestricted access)
276
+ * - Single filter → returned as-is
277
+ * - All filters single-keyed on the same primitive field → `{ field: { $in: [...] } }`
278
+ * - Otherwise → `{ $or: [...] }`
279
+ *
280
+ * @returns the merged filter, or `undefined` for unrestricted access
281
+ */
282
+ function mergeScopeFilters(scopes) {
283
+ if (scopes.length === 0) return void 0;
284
+ if (scopes.some((s) => Object.keys(s).length === 0)) return void 0;
285
+ if (scopes.length === 1) return scopes[0];
286
+ if (canOptimizeToIn(scopes)) {
287
+ const key = Object.keys(scopes[0])[0];
288
+ const values = scopes.map((s) => s[key]);
289
+ return { [key]: { $in: values } };
290
+ }
291
+ return { $or: scopes };
292
+ }
293
+ function canOptimizeToIn(scopes) {
294
+ const firstKeys = Object.keys(scopes[0]);
295
+ if (firstKeys.length !== 1) return false;
296
+ const key = firstKeys[0];
297
+ for (const scope of scopes) {
298
+ const keys = Object.keys(scope);
299
+ if (keys.length !== 1 || keys[0] !== key) return false;
300
+ const value = scope[key];
301
+ if (value !== null && typeof value === "object") return false;
302
+ }
303
+ return true;
304
+ }
305
+ //#endregion
306
+ //#region src/scope/controls.ts
307
+ /**
308
+ * Controls whose values can be sensibly whitelisted via `readonly string[]`.
309
+ *
310
+ * - `$with` — relation names; uniquery parses to `{ name, … }[]`.
311
+ * - `$groupBy` — column names; the value already IS the array of names.
312
+ *
313
+ * Other controls only support boolean gates in v1; supplying a whitelist
314
+ * for them throws from {@link unionControlsPolicy}.
315
+ */
316
+ const WHITELISTABLE_CONTROLS = new Set(["$with", "$groupBy"]);
317
+ /**
318
+ * Union per-role `controls` policies. Additive RBAC semantics: more roles =
319
+ * broader access, mirroring how `mergeScopeFilters` and `unionProjections`
320
+ * combine other parts of an `ArbacDbScope`.
321
+ *
322
+ * Resolution per control key:
323
+ * 1. Collect each scope's value for the key (including `undefined` for
324
+ * silent scopes — i.e., scopes that have a `controls` map but no entry
325
+ * for this key).
326
+ * 2. If any value is `undefined` or `true` → return `true` (full allow).
327
+ * Silence and explicit allow both mean "this role has no objection".
328
+ * 3. Else (every scope is `false` or `string[]`):
329
+ * - If all are `false` → return `false` (full deny).
330
+ * - Else collect every `string[]` into a single sorted, de-duplicated
331
+ * array; return that array. (Roles that say `false` don't contribute
332
+ * positively; roles with whitelists do.)
333
+ *
334
+ * Scopes that omit the `controls` map entirely are treated as silent on
335
+ * EVERY key — i.e., they grant unrestricted control usage. As a result, if
336
+ * even a single scope in the input lacks a `controls` map, the result is
337
+ * always `{}` (no restrictions).
338
+ *
339
+ * Whitelist support is restricted to {@link WHITELISTABLE_CONTROLS}. Passing
340
+ * a whitelist for any other control throws — the gate cannot be enforced
341
+ * meaningfully in v1, so the misuse is rejected loudly at scope-merge time.
342
+ *
343
+ * @returns merged policy keyed by control name. Keys absent from the result
344
+ * mean "no opinion → fully allowed". The caller should treat a
345
+ * missing key the same as `true`.
346
+ *
347
+ * @throws when a non-whitelistable control is given a `readonly string[]` gate.
348
+ */
349
+ function unionControlsPolicy(scopes) {
350
+ if (scopes.length === 0) return {};
351
+ if (scopes.some((s) => !s.controls)) return {};
352
+ const allKeys = /* @__PURE__ */ new Set();
353
+ for (const s of scopes) for (const k of Object.keys(s.controls)) allKeys.add(k);
354
+ const result = {};
355
+ for (const key of allKeys) {
356
+ let anyAllow = false;
357
+ const whitelistAcc = /* @__PURE__ */ new Set();
358
+ let hadWhitelist = false;
359
+ for (const s of scopes) {
360
+ const v = s.controls[key];
361
+ if (v === void 0 || v === true) {
362
+ anyAllow = true;
363
+ break;
364
+ }
365
+ if (v === false) continue;
366
+ if (Array.isArray(v)) {
367
+ if (!WHITELISTABLE_CONTROLS.has(key)) throw new Error(`unionControlsPolicy: control "${key}" only supports boolean gates; whitelist arrays not yet implemented`);
368
+ hadWhitelist = true;
369
+ for (const item of v) whitelistAcc.add(item);
370
+ continue;
371
+ }
372
+ throw new Error(`unionControlsPolicy: invalid gate value ${String(v)} for control "${key}" (must be boolean or string[])`);
373
+ }
374
+ if (anyAllow) continue;
375
+ if (hadWhitelist) {
376
+ result[key] = [...whitelistAcc].toSorted();
377
+ continue;
378
+ }
379
+ result[key] = false;
380
+ }
381
+ return result;
382
+ }
383
+ //#endregion
384
+ //#region src/codegen/extract.ts
385
+ function hasWildcard(s) {
386
+ return s.includes("*");
387
+ }
388
+ /**
389
+ * Extract all resource and action strings from role definitions.
390
+ *
391
+ * @param roles - Array of built role definitions
392
+ * @param options.includeWildcards - If false (default), skip entries containing `*` or `**`
393
+ */
394
+ function extractResourceActions(roles, options) {
395
+ const includeWildcards = options?.includeWildcards ?? false;
396
+ const resources = /* @__PURE__ */ new Map();
397
+ const allResources = /* @__PURE__ */ new Set();
398
+ const allActions = /* @__PURE__ */ new Set();
399
+ for (const role of roles) for (const rule of role.rules) {
400
+ const skipResource = !includeWildcards && hasWildcard(rule.resource);
401
+ const skipAction = !includeWildcards && hasWildcard(rule.action);
402
+ if (!skipResource) {
403
+ allResources.add(rule.resource);
404
+ if (!skipAction) {
405
+ if (!resources.has(rule.resource)) resources.set(rule.resource, /* @__PURE__ */ new Set());
406
+ resources.get(rule.resource).add(rule.action);
407
+ }
408
+ }
409
+ if (!skipAction) allActions.add(rule.action);
410
+ }
411
+ return {
412
+ resources,
413
+ allResources,
414
+ allActions
415
+ };
416
+ }
417
+ //#endregion
418
+ //#region src/codegen/generate.ts
419
+ /**
420
+ * Escape a string for safe embedding inside a TypeScript double-quoted literal.
421
+ * Handles backslashes, double quotes, and control chars so resource/action
422
+ * names containing these characters can't break the generated source.
423
+ */
424
+ function escapeForTsString(value) {
425
+ return value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"").replace(/\n/gu, "\\n").replace(/\r/gu, "\\r").replace(/\t/gu, "\\t");
426
+ }
427
+ function toUnion(values) {
428
+ const sorted = [...values].toSorted();
429
+ if (sorted.length === 0) return "never";
430
+ return sorted.map((v) => `"${escapeForTsString(v)}"`).join(" | ");
431
+ }
432
+ /**
433
+ * Generate TypeScript source defining union types for resources and actions.
434
+ */
435
+ function generateResourceTypes(map, options) {
436
+ const resourceType = options?.resourceTypeName ?? "Resource";
437
+ const actionType = options?.actionTypeName ?? "Action";
438
+ const includeMap = options?.resourceActionMap ?? true;
439
+ const header = options?.header ?? "// Auto-generated by @aooth/arbac codegen";
440
+ const lines = [];
441
+ if (header) lines.push(header, "");
442
+ lines.push(`export type ${resourceType} = ${toUnion(map.allResources)};`);
443
+ lines.push(`export type ${actionType} = ${toUnion(map.allActions)};`);
444
+ if (includeMap) {
445
+ lines.push("");
446
+ lines.push(`export type ${resourceType}ActionMap = {`);
447
+ const sorted = [...map.resources.entries()].toSorted(([a], [b]) => a.localeCompare(b));
448
+ for (const [resource, actions] of sorted) lines.push(` "${escapeForTsString(resource)}": ${toUnion(actions)};`);
449
+ lines.push("};");
450
+ }
451
+ lines.push("");
452
+ return lines.join("\n");
453
+ }
454
+ //#endregion
455
+ exports.allowTableAction = allowTableAction;
456
+ exports.allowTableRead = allowTableRead;
457
+ exports.allowTableWrite = allowTableWrite;
458
+ exports.definePrivilege = definePrivilege;
459
+ exports.defineRole = defineRole;
460
+ exports.extractResourceActions = extractResourceActions;
461
+ exports.generateResourceTypes = generateResourceTypes;
462
+ exports.getProjectionMode = getProjectionMode;
463
+ exports.isFieldAllowed = isFieldAllowed;
464
+ exports.mergeScopeFilters = mergeScopeFilters;
465
+ exports.restrictProjection = restrictProjection;
466
+ exports.unionControlsPolicy = unionControlsPolicy;
467
+ exports.unionProjections = unionProjections;
468
+ var _aooth_arbac_core = require("@aooth/arbac-core");
469
+ Object.keys(_aooth_arbac_core).forEach(function(k) {
470
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
471
+ enumerable: true,
472
+ get: function() {
473
+ return _aooth_arbac_core[k];
474
+ }
475
+ });
476
+ });