@siremzam/sentinel 0.3.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/LICENSE +21 -0
- package/README.md +662 -0
- package/dist/engine.d.ts +70 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +562 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/express.d.ts +35 -0
- package/dist/middleware/express.d.ts.map +1 -0
- package/dist/middleware/express.js +41 -0
- package/dist/middleware/express.js.map +1 -0
- package/dist/middleware/fastify.d.ts +29 -0
- package/dist/middleware/fastify.d.ts.map +1 -0
- package/dist/middleware/fastify.js +41 -0
- package/dist/middleware/fastify.js.map +1 -0
- package/dist/middleware/nestjs.d.ts +67 -0
- package/dist/middleware/nestjs.d.ts.map +1 -0
- package/dist/middleware/nestjs.js +82 -0
- package/dist/middleware/nestjs.js.map +1 -0
- package/dist/policy-builder.d.ts +39 -0
- package/dist/policy-builder.d.ts.map +1 -0
- package/dist/policy-builder.js +92 -0
- package/dist/policy-builder.js.map +1 -0
- package/dist/role-hierarchy.d.ts +42 -0
- package/dist/role-hierarchy.d.ts.map +1 -0
- package/dist/role-hierarchy.js +87 -0
- package/dist/role-hierarchy.js.map +1 -0
- package/dist/serialization.d.ts +52 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +144 -0
- package/dist/serialization.js.map +1 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +163 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/package.json +75 -0
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SchemaDefinition, InferAction, InferResource, PolicyRule, Decision, Subject, ResourceContext, DecisionListener, EngineOptions, ExplainResult } from "./types.js";
|
|
2
|
+
import { RuleBuilder } from "./policy-builder.js";
|
|
3
|
+
import type { RoleHierarchy } from "./role-hierarchy.js";
|
|
4
|
+
export interface AccessEngineOptions<S extends SchemaDefinition> extends EngineOptions<S> {
|
|
5
|
+
roleHierarchy?: RoleHierarchy<S>;
|
|
6
|
+
/**
|
|
7
|
+
* Enable LRU cache for evaluation results.
|
|
8
|
+
* Only caches evaluations of rules WITHOUT conditions (context-independent).
|
|
9
|
+
* Rules with conditions are never cached since their result depends on resourceContext.
|
|
10
|
+
*/
|
|
11
|
+
cacheSize?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class AccessEngine<S extends SchemaDefinition> {
|
|
14
|
+
private compiled;
|
|
15
|
+
private listeners;
|
|
16
|
+
private asyncConditions;
|
|
17
|
+
private _defaultDeny;
|
|
18
|
+
private _strictTenancy;
|
|
19
|
+
private hierarchy?;
|
|
20
|
+
private cache?;
|
|
21
|
+
private conditionErrorHandler?;
|
|
22
|
+
constructor(options: AccessEngineOptions<S>);
|
|
23
|
+
addRule(rule: PolicyRule<S>): this;
|
|
24
|
+
addRules(...rules: PolicyRule<S>[]): this;
|
|
25
|
+
removeRule(ruleId: string): boolean;
|
|
26
|
+
getRules(): ReadonlyArray<PolicyRule<S>>;
|
|
27
|
+
clearRules(): void;
|
|
28
|
+
clearCache(): void;
|
|
29
|
+
get cacheStats(): {
|
|
30
|
+
size: number;
|
|
31
|
+
maxSize: number;
|
|
32
|
+
} | null;
|
|
33
|
+
allow(): RuleBuilder<S>;
|
|
34
|
+
deny(): RuleBuilder<S>;
|
|
35
|
+
onDecision(listener: DecisionListener<S>): () => void;
|
|
36
|
+
private emit;
|
|
37
|
+
evaluate(subject: Subject<S>, action: InferAction<S>, resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): Decision<S>;
|
|
38
|
+
evaluateAsync(subject: Subject<S>, action: InferAction<S>, resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): Promise<Decision<S>>;
|
|
39
|
+
permitted(subject: Subject<S>, resource: InferResource<S>, actions: InferAction<S>[], resourceContext?: ResourceContext, tenantId?: string): Set<InferAction<S>>;
|
|
40
|
+
permittedAsync(subject: Subject<S>, resource: InferResource<S>, actions: InferAction<S>[], resourceContext?: ResourceContext, tenantId?: string): Promise<Set<InferAction<S>>>;
|
|
41
|
+
explain(subject: Subject<S>, action: InferAction<S>, resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): ExplainResult<S>;
|
|
42
|
+
explainAsync(subject: Subject<S>, action: InferAction<S>, resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): Promise<ExplainResult<S>>;
|
|
43
|
+
can(subject: Subject<S>): PerformStep<S>;
|
|
44
|
+
private validateInput;
|
|
45
|
+
private enforceTenancy;
|
|
46
|
+
private buildContext;
|
|
47
|
+
private evaluateConditionsSync;
|
|
48
|
+
private emitConditionError;
|
|
49
|
+
private matchRules;
|
|
50
|
+
private sortCandidates;
|
|
51
|
+
private matchesAction;
|
|
52
|
+
private resolveRoles;
|
|
53
|
+
private buildDecision;
|
|
54
|
+
}
|
|
55
|
+
declare class PerformStep<S extends SchemaDefinition> {
|
|
56
|
+
private engine;
|
|
57
|
+
private subject;
|
|
58
|
+
constructor(engine: AccessEngine<S>, subject: Subject<S>);
|
|
59
|
+
perform(action: InferAction<S>): OnStep<S>;
|
|
60
|
+
}
|
|
61
|
+
declare class OnStep<S extends SchemaDefinition> {
|
|
62
|
+
private engine;
|
|
63
|
+
private subject;
|
|
64
|
+
private action;
|
|
65
|
+
constructor(engine: AccessEngine<S>, subject: Subject<S>, action: InferAction<S>);
|
|
66
|
+
on(resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): Decision<S>;
|
|
67
|
+
onAsync(resource: InferResource<S>, resourceContext?: ResourceContext, tenantId?: string): Promise<Decision<S>>;
|
|
68
|
+
}
|
|
69
|
+
export {};
|
|
70
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { allow as _allow, deny as _deny } from "./policy-builder.js";
|
|
2
|
+
function escapeRegexMeta(s) {
|
|
3
|
+
return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
function compileActionPatterns(actions) {
|
|
6
|
+
if (actions === "*")
|
|
7
|
+
return null;
|
|
8
|
+
const patterns = [];
|
|
9
|
+
for (const action of actions) {
|
|
10
|
+
if (action.includes("*")) {
|
|
11
|
+
const escaped = escapeRegexMeta(action).replace(/\*/g, "[^:]*");
|
|
12
|
+
patterns.push(new RegExp("^" + escaped + "$"));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return patterns.length > 0 ? patterns : null;
|
|
16
|
+
}
|
|
17
|
+
export class AccessEngine {
|
|
18
|
+
compiled = [];
|
|
19
|
+
listeners = [];
|
|
20
|
+
asyncConditions;
|
|
21
|
+
_defaultDeny;
|
|
22
|
+
_strictTenancy;
|
|
23
|
+
hierarchy;
|
|
24
|
+
cache;
|
|
25
|
+
conditionErrorHandler;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.asyncConditions = options.asyncConditions ?? false;
|
|
28
|
+
this._defaultDeny = (options.defaultEffect ?? "deny") === "deny";
|
|
29
|
+
this._strictTenancy = options.strictTenancy ?? false;
|
|
30
|
+
this.hierarchy = options.roleHierarchy;
|
|
31
|
+
this.conditionErrorHandler = options.onConditionError;
|
|
32
|
+
if (options.cacheSize && options.cacheSize > 0) {
|
|
33
|
+
this.cache = new LRUCache(options.cacheSize);
|
|
34
|
+
}
|
|
35
|
+
if (options.onDecision) {
|
|
36
|
+
this.listeners.push(options.onDecision);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// -----------------------------------------------------------------------
|
|
40
|
+
// Rule management
|
|
41
|
+
// -----------------------------------------------------------------------
|
|
42
|
+
addRule(rule) {
|
|
43
|
+
const frozen = Object.freeze({ ...rule });
|
|
44
|
+
this.compiled.push({
|
|
45
|
+
rule: frozen,
|
|
46
|
+
actionPatterns: compileActionPatterns(frozen.actions),
|
|
47
|
+
});
|
|
48
|
+
this.cache?.clear();
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
addRules(...rules) {
|
|
52
|
+
for (const rule of rules) {
|
|
53
|
+
const frozen = Object.freeze({ ...rule });
|
|
54
|
+
this.compiled.push({
|
|
55
|
+
rule: frozen,
|
|
56
|
+
actionPatterns: compileActionPatterns(frozen.actions),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.cache?.clear();
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
removeRule(ruleId) {
|
|
63
|
+
const idx = this.compiled.findIndex((c) => c.rule.id === ruleId);
|
|
64
|
+
if (idx === -1)
|
|
65
|
+
return false;
|
|
66
|
+
this.compiled.splice(idx, 1);
|
|
67
|
+
this.cache?.clear();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
getRules() {
|
|
71
|
+
return this.compiled.map((c) => c.rule);
|
|
72
|
+
}
|
|
73
|
+
clearRules() {
|
|
74
|
+
this.compiled = [];
|
|
75
|
+
this.cache?.clear();
|
|
76
|
+
}
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
// Cache control
|
|
79
|
+
// -----------------------------------------------------------------------
|
|
80
|
+
clearCache() {
|
|
81
|
+
this.cache?.clear();
|
|
82
|
+
}
|
|
83
|
+
get cacheStats() {
|
|
84
|
+
if (!this.cache)
|
|
85
|
+
return null;
|
|
86
|
+
return { size: this.cache.size, maxSize: this.cache.maxSize };
|
|
87
|
+
}
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
// Fluent rule builders bound to this engine's schema
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
allow() {
|
|
92
|
+
return _allow();
|
|
93
|
+
}
|
|
94
|
+
deny() {
|
|
95
|
+
return _deny();
|
|
96
|
+
}
|
|
97
|
+
// -----------------------------------------------------------------------
|
|
98
|
+
// Observability
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
onDecision(listener) {
|
|
101
|
+
this.listeners.push(listener);
|
|
102
|
+
return () => {
|
|
103
|
+
const idx = this.listeners.indexOf(listener);
|
|
104
|
+
if (idx !== -1)
|
|
105
|
+
this.listeners.splice(idx, 1);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
emit(decision) {
|
|
109
|
+
for (const listener of this.listeners) {
|
|
110
|
+
try {
|
|
111
|
+
const result = listener(decision);
|
|
112
|
+
if (result instanceof Promise) {
|
|
113
|
+
result.catch(() => { });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// listeners must not break evaluation
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
// Evaluation
|
|
123
|
+
// -----------------------------------------------------------------------
|
|
124
|
+
evaluate(subject, action, resource, resourceContext = {}, tenantId) {
|
|
125
|
+
if (this.asyncConditions) {
|
|
126
|
+
throw new Error("Engine has asyncConditions enabled. Use evaluateAsync() instead.");
|
|
127
|
+
}
|
|
128
|
+
this.validateInput(subject, action, resource);
|
|
129
|
+
this.enforceTenancy(subject, tenantId);
|
|
130
|
+
const cacheKey = this.cache
|
|
131
|
+
? buildCacheKey(subject.id, action, resource, tenantId)
|
|
132
|
+
: undefined;
|
|
133
|
+
if (cacheKey) {
|
|
134
|
+
const cached = this.cache.get(cacheKey);
|
|
135
|
+
if (cached) {
|
|
136
|
+
this.emit(cached);
|
|
137
|
+
return cached;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const start = performance.now();
|
|
141
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
142
|
+
const candidates = this.matchRules(subject, action, resource, tenantId);
|
|
143
|
+
let matched = null;
|
|
144
|
+
let matchedHasConditions = false;
|
|
145
|
+
for (const compiled of candidates) {
|
|
146
|
+
const { rule } = compiled;
|
|
147
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
148
|
+
matched = compiled;
|
|
149
|
+
matchedHasConditions = false;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
const allMet = this.evaluateConditionsSync(rule, ctx);
|
|
153
|
+
if (allMet) {
|
|
154
|
+
matched = compiled;
|
|
155
|
+
matchedHasConditions = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const decision = this.buildDecision(matched?.rule ?? null, ctx, start);
|
|
160
|
+
if (cacheKey && !matchedHasConditions) {
|
|
161
|
+
this.cache.set(cacheKey, decision);
|
|
162
|
+
}
|
|
163
|
+
this.emit(decision);
|
|
164
|
+
return decision;
|
|
165
|
+
}
|
|
166
|
+
async evaluateAsync(subject, action, resource, resourceContext = {}, tenantId) {
|
|
167
|
+
this.validateInput(subject, action, resource);
|
|
168
|
+
this.enforceTenancy(subject, tenantId);
|
|
169
|
+
const start = performance.now();
|
|
170
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
171
|
+
const candidates = this.matchRules(subject, action, resource, tenantId);
|
|
172
|
+
let matched = null;
|
|
173
|
+
for (const compiled of candidates) {
|
|
174
|
+
const { rule } = compiled;
|
|
175
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
176
|
+
matched = rule;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
const results = await Promise.all(rule.conditions.map((c, i) => Promise.resolve()
|
|
180
|
+
.then(() => c(ctx))
|
|
181
|
+
.catch((err) => {
|
|
182
|
+
this.emitConditionError(rule.id, i, err);
|
|
183
|
+
return false;
|
|
184
|
+
})));
|
|
185
|
+
if (results.every(Boolean)) {
|
|
186
|
+
matched = rule;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const decision = this.buildDecision(matched, ctx, start);
|
|
191
|
+
this.emit(decision);
|
|
192
|
+
return decision;
|
|
193
|
+
}
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// permitted() — list allowed actions on a resource for UI rendering
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
permitted(subject, resource, actions, resourceContext = {}, tenantId) {
|
|
198
|
+
const allowed = new Set();
|
|
199
|
+
for (const action of actions) {
|
|
200
|
+
const decision = this.asyncConditions
|
|
201
|
+
? (() => { throw new Error("Use permittedAsync() with asyncConditions enabled."); })()
|
|
202
|
+
: this.evaluate(subject, action, resource, resourceContext, tenantId);
|
|
203
|
+
if (decision.allowed) {
|
|
204
|
+
allowed.add(action);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return allowed;
|
|
208
|
+
}
|
|
209
|
+
async permittedAsync(subject, resource, actions, resourceContext = {}, tenantId) {
|
|
210
|
+
const allowed = new Set();
|
|
211
|
+
const results = await Promise.all(actions.map((action) => this.evaluateAsync(subject, action, resource, resourceContext, tenantId)));
|
|
212
|
+
for (let i = 0; i < actions.length; i++) {
|
|
213
|
+
if (results[i].allowed) {
|
|
214
|
+
allowed.add(actions[i]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return allowed;
|
|
218
|
+
}
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
// explain() — full evaluation trace for debugging
|
|
221
|
+
// -----------------------------------------------------------------------
|
|
222
|
+
explain(subject, action, resource, resourceContext = {}, tenantId) {
|
|
223
|
+
if (this.asyncConditions) {
|
|
224
|
+
throw new Error("Engine has asyncConditions enabled. Use explainAsync() instead.");
|
|
225
|
+
}
|
|
226
|
+
this.enforceTenancy(subject, tenantId);
|
|
227
|
+
const start = performance.now();
|
|
228
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
229
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
230
|
+
const evaluatedRules = [];
|
|
231
|
+
let firstMatch = null;
|
|
232
|
+
const sorted = this.sortCandidates([...this.compiled]);
|
|
233
|
+
for (const compiled of sorted) {
|
|
234
|
+
const { rule } = compiled;
|
|
235
|
+
const roleMatched = rule.roles === "*" || rule.roles.some((r) => subjectRoles.has(r));
|
|
236
|
+
const actionMatched = rule.actions === "*" || this.matchesAction(compiled, action);
|
|
237
|
+
const resourceMatched = rule.resources === "*" || rule.resources.includes(resource);
|
|
238
|
+
const conditionResults = [];
|
|
239
|
+
let allConditionsPassed = true;
|
|
240
|
+
if (roleMatched && actionMatched && resourceMatched && rule.conditions) {
|
|
241
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
242
|
+
try {
|
|
243
|
+
const result = rule.conditions[i](ctx);
|
|
244
|
+
if (result !== true) {
|
|
245
|
+
conditionResults.push({ index: i, passed: false });
|
|
246
|
+
allConditionsPassed = false;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
conditionResults.push({ index: i, passed: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
conditionResults.push({
|
|
254
|
+
index: i,
|
|
255
|
+
passed: false,
|
|
256
|
+
error: err instanceof Error ? err.message : String(err),
|
|
257
|
+
});
|
|
258
|
+
allConditionsPassed = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const matched = roleMatched && actionMatched && resourceMatched &&
|
|
263
|
+
(!rule.conditions || rule.conditions.length === 0 || allConditionsPassed);
|
|
264
|
+
evaluatedRules.push({
|
|
265
|
+
rule,
|
|
266
|
+
roleMatched,
|
|
267
|
+
actionMatched,
|
|
268
|
+
resourceMatched,
|
|
269
|
+
conditionResults,
|
|
270
|
+
matched,
|
|
271
|
+
});
|
|
272
|
+
if (matched && !firstMatch) {
|
|
273
|
+
firstMatch = rule;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const allowed = firstMatch != null ? firstMatch.effect === "allow" : !this._defaultDeny;
|
|
277
|
+
const effect = firstMatch?.effect ?? "default-deny";
|
|
278
|
+
const reason = firstMatch
|
|
279
|
+
? `Matched rule "${firstMatch.id}"${firstMatch.description ? `: ${firstMatch.description}` : ""}`
|
|
280
|
+
: "No matching rule — default deny";
|
|
281
|
+
return {
|
|
282
|
+
allowed,
|
|
283
|
+
effect,
|
|
284
|
+
reason,
|
|
285
|
+
evaluatedRules,
|
|
286
|
+
durationMs: performance.now() - start,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async explainAsync(subject, action, resource, resourceContext = {}, tenantId) {
|
|
290
|
+
this.enforceTenancy(subject, tenantId);
|
|
291
|
+
const start = performance.now();
|
|
292
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
293
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
294
|
+
const evaluatedRules = [];
|
|
295
|
+
let firstMatch = null;
|
|
296
|
+
const sorted = this.sortCandidates([...this.compiled]);
|
|
297
|
+
for (const compiled of sorted) {
|
|
298
|
+
const { rule } = compiled;
|
|
299
|
+
const roleMatched = rule.roles === "*" || rule.roles.some((r) => subjectRoles.has(r));
|
|
300
|
+
const actionMatched = rule.actions === "*" || this.matchesAction(compiled, action);
|
|
301
|
+
const resourceMatched = rule.resources === "*" || rule.resources.includes(resource);
|
|
302
|
+
const conditionResults = [];
|
|
303
|
+
let allConditionsPassed = true;
|
|
304
|
+
if (roleMatched && actionMatched && resourceMatched && rule.conditions) {
|
|
305
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
306
|
+
try {
|
|
307
|
+
const result = await rule.conditions[i](ctx);
|
|
308
|
+
if (result !== true) {
|
|
309
|
+
conditionResults.push({ index: i, passed: false });
|
|
310
|
+
allConditionsPassed = false;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
conditionResults.push({ index: i, passed: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
conditionResults.push({
|
|
318
|
+
index: i,
|
|
319
|
+
passed: false,
|
|
320
|
+
error: err instanceof Error ? err.message : String(err),
|
|
321
|
+
});
|
|
322
|
+
allConditionsPassed = false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const matched = roleMatched && actionMatched && resourceMatched &&
|
|
327
|
+
(!rule.conditions || rule.conditions.length === 0 || allConditionsPassed);
|
|
328
|
+
evaluatedRules.push({
|
|
329
|
+
rule,
|
|
330
|
+
roleMatched,
|
|
331
|
+
actionMatched,
|
|
332
|
+
resourceMatched,
|
|
333
|
+
conditionResults,
|
|
334
|
+
matched,
|
|
335
|
+
});
|
|
336
|
+
if (matched && !firstMatch) {
|
|
337
|
+
firstMatch = rule;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const allowed = firstMatch != null ? firstMatch.effect === "allow" : !this._defaultDeny;
|
|
341
|
+
const effect = firstMatch?.effect ?? "default-deny";
|
|
342
|
+
const reason = firstMatch
|
|
343
|
+
? `Matched rule "${firstMatch.id}"${firstMatch.description ? `: ${firstMatch.description}` : ""}`
|
|
344
|
+
: "No matching rule — default deny";
|
|
345
|
+
return {
|
|
346
|
+
allowed,
|
|
347
|
+
effect,
|
|
348
|
+
reason,
|
|
349
|
+
evaluatedRules,
|
|
350
|
+
durationMs: performance.now() - start,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// -----------------------------------------------------------------------
|
|
354
|
+
// Fluent check API: can(subject).perform(action).on(resource)
|
|
355
|
+
// -----------------------------------------------------------------------
|
|
356
|
+
can(subject) {
|
|
357
|
+
return new PerformStep(this, subject);
|
|
358
|
+
}
|
|
359
|
+
// -----------------------------------------------------------------------
|
|
360
|
+
// Internal helpers
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
validateInput(subject, action, resource) {
|
|
363
|
+
if (!subject || typeof subject.id !== "string") {
|
|
364
|
+
throw new Error("subject must be an object with a string id");
|
|
365
|
+
}
|
|
366
|
+
if (!Array.isArray(subject.roles)) {
|
|
367
|
+
throw new Error("subject.roles must be an array");
|
|
368
|
+
}
|
|
369
|
+
if (!action || typeof action !== "string") {
|
|
370
|
+
throw new Error("action must be a non-empty string");
|
|
371
|
+
}
|
|
372
|
+
if (!resource || typeof resource !== "string") {
|
|
373
|
+
throw new Error("resource must be a non-empty string");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
enforceTenancy(subject, tenantId) {
|
|
377
|
+
if (!this._strictTenancy || tenantId != null)
|
|
378
|
+
return;
|
|
379
|
+
const hasTenantScoped = subject.roles.some((r) => r.tenantId != null);
|
|
380
|
+
if (hasTenantScoped) {
|
|
381
|
+
throw new Error("strictTenancy is enabled and subject has tenant-scoped roles, " +
|
|
382
|
+
"but no tenantId was provided to evaluate(). This could cause " +
|
|
383
|
+
"cross-tenant privilege escalation. Pass an explicit tenantId.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
buildContext(subject, action, resource, resourceContext, tenantId) {
|
|
387
|
+
return { subject, action, resource, resourceContext, tenantId };
|
|
388
|
+
}
|
|
389
|
+
evaluateConditionsSync(rule, ctx) {
|
|
390
|
+
if (!rule.conditions)
|
|
391
|
+
return true;
|
|
392
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
393
|
+
try {
|
|
394
|
+
if (rule.conditions[i](ctx) !== true)
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
this.emitConditionError(rule.id, i, err);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
emitConditionError(ruleId, conditionIndex, error) {
|
|
405
|
+
if (this.conditionErrorHandler) {
|
|
406
|
+
try {
|
|
407
|
+
this.conditionErrorHandler({ ruleId, conditionIndex, error });
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// error handler must not break evaluation
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
matchRules(subject, action, resource, tenantId) {
|
|
415
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
416
|
+
const matched = this.compiled.filter((compiled) => {
|
|
417
|
+
const { rule } = compiled;
|
|
418
|
+
if (rule.roles !== "*" && !rule.roles.some((r) => subjectRoles.has(r)))
|
|
419
|
+
return false;
|
|
420
|
+
if (rule.actions !== "*" && !this.matchesAction(compiled, action))
|
|
421
|
+
return false;
|
|
422
|
+
if (rule.resources !== "*" && !rule.resources.includes(resource))
|
|
423
|
+
return false;
|
|
424
|
+
return true;
|
|
425
|
+
});
|
|
426
|
+
return this.sortCandidates(matched);
|
|
427
|
+
}
|
|
428
|
+
sortCandidates(candidates) {
|
|
429
|
+
return candidates.sort((a, b) => {
|
|
430
|
+
const pa = a.rule.priority ?? 0;
|
|
431
|
+
const pb = b.rule.priority ?? 0;
|
|
432
|
+
if (pb !== pa)
|
|
433
|
+
return pb - pa;
|
|
434
|
+
if (a.rule.effect === "deny" && b.rule.effect === "allow")
|
|
435
|
+
return -1;
|
|
436
|
+
if (a.rule.effect === "allow" && b.rule.effect === "deny")
|
|
437
|
+
return 1;
|
|
438
|
+
return 0;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
matchesAction(compiled, action) {
|
|
442
|
+
const { rule, actionPatterns } = compiled;
|
|
443
|
+
if (rule.actions === "*")
|
|
444
|
+
return true;
|
|
445
|
+
const actionStr = action;
|
|
446
|
+
if (rule.actions.includes(actionStr))
|
|
447
|
+
return true;
|
|
448
|
+
if (actionPatterns) {
|
|
449
|
+
for (const pattern of actionPatterns) {
|
|
450
|
+
if (pattern.test(actionStr))
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
resolveRoles(subject, tenantId) {
|
|
457
|
+
const directRoles = new Set();
|
|
458
|
+
for (const assignment of subject.roles) {
|
|
459
|
+
if (tenantId == null || assignment.tenantId == null || assignment.tenantId === tenantId) {
|
|
460
|
+
directRoles.add(assignment.role);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!this.hierarchy)
|
|
464
|
+
return directRoles;
|
|
465
|
+
const expanded = new Set();
|
|
466
|
+
for (const role of directRoles) {
|
|
467
|
+
for (const r of this.hierarchy.resolve(role)) {
|
|
468
|
+
expanded.add(r);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return expanded;
|
|
472
|
+
}
|
|
473
|
+
buildDecision(matched, ctx, start) {
|
|
474
|
+
const allowed = matched != null ? matched.effect === "allow" : !this._defaultDeny;
|
|
475
|
+
const effect = matched?.effect ?? "default-deny";
|
|
476
|
+
const reason = matched
|
|
477
|
+
? `Matched rule "${matched.id}"${matched.description ? `: ${matched.description}` : ""}`
|
|
478
|
+
: "No matching rule — default deny";
|
|
479
|
+
return {
|
|
480
|
+
allowed,
|
|
481
|
+
effect,
|
|
482
|
+
matchedRule: matched,
|
|
483
|
+
subject: ctx.subject,
|
|
484
|
+
action: ctx.action,
|
|
485
|
+
resource: ctx.resource,
|
|
486
|
+
resourceContext: ctx.resourceContext,
|
|
487
|
+
tenantId: ctx.tenantId,
|
|
488
|
+
timestamp: Date.now(),
|
|
489
|
+
durationMs: performance.now() - start,
|
|
490
|
+
reason,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Fluent check chain: can(user).perform(action).on(resource)
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
class PerformStep {
|
|
498
|
+
engine;
|
|
499
|
+
subject;
|
|
500
|
+
constructor(engine, subject) {
|
|
501
|
+
this.engine = engine;
|
|
502
|
+
this.subject = subject;
|
|
503
|
+
}
|
|
504
|
+
perform(action) {
|
|
505
|
+
return new OnStep(this.engine, this.subject, action);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
class OnStep {
|
|
509
|
+
engine;
|
|
510
|
+
subject;
|
|
511
|
+
action;
|
|
512
|
+
constructor(engine, subject, action) {
|
|
513
|
+
this.engine = engine;
|
|
514
|
+
this.subject = subject;
|
|
515
|
+
this.action = action;
|
|
516
|
+
}
|
|
517
|
+
on(resource, resourceContext = {}, tenantId) {
|
|
518
|
+
return this.engine.evaluate(this.subject, this.action, resource, resourceContext, tenantId);
|
|
519
|
+
}
|
|
520
|
+
async onAsync(resource, resourceContext = {}, tenantId) {
|
|
521
|
+
return this.engine.evaluateAsync(this.subject, this.action, resource, resourceContext, tenantId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Simple LRU cache
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
function buildCacheKey(subjectId, action, resource, tenantId) {
|
|
528
|
+
return `${subjectId.length}:${subjectId}\0${action}\0${resource}\0${tenantId ?? ""}`;
|
|
529
|
+
}
|
|
530
|
+
class LRUCache {
|
|
531
|
+
map = new Map();
|
|
532
|
+
maxSize;
|
|
533
|
+
constructor(maxSize) {
|
|
534
|
+
this.maxSize = maxSize;
|
|
535
|
+
}
|
|
536
|
+
get size() {
|
|
537
|
+
return this.map.size;
|
|
538
|
+
}
|
|
539
|
+
get(key) {
|
|
540
|
+
const value = this.map.get(key);
|
|
541
|
+
if (value !== undefined) {
|
|
542
|
+
this.map.delete(key);
|
|
543
|
+
this.map.set(key, value);
|
|
544
|
+
}
|
|
545
|
+
return value;
|
|
546
|
+
}
|
|
547
|
+
set(key, value) {
|
|
548
|
+
if (this.map.has(key)) {
|
|
549
|
+
this.map.delete(key);
|
|
550
|
+
}
|
|
551
|
+
else if (this.map.size >= this.maxSize) {
|
|
552
|
+
const oldest = this.map.keys().next().value;
|
|
553
|
+
if (oldest !== undefined)
|
|
554
|
+
this.map.delete(oldest);
|
|
555
|
+
}
|
|
556
|
+
this.map.set(key, value);
|
|
557
|
+
}
|
|
558
|
+
clear() {
|
|
559
|
+
this.map.clear();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
//# sourceMappingURL=engine.js.map
|