@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 siya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# @siremzam/sentinel
|
|
2
|
+
|
|
3
|
+
**TypeScript-first, domain-driven authorization engine for modern SaaS apps.**
|
|
4
|
+
|
|
5
|
+
Most Node.js authorization libraries were built in the CRUD era — they model permissions as `create`, `read`, `update`, `delete` on "resources." But modern apps don't think that way. They think in domain verbs: `invoice:approve`, `project:archive`, `user:impersonate`.
|
|
6
|
+
|
|
7
|
+
This library was built from a different starting point:
|
|
8
|
+
|
|
9
|
+
- **Your domain actions are not CRUD.** Model `order:ship`, not `update`.
|
|
10
|
+
- **Your tenants are not an afterthought.** A user is admin in Tenant A and viewer in Tenant B. That's the default, not an edge case.
|
|
11
|
+
- **Your types should work for you.** TypeScript autocompletes your actions, resources, and roles everywhere — policies, checks, middleware.
|
|
12
|
+
- **Your authorization decisions should be observable.** Every `allow` and `deny` emits a structured event with timing, reason, and the matched rule.
|
|
13
|
+
- **Your policies belong in one place.** Not scattered across 47 route handlers.
|
|
14
|
+
|
|
15
|
+
**Zero runtime dependencies. ~1,800 lines. 1:1 test-to-code ratio.**
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## How It Compares
|
|
20
|
+
|
|
21
|
+
| Feature | **@siremzam/sentinel** | Casbin | accesscontrol | CASL |
|
|
22
|
+
|---|---|---|---|---|
|
|
23
|
+
| TypeScript-first (full inference) | Yes | Partial | Partial | Yes |
|
|
24
|
+
| Domain actions (`invoice:approve`) | Native | Via model config | No (CRUD only) | Via `subject` |
|
|
25
|
+
| Multi-tenancy (per-tenant roles) | Built-in | Manual | No | Manual |
|
|
26
|
+
| ABAC conditions | Sync + async | Via matchers | No | Via `conditions` |
|
|
27
|
+
| Role hierarchy | Built-in, cycle-detected | Via model | Built-in | No |
|
|
28
|
+
| Evaluation audit trail | `onDecision` + `toAuditEntry()` | Via watcher | No | No |
|
|
29
|
+
| Debug/explain mode | `explain()` with per-rule trace | No | No | No |
|
|
30
|
+
| UI permission set | `permitted()` returns `Set` | No | `permission.filter()` | `ability.can()` per action |
|
|
31
|
+
| JSON policy storage | `exportRules` / `importRules` + `ConditionRegistry` | CSV / JSON adapters | No | Via `@casl/ability/extra` |
|
|
32
|
+
| Server mode (HTTP microservice) | Built-in (`createAuthServer`) | No | No | No |
|
|
33
|
+
| Middleware | Express, Fastify, NestJS | Express (community) | Express (community) | Express, NestJS |
|
|
34
|
+
| Dependencies | **0** | 2+ | 2 | 1+ |
|
|
35
|
+
| DSL required | **No** (pure TypeScript) | Yes (Casbin model) | No | No |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install @siremzam/sentinel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Define your schema
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { AccessEngine, createPolicyFactory, RoleHierarchy } from "@siremzam/sentinel";
|
|
53
|
+
import type { SchemaDefinition, Subject } from "@siremzam/sentinel";
|
|
54
|
+
|
|
55
|
+
interface MySchema extends SchemaDefinition {
|
|
56
|
+
roles: "owner" | "admin" | "manager" | "member" | "viewer";
|
|
57
|
+
resources: "invoice" | "project" | "user";
|
|
58
|
+
actions:
|
|
59
|
+
| "invoice:create"
|
|
60
|
+
| "invoice:read"
|
|
61
|
+
| "invoice:approve"
|
|
62
|
+
| "invoice:send"
|
|
63
|
+
| "project:read"
|
|
64
|
+
| "project:archive"
|
|
65
|
+
| "user:read"
|
|
66
|
+
| "user:impersonate";
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
TypeScript now knows every valid role, resource, and action. Autocomplete works everywhere.
|
|
71
|
+
|
|
72
|
+
### 2. Create the engine and add policies
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
const { allow, deny } = createPolicyFactory<MySchema>();
|
|
76
|
+
|
|
77
|
+
const engine = new AccessEngine<MySchema>({
|
|
78
|
+
schema: {} as MySchema,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
engine.addRules(
|
|
82
|
+
allow()
|
|
83
|
+
.id("admin-full-access")
|
|
84
|
+
.roles("admin", "owner")
|
|
85
|
+
.anyAction()
|
|
86
|
+
.anyResource()
|
|
87
|
+
.describe("Admins and owners have full access")
|
|
88
|
+
.build(),
|
|
89
|
+
|
|
90
|
+
allow()
|
|
91
|
+
.id("manager-invoices")
|
|
92
|
+
.roles("manager")
|
|
93
|
+
.actions("invoice:*" as MySchema["actions"])
|
|
94
|
+
.on("invoice")
|
|
95
|
+
.describe("Managers can do anything with invoices")
|
|
96
|
+
.build(),
|
|
97
|
+
|
|
98
|
+
allow()
|
|
99
|
+
.id("member-own-invoices")
|
|
100
|
+
.roles("member")
|
|
101
|
+
.actions("invoice:read", "invoice:create")
|
|
102
|
+
.on("invoice")
|
|
103
|
+
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
|
|
104
|
+
.describe("Members can read/create their own invoices")
|
|
105
|
+
.build(),
|
|
106
|
+
|
|
107
|
+
deny()
|
|
108
|
+
.id("no-impersonation")
|
|
109
|
+
.anyRole()
|
|
110
|
+
.actions("user:impersonate")
|
|
111
|
+
.on("user")
|
|
112
|
+
.describe("Nobody can impersonate by default")
|
|
113
|
+
.build(),
|
|
114
|
+
|
|
115
|
+
allow()
|
|
116
|
+
.id("owner-impersonate")
|
|
117
|
+
.roles("owner")
|
|
118
|
+
.actions("user:impersonate")
|
|
119
|
+
.on("user")
|
|
120
|
+
.priority(10)
|
|
121
|
+
.describe("Except owners, who can impersonate")
|
|
122
|
+
.build(),
|
|
123
|
+
);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Check permissions
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const user: Subject<MySchema> = {
|
|
130
|
+
id: "user-42",
|
|
131
|
+
roles: [
|
|
132
|
+
{ role: "admin", tenantId: "tenant-a" },
|
|
133
|
+
{ role: "viewer", tenantId: "tenant-b" },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Fluent API
|
|
138
|
+
const decision = engine.can(user).perform("invoice:approve").on("invoice", {}, "tenant-a");
|
|
139
|
+
// decision.allowed === true
|
|
140
|
+
|
|
141
|
+
// Direct evaluation
|
|
142
|
+
const d2 = engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-b");
|
|
143
|
+
// d2.allowed === false (user is only a viewer in tenant-b)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 4. Observe decisions
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { toAuditEntry } from "@siremzam/sentinel";
|
|
150
|
+
|
|
151
|
+
const engine = new AccessEngine<MySchema>({
|
|
152
|
+
schema: {} as MySchema,
|
|
153
|
+
onDecision: (decision) => {
|
|
154
|
+
const entry = toAuditEntry(decision);
|
|
155
|
+
auditLog.write(entry);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Or subscribe at runtime:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
|
|
164
|
+
unsubscribe(); // when done
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Features
|
|
170
|
+
|
|
171
|
+
### createPolicyFactory
|
|
172
|
+
|
|
173
|
+
Eliminates the `<MySchema>` generic parameter on every rule:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { createPolicyFactory } from "@siremzam/sentinel";
|
|
177
|
+
|
|
178
|
+
const { allow, deny } = createPolicyFactory<MySchema>();
|
|
179
|
+
|
|
180
|
+
allow().roles("admin").anyAction().anyResource().build();
|
|
181
|
+
deny().roles("viewer").actions("report:export").on("report").build();
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Strict Tenancy
|
|
185
|
+
|
|
186
|
+
Prevents accidental cross-tenant access by requiring explicit `tenantId` when the subject has tenant-scoped roles:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
const engine = new AccessEngine<MySchema>({
|
|
190
|
+
schema: {} as MySchema,
|
|
191
|
+
strictTenancy: true,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// THROWS — tenantId is required because user has tenant-scoped roles
|
|
195
|
+
engine.evaluate(user, "invoice:read", "invoice");
|
|
196
|
+
|
|
197
|
+
// OK — explicit tenant context
|
|
198
|
+
engine.evaluate(user, "invoice:read", "invoice", {}, "acme");
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Condition Error Handling
|
|
202
|
+
|
|
203
|
+
Conditions that throw are treated as `false` (fail-closed). Surface errors with `onConditionError`:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const engine = new AccessEngine<MySchema>({
|
|
207
|
+
schema: {} as MySchema,
|
|
208
|
+
onConditionError: ({ ruleId, conditionIndex, error }) => {
|
|
209
|
+
logger.warn("Condition failed", { ruleId, conditionIndex, error });
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### permitted() — UI Rendering
|
|
215
|
+
|
|
216
|
+
Ask "what can this user do?" to drive button visibility and menu items:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const actions = engine.permitted(
|
|
220
|
+
user,
|
|
221
|
+
"invoice",
|
|
222
|
+
["invoice:create", "invoice:read", "invoice:approve", "invoice:send"],
|
|
223
|
+
{ ownerId: user.id },
|
|
224
|
+
"tenant-a",
|
|
225
|
+
);
|
|
226
|
+
// Set { "invoice:create", "invoice:read" }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
For async conditions, use `engine.permittedAsync()`.
|
|
230
|
+
|
|
231
|
+
### explain() — Debug Authorization
|
|
232
|
+
|
|
233
|
+
Full evaluation trace showing every rule, whether it matched, and why:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const result = engine.explain(user, "invoice:approve", "invoice");
|
|
237
|
+
|
|
238
|
+
console.log(result.allowed); // false
|
|
239
|
+
console.log(result.reason); // "No matching rule — default deny"
|
|
240
|
+
|
|
241
|
+
for (const evalRule of result.evaluatedRules) {
|
|
242
|
+
console.log({
|
|
243
|
+
ruleId: evalRule.rule.id,
|
|
244
|
+
roleMatched: evalRule.roleMatched,
|
|
245
|
+
actionMatched: evalRule.actionMatched,
|
|
246
|
+
resourceMatched: evalRule.resourceMatched,
|
|
247
|
+
conditionResults: evalRule.conditionResults,
|
|
248
|
+
matched: evalRule.matched,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
For async conditions, use `engine.explainAsync()`.
|
|
254
|
+
|
|
255
|
+
### toAuditEntry()
|
|
256
|
+
|
|
257
|
+
Convert a `Decision` to a serialization-safe format for logging, queuing, or storage:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { toAuditEntry } from "@siremzam/sentinel";
|
|
261
|
+
|
|
262
|
+
const decision = engine.evaluate(user, "invoice:approve", "invoice");
|
|
263
|
+
const entry = toAuditEntry(decision);
|
|
264
|
+
// Safe to JSON.stringify — no functions, no circular references
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Role Hierarchy
|
|
268
|
+
|
|
269
|
+
Define that higher roles inherit all permissions of lower roles:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import { RoleHierarchy } from "@siremzam/sentinel";
|
|
273
|
+
|
|
274
|
+
const hierarchy = new RoleHierarchy<MySchema>()
|
|
275
|
+
.define("owner", ["admin"])
|
|
276
|
+
.define("admin", ["manager"])
|
|
277
|
+
.define("manager", ["member"])
|
|
278
|
+
.define("member", ["viewer"]);
|
|
279
|
+
|
|
280
|
+
const engine = new AccessEngine<MySchema>({
|
|
281
|
+
schema: {} as MySchema,
|
|
282
|
+
roleHierarchy: hierarchy,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
engine.addRules(
|
|
286
|
+
allow().id("viewer-read").roles("viewer").actions("invoice:read").on("invoice").build(),
|
|
287
|
+
allow().id("member-create").roles("member").actions("invoice:create").on("invoice").build(),
|
|
288
|
+
allow().id("admin-approve").roles("admin").actions("invoice:approve").on("invoice").build(),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Admins can read (inherited from viewer), create (from member), AND approve (their own)
|
|
292
|
+
// Members can read (from viewer) and create, but NOT approve
|
|
293
|
+
// Viewers can only read
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Cycles are detected at definition time and throw immediately.
|
|
297
|
+
|
|
298
|
+
### Wildcard Action Patterns
|
|
299
|
+
|
|
300
|
+
Use `*` in action patterns to match groups of actions:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// Match all invoice actions
|
|
304
|
+
allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
|
|
305
|
+
|
|
306
|
+
// Match all read actions across resources
|
|
307
|
+
allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Wildcard patterns are pre-compiled to regexes at `addRule()` time for performance.
|
|
311
|
+
|
|
312
|
+
### JSON Policy Serialization
|
|
313
|
+
|
|
314
|
+
Store policies in a database, config file, or load them from an API:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import {
|
|
318
|
+
exportRulesToJson,
|
|
319
|
+
importRulesFromJson,
|
|
320
|
+
ConditionRegistry,
|
|
321
|
+
} from "@siremzam/sentinel";
|
|
322
|
+
|
|
323
|
+
// Export rules to JSON
|
|
324
|
+
const json = exportRulesToJson(engine.getRules());
|
|
325
|
+
|
|
326
|
+
// Import rules back (validates effect and id fields)
|
|
327
|
+
const rules = importRulesFromJson<MySchema>(json);
|
|
328
|
+
engine.addRules(...rules);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Conditions use a named registry since functions can't be serialized:
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const conditions = new ConditionRegistry<MySchema>();
|
|
335
|
+
conditions.register("isOwner", (ctx) => ctx.subject.id === ctx.resourceContext.ownerId);
|
|
336
|
+
conditions.register("isActive", (ctx) => ctx.resourceContext.status === "active");
|
|
337
|
+
|
|
338
|
+
const rules = importRulesFromJson<MySchema>(json, conditions);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Unknown condition names throw with a helpful error listing available conditions.
|
|
342
|
+
|
|
343
|
+
### Evaluation Cache
|
|
344
|
+
|
|
345
|
+
For hot paths where the same subject/action/resource is checked repeatedly:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
const engine = new AccessEngine<MySchema>({
|
|
349
|
+
schema: {} as MySchema,
|
|
350
|
+
cacheSize: 1000,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
engine.evaluate(user, "invoice:read", "invoice"); // evaluated
|
|
354
|
+
engine.evaluate(user, "invoice:read", "invoice"); // cache hit
|
|
355
|
+
|
|
356
|
+
engine.addRule(newRule); // cache cleared automatically
|
|
357
|
+
engine.clearCache(); // manual control
|
|
358
|
+
engine.cacheStats; // { size: 0, maxSize: 1000 }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Only unconditional rule evaluations are cached — conditional results are always re-evaluated because they depend on `resourceContext`.
|
|
362
|
+
|
|
363
|
+
### Server Mode
|
|
364
|
+
|
|
365
|
+
Run the engine as a standalone HTTP authorization microservice:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { createAuthServer, AccessEngine } from "@siremzam/sentinel";
|
|
369
|
+
|
|
370
|
+
const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
|
|
371
|
+
engine.addRules(/* ... */);
|
|
372
|
+
|
|
373
|
+
const server = createAuthServer({
|
|
374
|
+
engine,
|
|
375
|
+
port: 3100,
|
|
376
|
+
authenticate: (req) => {
|
|
377
|
+
return req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY;
|
|
378
|
+
},
|
|
379
|
+
maxBodyBytes: 1024 * 1024, // 1 MB (default)
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await server.start();
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Endpoints:**
|
|
386
|
+
|
|
387
|
+
| Endpoint | Method | Description |
|
|
388
|
+
|---|---|---|
|
|
389
|
+
| `/health` | GET | Health check with rules count and uptime |
|
|
390
|
+
| `/rules` | GET | List loaded rules (serialization-safe) |
|
|
391
|
+
| `/evaluate` | POST | Evaluate an authorization request |
|
|
392
|
+
|
|
393
|
+
Zero dependencies. Uses Node's built-in `http` module.
|
|
394
|
+
|
|
395
|
+
### Middleware
|
|
396
|
+
|
|
397
|
+
**Express:**
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import { guard } from "@siremzam/sentinel/middleware/express";
|
|
401
|
+
|
|
402
|
+
app.post(
|
|
403
|
+
"/invoices/:id/approve",
|
|
404
|
+
guard(engine, "invoice:approve", "invoice", {
|
|
405
|
+
getSubject: (req) => req.user,
|
|
406
|
+
getResourceContext: (req) => ({ id: req.params.id }),
|
|
407
|
+
getTenantId: (req) => req.headers["x-tenant-id"],
|
|
408
|
+
}),
|
|
409
|
+
handler,
|
|
410
|
+
);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Fastify:**
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
|
|
417
|
+
|
|
418
|
+
fastify.post("/invoices/:id/approve", {
|
|
419
|
+
preHandler: fastifyGuard(engine, "invoice:approve", "invoice", {
|
|
420
|
+
getSubject: (req) => req.user,
|
|
421
|
+
getResourceContext: (req) => ({ id: req.params.id }),
|
|
422
|
+
getTenantId: (req) => req.headers["x-tenant-id"],
|
|
423
|
+
}),
|
|
424
|
+
}, handler);
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**NestJS:**
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import {
|
|
431
|
+
createAuthorizeDecorator,
|
|
432
|
+
createAuthGuard,
|
|
433
|
+
} from "@siremzam/sentinel/middleware/nestjs";
|
|
434
|
+
|
|
435
|
+
const Authorize = createAuthorizeDecorator<MySchema>();
|
|
436
|
+
|
|
437
|
+
const AuthGuard = createAuthGuard<MySchema>({
|
|
438
|
+
engine,
|
|
439
|
+
getSubject: (req) => req.user as Subject<MySchema>,
|
|
440
|
+
getTenantId: (req) => req.headers["x-tenant-id"] as string,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
@Controller("invoices")
|
|
444
|
+
class InvoiceController {
|
|
445
|
+
@Post(":id/approve")
|
|
446
|
+
@Authorize("invoice:approve", "invoice")
|
|
447
|
+
approve(@Param("id") id: string) {
|
|
448
|
+
return { approved: true };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
app.useGlobalGuards(new AuthGuard());
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
No dependency on `@nestjs/common` or `reflect-metadata`. Uses a WeakMap for metadata storage.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Security
|
|
460
|
+
|
|
461
|
+
### Design Principles
|
|
462
|
+
|
|
463
|
+
- **Deny by default.** If no rule matches, the answer is no.
|
|
464
|
+
- **Fail closed.** If a condition throws, it evaluates to `false`. No silent privilege escalation.
|
|
465
|
+
- **Frozen rules.** Rules are `Object.freeze`'d on add. Mutation after insertion is impossible.
|
|
466
|
+
- **Cache safety.** Only unconditional rule evaluations are cached. Conditional results (which depend on `resourceContext`) are never cached, preventing stale cache entries from granting access.
|
|
467
|
+
- **Strict tenancy.** Optional mode that throws if `tenantId` is omitted for subjects with tenant-scoped roles, preventing accidental cross-tenant privilege escalation.
|
|
468
|
+
- **Import validation.** `importRulesFromJson()` validates the `effect` field and rejects invalid or missing values.
|
|
469
|
+
- **Server hardening.** `createAuthServer` supports an `authenticate` callback (rejects with 401 on failure) and configurable `maxBodyBytes` (default 1 MB) to prevent DoS via oversized request bodies.
|
|
470
|
+
|
|
471
|
+
### Reporting Vulnerabilities
|
|
472
|
+
|
|
473
|
+
See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## API Reference
|
|
478
|
+
|
|
479
|
+
### AccessEngine\<S\>
|
|
480
|
+
|
|
481
|
+
| Method | Description |
|
|
482
|
+
|---|---|
|
|
483
|
+
| `addRule(rule)` | Add a single policy rule (frozen on add) |
|
|
484
|
+
| `addRules(...rules)` | Add multiple rules |
|
|
485
|
+
| `removeRule(id)` | Remove a rule by ID |
|
|
486
|
+
| `getRules()` | Get all rules (frozen, readonly) |
|
|
487
|
+
| `clearRules()` | Remove all rules |
|
|
488
|
+
| `evaluate(subject, action, resource, ctx?, tenantId?)` | Synchronous evaluation |
|
|
489
|
+
| `evaluateAsync(...)` | Async evaluation (for async conditions) |
|
|
490
|
+
| `permitted(subject, resource, actions, ctx?, tenantId?)` | Which actions are allowed? Returns `Set` |
|
|
491
|
+
| `permittedAsync(...)` | Async version of `permitted()` |
|
|
492
|
+
| `explain(subject, action, resource, ctx?, tenantId?)` | Full evaluation trace |
|
|
493
|
+
| `explainAsync(...)` | Async version of `explain()` |
|
|
494
|
+
| `can(subject)` | Start fluent check chain |
|
|
495
|
+
| `onDecision(listener)` | Subscribe to decisions, returns unsubscribe fn |
|
|
496
|
+
| `allow()` / `deny()` | Shorthand rule builders |
|
|
497
|
+
| `clearCache()` | Clear the evaluation cache |
|
|
498
|
+
| `cacheStats` | `{ size, maxSize }` or `null` if caching disabled |
|
|
499
|
+
|
|
500
|
+
### AccessEngineOptions\<S\>
|
|
501
|
+
|
|
502
|
+
| Option | Description |
|
|
503
|
+
|---|---|
|
|
504
|
+
| `schema` | Your schema type (used for type inference) |
|
|
505
|
+
| `defaultEffect` | `"deny"` (default) or `"allow"` |
|
|
506
|
+
| `onDecision` | Listener called on every evaluation |
|
|
507
|
+
| `onConditionError` | Called when a condition throws (fail-closed) |
|
|
508
|
+
| `asyncConditions` | Enable async condition support |
|
|
509
|
+
| `strictTenancy` | Throw if tenantId is omitted for tenant-scoped subjects |
|
|
510
|
+
| `roleHierarchy` | A `RoleHierarchy` instance |
|
|
511
|
+
| `cacheSize` | LRU cache capacity (0 = disabled) |
|
|
512
|
+
|
|
513
|
+
### RuleBuilder\<S\>
|
|
514
|
+
|
|
515
|
+
| Method | Description |
|
|
516
|
+
|---|---|
|
|
517
|
+
| `.id(id)` | Set rule ID |
|
|
518
|
+
| `.roles(...roles)` | Restrict to specific roles |
|
|
519
|
+
| `.anyRole()` | Match any role |
|
|
520
|
+
| `.actions(...actions)` | Restrict to specific actions (supports `*` wildcards) |
|
|
521
|
+
| `.anyAction()` | Match any action |
|
|
522
|
+
| `.on(...resources)` | Restrict to specific resources |
|
|
523
|
+
| `.anyResource()` | Match any resource |
|
|
524
|
+
| `.when(condition)` | Add a condition (stackable) |
|
|
525
|
+
| `.priority(n)` | Set priority (higher wins) |
|
|
526
|
+
| `.describe(text)` | Human-readable description |
|
|
527
|
+
| `.build()` | Produce the `PolicyRule` object |
|
|
528
|
+
|
|
529
|
+
### RoleHierarchy\<S\>
|
|
530
|
+
|
|
531
|
+
| Method | Description |
|
|
532
|
+
|---|---|
|
|
533
|
+
| `.define(role, inheritsFrom)` | Define inheritance (detects cycles) |
|
|
534
|
+
| `.resolve(role)` | Get full set of roles including inherited |
|
|
535
|
+
| `.resolveAll(roles)` | Resolve multiple roles merged |
|
|
536
|
+
| `.definedRoles()` | List roles with inheritance rules |
|
|
537
|
+
|
|
538
|
+
### ConditionRegistry\<S\>
|
|
539
|
+
|
|
540
|
+
| Method | Description |
|
|
541
|
+
|---|---|
|
|
542
|
+
| `.register(name, fn)` | Register a named condition |
|
|
543
|
+
| `.get(name)` | Look up a condition |
|
|
544
|
+
| `.has(name)` | Check if registered |
|
|
545
|
+
| `.names()` | List all registered names |
|
|
546
|
+
|
|
547
|
+
### Decision\<S\>
|
|
548
|
+
|
|
549
|
+
Every evaluation returns a `Decision` containing:
|
|
550
|
+
|
|
551
|
+
- `allowed` — boolean result
|
|
552
|
+
- `effect` — `"allow"`, `"deny"`, or `"default-deny"`
|
|
553
|
+
- `matchedRule` — the rule that determined the outcome (or null)
|
|
554
|
+
- `reason` — human-readable explanation
|
|
555
|
+
- `durationMs` — evaluation time
|
|
556
|
+
- `timestamp` — when the decision was made
|
|
557
|
+
- Full request context (subject, action, resource, tenantId)
|
|
558
|
+
|
|
559
|
+
### AuditEntry
|
|
560
|
+
|
|
561
|
+
Serialization-safe version of `Decision` via `toAuditEntry()`:
|
|
562
|
+
|
|
563
|
+
- `allowed`, `effect`, `reason`, `durationMs`, `timestamp`
|
|
564
|
+
- `matchedRuleId`, `matchedRuleDescription`
|
|
565
|
+
- `subjectId`, `action`, `resource`, `tenantId`
|
|
566
|
+
|
|
567
|
+
### ExplainResult\<S\>
|
|
568
|
+
|
|
569
|
+
Returned by `engine.explain()`:
|
|
570
|
+
|
|
571
|
+
- `allowed`, `effect`, `reason`, `durationMs`
|
|
572
|
+
- `evaluatedRules` — array of `RuleEvaluation<S>` with per-rule and per-condition details
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Key Concepts
|
|
577
|
+
|
|
578
|
+
### Domain Actions, Not CRUD
|
|
579
|
+
|
|
580
|
+
Actions use `resource:verb` format: `invoice:approve`, `order:ship`, `user:impersonate`. Your domain language, not generic CRUD.
|
|
581
|
+
|
|
582
|
+
### Conditions (ABAC)
|
|
583
|
+
|
|
584
|
+
Attach predicates to any rule. All conditions on a rule must pass for it to match:
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
allow()
|
|
588
|
+
.roles("member")
|
|
589
|
+
.actions("invoice:update")
|
|
590
|
+
.on("invoice")
|
|
591
|
+
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
|
|
592
|
+
.when(ctx => ctx.resourceContext.status !== "finalized")
|
|
593
|
+
.build();
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Async Conditions
|
|
597
|
+
|
|
598
|
+
For conditions that need database lookups or API calls:
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
const engine = new AccessEngine<MySchema>({
|
|
602
|
+
schema: {} as MySchema,
|
|
603
|
+
asyncConditions: true,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
engine.addRule(
|
|
607
|
+
allow()
|
|
608
|
+
.roles("member")
|
|
609
|
+
.actions("report:export")
|
|
610
|
+
.on("report")
|
|
611
|
+
.when(async (ctx) => {
|
|
612
|
+
const quota = await db.getExportQuota(ctx.subject.id);
|
|
613
|
+
return quota.remaining > 0;
|
|
614
|
+
})
|
|
615
|
+
.build(),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const decision = await engine.evaluateAsync(user, "report:export", "report");
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### Priority & Deny Resolution
|
|
622
|
+
|
|
623
|
+
- Higher `priority` wins (default: 0)
|
|
624
|
+
- At equal priority, `deny` wins over `allow`
|
|
625
|
+
- This lets you create broad deny rules with targeted allow overrides
|
|
626
|
+
|
|
627
|
+
### Multitenancy
|
|
628
|
+
|
|
629
|
+
Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles assigned to that tenant (or globally, with no tenantId) are considered:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
const user: Subject<MySchema> = {
|
|
633
|
+
id: "user-1",
|
|
634
|
+
roles: [
|
|
635
|
+
{ role: "admin", tenantId: "acme-corp" },
|
|
636
|
+
{ role: "viewer", tenantId: "globex" },
|
|
637
|
+
{ role: "member" }, // global — applies in any tenant
|
|
638
|
+
],
|
|
639
|
+
};
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## Philosophy
|
|
645
|
+
|
|
646
|
+
1. **Policies belong in one place.** Not scattered across middleware, handlers, and services.
|
|
647
|
+
2. **Authorization is not authentication.** This library does not care how you identify users. It cares what they're allowed to do.
|
|
648
|
+
3. **Types are documentation.** If your IDE can't autocomplete it, the API is wrong.
|
|
649
|
+
4. **Every decision is observable.** If you can't audit it, you can't trust it.
|
|
650
|
+
5. **Deny by default.** If no rule matches, the answer is no.
|
|
651
|
+
6. **Fail closed.** If a condition throws, the answer is no.
|
|
652
|
+
7. **Zero dependencies.** The core engine, server, and middleware use nothing outside Node.js built-ins.
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## Contributing
|
|
657
|
+
|
|
658
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
659
|
+
|
|
660
|
+
## License
|
|
661
|
+
|
|
662
|
+
[MIT](./LICENSE)
|