@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 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 a
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}. */
@@ -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 ? `${base}${KEY_DELIMITER}${resourceId}` : base;
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
- const roleMap = new Map(list.map((role) => [role.name, role]));
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
- const evaluatorOptions = () => ({
90
- getRole: roleLookup,
91
- policies: resolvePolicies(),
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.allowed, outcome.decision, permissionStr);
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(ruleAllowed, policyDecision, permissionStr) {
214
+ function combine(ruleResult, policyDecision, permissionStr) {
211
215
  if (policyDecision && !policyDecision.allowed)
212
216
  return policyDecision;
213
- if (ruleAllowed) {
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
- policies.set(definition.name, Object.freeze({ ...definition }));
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, Object.freeze({ ...definition }));
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.0.0",
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.0"
29
+ "@zudojs/errors": "1.0.1"
26
30
  },
27
31
  "peerDependencies": {
28
- "@zudojs/http": "1.0.0"
32
+ "@zudojs/http": "1.1.0"
29
33
  },
30
34
  "peerDependenciesMeta": {
31
35
  "@zudojs/http": {