@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
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { evaluate, evaluateWithTrace } from "./evaluator.core.js";
|
|
7
7
|
import { createAbility } from "../ability/ability.core.js";
|
|
8
|
-
import { PermissionDeniedError
|
|
9
|
-
import { isValidPermission } from "../permission/permission.core.js";
|
|
8
|
+
import { PermissionDeniedError } from "../permissionErrors/index.js";
|
|
10
9
|
import { memoizeRoleLookup } from "../role/roleHierarchy.js";
|
|
10
|
+
import { freezeRoleDefinition } from "../role/roleRegistry.js";
|
|
11
|
+
import { validatePolicy, validateRole, validateRoles, validateRule, } from "./engineSupport/index.js";
|
|
12
|
+
import { observed } from "./engineSupport/index.js";
|
|
11
13
|
function isRoleSource(roles) {
|
|
12
14
|
return (roles !== undefined &&
|
|
13
15
|
!Array.isArray(roles) &&
|
|
@@ -18,35 +20,15 @@ function isPolicySource(policies) {
|
|
|
18
20
|
!Array.isArray(policies) &&
|
|
19
21
|
typeof policies.names === "function");
|
|
20
22
|
}
|
|
21
|
-
/** Rejects a role whose grants could never match. */
|
|
22
|
-
function validateRole(role) {
|
|
23
|
-
if (!role.name || role.name.trim() === "") {
|
|
24
|
-
throw new InvalidRoleError("Role name cannot be empty");
|
|
25
|
-
}
|
|
26
|
-
for (const permission of role.permissions) {
|
|
27
|
-
if (!isValidPermission(permission)) {
|
|
28
|
-
// A malformed grant can never match, so it is a silent no-op unless
|
|
29
|
-
// it is rejected here.
|
|
30
|
-
throw new InvalidRoleError(`Role "${role.name}" grants "${permission}", which is not a valid ` +
|
|
31
|
-
`"resource:action" permission`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function validateRoles(roles) {
|
|
36
|
-
const seen = new Set();
|
|
37
|
-
for (const role of roles) {
|
|
38
|
-
validateRole(role);
|
|
39
|
-
if (seen.has(role.name)) {
|
|
40
|
-
throw new InvalidRoleError(`Role "${role.name}" is defined more than once`);
|
|
41
|
-
}
|
|
42
|
-
seen.add(role.name);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
23
|
/**
|
|
46
24
|
* Create a permission engine.
|
|
47
25
|
*/
|
|
48
26
|
export function createPermissionEngine(options) {
|
|
49
27
|
const validateConfiguration = options?.validateConfiguration ?? true;
|
|
28
|
+
if (validateConfiguration) {
|
|
29
|
+
for (const rule of options?.rules ?? [])
|
|
30
|
+
validateRule(rule);
|
|
31
|
+
}
|
|
50
32
|
let roleLookup;
|
|
51
33
|
let invalidateRoleCache;
|
|
52
34
|
if (isRoleSource(options?.roles)) {
|
|
@@ -68,7 +50,9 @@ export function createPermissionEngine(options) {
|
|
|
68
50
|
const list = (options?.roles ?? []);
|
|
69
51
|
if (validateConfiguration)
|
|
70
52
|
validateRoles(list);
|
|
71
|
-
|
|
53
|
+
// Copy each role, so a caller still holding the arrays it passed in
|
|
54
|
+
// cannot widen a grant after validation has run.
|
|
55
|
+
const roleMap = new Map(list.map((role) => [role.name, freezeRoleDefinition(role)]));
|
|
72
56
|
roleLookup = (name) => roleMap.get(name);
|
|
73
57
|
invalidateRoleCache = () => { };
|
|
74
58
|
}
|
|
@@ -78,17 +62,53 @@ export function createPermissionEngine(options) {
|
|
|
78
62
|
const policyList = policySource
|
|
79
63
|
? undefined
|
|
80
64
|
: (options?.policies ?? []);
|
|
65
|
+
if (validateConfiguration)
|
|
66
|
+
policyList?.forEach(validatePolicy);
|
|
67
|
+
// A policy source is read lazily, so its validation is lazy too. Each
|
|
68
|
+
// definition is checked once; a malformed one makes the check throw, which
|
|
69
|
+
// never reads as an allow.
|
|
70
|
+
const validatedPolicies = new WeakSet();
|
|
81
71
|
const resolvePolicies = () => {
|
|
82
72
|
if (!policySource)
|
|
83
73
|
return policyList ?? [];
|
|
84
74
|
return policySource
|
|
85
75
|
.names()
|
|
86
76
|
.map((name) => policySource.get(name))
|
|
87
|
-
.filter((policy) => policy !== undefined)
|
|
77
|
+
.filter((policy) => policy !== undefined)
|
|
78
|
+
.map((policy) => {
|
|
79
|
+
if (validateConfiguration && !validatedPolicies.has(policy)) {
|
|
80
|
+
validatePolicy(policy);
|
|
81
|
+
validatedPolicies.add(policy);
|
|
82
|
+
}
|
|
83
|
+
return policy;
|
|
84
|
+
});
|
|
88
85
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
// Every change to the configuration bumps the generation, which is part
|
|
87
|
+
// of every decision-cache key: an entry written under an older role or
|
|
88
|
+
// policy set can no longer be found, whatever cache adapter is in use.
|
|
89
|
+
let generation = 0;
|
|
90
|
+
const invalidateConfiguration = () => {
|
|
91
|
+
generation += 1;
|
|
92
|
+
invalidateRoleCache();
|
|
93
|
+
const cleared = options?.cache?.clear?.();
|
|
94
|
+
cleared?.catch((error) => {
|
|
95
|
+
options?.onError?.(error, "PermissionCache.clear");
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
if (isRoleSource(options?.roles)) {
|
|
99
|
+
options.roles.subscribe?.(invalidateConfiguration);
|
|
100
|
+
}
|
|
101
|
+
policySource?.subscribe?.(invalidateConfiguration);
|
|
102
|
+
// One live view over the configuration. `policies` is a getter so a
|
|
103
|
+
// registry-backed engine re-reads the registry on every evaluation — an
|
|
104
|
+
// Ability used to capture a snapshot of the policy list when it was
|
|
105
|
+
// created, so a policy defined afterwards was enforced by `engine.can()`
|
|
106
|
+
// and ignored by `ability.can()` for the same actor.
|
|
107
|
+
const liveOptions = {
|
|
108
|
+
getRole: (name) => roleLookup(name),
|
|
109
|
+
get policies() {
|
|
110
|
+
return resolvePolicies();
|
|
111
|
+
},
|
|
92
112
|
rules: options?.rules,
|
|
93
113
|
policyTimeout: options?.policyTimeout,
|
|
94
114
|
algorithm: options?.algorithm,
|
|
@@ -98,37 +118,13 @@ export function createPermissionEngine(options) {
|
|
|
98
118
|
roleResolver: options?.roleResolver,
|
|
99
119
|
expandImplied: options?.expandImplied,
|
|
100
120
|
onError: options?.onError,
|
|
101
|
-
|
|
121
|
+
cacheScope: () => `g${generation}`,
|
|
122
|
+
};
|
|
123
|
+
const evaluatorOptions = () => liveOptions;
|
|
102
124
|
const emitter = options?.emitter;
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
* paths is not a trail.
|
|
107
|
-
*/
|
|
108
|
-
async function runCheck(actor, permission, resource, authOptions) {
|
|
109
|
-
const start = performance.now();
|
|
110
|
-
let decision;
|
|
111
|
-
let failure;
|
|
112
|
-
try {
|
|
113
|
-
decision = await evaluate(actor, permission, resource, evaluatorOptions(), authOptions);
|
|
114
|
-
return decision;
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
failure = error;
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
finally {
|
|
121
|
-
emitter?.emit({
|
|
122
|
-
actorId: actor.id,
|
|
123
|
-
permission,
|
|
124
|
-
resourceType: resourceTypeOf(resource),
|
|
125
|
-
allowed: decision?.allowed ?? false,
|
|
126
|
-
reason: decision?.reason ??
|
|
127
|
-
(failure instanceof Error ? `error:${failure.name}` : undefined),
|
|
128
|
-
durationMs: performance.now() - start,
|
|
129
|
-
errored: failure !== undefined,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
125
|
+
/** Runs a check and emits an audit event whichever way it ends. */
|
|
126
|
+
function runCheck(actor, permission, resource, authOptions) {
|
|
127
|
+
return observed(emitter, actor, permission, resource, () => evaluate(actor, permission, resource, evaluatorOptions(), authOptions), (decision) => decision);
|
|
132
128
|
}
|
|
133
129
|
return {
|
|
134
130
|
async can(actor, permission, resource, authOptions) {
|
|
@@ -152,7 +148,7 @@ export function createPermissionEngine(options) {
|
|
|
152
148
|
}
|
|
153
149
|
},
|
|
154
150
|
async explain(actor, permission, resource, authOptions) {
|
|
155
|
-
return evaluateWithTrace(actor, permission, resource, evaluatorOptions(), authOptions);
|
|
151
|
+
return observed(emitter, actor, permission, resource, () => evaluateWithTrace(actor, permission, resource, evaluatorOptions(), authOptions), (result) => result.decision);
|
|
156
152
|
},
|
|
157
153
|
createAbility(actor) {
|
|
158
154
|
return createAbility(actor, evaluatorOptions(), emitter);
|
|
@@ -161,18 +157,8 @@ export function createPermissionEngine(options) {
|
|
|
161
157
|
await options?.cache?.invalidateActor(actorId);
|
|
162
158
|
},
|
|
163
159
|
invalidateRoles() {
|
|
164
|
-
|
|
160
|
+
invalidateConfiguration();
|
|
165
161
|
},
|
|
166
162
|
};
|
|
167
163
|
}
|
|
168
|
-
/** Best-effort resource type for an audit event. */
|
|
169
|
-
function resourceTypeOf(resource) {
|
|
170
|
-
if (typeof resource !== "object" || resource === null)
|
|
171
|
-
return undefined;
|
|
172
|
-
const record = resource;
|
|
173
|
-
if (typeof record.type === "string")
|
|
174
|
-
return record.type;
|
|
175
|
-
const name = record.constructor?.name;
|
|
176
|
-
return name && name !== "Object" ? name : undefined;
|
|
177
|
-
}
|
|
178
164
|
//# sourceMappingURL=authorizationEngine.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation for the authorization engine.
|
|
3
|
+
*
|
|
4
|
+
* A malformed pattern can never match. In a grant that is a silent no-op; in
|
|
5
|
+
* a denying policy or a deny rule it is a restriction that fails open. The
|
|
6
|
+
* same pattern therefore has to be rejected wherever it can be written, not
|
|
7
|
+
* only in a role's grant list.
|
|
8
|
+
*
|
|
9
|
+
* @module evaluator/authorizationEngine.validation
|
|
10
|
+
*/
|
|
11
|
+
import type { PermissionPolicyDefinition, PermissionRule, RoleDefinition } from "../../permissionTypes/index.js";
|
|
12
|
+
/**
|
|
13
|
+
* The first `resource:action` pair a rule names that is not a valid
|
|
14
|
+
* permission pattern, or `undefined` when every pair is valid.
|
|
15
|
+
*/
|
|
16
|
+
export declare function invalidRulePattern(rule: PermissionRule): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Reject a rule whose resource or action could never match.
|
|
19
|
+
*
|
|
20
|
+
* @throws {InvalidPermissionError} naming the offending pair.
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateRule(rule: PermissionRule): void;
|
|
23
|
+
/**
|
|
24
|
+
* Reject a policy scoped to a pattern that could never match.
|
|
25
|
+
*
|
|
26
|
+
* @throws {InvalidPermissionError} naming the offending pattern.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validatePolicy(policy: PermissionPolicyDefinition): void;
|
|
29
|
+
/**
|
|
30
|
+
* Reject a role whose grants or rules could never match.
|
|
31
|
+
*
|
|
32
|
+
* @throws {InvalidRoleError} naming the role and the offending pattern.
|
|
33
|
+
*/
|
|
34
|
+
export declare function validateRole(role: RoleDefinition): void;
|
|
35
|
+
/** Validate a role list, rejecting duplicates as well. */
|
|
36
|
+
export declare function validateRoles(roles: readonly RoleDefinition[]): void;
|
|
37
|
+
//# sourceMappingURL=authorizationEngine.validation.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation for the authorization engine.
|
|
3
|
+
*
|
|
4
|
+
* A malformed pattern can never match. In a grant that is a silent no-op; in
|
|
5
|
+
* a denying policy or a deny rule it is a restriction that fails open. The
|
|
6
|
+
* same pattern therefore has to be rejected wherever it can be written, not
|
|
7
|
+
* only in a role's grant list.
|
|
8
|
+
*
|
|
9
|
+
* @module evaluator/authorizationEngine.validation
|
|
10
|
+
*/
|
|
11
|
+
import { InvalidPermissionError, InvalidRoleError, } from "../../permissionErrors/index.js";
|
|
12
|
+
import { isValidPermission } from "../../permission/permission.core.js";
|
|
13
|
+
function patterns(value) {
|
|
14
|
+
return Array.isArray(value) ? value : [value];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The first `resource:action` pair a rule names that is not a valid
|
|
18
|
+
* permission pattern, or `undefined` when every pair is valid.
|
|
19
|
+
*/
|
|
20
|
+
export function invalidRulePattern(rule) {
|
|
21
|
+
const resources = patterns(rule.resource);
|
|
22
|
+
const actions = patterns(rule.action);
|
|
23
|
+
if (resources.length === 0 || actions.length === 0) {
|
|
24
|
+
return `${resources.join(",")}:${actions.join(",")}`;
|
|
25
|
+
}
|
|
26
|
+
for (const resource of resources) {
|
|
27
|
+
for (const action of actions) {
|
|
28
|
+
const pair = `${String(resource)}:${String(action)}`;
|
|
29
|
+
if (!isValidPermission(pair))
|
|
30
|
+
return pair;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Reject a rule whose resource or action could never match.
|
|
37
|
+
*
|
|
38
|
+
* @throws {InvalidPermissionError} naming the offending pair.
|
|
39
|
+
*/
|
|
40
|
+
export function validateRule(rule) {
|
|
41
|
+
const invalid = invalidRulePattern(rule);
|
|
42
|
+
if (invalid !== undefined)
|
|
43
|
+
throw new InvalidPermissionError(invalid);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reject a policy scoped to a pattern that could never match.
|
|
47
|
+
*
|
|
48
|
+
* @throws {InvalidPermissionError} naming the offending pattern.
|
|
49
|
+
*/
|
|
50
|
+
export function validatePolicy(policy) {
|
|
51
|
+
for (const pattern of policy.permissions) {
|
|
52
|
+
if (!isValidPermission(pattern))
|
|
53
|
+
throw new InvalidPermissionError(pattern);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reject a role whose grants or rules could never match.
|
|
58
|
+
*
|
|
59
|
+
* @throws {InvalidRoleError} naming the role and the offending pattern.
|
|
60
|
+
*/
|
|
61
|
+
export function validateRole(role) {
|
|
62
|
+
if (!role.name || role.name.trim() === "") {
|
|
63
|
+
throw new InvalidRoleError("Role name cannot be empty");
|
|
64
|
+
}
|
|
65
|
+
for (const permission of role.permissions) {
|
|
66
|
+
if (!isValidPermission(permission)) {
|
|
67
|
+
throw new InvalidRoleError(`Role "${role.name}" grants "${permission}", which is not a valid ` +
|
|
68
|
+
`"resource:action" permission`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const rule of role.rules ?? []) {
|
|
72
|
+
const invalid = invalidRulePattern(rule);
|
|
73
|
+
if (invalid !== undefined) {
|
|
74
|
+
throw new InvalidRoleError(`Role "${role.name}" has a rule on "${invalid}", which is not a valid ` +
|
|
75
|
+
`"resource:action" pattern`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Validate a role list, rejecting duplicates as well. */
|
|
80
|
+
export function validateRoles(roles) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const role of roles) {
|
|
83
|
+
validateRole(role);
|
|
84
|
+
if (seen.has(role.name)) {
|
|
85
|
+
throw new InvalidRoleError(`Role "${role.name}" is defined more than once`);
|
|
86
|
+
}
|
|
87
|
+
seen.add(role.name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=authorizationEngine.validation.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision cache key for one evaluation.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator.cacheKey
|
|
5
|
+
*/
|
|
6
|
+
import type { AuthorizationOptions, PermissionActor } from "../../permissionTypes/index.js";
|
|
7
|
+
import type { EvaluatorOptions } from "../evaluator.pipeline.js";
|
|
8
|
+
/**
|
|
9
|
+
* The cache key for a decision, or `undefined` when it must not be cached.
|
|
10
|
+
*
|
|
11
|
+
* Only a decision the key can describe completely may be cached:
|
|
12
|
+
*
|
|
13
|
+
* - A resource with no derivable id would collapse the key to
|
|
14
|
+
* `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer for
|
|
15
|
+
* `{ ownerId: "bob" }`.
|
|
16
|
+
* - Request metadata is not in the key, and `tenantIsolation()` reads the
|
|
17
|
+
* tenant from it.
|
|
18
|
+
* - The actor's roles, permissions, type and any other field it carries are
|
|
19
|
+
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
20
|
+
* - The engine's configuration generation is in the key, so a role removed
|
|
21
|
+
* from a live registry cannot be served from an entry written before.
|
|
22
|
+
*/
|
|
23
|
+
export declare function decisionCacheKey(actor: PermissionActor, permissionStr: string, resource: unknown, options: EvaluatorOptions, authOptions?: AuthorizationOptions): string | undefined;
|
|
24
|
+
//# sourceMappingURL=evaluator.cacheKey.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision cache key for one evaluation.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator.cacheKey
|
|
5
|
+
*/
|
|
6
|
+
import { permissionCacheKey } from "../../cache/cache.core.js";
|
|
7
|
+
import { actorCacheDigest } from "../../cache/cache.actorDigest.js";
|
|
8
|
+
/** True when the caller supplied request metadata the cache key cannot carry. */
|
|
9
|
+
function hasMetadata(metadata) {
|
|
10
|
+
if (!metadata)
|
|
11
|
+
return false;
|
|
12
|
+
if (metadata instanceof Map)
|
|
13
|
+
return metadata.size > 0;
|
|
14
|
+
return Object.keys(metadata).length > 0;
|
|
15
|
+
}
|
|
16
|
+
/** Best-effort resource identity for the cache key. */
|
|
17
|
+
function resourceIdOf(resource) {
|
|
18
|
+
if (typeof resource !== "object" || resource === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const id = resource.id;
|
|
21
|
+
if (typeof id === "string")
|
|
22
|
+
return id;
|
|
23
|
+
if (typeof id === "number")
|
|
24
|
+
return String(id);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The cache key for a decision, or `undefined` when it must not be cached.
|
|
29
|
+
*
|
|
30
|
+
* Only a decision the key can describe completely may be cached:
|
|
31
|
+
*
|
|
32
|
+
* - A resource with no derivable id would collapse the key to
|
|
33
|
+
* `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer for
|
|
34
|
+
* `{ ownerId: "bob" }`.
|
|
35
|
+
* - Request metadata is not in the key, and `tenantIsolation()` reads the
|
|
36
|
+
* tenant from it.
|
|
37
|
+
* - The actor's roles, permissions, type and any other field it carries are
|
|
38
|
+
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
39
|
+
* - The engine's configuration generation is in the key, so a role removed
|
|
40
|
+
* from a live registry cannot be served from an entry written before.
|
|
41
|
+
*/
|
|
42
|
+
export function decisionCacheKey(actor, permissionStr, resource, options, authOptions) {
|
|
43
|
+
if (!options.cache || authOptions?.skipCache === true)
|
|
44
|
+
return undefined;
|
|
45
|
+
const resourceId = authOptions?.resourceId ?? resourceIdOf(resource);
|
|
46
|
+
if (resource !== undefined && resourceId === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (hasMetadata(authOptions?.metadata))
|
|
49
|
+
return undefined;
|
|
50
|
+
const digest = actorCacheDigest(actor);
|
|
51
|
+
if (digest === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
const generation = options.cacheScope?.() ?? "";
|
|
54
|
+
return permissionCacheKey(actor.id, permissionStr, resourceId, `${generation}${digest}`);
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=evaluator.cacheKey.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit-event wrapper shared by every path that makes a decision.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator.observed
|
|
5
|
+
*/
|
|
6
|
+
import type { PermissionActor, PermissionDecision } from "../../permissionTypes/index.js";
|
|
7
|
+
import type { PermissionEventEmitter } from "../../observability/observability.core.js";
|
|
8
|
+
/** Best-effort resource type for an audit event. */
|
|
9
|
+
export declare function resourceTypeOf(resource: unknown): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Run one authorization and emit an audit event whichever way it ends —
|
|
12
|
+
* including when it throws. An authorization trail that records only the
|
|
13
|
+
* successful paths is not a trail, and one that skips `explain()` misses
|
|
14
|
+
* decisions that are real: they run the same evaluation and write the same
|
|
15
|
+
* cache entries that later checks are served from.
|
|
16
|
+
*/
|
|
17
|
+
export declare function observed<T>(emitter: PermissionEventEmitter | undefined, actor: PermissionActor, permission: string, resource: unknown, run: () => Promise<T>, decisionOf: (result: T) => PermissionDecision): Promise<T>;
|
|
18
|
+
//# sourceMappingURL=evaluator.observed.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit-event wrapper shared by every path that makes a decision.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator.observed
|
|
5
|
+
*/
|
|
6
|
+
/** Best-effort resource type for an audit event. */
|
|
7
|
+
export function resourceTypeOf(resource) {
|
|
8
|
+
if (typeof resource !== "object" || resource === null)
|
|
9
|
+
return undefined;
|
|
10
|
+
const record = resource;
|
|
11
|
+
if (typeof record.type === "string")
|
|
12
|
+
return record.type;
|
|
13
|
+
const name = record.constructor?.name;
|
|
14
|
+
return name && name !== "Object" ? name : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run one authorization and emit an audit event whichever way it ends —
|
|
18
|
+
* including when it throws. An authorization trail that records only the
|
|
19
|
+
* successful paths is not a trail, and one that skips `explain()` misses
|
|
20
|
+
* decisions that are real: they run the same evaluation and write the same
|
|
21
|
+
* cache entries that later checks are served from.
|
|
22
|
+
*/
|
|
23
|
+
export async function observed(emitter, actor, permission, resource, run, decisionOf) {
|
|
24
|
+
const start = performance.now();
|
|
25
|
+
let decision;
|
|
26
|
+
let failure;
|
|
27
|
+
try {
|
|
28
|
+
const result = await run();
|
|
29
|
+
decision = decisionOf(result);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
failure = error;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
emitter?.emit({
|
|
38
|
+
actorId: actor.id,
|
|
39
|
+
permission,
|
|
40
|
+
resourceType: resourceTypeOf(resource),
|
|
41
|
+
allowed: decision?.allowed ?? false,
|
|
42
|
+
reason: decision?.reason ??
|
|
43
|
+
(failure instanceof Error ? `error:${failure.name}` : undefined),
|
|
44
|
+
durationMs: performance.now() - start,
|
|
45
|
+
errored: failure !== undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=evaluator.observed.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support for the authorization engine: configuration validation, the
|
|
3
|
+
* decision-cache key, and the audit-event wrapper every decision path uses.
|
|
4
|
+
*
|
|
5
|
+
* @module evaluator/engineSupport
|
|
6
|
+
*/
|
|
7
|
+
export { invalidRulePattern, validatePolicy, validateRole, validateRoles, validateRule, } from "./authorizationEngine.validation.js";
|
|
8
|
+
export { decisionCacheKey } from "./evaluator.cacheKey.js";
|
|
9
|
+
export { observed, resourceTypeOf } from "./evaluator.observed.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support for the authorization engine: configuration validation, the
|
|
3
|
+
* decision-cache key, and the audit-event wrapper every decision path uses.
|
|
4
|
+
*
|
|
5
|
+
* @module evaluator/engineSupport
|
|
6
|
+
*/
|
|
7
|
+
export { invalidRulePattern, validatePolicy, validateRole, validateRoles, validateRule, } from "./authorizationEngine.validation.js";
|
|
8
|
+
export { decisionCacheKey } from "./evaluator.cacheKey.js";
|
|
9
|
+
export { observed, resourceTypeOf } from "./evaluator.observed.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module evaluator/evaluator
|
|
10
10
|
*/
|
|
11
|
-
import { parsePermissionSafe, matches } from "../permission/permission.core.js";
|
|
11
|
+
import { parsePermissionSafe, matches, permissionsOverlap, } from "../permission/permission.core.js";
|
|
12
12
|
import { InvalidPermissionError } from "../permissionErrors/index.js";
|
|
13
13
|
import { compileRules, findMatchingRules } from "../rule/ruleCompiler.js";
|
|
14
14
|
import { evaluateRules } from "../rule/rule.core.js";
|
|
15
|
+
import { isWildcardTarget } from "../rule/rule.pattern.js";
|
|
15
16
|
import { assertNotAborted, evaluatePolicies, resolveActorGrants, toMetadataMap, } from "./evaluator.pipeline.js";
|
|
16
|
-
import {
|
|
17
|
+
import { decisionCacheKey } from "./engineSupport/index.js";
|
|
17
18
|
export { evaluateWithExplain } from "./evaluator.explain.js";
|
|
18
19
|
/** A public message that names nothing internal. */
|
|
19
20
|
const PUBLIC_DENIED = "Access denied";
|
|
@@ -66,7 +67,10 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
66
67
|
});
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
+
// Overlap, not match: for a concrete target the two are the same, but a
|
|
71
|
+
// wildcard target (`post:*`) asks about every action under it, and a
|
|
72
|
+
// deny on one of them has to refuse it.
|
|
73
|
+
if (permissionsOverlap(deny, permissionStr)) {
|
|
70
74
|
trace?.push({
|
|
71
75
|
type: "deny",
|
|
72
76
|
detail: `Explicit deny: ${deny}`,
|
|
@@ -76,19 +80,7 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
/* ── Cache ───────────────────────────────────────────────────────────── */
|
|
79
|
-
const
|
|
80
|
-
// Only a decision the key can fully describe may be cached.
|
|
81
|
-
//
|
|
82
|
-
// A resource with no derivable id is the sharp case: the key would collapse
|
|
83
|
-
// to `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer
|
|
84
|
-
// for `{ ownerId: "bob" }` on the next call. Request metadata is the other:
|
|
85
|
-
// `tenantIsolation()` reads the tenant from it, and it is not part of the
|
|
86
|
-
// key, so a decision made for one tenant must not answer for another.
|
|
87
|
-
const keyable = (resource === undefined || resourceId !== undefined) &&
|
|
88
|
-
!hasMetadata(authOptions?.metadata);
|
|
89
|
-
const cacheKey = options.cache && authOptions?.skipCache !== true && keyable
|
|
90
|
-
? permissionCacheKey(actor.id, permissionStr, resourceId)
|
|
91
|
-
: undefined;
|
|
83
|
+
const cacheKey = decisionCacheKey(actor, permissionStr, resource, options, authOptions);
|
|
92
84
|
if (options.cache && cacheKey) {
|
|
93
85
|
try {
|
|
94
86
|
const cached = await options.cache.get(cacheKey);
|
|
@@ -160,9 +152,16 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
160
152
|
})),
|
|
161
153
|
...grants.rules,
|
|
162
154
|
];
|
|
163
|
-
|
|
155
|
+
// A wildcard target is not a key the index can look up, and the deny
|
|
156
|
+
// rules that merely overlap it are exactly the ones an index lookup misses,
|
|
157
|
+
// so it is checked against every rule.
|
|
158
|
+
let conditionFailed = false;
|
|
159
|
+
const ruleResult = await evaluateRules(isWildcardTarget(permission)
|
|
160
|
+
? rules
|
|
161
|
+
: findMatchingRules(compileRules(rules), permission), permission, context, {
|
|
164
162
|
algorithm: options.algorithm,
|
|
165
163
|
onConditionError: (rule, error) => {
|
|
164
|
+
conditionFailed = true;
|
|
166
165
|
options.onError?.(error, `RuleCondition.${rule.name ?? "unnamed"}`);
|
|
167
166
|
},
|
|
168
167
|
});
|
|
@@ -186,9 +185,11 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
186
185
|
});
|
|
187
186
|
}
|
|
188
187
|
}
|
|
189
|
-
const decision = combine(ruleResult
|
|
188
|
+
const decision = combine(ruleResult, outcome.decision, permissionStr);
|
|
190
189
|
/* ── Cache write ─────────────────────────────────────────────────────── */
|
|
191
|
-
|
|
190
|
+
// A decision forced by a condition that threw describes the failure, not
|
|
191
|
+
// the actor, and must not outlive it.
|
|
192
|
+
if (options.cache && cacheKey && outcome.cacheable && !conditionFailed) {
|
|
192
193
|
try {
|
|
193
194
|
await options.cache.set(cacheKey, decision, {
|
|
194
195
|
ttl: options.cacheTtlMs,
|
|
@@ -205,12 +206,25 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
205
206
|
*
|
|
206
207
|
* A denying policy always wins. An allowing policy can grant access the rules
|
|
207
208
|
* did not, which is what makes a policy an ABAC escape hatch rather than a
|
|
208
|
-
* filter — but it can never override a denial
|
|
209
|
+
* filter — but it can never override a denial, and that includes a denial
|
|
210
|
+
* the *rules* produced. "The rules did not allow" covers two cases: no rule
|
|
211
|
+
* matched, and a deny rule matched. Treating them alike let an allowing
|
|
212
|
+
* policy for `post:*` cancel a `deny post:update` rule — the exact inversion
|
|
213
|
+
* of `deny-overrides`.
|
|
209
214
|
*/
|
|
210
|
-
function combine(
|
|
215
|
+
function combine(ruleResult, policyDecision, permissionStr) {
|
|
211
216
|
if (policyDecision && !policyDecision.allowed)
|
|
212
217
|
return policyDecision;
|
|
213
|
-
|
|
218
|
+
const denyRule = !ruleResult.allowed && ruleResult.matchedRule?.effect === "deny"
|
|
219
|
+
? ruleResult.matchedRule
|
|
220
|
+
: undefined;
|
|
221
|
+
if (denyRule) {
|
|
222
|
+
return denied("rule_deny", {
|
|
223
|
+
matchedPermission: permissionStr,
|
|
224
|
+
...(denyRule.name ? { policy: denyRule.name } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (ruleResult.allowed) {
|
|
214
228
|
return Object.freeze({
|
|
215
229
|
allowed: true,
|
|
216
230
|
reason: "role_permission",
|
|
@@ -222,25 +236,6 @@ function combine(ruleAllowed, policyDecision, permissionStr) {
|
|
|
222
236
|
return policyDecision;
|
|
223
237
|
return denied("no_matching_rule");
|
|
224
238
|
}
|
|
225
|
-
/** True when the caller supplied request metadata the cache key cannot carry. */
|
|
226
|
-
function hasMetadata(metadata) {
|
|
227
|
-
if (!metadata)
|
|
228
|
-
return false;
|
|
229
|
-
if (metadata instanceof Map)
|
|
230
|
-
return metadata.size > 0;
|
|
231
|
-
return Object.keys(metadata).length > 0;
|
|
232
|
-
}
|
|
233
|
-
/** Best-effort resource identity for the cache key. */
|
|
234
|
-
function resourceIdOf(resource) {
|
|
235
|
-
if (typeof resource !== "object" || resource === null)
|
|
236
|
-
return undefined;
|
|
237
|
-
const id = resource.id;
|
|
238
|
-
if (typeof id === "string")
|
|
239
|
-
return id;
|
|
240
|
-
if (typeof id === "number")
|
|
241
|
-
return String(id);
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
239
|
/**
|
|
245
240
|
* Evaluate and collect the trace, in one pass.
|
|
246
241
|
*
|
|
@@ -31,6 +31,12 @@ export interface EvaluatorOptions {
|
|
|
31
31
|
readonly expandImplied?: (permission: string) => readonly string[];
|
|
32
32
|
/** Reports a failure that authorization swallowed to stay fail-closed. */
|
|
33
33
|
readonly onError?: (error: unknown, source: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Extra decision-cache key scope, read on every evaluation. The engine
|
|
36
|
+
* passes its configuration generation, so a role change invalidates every
|
|
37
|
+
* entry written before it.
|
|
38
|
+
*/
|
|
39
|
+
readonly cacheScope?: () => string;
|
|
34
40
|
}
|
|
35
41
|
/** The permissions and rules an actor holds, once everything is resolved. */
|
|
36
42
|
export interface ResolvedGrants {
|