@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 +21 -0
- package/README.md +38 -0
- package/dist/index.cjs +476 -0
- package/dist/index.d.cts +282 -0
- package/dist/index.d.mts +282 -0
- package/dist/index.mjs +455 -0
- package/package.json +56 -0
- package/scripts/codegen.mjs +157 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
export * from "@aooth/arbac-core";
|
|
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
|
+
export { allowTableAction, allowTableRead, allowTableWrite, definePrivilege, defineRole, extractResourceActions, generateResourceTypes, getProjectionMode, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aooth/arbac",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Batteries-included RBAC: builder API, privilege factories, scope merge utilities",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"access-control",
|
|
7
|
+
"aoothjs",
|
|
8
|
+
"arbac",
|
|
9
|
+
"authorization",
|
|
10
|
+
"builder",
|
|
11
|
+
"rbac"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/moostjs/aoothjs/tree/main/packages/arbac#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/moostjs/aoothjs/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Artem Maltsev",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/moostjs/aoothjs.git",
|
|
22
|
+
"directory": "packages/arbac"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"aoothjs-arbac-codegen": "./scripts/codegen.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"scripts"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"main": "dist/index.mjs",
|
|
34
|
+
"module": "./dist/index.mjs",
|
|
35
|
+
"types": "dist/index.d.mts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.mts",
|
|
39
|
+
"import": "./dist/index.mjs",
|
|
40
|
+
"require": "./dist/index.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@aooth/arbac-core": "0.1.1"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "vp pack",
|
|
52
|
+
"dev": "vp pack --watch",
|
|
53
|
+
"test": "vp test",
|
|
54
|
+
"check": "vp check"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @aooth/arbac codegen CLI
|
|
3
|
+
//
|
|
4
|
+
// Generates TypeScript union types from a built array of @aooth/arbac roles.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// aoothjs-arbac-codegen --roles <path> --output <path> [--export-name <name>]
|
|
8
|
+
//
|
|
9
|
+
// Notes:
|
|
10
|
+
// * --roles must point to a JS/MJS module that node can import directly.
|
|
11
|
+
// The module must export either a default export or a named `roles` export
|
|
12
|
+
// containing an array of built roles (the result of `defineRole(...).build()`).
|
|
13
|
+
// * If you author roles in TypeScript, build them first with your existing
|
|
14
|
+
// toolchain and point --roles at the built JS output.
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
18
|
+
import { argv, cwd, exit, stderr, stdout } from "node:process";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ rolesPath?: string; outputPath?: string; resourceTypeName?: string; actionTypeName?: string; help?: boolean }} TParsedArgs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const HELP_TEXT = `aoothjs-arbac-codegen — generate TS resource/action union types
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
aoothjs-arbac-codegen --roles <path> --output <path> [options]
|
|
29
|
+
|
|
30
|
+
Required:
|
|
31
|
+
--roles <path> Path to a JS/MJS file that exports built roles
|
|
32
|
+
(default export, or named export \`roles\`).
|
|
33
|
+
--output <path> Path to write the generated .ts file.
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--resource-type <name> Name of the resource union type. Default: Resource
|
|
37
|
+
--action-type <name> Name of the action union type. Default: Action
|
|
38
|
+
--export-name <name> Alias for --resource-type (kept for compatibility).
|
|
39
|
+
-h, --help Show this help.
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string[]} args
|
|
44
|
+
* @returns {TParsedArgs}
|
|
45
|
+
*/
|
|
46
|
+
function parseArgs(args) {
|
|
47
|
+
/** @type {TParsedArgs} */
|
|
48
|
+
const out = {};
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const a = args[i];
|
|
51
|
+
switch (a) {
|
|
52
|
+
case "-h":
|
|
53
|
+
case "--help":
|
|
54
|
+
out.help = true;
|
|
55
|
+
break;
|
|
56
|
+
case "--roles":
|
|
57
|
+
out.rolesPath = args[++i];
|
|
58
|
+
break;
|
|
59
|
+
case "--output":
|
|
60
|
+
out.outputPath = args[++i];
|
|
61
|
+
break;
|
|
62
|
+
case "--resource-type":
|
|
63
|
+
case "--export-name":
|
|
64
|
+
out.resourceTypeName = args[++i];
|
|
65
|
+
break;
|
|
66
|
+
case "--action-type":
|
|
67
|
+
out.actionTypeName = args[++i];
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
throw new Error(`Unknown argument: ${a}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a CLI path argument to an absolute path.
|
|
78
|
+
* @param {string} p
|
|
79
|
+
*/
|
|
80
|
+
function toAbsolute(p) {
|
|
81
|
+
return isAbsolute(p) ? p : resolve(cwd(), p);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pick the roles array from a dynamically-imported module.
|
|
86
|
+
* Accepts: default export array, or named `roles` export array.
|
|
87
|
+
* @param {Record<string, unknown>} mod
|
|
88
|
+
* @param {string} importedFrom
|
|
89
|
+
*/
|
|
90
|
+
function pickRoles(mod, importedFrom) {
|
|
91
|
+
/** @type {unknown} */
|
|
92
|
+
let candidate = mod.default;
|
|
93
|
+
if (!Array.isArray(candidate)) {
|
|
94
|
+
candidate = mod.roles;
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(candidate)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Module at ${importedFrom} must export a default array or a named \`roles\` array of built roles.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return /** @type {import("@aooth/arbac-core").TArbacRole<unknown, unknown>[]} */ (candidate);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function loadCodegenApi() {
|
|
105
|
+
// Prefer the built dist (works after `vp pack`); fall back to the source for
|
|
106
|
+
// dev workflows where dist hasn't been built yet.
|
|
107
|
+
try {
|
|
108
|
+
return await import("../dist/index.mjs");
|
|
109
|
+
} catch {
|
|
110
|
+
return await import("../src/index.ts");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
/** @type {TParsedArgs} */
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseArgs(argv.slice(2));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
stderr.write(`${/** @type {Error} */ (err).message}\n\n${HELP_TEXT}`);
|
|
121
|
+
exit(2);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (parsed.help) {
|
|
126
|
+
stdout.write(HELP_TEXT);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!parsed.rolesPath || !parsed.outputPath) {
|
|
131
|
+
stderr.write(`Both --roles and --output are required.\n\n${HELP_TEXT}`);
|
|
132
|
+
exit(2);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rolesAbs = toAbsolute(parsed.rolesPath);
|
|
137
|
+
const outAbs = toAbsolute(parsed.outputPath);
|
|
138
|
+
|
|
139
|
+
const mod = await import(pathToFileURL(rolesAbs).href);
|
|
140
|
+
const roles = pickRoles(/** @type {Record<string, unknown>} */ (mod), rolesAbs);
|
|
141
|
+
|
|
142
|
+
const { extractResourceActions, generateResourceTypes } = await loadCodegenApi();
|
|
143
|
+
const map = extractResourceActions(roles);
|
|
144
|
+
const source = generateResourceTypes(map, {
|
|
145
|
+
resourceTypeName: parsed.resourceTypeName,
|
|
146
|
+
actionTypeName: parsed.actionTypeName,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await mkdir(dirname(outAbs), { recursive: true });
|
|
150
|
+
await writeFile(outAbs, source, "utf8");
|
|
151
|
+
stdout.write(`aoothjs-arbac-codegen: wrote ${outAbs}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
main().catch((err) => {
|
|
155
|
+
stderr.write(`aoothjs-arbac-codegen: ${err.stack || err.message || String(err)}\n`);
|
|
156
|
+
exit(1);
|
|
157
|
+
});
|