@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/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
|
+
});
|