@siremzam/sentinel 0.3.0 → 0.3.2
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 +573 -176
- package/dist/engine-C6IASR5F.d.cts +283 -0
- package/dist/engine-C6IASR5F.d.ts +283 -0
- package/dist/index.cjs +877 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +58 -0
- package/dist/index.d.ts +58 -11
- package/dist/index.js +838 -6
- package/dist/index.js.map +1 -1
- package/dist/middleware/express.cjs +58 -0
- package/dist/middleware/express.cjs.map +1 -0
- package/dist/middleware/express.d.cts +35 -0
- package/dist/middleware/express.d.ts +6 -6
- package/dist/middleware/express.js +31 -39
- package/dist/middleware/express.js.map +1 -1
- package/dist/middleware/fastify.cjs +59 -0
- package/dist/middleware/fastify.cjs.map +1 -0
- package/dist/middleware/fastify.d.cts +29 -0
- package/dist/middleware/fastify.d.ts +6 -6
- package/dist/middleware/fastify.js +32 -39
- package/dist/middleware/fastify.js.map +1 -1
- package/dist/middleware/nestjs.cjs +84 -0
- package/dist/middleware/nestjs.cjs.map +1 -0
- package/dist/middleware/nestjs.d.cts +67 -0
- package/dist/middleware/nestjs.d.ts +9 -9
- package/dist/middleware/nestjs.js +51 -76
- package/dist/middleware/nestjs.js.map +1 -1
- package/dist/server.cjs +184 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +54 -0
- package/dist/server.d.ts +10 -8
- package/dist/server.js +149 -153
- package/dist/server.js.map +1 -1
- package/package.json +22 -9
- package/dist/engine.d.ts +0 -70
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -562
- package/dist/engine.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/middleware/express.d.ts.map +0 -1
- package/dist/middleware/fastify.d.ts.map +0 -1
- package/dist/middleware/nestjs.d.ts.map +0 -1
- package/dist/policy-builder.d.ts +0 -39
- package/dist/policy-builder.d.ts.map +0 -1
- package/dist/policy-builder.js +0 -92
- package/dist/policy-builder.js.map +0 -1
- package/dist/role-hierarchy.d.ts +0 -42
- package/dist/role-hierarchy.d.ts.map +0 -1
- package/dist/role-hierarchy.js +0 -87
- package/dist/role-hierarchy.js.map +0 -1
- package/dist/serialization.d.ts +0 -52
- package/dist/serialization.d.ts.map +0 -1
- package/dist/serialization.js +0 -144
- package/dist/serialization.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/types.d.ts +0 -137
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -27
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,839 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// src/policy-builder.ts
|
|
2
|
+
var ruleCounter = 0;
|
|
3
|
+
function nextRuleId(prefix) {
|
|
4
|
+
return `${prefix}-${++ruleCounter}`;
|
|
5
|
+
}
|
|
6
|
+
var RuleBuilder = class {
|
|
7
|
+
_effect;
|
|
8
|
+
_roles = "*";
|
|
9
|
+
_actions = "*";
|
|
10
|
+
_resources = "*";
|
|
11
|
+
_conditions = [];
|
|
12
|
+
_priority = 0;
|
|
13
|
+
_description;
|
|
14
|
+
_id;
|
|
15
|
+
constructor(effect) {
|
|
16
|
+
this._effect = effect;
|
|
17
|
+
this._id = nextRuleId(effect);
|
|
18
|
+
}
|
|
19
|
+
id(id) {
|
|
20
|
+
this._id = id;
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
roles(...roles) {
|
|
24
|
+
this._roles = roles;
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
anyRole() {
|
|
28
|
+
this._roles = "*";
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
actions(...actions) {
|
|
32
|
+
this._actions = actions;
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
anyAction() {
|
|
36
|
+
this._actions = "*";
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
on(...resources) {
|
|
40
|
+
this._resources = resources;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
anyResource() {
|
|
44
|
+
this._resources = "*";
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
when(condition) {
|
|
48
|
+
this._conditions.push(condition);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
priority(p) {
|
|
52
|
+
this._priority = p;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
describe(desc) {
|
|
56
|
+
this._description = desc;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
build() {
|
|
60
|
+
return {
|
|
61
|
+
id: this._id,
|
|
62
|
+
effect: this._effect,
|
|
63
|
+
roles: this._roles,
|
|
64
|
+
actions: this._actions,
|
|
65
|
+
resources: this._resources,
|
|
66
|
+
conditions: this._conditions.length > 0 ? this._conditions : void 0,
|
|
67
|
+
priority: this._priority,
|
|
68
|
+
description: this._description
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function allow() {
|
|
73
|
+
return new RuleBuilder("allow");
|
|
74
|
+
}
|
|
75
|
+
function deny() {
|
|
76
|
+
return new RuleBuilder("deny");
|
|
77
|
+
}
|
|
78
|
+
function createPolicyFactory() {
|
|
79
|
+
return {
|
|
80
|
+
allow: () => new RuleBuilder("allow"),
|
|
81
|
+
deny: () => new RuleBuilder("deny")
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/engine.ts
|
|
86
|
+
function escapeRegexMeta(s) {
|
|
87
|
+
return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
}
|
|
89
|
+
function compileActionPatterns(actions) {
|
|
90
|
+
if (actions === "*") return null;
|
|
91
|
+
const patterns = [];
|
|
92
|
+
for (const action of actions) {
|
|
93
|
+
if (action.includes("*")) {
|
|
94
|
+
const escaped = escapeRegexMeta(action).replace(/\*/g, "[^:]*");
|
|
95
|
+
patterns.push(new RegExp("^" + escaped + "$"));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return patterns.length > 0 ? patterns : null;
|
|
99
|
+
}
|
|
100
|
+
var AccessEngine = class {
|
|
101
|
+
compiled = [];
|
|
102
|
+
listeners = [];
|
|
103
|
+
asyncConditions;
|
|
104
|
+
_defaultDeny;
|
|
105
|
+
_strictTenancy;
|
|
106
|
+
hierarchy;
|
|
107
|
+
cache;
|
|
108
|
+
conditionErrorHandler;
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.asyncConditions = options.asyncConditions ?? false;
|
|
111
|
+
this._defaultDeny = (options.defaultEffect ?? "deny") === "deny";
|
|
112
|
+
this._strictTenancy = options.strictTenancy ?? false;
|
|
113
|
+
this.hierarchy = options.roleHierarchy;
|
|
114
|
+
this.conditionErrorHandler = options.onConditionError;
|
|
115
|
+
if (options.cacheSize && options.cacheSize > 0) {
|
|
116
|
+
this.cache = new LRUCache(options.cacheSize);
|
|
117
|
+
}
|
|
118
|
+
if (options.onDecision) {
|
|
119
|
+
this.listeners.push(options.onDecision);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// Rule management
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
addRule(rule) {
|
|
126
|
+
const frozen = Object.freeze({ ...rule });
|
|
127
|
+
this.compiled.push({
|
|
128
|
+
rule: frozen,
|
|
129
|
+
actionPatterns: compileActionPatterns(frozen.actions)
|
|
130
|
+
});
|
|
131
|
+
this.cache?.clear();
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
addRules(...rules) {
|
|
135
|
+
for (const rule of rules) {
|
|
136
|
+
const frozen = Object.freeze({ ...rule });
|
|
137
|
+
this.compiled.push({
|
|
138
|
+
rule: frozen,
|
|
139
|
+
actionPatterns: compileActionPatterns(frozen.actions)
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
this.cache?.clear();
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
removeRule(ruleId) {
|
|
146
|
+
const idx = this.compiled.findIndex((c) => c.rule.id === ruleId);
|
|
147
|
+
if (idx === -1) return false;
|
|
148
|
+
this.compiled.splice(idx, 1);
|
|
149
|
+
this.cache?.clear();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
getRules() {
|
|
153
|
+
return this.compiled.map((c) => c.rule);
|
|
154
|
+
}
|
|
155
|
+
clearRules() {
|
|
156
|
+
this.compiled = [];
|
|
157
|
+
this.cache?.clear();
|
|
158
|
+
}
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
// Cache control
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
clearCache() {
|
|
163
|
+
this.cache?.clear();
|
|
164
|
+
}
|
|
165
|
+
get cacheStats() {
|
|
166
|
+
if (!this.cache) return null;
|
|
167
|
+
return { size: this.cache.size, maxSize: this.cache.maxSize };
|
|
168
|
+
}
|
|
169
|
+
// -----------------------------------------------------------------------
|
|
170
|
+
// Fluent rule builders bound to this engine's schema
|
|
171
|
+
// -----------------------------------------------------------------------
|
|
172
|
+
allow() {
|
|
173
|
+
return allow();
|
|
174
|
+
}
|
|
175
|
+
deny() {
|
|
176
|
+
return deny();
|
|
177
|
+
}
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
// Observability
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
onDecision(listener) {
|
|
182
|
+
this.listeners.push(listener);
|
|
183
|
+
return () => {
|
|
184
|
+
const idx = this.listeners.indexOf(listener);
|
|
185
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
emit(decision) {
|
|
189
|
+
for (const listener of this.listeners) {
|
|
190
|
+
try {
|
|
191
|
+
const result = listener(decision);
|
|
192
|
+
if (result instanceof Promise) {
|
|
193
|
+
result.catch(() => {
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// -----------------------------------------------------------------------
|
|
201
|
+
// Evaluation
|
|
202
|
+
// -----------------------------------------------------------------------
|
|
203
|
+
evaluate(subject, action, resource, resourceContext = {}, tenantId) {
|
|
204
|
+
if (this.asyncConditions) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
"Engine has asyncConditions enabled. Use evaluateAsync() instead."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
this.validateInput(subject, action, resource);
|
|
210
|
+
this.enforceTenancy(subject, tenantId);
|
|
211
|
+
const cacheKey = this.cache ? buildCacheKey(subject.id, action, resource, tenantId) : void 0;
|
|
212
|
+
if (cacheKey) {
|
|
213
|
+
const cached = this.cache.get(cacheKey);
|
|
214
|
+
if (cached) {
|
|
215
|
+
this.emit(cached);
|
|
216
|
+
return cached;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const start = performance.now();
|
|
220
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
221
|
+
const candidates = this.matchRules(subject, action, resource, tenantId);
|
|
222
|
+
let matched = null;
|
|
223
|
+
let matchedHasConditions = false;
|
|
224
|
+
for (const compiled of candidates) {
|
|
225
|
+
const { rule } = compiled;
|
|
226
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
227
|
+
matched = compiled;
|
|
228
|
+
matchedHasConditions = false;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
const allMet = this.evaluateConditionsSync(rule, ctx);
|
|
232
|
+
if (allMet) {
|
|
233
|
+
matched = compiled;
|
|
234
|
+
matchedHasConditions = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const decision = this.buildDecision(matched?.rule ?? null, ctx, start);
|
|
239
|
+
if (cacheKey && !matchedHasConditions) {
|
|
240
|
+
this.cache.set(cacheKey, decision);
|
|
241
|
+
}
|
|
242
|
+
this.emit(decision);
|
|
243
|
+
return decision;
|
|
244
|
+
}
|
|
245
|
+
async evaluateAsync(subject, action, resource, resourceContext = {}, tenantId) {
|
|
246
|
+
this.validateInput(subject, action, resource);
|
|
247
|
+
this.enforceTenancy(subject, tenantId);
|
|
248
|
+
const start = performance.now();
|
|
249
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
250
|
+
const candidates = this.matchRules(subject, action, resource, tenantId);
|
|
251
|
+
let matched = null;
|
|
252
|
+
for (const compiled of candidates) {
|
|
253
|
+
const { rule } = compiled;
|
|
254
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
255
|
+
matched = rule;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
const results = await Promise.all(
|
|
259
|
+
rule.conditions.map(
|
|
260
|
+
(c, i) => Promise.resolve().then(() => c(ctx)).catch((err) => {
|
|
261
|
+
this.emitConditionError(rule.id, i, err);
|
|
262
|
+
return false;
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
if (results.every(Boolean)) {
|
|
267
|
+
matched = rule;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const decision = this.buildDecision(matched, ctx, start);
|
|
272
|
+
this.emit(decision);
|
|
273
|
+
return decision;
|
|
274
|
+
}
|
|
275
|
+
// -----------------------------------------------------------------------
|
|
276
|
+
// permitted() — list allowed actions on a resource for UI rendering
|
|
277
|
+
// -----------------------------------------------------------------------
|
|
278
|
+
permitted(subject, resource, actions, resourceContext = {}, tenantId) {
|
|
279
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
280
|
+
for (const action of actions) {
|
|
281
|
+
const decision = this.asyncConditions ? (() => {
|
|
282
|
+
throw new Error("Use permittedAsync() with asyncConditions enabled.");
|
|
283
|
+
})() : this.evaluate(subject, action, resource, resourceContext, tenantId);
|
|
284
|
+
if (decision.allowed) {
|
|
285
|
+
allowed.add(action);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return allowed;
|
|
289
|
+
}
|
|
290
|
+
async permittedAsync(subject, resource, actions, resourceContext = {}, tenantId) {
|
|
291
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
292
|
+
const results = await Promise.all(
|
|
293
|
+
actions.map(
|
|
294
|
+
(action) => this.evaluateAsync(subject, action, resource, resourceContext, tenantId)
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
for (let i = 0; i < actions.length; i++) {
|
|
298
|
+
if (results[i].allowed) {
|
|
299
|
+
allowed.add(actions[i]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return allowed;
|
|
303
|
+
}
|
|
304
|
+
// -----------------------------------------------------------------------
|
|
305
|
+
// explain() — full evaluation trace for debugging
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
explain(subject, action, resource, resourceContext = {}, tenantId) {
|
|
308
|
+
if (this.asyncConditions) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"Engine has asyncConditions enabled. Use explainAsync() instead."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
this.enforceTenancy(subject, tenantId);
|
|
314
|
+
const start = performance.now();
|
|
315
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
316
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
317
|
+
const evaluatedRules = [];
|
|
318
|
+
let firstMatch = null;
|
|
319
|
+
const sorted = this.sortCandidates([...this.compiled]);
|
|
320
|
+
for (const compiled of sorted) {
|
|
321
|
+
const { rule } = compiled;
|
|
322
|
+
const roleMatched = rule.roles === "*" || rule.roles.some((r) => subjectRoles.has(r));
|
|
323
|
+
const actionMatched = rule.actions === "*" || this.matchesAction(compiled, action);
|
|
324
|
+
const resourceMatched = rule.resources === "*" || rule.resources.includes(resource);
|
|
325
|
+
const conditionResults = [];
|
|
326
|
+
let allConditionsPassed = true;
|
|
327
|
+
if (roleMatched && actionMatched && resourceMatched && rule.conditions) {
|
|
328
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
329
|
+
try {
|
|
330
|
+
const result = rule.conditions[i](ctx);
|
|
331
|
+
if (result !== true) {
|
|
332
|
+
conditionResults.push({ index: i, passed: false });
|
|
333
|
+
allConditionsPassed = false;
|
|
334
|
+
} else {
|
|
335
|
+
conditionResults.push({ index: i, passed: true });
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
conditionResults.push({
|
|
339
|
+
index: i,
|
|
340
|
+
passed: false,
|
|
341
|
+
error: err instanceof Error ? err.message : String(err)
|
|
342
|
+
});
|
|
343
|
+
allConditionsPassed = false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const matched = roleMatched && actionMatched && resourceMatched && (!rule.conditions || rule.conditions.length === 0 || allConditionsPassed);
|
|
348
|
+
evaluatedRules.push({
|
|
349
|
+
rule,
|
|
350
|
+
roleMatched,
|
|
351
|
+
actionMatched,
|
|
352
|
+
resourceMatched,
|
|
353
|
+
conditionResults,
|
|
354
|
+
matched
|
|
355
|
+
});
|
|
356
|
+
if (matched && !firstMatch) {
|
|
357
|
+
firstMatch = rule;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const allowed = firstMatch != null ? firstMatch.effect === "allow" : !this._defaultDeny;
|
|
361
|
+
const effect = firstMatch?.effect ?? "default-deny";
|
|
362
|
+
const reason = firstMatch ? `Matched rule "${firstMatch.id}"${firstMatch.description ? `: ${firstMatch.description}` : ""}` : "No matching rule \u2014 default deny";
|
|
363
|
+
return {
|
|
364
|
+
allowed,
|
|
365
|
+
effect,
|
|
366
|
+
reason,
|
|
367
|
+
evaluatedRules,
|
|
368
|
+
durationMs: performance.now() - start
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async explainAsync(subject, action, resource, resourceContext = {}, tenantId) {
|
|
372
|
+
this.enforceTenancy(subject, tenantId);
|
|
373
|
+
const start = performance.now();
|
|
374
|
+
const ctx = this.buildContext(subject, action, resource, resourceContext, tenantId);
|
|
375
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
376
|
+
const evaluatedRules = [];
|
|
377
|
+
let firstMatch = null;
|
|
378
|
+
const sorted = this.sortCandidates([...this.compiled]);
|
|
379
|
+
for (const compiled of sorted) {
|
|
380
|
+
const { rule } = compiled;
|
|
381
|
+
const roleMatched = rule.roles === "*" || rule.roles.some((r) => subjectRoles.has(r));
|
|
382
|
+
const actionMatched = rule.actions === "*" || this.matchesAction(compiled, action);
|
|
383
|
+
const resourceMatched = rule.resources === "*" || rule.resources.includes(resource);
|
|
384
|
+
const conditionResults = [];
|
|
385
|
+
let allConditionsPassed = true;
|
|
386
|
+
if (roleMatched && actionMatched && resourceMatched && rule.conditions) {
|
|
387
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
388
|
+
try {
|
|
389
|
+
const result = await rule.conditions[i](ctx);
|
|
390
|
+
if (result !== true) {
|
|
391
|
+
conditionResults.push({ index: i, passed: false });
|
|
392
|
+
allConditionsPassed = false;
|
|
393
|
+
} else {
|
|
394
|
+
conditionResults.push({ index: i, passed: true });
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
conditionResults.push({
|
|
398
|
+
index: i,
|
|
399
|
+
passed: false,
|
|
400
|
+
error: err instanceof Error ? err.message : String(err)
|
|
401
|
+
});
|
|
402
|
+
allConditionsPassed = false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const matched = roleMatched && actionMatched && resourceMatched && (!rule.conditions || rule.conditions.length === 0 || allConditionsPassed);
|
|
407
|
+
evaluatedRules.push({
|
|
408
|
+
rule,
|
|
409
|
+
roleMatched,
|
|
410
|
+
actionMatched,
|
|
411
|
+
resourceMatched,
|
|
412
|
+
conditionResults,
|
|
413
|
+
matched
|
|
414
|
+
});
|
|
415
|
+
if (matched && !firstMatch) {
|
|
416
|
+
firstMatch = rule;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const allowed = firstMatch != null ? firstMatch.effect === "allow" : !this._defaultDeny;
|
|
420
|
+
const effect = firstMatch?.effect ?? "default-deny";
|
|
421
|
+
const reason = firstMatch ? `Matched rule "${firstMatch.id}"${firstMatch.description ? `: ${firstMatch.description}` : ""}` : "No matching rule \u2014 default deny";
|
|
422
|
+
return {
|
|
423
|
+
allowed,
|
|
424
|
+
effect,
|
|
425
|
+
reason,
|
|
426
|
+
evaluatedRules,
|
|
427
|
+
durationMs: performance.now() - start
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
// Fluent check API: can(subject).perform(action).on(resource)
|
|
432
|
+
// -----------------------------------------------------------------------
|
|
433
|
+
can(subject) {
|
|
434
|
+
return new PerformStep(this, subject);
|
|
435
|
+
}
|
|
436
|
+
// -----------------------------------------------------------------------
|
|
437
|
+
// Internal helpers
|
|
438
|
+
// -----------------------------------------------------------------------
|
|
439
|
+
validateInput(subject, action, resource) {
|
|
440
|
+
if (!subject || typeof subject.id !== "string") {
|
|
441
|
+
throw new Error("subject must be an object with a string id");
|
|
442
|
+
}
|
|
443
|
+
if (!Array.isArray(subject.roles)) {
|
|
444
|
+
throw new Error("subject.roles must be an array");
|
|
445
|
+
}
|
|
446
|
+
if (!action || typeof action !== "string") {
|
|
447
|
+
throw new Error("action must be a non-empty string");
|
|
448
|
+
}
|
|
449
|
+
if (!resource || typeof resource !== "string") {
|
|
450
|
+
throw new Error("resource must be a non-empty string");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
enforceTenancy(subject, tenantId) {
|
|
454
|
+
if (!this._strictTenancy || tenantId != null) return;
|
|
455
|
+
const hasTenantScoped = subject.roles.some((r) => r.tenantId != null);
|
|
456
|
+
if (hasTenantScoped) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"strictTenancy is enabled and subject has tenant-scoped roles, but no tenantId was provided to evaluate(). This could cause cross-tenant privilege escalation. Pass an explicit tenantId."
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
buildContext(subject, action, resource, resourceContext, tenantId) {
|
|
463
|
+
return { subject, action, resource, resourceContext, tenantId };
|
|
464
|
+
}
|
|
465
|
+
evaluateConditionsSync(rule, ctx) {
|
|
466
|
+
if (!rule.conditions) return true;
|
|
467
|
+
for (let i = 0; i < rule.conditions.length; i++) {
|
|
468
|
+
try {
|
|
469
|
+
if (rule.conditions[i](ctx) !== true) return false;
|
|
470
|
+
} catch (err) {
|
|
471
|
+
this.emitConditionError(rule.id, i, err);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
emitConditionError(ruleId, conditionIndex, error) {
|
|
478
|
+
if (this.conditionErrorHandler) {
|
|
479
|
+
try {
|
|
480
|
+
this.conditionErrorHandler({ ruleId, conditionIndex, error });
|
|
481
|
+
} catch {
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
matchRules(subject, action, resource, tenantId) {
|
|
486
|
+
const subjectRoles = this.resolveRoles(subject, tenantId);
|
|
487
|
+
const matched = this.compiled.filter((compiled) => {
|
|
488
|
+
const { rule } = compiled;
|
|
489
|
+
if (rule.roles !== "*" && !rule.roles.some((r) => subjectRoles.has(r))) return false;
|
|
490
|
+
if (rule.actions !== "*" && !this.matchesAction(compiled, action)) return false;
|
|
491
|
+
if (rule.resources !== "*" && !rule.resources.includes(resource)) return false;
|
|
492
|
+
return true;
|
|
493
|
+
});
|
|
494
|
+
return this.sortCandidates(matched);
|
|
495
|
+
}
|
|
496
|
+
sortCandidates(candidates) {
|
|
497
|
+
return candidates.sort((a, b) => {
|
|
498
|
+
const pa = a.rule.priority ?? 0;
|
|
499
|
+
const pb = b.rule.priority ?? 0;
|
|
500
|
+
if (pb !== pa) return pb - pa;
|
|
501
|
+
if (a.rule.effect === "deny" && b.rule.effect === "allow") return -1;
|
|
502
|
+
if (a.rule.effect === "allow" && b.rule.effect === "deny") return 1;
|
|
503
|
+
return 0;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
matchesAction(compiled, action) {
|
|
507
|
+
const { rule, actionPatterns } = compiled;
|
|
508
|
+
if (rule.actions === "*") return true;
|
|
509
|
+
const actionStr = action;
|
|
510
|
+
if (rule.actions.includes(actionStr)) return true;
|
|
511
|
+
if (actionPatterns) {
|
|
512
|
+
for (const pattern of actionPatterns) {
|
|
513
|
+
if (pattern.test(actionStr)) return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
resolveRoles(subject, tenantId) {
|
|
519
|
+
const directRoles = /* @__PURE__ */ new Set();
|
|
520
|
+
for (const assignment of subject.roles) {
|
|
521
|
+
if (tenantId == null || assignment.tenantId == null || assignment.tenantId === tenantId) {
|
|
522
|
+
directRoles.add(assignment.role);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!this.hierarchy) return directRoles;
|
|
526
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
527
|
+
for (const role of directRoles) {
|
|
528
|
+
for (const r of this.hierarchy.resolve(role)) {
|
|
529
|
+
expanded.add(r);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return expanded;
|
|
533
|
+
}
|
|
534
|
+
buildDecision(matched, ctx, start) {
|
|
535
|
+
const allowed = matched != null ? matched.effect === "allow" : !this._defaultDeny;
|
|
536
|
+
const effect = matched?.effect ?? "default-deny";
|
|
537
|
+
const reason = matched ? `Matched rule "${matched.id}"${matched.description ? `: ${matched.description}` : ""}` : "No matching rule \u2014 default deny";
|
|
538
|
+
return {
|
|
539
|
+
allowed,
|
|
540
|
+
effect,
|
|
541
|
+
matchedRule: matched,
|
|
542
|
+
subject: ctx.subject,
|
|
543
|
+
action: ctx.action,
|
|
544
|
+
resource: ctx.resource,
|
|
545
|
+
resourceContext: ctx.resourceContext,
|
|
546
|
+
tenantId: ctx.tenantId,
|
|
547
|
+
timestamp: Date.now(),
|
|
548
|
+
durationMs: performance.now() - start,
|
|
549
|
+
reason
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
var PerformStep = class {
|
|
554
|
+
constructor(engine, subject) {
|
|
555
|
+
this.engine = engine;
|
|
556
|
+
this.subject = subject;
|
|
557
|
+
}
|
|
558
|
+
perform(action) {
|
|
559
|
+
return new OnStep(this.engine, this.subject, action);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
var OnStep = class {
|
|
563
|
+
constructor(engine, subject, action) {
|
|
564
|
+
this.engine = engine;
|
|
565
|
+
this.subject = subject;
|
|
566
|
+
this.action = action;
|
|
567
|
+
}
|
|
568
|
+
on(resource, resourceContext = {}, tenantId) {
|
|
569
|
+
return this.engine.evaluate(
|
|
570
|
+
this.subject,
|
|
571
|
+
this.action,
|
|
572
|
+
resource,
|
|
573
|
+
resourceContext,
|
|
574
|
+
tenantId
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
async onAsync(resource, resourceContext = {}, tenantId) {
|
|
578
|
+
return this.engine.evaluateAsync(
|
|
579
|
+
this.subject,
|
|
580
|
+
this.action,
|
|
581
|
+
resource,
|
|
582
|
+
resourceContext,
|
|
583
|
+
tenantId
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
function buildCacheKey(subjectId, action, resource, tenantId) {
|
|
588
|
+
return `${subjectId.length}:${subjectId}\0${action}\0${resource}\0${tenantId ?? ""}`;
|
|
589
|
+
}
|
|
590
|
+
var LRUCache = class {
|
|
591
|
+
map = /* @__PURE__ */ new Map();
|
|
592
|
+
maxSize;
|
|
593
|
+
constructor(maxSize) {
|
|
594
|
+
this.maxSize = maxSize;
|
|
595
|
+
}
|
|
596
|
+
get size() {
|
|
597
|
+
return this.map.size;
|
|
598
|
+
}
|
|
599
|
+
get(key) {
|
|
600
|
+
const value = this.map.get(key);
|
|
601
|
+
if (value !== void 0) {
|
|
602
|
+
this.map.delete(key);
|
|
603
|
+
this.map.set(key, value);
|
|
604
|
+
}
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
set(key, value) {
|
|
608
|
+
if (this.map.has(key)) {
|
|
609
|
+
this.map.delete(key);
|
|
610
|
+
} else if (this.map.size >= this.maxSize) {
|
|
611
|
+
const oldest = this.map.keys().next().value;
|
|
612
|
+
if (oldest !== void 0) this.map.delete(oldest);
|
|
613
|
+
}
|
|
614
|
+
this.map.set(key, value);
|
|
615
|
+
}
|
|
616
|
+
clear() {
|
|
617
|
+
this.map.clear();
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// src/role-hierarchy.ts
|
|
622
|
+
var RoleHierarchy = class {
|
|
623
|
+
parents = /* @__PURE__ */ new Map();
|
|
624
|
+
cache = /* @__PURE__ */ new Map();
|
|
625
|
+
/**
|
|
626
|
+
* Define that `role` inherits permissions from `inheritsFrom` roles.
|
|
627
|
+
* Clears the resolution cache.
|
|
628
|
+
*/
|
|
629
|
+
define(role, inheritsFrom) {
|
|
630
|
+
this.parents.set(role, inheritsFrom);
|
|
631
|
+
this.cache.clear();
|
|
632
|
+
this.detectCycle(role, /* @__PURE__ */ new Set());
|
|
633
|
+
return this;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Resolve the full set of roles a given role expands to,
|
|
637
|
+
* including all inherited roles (transitive).
|
|
638
|
+
*/
|
|
639
|
+
resolve(role) {
|
|
640
|
+
const roleStr = role;
|
|
641
|
+
const cached = this.cache.get(roleStr);
|
|
642
|
+
if (cached) return cached;
|
|
643
|
+
const result = /* @__PURE__ */ new Set();
|
|
644
|
+
this.walk(roleStr, result);
|
|
645
|
+
this.cache.set(roleStr, result);
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Resolve multiple roles at once, returning the merged set.
|
|
650
|
+
*/
|
|
651
|
+
resolveAll(roles) {
|
|
652
|
+
const result = /* @__PURE__ */ new Set();
|
|
653
|
+
for (const role of roles) {
|
|
654
|
+
for (const r of this.resolve(role)) {
|
|
655
|
+
result.add(r);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Get all defined roles that have inheritance rules.
|
|
662
|
+
*/
|
|
663
|
+
definedRoles() {
|
|
664
|
+
return [...this.parents.keys()];
|
|
665
|
+
}
|
|
666
|
+
walk(role, visited) {
|
|
667
|
+
if (visited.has(role)) return;
|
|
668
|
+
visited.add(role);
|
|
669
|
+
const parents = this.parents.get(role);
|
|
670
|
+
if (parents) {
|
|
671
|
+
for (const parent of parents) {
|
|
672
|
+
this.walk(parent, visited);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
detectCycle(role, visiting) {
|
|
677
|
+
if (visiting.has(role)) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`Cycle detected in role hierarchy: ${[...visiting, role].join(" \u2192 ")}`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
visiting.add(role);
|
|
683
|
+
const parents = this.parents.get(role);
|
|
684
|
+
if (parents) {
|
|
685
|
+
for (const parent of parents) {
|
|
686
|
+
this.detectCycle(parent, visiting);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
visiting.delete(role);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// src/serialization.ts
|
|
694
|
+
var ConditionRegistry = class {
|
|
695
|
+
conditions = /* @__PURE__ */ new Map();
|
|
696
|
+
register(name, condition) {
|
|
697
|
+
if (!name || typeof name !== "string") {
|
|
698
|
+
throw new Error("Condition name must be a non-empty string");
|
|
699
|
+
}
|
|
700
|
+
if (typeof condition !== "function") {
|
|
701
|
+
throw new Error(`Condition "${name}" must be a function`);
|
|
702
|
+
}
|
|
703
|
+
this.conditions.set(name, condition);
|
|
704
|
+
return this;
|
|
705
|
+
}
|
|
706
|
+
get(name) {
|
|
707
|
+
return this.conditions.get(name);
|
|
708
|
+
}
|
|
709
|
+
has(name) {
|
|
710
|
+
return this.conditions.has(name);
|
|
711
|
+
}
|
|
712
|
+
names() {
|
|
713
|
+
return [...this.conditions.keys()];
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function exportRules(rules, conditionNames) {
|
|
717
|
+
const jsonRules = rules.map((rule) => {
|
|
718
|
+
const jr = {
|
|
719
|
+
id: rule.id,
|
|
720
|
+
effect: rule.effect,
|
|
721
|
+
roles: rule.roles,
|
|
722
|
+
actions: rule.actions,
|
|
723
|
+
resources: rule.resources,
|
|
724
|
+
priority: rule.priority,
|
|
725
|
+
description: rule.description
|
|
726
|
+
};
|
|
727
|
+
if (rule.conditions && conditionNames) {
|
|
728
|
+
const names = [];
|
|
729
|
+
for (const cond of rule.conditions) {
|
|
730
|
+
const name = conditionNames.get(cond);
|
|
731
|
+
if (name) names.push(name);
|
|
732
|
+
}
|
|
733
|
+
if (names.length > 0) jr.conditions = names;
|
|
734
|
+
}
|
|
735
|
+
return jr;
|
|
736
|
+
});
|
|
737
|
+
return { version: 1, rules: jsonRules };
|
|
738
|
+
}
|
|
739
|
+
function exportRulesToJson(rules, conditionNames) {
|
|
740
|
+
return JSON.stringify(exportRules(rules, conditionNames), null, 2);
|
|
741
|
+
}
|
|
742
|
+
function importRules(doc, registry) {
|
|
743
|
+
if (!doc || typeof doc !== "object") {
|
|
744
|
+
throw new Error("Policy document must be a non-null object");
|
|
745
|
+
}
|
|
746
|
+
if (doc.version !== 1) {
|
|
747
|
+
throw new Error(`Unsupported policy document version: ${doc.version}`);
|
|
748
|
+
}
|
|
749
|
+
if (!Array.isArray(doc.rules)) {
|
|
750
|
+
throw new Error("Policy document must have a 'rules' array");
|
|
751
|
+
}
|
|
752
|
+
return doc.rules.map((jr, index) => {
|
|
753
|
+
if (!jr || typeof jr !== "object") {
|
|
754
|
+
throw new Error(`Rule at index ${index} must be a non-null object`);
|
|
755
|
+
}
|
|
756
|
+
if (!jr.id || typeof jr.id !== "string") {
|
|
757
|
+
throw new Error(`Rule at index ${index} is missing a valid "id" field.`);
|
|
758
|
+
}
|
|
759
|
+
if (jr.effect !== "allow" && jr.effect !== "deny") {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Invalid effect "${jr.effect}" in rule "${jr.id}". Must be "allow" or "deny".`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (jr.roles !== "*" && !Array.isArray(jr.roles)) {
|
|
765
|
+
throw new Error(`Rule "${jr.id}": roles must be "*" or an array of strings`);
|
|
766
|
+
}
|
|
767
|
+
if (jr.actions !== "*" && !Array.isArray(jr.actions)) {
|
|
768
|
+
throw new Error(`Rule "${jr.id}": actions must be "*" or an array of strings`);
|
|
769
|
+
}
|
|
770
|
+
if (jr.resources !== "*" && !Array.isArray(jr.resources)) {
|
|
771
|
+
throw new Error(`Rule "${jr.id}": resources must be "*" or an array of strings`);
|
|
772
|
+
}
|
|
773
|
+
const conditions = [];
|
|
774
|
+
if (jr.conditions && registry) {
|
|
775
|
+
for (const name of jr.conditions) {
|
|
776
|
+
const cond = registry.get(name);
|
|
777
|
+
if (!cond) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
`Unknown condition "${name}" in rule "${jr.id}". Registered conditions: ${registry.names().join(", ") || "(none)"}`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
conditions.push(cond);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
id: jr.id,
|
|
787
|
+
effect: jr.effect,
|
|
788
|
+
roles: jr.roles,
|
|
789
|
+
actions: jr.actions,
|
|
790
|
+
resources: jr.resources,
|
|
791
|
+
conditions: conditions.length > 0 ? conditions : void 0,
|
|
792
|
+
priority: jr.priority,
|
|
793
|
+
description: jr.description
|
|
794
|
+
};
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
function importRulesFromJson(json, registry) {
|
|
798
|
+
let doc;
|
|
799
|
+
try {
|
|
800
|
+
doc = JSON.parse(json);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`Failed to parse policy JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return importRules(doc, registry);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/types.ts
|
|
810
|
+
function toAuditEntry(decision) {
|
|
811
|
+
return {
|
|
812
|
+
allowed: decision.allowed,
|
|
813
|
+
effect: decision.effect,
|
|
814
|
+
matchedRuleId: decision.matchedRule?.id ?? null,
|
|
815
|
+
matchedRuleDescription: decision.matchedRule?.description ?? null,
|
|
816
|
+
subjectId: decision.subject.id,
|
|
817
|
+
action: decision.action,
|
|
818
|
+
resource: decision.resource,
|
|
819
|
+
tenantId: decision.tenantId,
|
|
820
|
+
timestamp: decision.timestamp,
|
|
821
|
+
durationMs: decision.durationMs,
|
|
822
|
+
reason: decision.reason
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
export {
|
|
826
|
+
AccessEngine,
|
|
827
|
+
ConditionRegistry,
|
|
828
|
+
RoleHierarchy,
|
|
829
|
+
RuleBuilder,
|
|
830
|
+
allow,
|
|
831
|
+
createPolicyFactory,
|
|
832
|
+
deny,
|
|
833
|
+
exportRules,
|
|
834
|
+
exportRulesToJson,
|
|
835
|
+
importRules,
|
|
836
|
+
importRulesFromJson,
|
|
837
|
+
toAuditEntry
|
|
838
|
+
};
|
|
7
839
|
//# sourceMappingURL=index.js.map
|