@zudojs/permissions 1.0.0 → 1.2.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 +76 -21
- package/dist/ability/ability.core.js +4 -24
- package/dist/cache/cache.actorDigest.d.ts +26 -0
- package/dist/cache/cache.actorDigest.js +90 -0
- package/dist/cache/cache.core.d.ts +9 -2
- package/dist/cache/cache.core.js +27 -5
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/evaluator/authorizationEngine.d.ts +20 -3
- package/dist/evaluator/authorizationEngine.js +59 -73
- package/dist/evaluator/engineSupport/authorizationEngine.validation.d.ts +37 -0
- package/dist/evaluator/engineSupport/authorizationEngine.validation.js +90 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +24 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.js +56 -0
- package/dist/evaluator/engineSupport/evaluator.observed.d.ts +18 -0
- package/dist/evaluator/engineSupport/evaluator.observed.js +49 -0
- package/dist/evaluator/engineSupport/index.d.ts +10 -0
- package/dist/evaluator/engineSupport/index.js +10 -0
- package/dist/evaluator/evaluator.core.js +36 -41
- package/dist/evaluator/evaluator.pipeline.d.ts +6 -0
- package/dist/evaluator/evaluator.pipeline.js +25 -3
- package/dist/http/httpMiddleware.core.d.ts +15 -5
- package/dist/http/httpMiddleware.core.js +18 -4
- package/dist/http/httpResource.helper.d.ts +30 -0
- package/dist/http/httpResource.helper.js +32 -0
- package/dist/http/httpTypes.d.ts +17 -4
- package/dist/http/index.d.ts +7 -5
- package/dist/http/index.js +6 -4
- package/dist/permission/index.d.ts +1 -1
- package/dist/permission/index.js +1 -1
- package/dist/permission/permission.core.d.ts +12 -0
- package/dist/permission/permission.core.js +20 -1
- package/dist/policy/policyRegistry.d.ts +12 -0
- package/dist/policy/policyRegistry.js +23 -2
- package/dist/role/roleRegistry.d.ts +18 -0
- package/dist/role/roleRegistry.js +38 -2
- package/dist/rule/rule.core.d.ts +14 -22
- package/dist/rule/rule.core.js +49 -86
- package/dist/rule/rule.pattern.d.ts +44 -0
- package/dist/rule/rule.pattern.js +72 -0
- package/dist/utils/utils.notifier.d.ts +19 -0
- package/dist/utils/utils.notifier.js +31 -0
- package/package.json +6 -10
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* @module evaluator/evaluator.pipeline
|
|
5
5
|
*/
|
|
6
6
|
import { resolveRolePermissions } from "../role/roleHierarchy.js";
|
|
7
|
-
import { matches } from "../permission/permission.core.js";
|
|
7
|
+
import { matches, permissionsOverlap, parsePermissionSafe, } from "../permission/permission.core.js";
|
|
8
|
+
import { isWildcardTarget } from "../rule/rule.pattern.js";
|
|
8
9
|
import { AuthorizationAbortedError, PermissionResolverError, PolicyError, PolicyTimeoutError, } from "../permissionErrors/index.js";
|
|
9
10
|
/**
|
|
10
11
|
* Normalize a thrown value into an `Error` before it reaches `onError`.
|
|
@@ -126,6 +127,23 @@ export function selectPolicies(policies, permissionStr) {
|
|
|
126
127
|
policy.permissions.some((pattern) => matches(pattern, permissionStr)))
|
|
127
128
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
128
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Policies narrower than a wildcard target, which may deny it but not grant it.
|
|
132
|
+
*
|
|
133
|
+
* `can(actor, "post:*")` asks about every action under `post`. A policy
|
|
134
|
+
* registered for `post:delete` does not cover that question, so its allow
|
|
135
|
+
* must not grant it — but its denial is a denial of part of it, and a
|
|
136
|
+
* wildcard check must not side-step it.
|
|
137
|
+
*/
|
|
138
|
+
function narrowerPolicies(policies, applicable, permissionStr) {
|
|
139
|
+
const target = parsePermissionSafe(permissionStr);
|
|
140
|
+
if (!target || !isWildcardTarget(target))
|
|
141
|
+
return [];
|
|
142
|
+
return policies
|
|
143
|
+
.filter((policy) => !applicable.includes(policy) &&
|
|
144
|
+
policy.permissions.some((pattern) => permissionsOverlap(pattern, permissionStr)))
|
|
145
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
146
|
+
}
|
|
129
147
|
/**
|
|
130
148
|
* Evaluate policies for a context.
|
|
131
149
|
*
|
|
@@ -139,7 +157,8 @@ export async function evaluatePolicies(context, policies, options, authOptions)
|
|
|
139
157
|
}
|
|
140
158
|
const permissionStr = `${context.permission.resource}:${context.permission.action}`;
|
|
141
159
|
const applicable = selectPolicies(policies, permissionStr);
|
|
142
|
-
|
|
160
|
+
const denyOnly = narrowerPolicies(policies, applicable, permissionStr);
|
|
161
|
+
if (applicable.length === 0 && denyOnly.length === 0) {
|
|
143
162
|
return { decision: null, cacheable: true, evaluated: [] };
|
|
144
163
|
}
|
|
145
164
|
// The per-call timeout wins, but the engine-level default is what makes a
|
|
@@ -147,7 +166,7 @@ export async function evaluatePolicies(context, policies, options, authOptions)
|
|
|
147
166
|
const timeoutMs = authOptions?.policyTimeout ?? options.policyTimeout;
|
|
148
167
|
const evaluated = [];
|
|
149
168
|
let cacheable = true;
|
|
150
|
-
for (const policy of applicable) {
|
|
169
|
+
for (const policy of [...applicable, ...denyOnly]) {
|
|
151
170
|
assertNotAborted(context.signal ?? authOptions?.signal);
|
|
152
171
|
evaluated.push(policy.name);
|
|
153
172
|
if (policy.cacheable === false)
|
|
@@ -184,6 +203,9 @@ export async function evaluatePolicies(context, policies, options, authOptions)
|
|
|
184
203
|
};
|
|
185
204
|
}
|
|
186
205
|
}
|
|
206
|
+
if (applicable.length === 0) {
|
|
207
|
+
return { decision: null, cacheable, evaluated };
|
|
208
|
+
}
|
|
187
209
|
return {
|
|
188
210
|
decision: Object.freeze({
|
|
189
211
|
allowed: true,
|
|
@@ -10,6 +10,7 @@ import type { PermissionActor, AuthorizationOptions } from "../permissionTypes/i
|
|
|
10
10
|
import type { PermissionEngine } from "../evaluator/authorizationEngine.js";
|
|
11
11
|
import type { HttpMiddleware, HttpMiddlewareContext } from "./httpTypes.js";
|
|
12
12
|
import { type DeniedResponseOptions } from "./httpHelpers.js";
|
|
13
|
+
import { type ResourceExtractor } from "./httpResource.helper.js";
|
|
13
14
|
/** Options shared by the permission middleware. */
|
|
14
15
|
export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
|
|
15
16
|
/**
|
|
@@ -30,13 +31,18 @@ export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
|
|
|
30
31
|
readonly forwardSignal?: boolean;
|
|
31
32
|
/** Builds per-request metadata for conditions and policies. */
|
|
32
33
|
readonly extractMetadata?: (context: HttpMiddlewareContext) => Record<string, unknown> | undefined;
|
|
34
|
+
/** Reports a failure the guard turned into a denial (a failed resource load). */
|
|
35
|
+
readonly onError?: (error: unknown, source: string) => void;
|
|
33
36
|
}
|
|
34
37
|
/** Options for the requirePermission middleware. */
|
|
35
38
|
export interface RequirePermissionMiddlewareOptions extends AuthorizeMiddlewareOptions {
|
|
36
39
|
/** The permission to check (e.g. "post:update"). */
|
|
37
40
|
readonly permission: string;
|
|
38
|
-
/**
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Loads the resource the permission is checked against (optional). May be
|
|
43
|
+
* async; it is awaited, and a loader that throws or rejects denies (403).
|
|
44
|
+
*/
|
|
45
|
+
readonly extractResource?: ResourceExtractor;
|
|
40
46
|
}
|
|
41
47
|
/** Options for {@link createActorMiddleware}. */
|
|
42
48
|
export interface ActorMiddlewareOptions extends AuthorizeMiddlewareOptions {
|
|
@@ -72,8 +78,11 @@ export declare function createRequirePermissionMiddleware(engine: PermissionEngi
|
|
|
72
78
|
export declare function authorize(engine: PermissionEngine, permission: string, options?: Omit<RequirePermissionMiddlewareOptions, "permission">): HttpMiddleware;
|
|
73
79
|
/** Options for {@link createRequirePermissionsMiddleware}. */
|
|
74
80
|
export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddlewareOptions {
|
|
75
|
-
/**
|
|
76
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Loads the resource checked for every permission (optional). May be
|
|
83
|
+
* async; it is awaited, and a loader that throws or rejects denies (403).
|
|
84
|
+
*/
|
|
85
|
+
readonly extractResource?: ResourceExtractor;
|
|
77
86
|
/**
|
|
78
87
|
* `"all"` (default) requires every permission; `"any"` requires one.
|
|
79
88
|
*/
|
|
@@ -83,7 +92,8 @@ export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddleware
|
|
|
83
92
|
* Create middleware that checks multiple permissions.
|
|
84
93
|
*
|
|
85
94
|
* Under `"all"` the first denial short-circuits: evaluating the rest costs
|
|
86
|
-
* policy calls and timeouts for an answer that is already decided.
|
|
95
|
+
* policy calls and timeouts for an answer that is already decided. An empty
|
|
96
|
+
* permission list denies in either mode.
|
|
87
97
|
*/
|
|
88
98
|
export declare function createRequirePermissionsMiddleware(engine: PermissionEngine, permissions: readonly string[], options?: RequirePermissionsMiddlewareOptions): HttpMiddleware;
|
|
89
99
|
//# sourceMappingURL=httpMiddleware.core.d.ts.map
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module http/httpMiddleware
|
|
8
8
|
*/
|
|
9
9
|
import { createForbiddenResponse, createUnauthorizedResponse, } from "./httpHelpers.js";
|
|
10
|
+
import { loadResource } from "./httpResource.helper.js";
|
|
10
11
|
// ─── State Keys ───────────────────────────────────────────────────────────
|
|
11
12
|
/** State key for the current actor. */
|
|
12
13
|
export const ACTOR_STATE_KEY = "permissions:actor";
|
|
@@ -64,8 +65,12 @@ export function createRequirePermissionMiddleware(engine, options) {
|
|
|
64
65
|
const actor = await resolveActor(context, options);
|
|
65
66
|
if (!actor)
|
|
66
67
|
return createUnauthorizedResponse(options);
|
|
67
|
-
const
|
|
68
|
-
|
|
68
|
+
const loaded = await loadResource(context, options.extractResource, options.onError);
|
|
69
|
+
if (!loaded.ok) {
|
|
70
|
+
context.state.set(DECISION_STATE_KEY, loaded.decision);
|
|
71
|
+
return createForbiddenResponse(loaded.decision, options);
|
|
72
|
+
}
|
|
73
|
+
const decision = await engine.check(actor, options.permission, loaded.resource, buildAuthorization(context, options));
|
|
69
74
|
context.state.set(DECISION_STATE_KEY, decision);
|
|
70
75
|
if (!decision.allowed)
|
|
71
76
|
return createForbiddenResponse(decision, options);
|
|
@@ -85,7 +90,8 @@ export function authorize(engine, permission, options = {}) {
|
|
|
85
90
|
* Create middleware that checks multiple permissions.
|
|
86
91
|
*
|
|
87
92
|
* Under `"all"` the first denial short-circuits: evaluating the rest costs
|
|
88
|
-
* policy calls and timeouts for an answer that is already decided.
|
|
93
|
+
* policy calls and timeouts for an answer that is already decided. An empty
|
|
94
|
+
* permission list denies in either mode.
|
|
89
95
|
*/
|
|
90
96
|
export function createRequirePermissionsMiddleware(engine, permissions, options = {}) {
|
|
91
97
|
const mode = options.mode ?? "all";
|
|
@@ -93,7 +99,15 @@ export function createRequirePermissionsMiddleware(engine, permissions, options
|
|
|
93
99
|
const actor = await resolveActor(context, options);
|
|
94
100
|
if (!actor)
|
|
95
101
|
return createUnauthorizedResponse(options);
|
|
96
|
-
|
|
102
|
+
// An empty list is a configuration error, not a grant: under "all" it
|
|
103
|
+
// used to fall through to next() for any authenticated actor.
|
|
104
|
+
if (permissions.length === 0) {
|
|
105
|
+
return createForbiddenResponse({ allowed: false, reason: "no_permissions", publicReason: "Access denied" }, options);
|
|
106
|
+
}
|
|
107
|
+
const loaded = await loadResource(context, options.extractResource, options.onError);
|
|
108
|
+
if (!loaded.ok)
|
|
109
|
+
return createForbiddenResponse(loaded.decision, options);
|
|
110
|
+
const resource = loaded.resource;
|
|
97
111
|
const authorization = buildAuthorization(context, options);
|
|
98
112
|
const results = new Map();
|
|
99
113
|
let lastDenial;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource loading for the permission guards.
|
|
3
|
+
*
|
|
4
|
+
* @module http/httpResource.helper
|
|
5
|
+
*/
|
|
6
|
+
import type { PermissionDecision } from "../permissionTypes/index.js";
|
|
7
|
+
import type { HttpMiddlewareContext } from "./httpTypes.js";
|
|
8
|
+
/** Loads the resource a permission is checked against. May be async. */
|
|
9
|
+
export type ResourceExtractor = (context: HttpMiddlewareContext) => unknown | Promise<unknown>;
|
|
10
|
+
/** The resource, or the denial to answer with when it could not be loaded. */
|
|
11
|
+
export type ResourceOutcome = {
|
|
12
|
+
readonly ok: true;
|
|
13
|
+
readonly resource: unknown;
|
|
14
|
+
} | {
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly decision: PermissionDecision;
|
|
17
|
+
};
|
|
18
|
+
/** The decision recorded when the resource loader fails. */
|
|
19
|
+
export declare const RESOURCE_ERROR_DECISION: PermissionDecision;
|
|
20
|
+
/**
|
|
21
|
+
* Run the extractor and await it.
|
|
22
|
+
*
|
|
23
|
+
* The guard used to hand the engine whatever the extractor returned — for an
|
|
24
|
+
* async loader, a Promise — so every resource condition saw `undefined`:
|
|
25
|
+
* deny rules on a locked or foreign resource never fired, and a loader that
|
|
26
|
+
* rejected let the request through while its rejection went unhandled. A
|
|
27
|
+
* loader that throws or rejects now denies; the error goes to `onError`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadResource(context: HttpMiddlewareContext, extract: ResourceExtractor | undefined, onError?: (error: unknown, source: string) => void): Promise<ResourceOutcome>;
|
|
30
|
+
//# sourceMappingURL=httpResource.helper.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource loading for the permission guards.
|
|
3
|
+
*
|
|
4
|
+
* @module http/httpResource.helper
|
|
5
|
+
*/
|
|
6
|
+
/** The decision recorded when the resource loader fails. */
|
|
7
|
+
export const RESOURCE_ERROR_DECISION = Object.freeze({
|
|
8
|
+
allowed: false,
|
|
9
|
+
reason: "resource_error",
|
|
10
|
+
publicReason: "Access denied",
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Run the extractor and await it.
|
|
14
|
+
*
|
|
15
|
+
* The guard used to hand the engine whatever the extractor returned — for an
|
|
16
|
+
* async loader, a Promise — so every resource condition saw `undefined`:
|
|
17
|
+
* deny rules on a locked or foreign resource never fired, and a loader that
|
|
18
|
+
* rejected let the request through while its rejection went unhandled. A
|
|
19
|
+
* loader that throws or rejects now denies; the error goes to `onError`.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadResource(context, extract, onError) {
|
|
22
|
+
if (!extract)
|
|
23
|
+
return { ok: true, resource: undefined };
|
|
24
|
+
try {
|
|
25
|
+
return { ok: true, resource: await extract(context) };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
onError?.(error, "extractResource");
|
|
29
|
+
return { ok: false, decision: RESOURCE_ERROR_DECISION };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=httpResource.helper.js.map
|
package/dist/http/httpTypes.d.ts
CHANGED
|
@@ -16,20 +16,33 @@ export interface HttpMiddlewareContext {
|
|
|
16
16
|
readonly signal: AbortSignal;
|
|
17
17
|
readonly metadata: Readonly<Record<string, unknown>>;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* A request's headers, params or query as either shape a caller may hold.
|
|
21
|
+
*
|
|
22
|
+
* The real `@zudojs/http` request exposes plain frozen objects
|
|
23
|
+
* (`Readonly<Record<…>>`); this mirror used to say `ReadonlyMap`, so code
|
|
24
|
+
* written against it called `.get()` on an object that has none. Both shapes
|
|
25
|
+
* are accepted, and the accessor methods below are the portable way to read.
|
|
26
|
+
*/
|
|
27
|
+
export type HttpRequestBag<V> = ReadonlyMap<string, V> | Readonly<Record<string, V | undefined>>;
|
|
19
28
|
/** HTTP request context from @zudojs/http. */
|
|
20
29
|
export interface HttpRequestContext {
|
|
21
30
|
readonly id: string;
|
|
22
31
|
readonly method: string;
|
|
23
32
|
readonly url: string;
|
|
24
33
|
readonly path: string;
|
|
25
|
-
readonly headers:
|
|
26
|
-
readonly params:
|
|
27
|
-
readonly query:
|
|
34
|
+
readonly headers: HttpRequestBag<string>;
|
|
35
|
+
readonly params: HttpRequestBag<string>;
|
|
36
|
+
readonly query: HttpRequestBag<string | readonly string[]>;
|
|
37
|
+
/** Case-insensitive header lookup, as `@zudojs/http` provides it. */
|
|
38
|
+
getHeader?(name: string): string | undefined;
|
|
39
|
+
/** Route parameter lookup, as `@zudojs/http` provides it. */
|
|
40
|
+
getParam?(name: string): string | undefined;
|
|
28
41
|
}
|
|
29
42
|
/** HTTP response context from @zudojs/http. */
|
|
30
43
|
export interface HttpResponseContext {
|
|
31
44
|
readonly status: number;
|
|
32
|
-
readonly headers: Headers | Record<string, string
|
|
45
|
+
readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
33
46
|
readonly body?: unknown;
|
|
34
47
|
}
|
|
35
48
|
/** HTTP middleware state from @zudojs/http. */
|
package/dist/http/index.d.ts
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* HTTP middleware adapter for @zudojs/permissions.
|
|
3
3
|
*
|
|
4
4
|
* The HTTP types are mirrored locally in `httpTypes.ts` so this package has no
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* dependency on @zudojs/http at all — http sits in a higher architecture tier,
|
|
6
|
+
* so it cannot be a dependency or a peer. The mirror is structural: anything
|
|
7
|
+
* satisfying the real `HttpMiddlewareContext` satisfies the local one, so the
|
|
8
|
+
* middleware composes with the real pipeline. A test runs the guard inside
|
|
9
|
+
* the real `HttpMiddlewarePipeline` to keep the two in step.
|
|
9
10
|
*
|
|
10
11
|
* @module http
|
|
11
12
|
*/
|
|
12
13
|
export { createActorMiddleware, createRequirePermissionMiddleware, authorize, createRequirePermissionsMiddleware, ACTOR_STATE_KEY, DECISION_STATE_KEY, DECISIONS_STATE_KEY, type AuthorizeMiddlewareOptions, type ActorMiddlewareOptions, type RequirePermissionMiddlewareOptions, type RequirePermissionsMiddlewareOptions, } from "./httpMiddleware.core.js";
|
|
13
14
|
export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, type DeniedResponseOptions, type PermissionHttpResponse, } from "./httpHelpers.js";
|
|
14
|
-
export
|
|
15
|
+
export { loadResource, RESOURCE_ERROR_DECISION, type ResourceExtractor, type ResourceOutcome, } from "./httpResource.helper.js";
|
|
16
|
+
export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.js";
|
|
15
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/http/index.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* HTTP middleware adapter for @zudojs/permissions.
|
|
3
3
|
*
|
|
4
4
|
* The HTTP types are mirrored locally in `httpTypes.ts` so this package has no
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* dependency on @zudojs/http at all — http sits in a higher architecture tier,
|
|
6
|
+
* so it cannot be a dependency or a peer. The mirror is structural: anything
|
|
7
|
+
* satisfying the real `HttpMiddlewareContext` satisfies the local one, so the
|
|
8
|
+
* middleware composes with the real pipeline. A test runs the guard inside
|
|
9
|
+
* the real `HttpMiddlewarePipeline` to keep the two in step.
|
|
9
10
|
*
|
|
10
11
|
* @module http
|
|
11
12
|
*/
|
|
12
13
|
export { createActorMiddleware, createRequirePermissionMiddleware, authorize, createRequirePermissionsMiddleware, ACTOR_STATE_KEY, DECISION_STATE_KEY, DECISIONS_STATE_KEY, } from "./httpMiddleware.core.js";
|
|
13
14
|
export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, } from "./httpHelpers.js";
|
|
15
|
+
export { loadResource, RESOURCE_ERROR_DECISION, } from "./httpResource.helper.js";
|
|
14
16
|
//# sourceMappingURL=index.js.map
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module permission
|
|
5
5
|
*/
|
|
6
|
-
export { parsePermission, parsePermissionSafe, isValidPermission, matches, matchesPermission, formatPermission, } from "./permission.core.js";
|
|
6
|
+
export { parsePermission, parsePermissionSafe, isValidPermission, matches, matchesPermission, permissionsOverlap, formatPermission, } from "./permission.core.js";
|
|
7
7
|
export { createPermissionRegistry, type PermissionRegistry, type PermissionRegistryOptions, type RegisteredPermission, } from "./permissionRegistry.js";
|
|
8
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/permission/index.js
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module permission
|
|
5
5
|
*/
|
|
6
|
-
export { parsePermission, parsePermissionSafe, isValidPermission, matches, matchesPermission, formatPermission, } from "./permission.core.js";
|
|
6
|
+
export { parsePermission, parsePermissionSafe, isValidPermission, matches, matchesPermission, permissionsOverlap, formatPermission, } from "./permission.core.js";
|
|
7
7
|
export { createPermissionRegistry, } from "./permissionRegistry.js";
|
|
8
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -32,6 +32,18 @@ export declare function matches(pattern: string, target: string): boolean;
|
|
|
32
32
|
* Check if a permission pattern matches a structured Permission object.
|
|
33
33
|
*/
|
|
34
34
|
export declare function matchesPermission(pattern: string, permission: Permission): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Check if two permission patterns can name a common permission.
|
|
37
|
+
*
|
|
38
|
+
* This is the test a *deny* needs. For a concrete target it is exactly
|
|
39
|
+
* {@link matches}; for a wildcard target such as `post:*` it also holds when
|
|
40
|
+
* the deny is narrower (`post:delete`), which `matches` does not see.
|
|
41
|
+
*
|
|
42
|
+
* @example permissionsOverlap("post:delete", "post:*") → true
|
|
43
|
+
* @example permissionsOverlap("*:delete", "post:*") → true
|
|
44
|
+
* @example permissionsOverlap("post:delete", "post:read") → false
|
|
45
|
+
*/
|
|
46
|
+
export declare function permissionsOverlap(a: string, b: string): boolean;
|
|
35
47
|
/**
|
|
36
48
|
* Parse a permission string, returning null on invalid format instead of throwing.
|
|
37
49
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @module permission/permission
|
|
5
5
|
*/
|
|
6
6
|
import { InvalidPermissionError } from "../permissionErrors/index.js";
|
|
7
|
-
import { patternStrMatches } from "../rule/rule.
|
|
7
|
+
import { patternStrMatches, patternsOverlap } from "../rule/rule.pattern.js";
|
|
8
8
|
/**
|
|
9
9
|
* Valid permission format: `resource:action`.
|
|
10
10
|
*
|
|
@@ -62,6 +62,25 @@ export function matchesPermission(pattern, permission) {
|
|
|
62
62
|
return (patternStrMatches(parsed.resource, permission.resource) &&
|
|
63
63
|
patternStrMatches(parsed.action, permission.action));
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Check if two permission patterns can name a common permission.
|
|
67
|
+
*
|
|
68
|
+
* This is the test a *deny* needs. For a concrete target it is exactly
|
|
69
|
+
* {@link matches}; for a wildcard target such as `post:*` it also holds when
|
|
70
|
+
* the deny is narrower (`post:delete`), which `matches` does not see.
|
|
71
|
+
*
|
|
72
|
+
* @example permissionsOverlap("post:delete", "post:*") → true
|
|
73
|
+
* @example permissionsOverlap("*:delete", "post:*") → true
|
|
74
|
+
* @example permissionsOverlap("post:delete", "post:read") → false
|
|
75
|
+
*/
|
|
76
|
+
export function permissionsOverlap(a, b) {
|
|
77
|
+
const left = parsePermissionSafe(a);
|
|
78
|
+
const right = parsePermissionSafe(b);
|
|
79
|
+
if (!left || !right)
|
|
80
|
+
return false;
|
|
81
|
+
return (patternsOverlap(left.resource, right.resource) &&
|
|
82
|
+
patternsOverlap(left.action, right.action));
|
|
83
|
+
}
|
|
65
84
|
/**
|
|
66
85
|
* Parse a permission string, returning null on invalid format instead of throwing.
|
|
67
86
|
*/
|
|
@@ -8,6 +8,12 @@ import type { PermissionPolicyDefinition } from "../permissionTypes/index.js";
|
|
|
8
8
|
export interface PolicyRegistryOptions {
|
|
9
9
|
/** Allow overwriting an existing policy. Defaults to false. */
|
|
10
10
|
readonly allowOverride?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Reject permission patterns that are not `resource:action`. Defaults to
|
|
13
|
+
* true — a policy scoped to a pattern that never matches never runs, so a
|
|
14
|
+
* mistyped lockdown would deny nothing.
|
|
15
|
+
*/
|
|
16
|
+
readonly validatePermissions?: boolean;
|
|
11
17
|
}
|
|
12
18
|
/** A policy registry, usable directly as the engine's `policies` source. */
|
|
13
19
|
export interface PolicyRegistry {
|
|
@@ -19,6 +25,12 @@ export interface PolicyRegistry {
|
|
|
19
25
|
all(): readonly PermissionPolicyDefinition[];
|
|
20
26
|
remove(name: string): boolean;
|
|
21
27
|
clear(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Be told whenever the policy set changes. Returns an unsubscribe function.
|
|
30
|
+
* An engine built on this registry subscribes itself, so a policy change
|
|
31
|
+
* is not bypassed by a cached decision.
|
|
32
|
+
*/
|
|
33
|
+
subscribe(listener: () => void): () => void;
|
|
22
34
|
}
|
|
23
35
|
/**
|
|
24
36
|
* Create a policy registry.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DuplicatePolicyError } from "../permissionErrors/index.js";
|
|
7
7
|
import { selectPolicies } from "../evaluator/evaluator.pipeline.js";
|
|
8
|
+
import { validatePolicy } from "../evaluator/engineSupport/index.js";
|
|
9
|
+
import { createChangeNotifier } from "../utils/utils.notifier.js";
|
|
8
10
|
/**
|
|
9
11
|
* Create a policy registry.
|
|
10
12
|
*
|
|
@@ -13,6 +15,8 @@ import { selectPolicies } from "../evaluator/evaluator.pipeline.js";
|
|
|
13
15
|
export function createPolicyRegistry(options) {
|
|
14
16
|
const policies = new Map();
|
|
15
17
|
const allowOverride = options?.allowOverride ?? false;
|
|
18
|
+
const validatePermissions = options?.validatePermissions ?? true;
|
|
19
|
+
const changes = createChangeNotifier();
|
|
16
20
|
return {
|
|
17
21
|
/**
|
|
18
22
|
* Register a policy.
|
|
@@ -20,12 +24,22 @@ export function createPolicyRegistry(options) {
|
|
|
20
24
|
* Re-registering a name is rejected unless `allowOverride` was set: a
|
|
21
25
|
* second `define("owner-only", …)` used to replace the first in silence,
|
|
22
26
|
* which is an authorization rule vanishing without a trace.
|
|
27
|
+
*
|
|
28
|
+
* @throws {InvalidPermissionError} when a permission pattern is malformed.
|
|
23
29
|
*/
|
|
24
30
|
define(definition) {
|
|
31
|
+
if (validatePermissions)
|
|
32
|
+
validatePolicy(definition);
|
|
25
33
|
if (policies.has(definition.name) && !allowOverride) {
|
|
26
34
|
throw new DuplicatePolicyError(definition.name);
|
|
27
35
|
}
|
|
28
|
-
|
|
36
|
+
// Copy the permission list: a caller mutating the array it passed in
|
|
37
|
+
// must not re-scope the policy after registration.
|
|
38
|
+
policies.set(definition.name, Object.freeze({
|
|
39
|
+
...definition,
|
|
40
|
+
permissions: Object.freeze([...definition.permissions]),
|
|
41
|
+
}));
|
|
42
|
+
changes.notify();
|
|
29
43
|
},
|
|
30
44
|
get(name) {
|
|
31
45
|
return policies.get(name);
|
|
@@ -50,10 +64,17 @@ export function createPolicyRegistry(options) {
|
|
|
50
64
|
return [...policies.values()];
|
|
51
65
|
},
|
|
52
66
|
remove(name) {
|
|
53
|
-
|
|
67
|
+
const removed = policies.delete(name);
|
|
68
|
+
if (removed)
|
|
69
|
+
changes.notify();
|
|
70
|
+
return removed;
|
|
54
71
|
},
|
|
55
72
|
clear() {
|
|
56
73
|
policies.clear();
|
|
74
|
+
changes.notify();
|
|
75
|
+
},
|
|
76
|
+
subscribe(listener) {
|
|
77
|
+
return changes.subscribe(listener);
|
|
57
78
|
},
|
|
58
79
|
};
|
|
59
80
|
}
|
|
@@ -26,7 +26,25 @@ export interface RoleRegistry {
|
|
|
26
26
|
all(): readonly RoleDefinition[];
|
|
27
27
|
remove(name: string): boolean;
|
|
28
28
|
clear(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Be told whenever the role set changes (`define`, a `remove` that removed
|
|
31
|
+
* something, `clear`). Returns an unsubscribe function.
|
|
32
|
+
*
|
|
33
|
+
* An engine built on this registry subscribes itself, which is what makes
|
|
34
|
+
* revoking a role take effect on the next check instead of whenever
|
|
35
|
+
* somebody remembers to call `engine.invalidateRoles()`.
|
|
36
|
+
*/
|
|
37
|
+
subscribe(listener: () => void): () => void;
|
|
29
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Copy a role definition so it no longer shares arrays with the caller.
|
|
41
|
+
*
|
|
42
|
+
* `Object.freeze({ ...definition })` froze the wrapper and kept the caller's
|
|
43
|
+
* `permissions`, `inherits` and `rules` arrays by reference — so pushing
|
|
44
|
+
* `"*:*"` onto an array *after* `define()` had validated it widened the role
|
|
45
|
+
* in silence.
|
|
46
|
+
*/
|
|
47
|
+
export declare function freezeRoleDefinition(definition: RoleDefinition): RoleDefinition;
|
|
30
48
|
/**
|
|
31
49
|
* Create a role registry.
|
|
32
50
|
*
|
|
@@ -5,6 +5,26 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DuplicateRoleError, InvalidRoleError, RoleNotFoundError, } from "../permissionErrors/index.js";
|
|
7
7
|
import { isValidPermission } from "../permission/permission.core.js";
|
|
8
|
+
import { invalidRulePattern } from "../evaluator/engineSupport/index.js";
|
|
9
|
+
import { createChangeNotifier } from "../utils/utils.notifier.js";
|
|
10
|
+
/**
|
|
11
|
+
* Copy a role definition so it no longer shares arrays with the caller.
|
|
12
|
+
*
|
|
13
|
+
* `Object.freeze({ ...definition })` froze the wrapper and kept the caller's
|
|
14
|
+
* `permissions`, `inherits` and `rules` arrays by reference — so pushing
|
|
15
|
+
* `"*:*"` onto an array *after* `define()` had validated it widened the role
|
|
16
|
+
* in silence.
|
|
17
|
+
*/
|
|
18
|
+
export function freezeRoleDefinition(definition) {
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
...definition,
|
|
21
|
+
permissions: Object.freeze([...definition.permissions]),
|
|
22
|
+
...(definition.inherits
|
|
23
|
+
? { inherits: Object.freeze([...definition.inherits]) }
|
|
24
|
+
: {}),
|
|
25
|
+
...(definition.rules ? { rules: Object.freeze([...definition.rules]) } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
8
28
|
/**
|
|
9
29
|
* Create a role registry.
|
|
10
30
|
*
|
|
@@ -14,6 +34,7 @@ export function createRoleRegistry(options) {
|
|
|
14
34
|
const roles = new Map();
|
|
15
35
|
const allowOverride = options?.allowOverride ?? false;
|
|
16
36
|
const validatePermissions = options?.validatePermissions ?? true;
|
|
37
|
+
const changes = createChangeNotifier();
|
|
17
38
|
return {
|
|
18
39
|
/**
|
|
19
40
|
* Register a role definition.
|
|
@@ -36,11 +57,19 @@ export function createRoleRegistry(options) {
|
|
|
36
57
|
`valid "resource:action" permission`);
|
|
37
58
|
}
|
|
38
59
|
}
|
|
60
|
+
for (const rule of definition.rules ?? []) {
|
|
61
|
+
const invalid = invalidRulePattern(rule);
|
|
62
|
+
if (invalid !== undefined) {
|
|
63
|
+
throw new InvalidRoleError(`Role "${definition.name}" has a rule on "${invalid}", which is ` +
|
|
64
|
+
`not a valid "resource:action" pattern`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
39
67
|
}
|
|
40
68
|
if (roles.has(definition.name) && !allowOverride) {
|
|
41
69
|
throw new DuplicateRoleError(definition.name);
|
|
42
70
|
}
|
|
43
|
-
roles.set(definition.name,
|
|
71
|
+
roles.set(definition.name, freezeRoleDefinition(definition));
|
|
72
|
+
changes.notify();
|
|
44
73
|
},
|
|
45
74
|
get(name) {
|
|
46
75
|
return roles.get(name);
|
|
@@ -61,10 +90,17 @@ export function createRoleRegistry(options) {
|
|
|
61
90
|
return [...roles.values()];
|
|
62
91
|
},
|
|
63
92
|
remove(name) {
|
|
64
|
-
|
|
93
|
+
const removed = roles.delete(name);
|
|
94
|
+
if (removed)
|
|
95
|
+
changes.notify();
|
|
96
|
+
return removed;
|
|
65
97
|
},
|
|
66
98
|
clear() {
|
|
67
99
|
roles.clear();
|
|
100
|
+
changes.notify();
|
|
101
|
+
},
|
|
102
|
+
subscribe(listener) {
|
|
103
|
+
return changes.subscribe(listener);
|
|
68
104
|
},
|
|
69
105
|
};
|
|
70
106
|
}
|
package/dist/rule/rule.core.d.ts
CHANGED
|
@@ -4,23 +4,7 @@
|
|
|
4
4
|
* @module rule/rule
|
|
5
5
|
*/
|
|
6
6
|
import type { PermissionRule, Permission, PermissionContext, RuleCombiningAlgorithm, RuleEvaluation } from "../permissionTypes/index.js";
|
|
7
|
-
|
|
8
|
-
* Check if a rule matches a target permission.
|
|
9
|
-
*
|
|
10
|
-
* Matching is about resource and action only. Whether the rule *applies* also
|
|
11
|
-
* depends on its condition, which needs a context and is therefore evaluated
|
|
12
|
-
* by {@link evaluateRules}.
|
|
13
|
-
*/
|
|
14
|
-
export declare function ruleMatches(rule: PermissionRule, target: Permission): boolean;
|
|
15
|
-
/**
|
|
16
|
-
* Check if a single pattern string matches a target, supporting wildcards.
|
|
17
|
-
*
|
|
18
|
-
* Two forms are supported, and they are the same two the permission matcher
|
|
19
|
-
* supports, so a pattern means the same thing wherever it is written:
|
|
20
|
-
* `*` — matches anything
|
|
21
|
-
* `billing.*` — matches `billing` and any `billing.…` namespace
|
|
22
|
-
*/
|
|
23
|
-
export declare function patternStrMatches(pattern: string, target: string): boolean;
|
|
7
|
+
export { ruleMatches, patternStrMatches } from "./rule.pattern.js";
|
|
24
8
|
/**
|
|
25
9
|
* Evaluate a set of rules against a target permission.
|
|
26
10
|
*
|
|
@@ -29,9 +13,15 @@ export declare function patternStrMatches(pattern: string, target: string): bool
|
|
|
29
13
|
* has to mean "owners may", not "anyone may". Conditions are async, so this
|
|
30
14
|
* function is too.
|
|
31
15
|
*
|
|
32
|
-
* A condition that
|
|
33
|
-
*
|
|
34
|
-
*
|
|
16
|
+
* A condition that cannot be evaluated fails closed, and what "closed" means
|
|
17
|
+
* depends on the rule's effect. An allow whose condition throws, or that has
|
|
18
|
+
* a condition but no context, does not apply. A *deny* in the same position
|
|
19
|
+
* does apply: treating it as unmet dropped the deny and let the role's allow
|
|
20
|
+
* win, so a missing resource or a blocklist service that was down granted
|
|
21
|
+
* the very access the deny existed to refuse.
|
|
22
|
+
*
|
|
23
|
+
* A deny also applies when its patterns merely overlap a wildcard target —
|
|
24
|
+
* `can(actor, "post:*")` is refused by a deny on `post:delete`.
|
|
35
25
|
*
|
|
36
26
|
* Default combining algorithm: `deny-overrides`. Any applicable deny wins,
|
|
37
27
|
* whatever its priority.
|
|
@@ -44,8 +34,10 @@ export declare function evaluateRules(rules: readonly PermissionRule[], target:
|
|
|
44
34
|
* Evaluate rules that carry no conditions.
|
|
45
35
|
*
|
|
46
36
|
* Synchronous, for callers that build their own condition-free rule sets.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
37
|
+
* A conditional rule cannot be evaluated without awaiting it — use
|
|
38
|
+
* {@link evaluateRules} for those — so it fails closed the same way an
|
|
39
|
+
* unevaluable condition does there: a conditional allow is skipped, and a
|
|
40
|
+
* conditional deny applies.
|
|
49
41
|
*/
|
|
50
42
|
export declare function evaluateRulesSync(rules: readonly PermissionRule[], target: Permission, options?: {
|
|
51
43
|
readonly algorithm?: RuleCombiningAlgorithm;
|