@zudojs/permissions 1.0.0 → 1.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.
- package/README.md +8 -3
- package/dist/cache/cache.core.d.ts +2 -1
- package/dist/cache/cache.core.js +18 -3
- package/dist/evaluator/authorizationEngine.js +16 -5
- package/dist/evaluator/evaluator.core.js +17 -4
- package/dist/policy/policyRegistry.js +6 -1
- package/dist/role/roleRegistry.d.ts +9 -0
- package/dist/role/roleRegistry.js +19 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -151,8 +151,8 @@ const engine = createPermissionEngine({
|
|
|
151
151
|
|
|
152
152
|
Policies run highest priority first and stop at the first denial. A policy
|
|
153
153
|
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins;
|
|
154
|
-
an allowing one can grant access the rules did not, but never overrides
|
|
155
|
-
denial.
|
|
154
|
+
an allowing one can grant access the rules did not decide, but never overrides
|
|
155
|
+
a denial — from another policy or from a deny rule that applied.
|
|
156
156
|
|
|
157
157
|
`policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
|
|
158
158
|
disable.
|
|
@@ -178,6 +178,9 @@ const engine = createPermissionEngine({ roles, policies });
|
|
|
178
178
|
roles.define({ name: "auditor", permissions: ["audit:read"] });
|
|
179
179
|
engine.invalidateRoles(); // pick up the change
|
|
180
180
|
|
|
181
|
+
policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
|
|
182
|
+
// enforced by the next check — through the engine or an existing Ability
|
|
183
|
+
|
|
181
184
|
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
182
185
|
```
|
|
183
186
|
|
|
@@ -225,7 +228,8 @@ await engine.invalidateActor("user_1");
|
|
|
225
228
|
|
|
226
229
|
Keys include the actor, the permission and the resource id (from
|
|
227
230
|
`resource.id`, or `options.resourceId`), so two resources never share one
|
|
228
|
-
decision.
|
|
231
|
+
decision. A `|` inside an actor or resource id is escaped, so two different
|
|
232
|
+
(actor, permission, resource) triples can never share a key either.
|
|
229
233
|
|
|
230
234
|
A check is cached only when the key can describe it completely:
|
|
231
235
|
|
|
@@ -265,6 +269,7 @@ Every failure denies:
|
|
|
265
269
|
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
266
270
|
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
267
271
|
| Condition throws | rule does not apply |
|
|
272
|
+
| A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
|
|
268
273
|
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
269
274
|
| Role inheritance cycle | denied; reported to `onError` |
|
|
270
275
|
| Role source throws | denied; reported to `onError` |
|
|
@@ -8,7 +8,8 @@ import type { PermissionCache } from "../permissionTypes/index.js";
|
|
|
8
8
|
* Generate a cache key for a permission check.
|
|
9
9
|
*
|
|
10
10
|
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
11
|
-
* actors `10` and `123
|
|
11
|
+
* actors `10` and `123`, and a delimiter inside an id is escaped so two
|
|
12
|
+
* different (actor, permission, resource) triples can never share a key.
|
|
12
13
|
*/
|
|
13
14
|
export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
|
|
14
15
|
/** Options for {@link createMemoryPermissionCache}. */
|
package/dist/cache/cache.core.js
CHANGED
|
@@ -5,19 +5,34 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Separates the actor id from the rest of the key. */
|
|
7
7
|
const KEY_DELIMITER = "|";
|
|
8
|
+
/**
|
|
9
|
+
* Escape a caller-supplied segment so it cannot forge a delimiter.
|
|
10
|
+
*
|
|
11
|
+
* Actor and resource ids are opaque strings and may contain `|`. Without
|
|
12
|
+
* escaping, actor `u|post:read` checking `x:y` produced the same key as
|
|
13
|
+
* actor `u` checking `post:read` on resource `x:y` — one actor's cached
|
|
14
|
+
* decision answering for another. Ids without `|` or `\` are unchanged, so
|
|
15
|
+
* existing keys keep their shape.
|
|
16
|
+
*/
|
|
17
|
+
function escapeSegment(segment) {
|
|
18
|
+
return segment.replace(/[\\|]/g, (char) => `\\${char}`);
|
|
19
|
+
}
|
|
8
20
|
/** Prefix identifying an actor's cache entries. */
|
|
9
21
|
function actorPrefix(actorId) {
|
|
10
|
-
return `actor:${actorId}${KEY_DELIMITER}`;
|
|
22
|
+
return `actor:${escapeSegment(actorId)}${KEY_DELIMITER}`;
|
|
11
23
|
}
|
|
12
24
|
/**
|
|
13
25
|
* Generate a cache key for a permission check.
|
|
14
26
|
*
|
|
15
27
|
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
16
|
-
* actors `10` and `123
|
|
28
|
+
* actors `10` and `123`, and a delimiter inside an id is escaped so two
|
|
29
|
+
* different (actor, permission, resource) triples can never share a key.
|
|
17
30
|
*/
|
|
18
31
|
export function permissionCacheKey(actorId, permission, resourceId) {
|
|
19
32
|
const base = `${actorPrefix(actorId)}${permission}`;
|
|
20
|
-
return resourceId
|
|
33
|
+
return resourceId
|
|
34
|
+
? `${base}${KEY_DELIMITER}${escapeSegment(resourceId)}`
|
|
35
|
+
: base;
|
|
21
36
|
}
|
|
22
37
|
const DEFAULT_TTL_MS = 60_000;
|
|
23
38
|
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
@@ -8,6 +8,7 @@ import { createAbility } from "../ability/ability.core.js";
|
|
|
8
8
|
import { PermissionDeniedError, InvalidRoleError, } from "../permissionErrors/index.js";
|
|
9
9
|
import { isValidPermission } from "../permission/permission.core.js";
|
|
10
10
|
import { memoizeRoleLookup } from "../role/roleHierarchy.js";
|
|
11
|
+
import { freezeRoleDefinition } from "../role/roleRegistry.js";
|
|
11
12
|
function isRoleSource(roles) {
|
|
12
13
|
return (roles !== undefined &&
|
|
13
14
|
!Array.isArray(roles) &&
|
|
@@ -68,7 +69,9 @@ export function createPermissionEngine(options) {
|
|
|
68
69
|
const list = (options?.roles ?? []);
|
|
69
70
|
if (validateConfiguration)
|
|
70
71
|
validateRoles(list);
|
|
71
|
-
|
|
72
|
+
// Copy each role, so a caller still holding the arrays it passed in
|
|
73
|
+
// cannot widen a grant after validation has run.
|
|
74
|
+
const roleMap = new Map(list.map((role) => [role.name, freezeRoleDefinition(role)]));
|
|
72
75
|
roleLookup = (name) => roleMap.get(name);
|
|
73
76
|
invalidateRoleCache = () => { };
|
|
74
77
|
}
|
|
@@ -86,9 +89,16 @@ export function createPermissionEngine(options) {
|
|
|
86
89
|
.map((name) => policySource.get(name))
|
|
87
90
|
.filter((policy) => policy !== undefined);
|
|
88
91
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
// One live view over the configuration. `policies` is a getter so a
|
|
93
|
+
// registry-backed engine re-reads the registry on every evaluation — an
|
|
94
|
+
// Ability used to capture a snapshot of the policy list when it was
|
|
95
|
+
// created, so a policy defined afterwards was enforced by `engine.can()`
|
|
96
|
+
// and ignored by `ability.can()` for the same actor.
|
|
97
|
+
const liveOptions = {
|
|
98
|
+
getRole: (name) => roleLookup(name),
|
|
99
|
+
get policies() {
|
|
100
|
+
return resolvePolicies();
|
|
101
|
+
},
|
|
92
102
|
rules: options?.rules,
|
|
93
103
|
policyTimeout: options?.policyTimeout,
|
|
94
104
|
algorithm: options?.algorithm,
|
|
@@ -98,7 +108,8 @@ export function createPermissionEngine(options) {
|
|
|
98
108
|
roleResolver: options?.roleResolver,
|
|
99
109
|
expandImplied: options?.expandImplied,
|
|
100
110
|
onError: options?.onError,
|
|
101
|
-
}
|
|
111
|
+
};
|
|
112
|
+
const evaluatorOptions = () => liveOptions;
|
|
102
113
|
const emitter = options?.emitter;
|
|
103
114
|
/**
|
|
104
115
|
* Runs a check and emits an audit event whichever way it ends — including
|
|
@@ -186,7 +186,7 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
186
186
|
});
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
const decision = combine(ruleResult
|
|
189
|
+
const decision = combine(ruleResult, outcome.decision, permissionStr);
|
|
190
190
|
/* ── Cache write ─────────────────────────────────────────────────────── */
|
|
191
191
|
if (options.cache && cacheKey && outcome.cacheable) {
|
|
192
192
|
try {
|
|
@@ -205,12 +205,25 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
205
205
|
*
|
|
206
206
|
* A denying policy always wins. An allowing policy can grant access the rules
|
|
207
207
|
* did not, which is what makes a policy an ABAC escape hatch rather than a
|
|
208
|
-
* filter — but it can never override a denial
|
|
208
|
+
* filter — but it can never override a denial, and that includes a denial
|
|
209
|
+
* the *rules* produced. "The rules did not allow" covers two cases: no rule
|
|
210
|
+
* matched, and a deny rule matched. Treating them alike let an allowing
|
|
211
|
+
* policy for `post:*` cancel a `deny post:update` rule — the exact inversion
|
|
212
|
+
* of `deny-overrides`.
|
|
209
213
|
*/
|
|
210
|
-
function combine(
|
|
214
|
+
function combine(ruleResult, policyDecision, permissionStr) {
|
|
211
215
|
if (policyDecision && !policyDecision.allowed)
|
|
212
216
|
return policyDecision;
|
|
213
|
-
|
|
217
|
+
const denyRule = !ruleResult.allowed && ruleResult.matchedRule?.effect === "deny"
|
|
218
|
+
? ruleResult.matchedRule
|
|
219
|
+
: undefined;
|
|
220
|
+
if (denyRule) {
|
|
221
|
+
return denied("rule_deny", {
|
|
222
|
+
matchedPermission: permissionStr,
|
|
223
|
+
...(denyRule.name ? { policy: denyRule.name } : {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (ruleResult.allowed) {
|
|
214
227
|
return Object.freeze({
|
|
215
228
|
allowed: true,
|
|
216
229
|
reason: "role_permission",
|
|
@@ -25,7 +25,12 @@ export function createPolicyRegistry(options) {
|
|
|
25
25
|
if (policies.has(definition.name) && !allowOverride) {
|
|
26
26
|
throw new DuplicatePolicyError(definition.name);
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
// Copy the permission list: a caller mutating the array it passed in
|
|
29
|
+
// must not re-scope the policy after registration.
|
|
30
|
+
policies.set(definition.name, Object.freeze({
|
|
31
|
+
...definition,
|
|
32
|
+
permissions: Object.freeze([...definition.permissions]),
|
|
33
|
+
}));
|
|
29
34
|
},
|
|
30
35
|
get(name) {
|
|
31
36
|
return policies.get(name);
|
|
@@ -27,6 +27,15 @@ export interface RoleRegistry {
|
|
|
27
27
|
remove(name: string): boolean;
|
|
28
28
|
clear(): void;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Copy a role definition so it no longer shares arrays with the caller.
|
|
32
|
+
*
|
|
33
|
+
* `Object.freeze({ ...definition })` froze the wrapper and kept the caller's
|
|
34
|
+
* `permissions`, `inherits` and `rules` arrays by reference — so pushing
|
|
35
|
+
* `"*:*"` onto an array *after* `define()` had validated it widened the role
|
|
36
|
+
* in silence.
|
|
37
|
+
*/
|
|
38
|
+
export declare function freezeRoleDefinition(definition: RoleDefinition): RoleDefinition;
|
|
30
39
|
/**
|
|
31
40
|
* Create a role registry.
|
|
32
41
|
*
|
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DuplicateRoleError, InvalidRoleError, RoleNotFoundError, } from "../permissionErrors/index.js";
|
|
7
7
|
import { isValidPermission } from "../permission/permission.core.js";
|
|
8
|
+
/**
|
|
9
|
+
* Copy a role definition so it no longer shares arrays with the caller.
|
|
10
|
+
*
|
|
11
|
+
* `Object.freeze({ ...definition })` froze the wrapper and kept the caller's
|
|
12
|
+
* `permissions`, `inherits` and `rules` arrays by reference — so pushing
|
|
13
|
+
* `"*:*"` onto an array *after* `define()` had validated it widened the role
|
|
14
|
+
* in silence.
|
|
15
|
+
*/
|
|
16
|
+
export function freezeRoleDefinition(definition) {
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
...definition,
|
|
19
|
+
permissions: Object.freeze([...definition.permissions]),
|
|
20
|
+
...(definition.inherits
|
|
21
|
+
? { inherits: Object.freeze([...definition.inherits]) }
|
|
22
|
+
: {}),
|
|
23
|
+
...(definition.rules ? { rules: Object.freeze([...definition.rules]) } : {}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
8
26
|
/**
|
|
9
27
|
* Create a role registry.
|
|
10
28
|
*
|
|
@@ -40,7 +58,7 @@ export function createRoleRegistry(options) {
|
|
|
40
58
|
if (roles.has(definition.name) && !allowOverride) {
|
|
41
59
|
throw new DuplicateRoleError(definition.name);
|
|
42
60
|
}
|
|
43
|
-
roles.set(definition.name,
|
|
61
|
+
roles.set(definition.name, freezeRoleDefinition(definition));
|
|
44
62
|
},
|
|
45
63
|
get(name) {
|
|
46
64
|
return roles.get(name);
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/permissions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -22,10 +26,10 @@
|
|
|
22
26
|
"!dist/.tsbuildinfo"
|
|
23
27
|
],
|
|
24
28
|
"dependencies": {
|
|
25
|
-
"@zudojs/errors": "1.0.
|
|
29
|
+
"@zudojs/errors": "1.0.1"
|
|
26
30
|
},
|
|
27
31
|
"peerDependencies": {
|
|
28
|
-
"@zudojs/http": "1.
|
|
32
|
+
"@zudojs/http": "1.1.0"
|
|
29
33
|
},
|
|
30
34
|
"peerDependenciesMeta": {
|
|
31
35
|
"@zudojs/http": {
|