@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +662 -0
  3. package/dist/engine.d.ts +70 -0
  4. package/dist/engine.d.ts.map +1 -0
  5. package/dist/engine.js +562 -0
  6. package/dist/engine.js.map +1 -0
  7. package/dist/index.d.ts +11 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +7 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/express.d.ts +35 -0
  12. package/dist/middleware/express.d.ts.map +1 -0
  13. package/dist/middleware/express.js +41 -0
  14. package/dist/middleware/express.js.map +1 -0
  15. package/dist/middleware/fastify.d.ts +29 -0
  16. package/dist/middleware/fastify.d.ts.map +1 -0
  17. package/dist/middleware/fastify.js +41 -0
  18. package/dist/middleware/fastify.js.map +1 -0
  19. package/dist/middleware/nestjs.d.ts +67 -0
  20. package/dist/middleware/nestjs.d.ts.map +1 -0
  21. package/dist/middleware/nestjs.js +82 -0
  22. package/dist/middleware/nestjs.js.map +1 -0
  23. package/dist/policy-builder.d.ts +39 -0
  24. package/dist/policy-builder.d.ts.map +1 -0
  25. package/dist/policy-builder.js +92 -0
  26. package/dist/policy-builder.js.map +1 -0
  27. package/dist/role-hierarchy.d.ts +42 -0
  28. package/dist/role-hierarchy.d.ts.map +1 -0
  29. package/dist/role-hierarchy.js +87 -0
  30. package/dist/role-hierarchy.js.map +1 -0
  31. package/dist/serialization.d.ts +52 -0
  32. package/dist/serialization.d.ts.map +1 -0
  33. package/dist/serialization.js +144 -0
  34. package/dist/serialization.js.map +1 -0
  35. package/dist/server.d.ts +52 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +163 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/types.d.ts +137 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +27 -0
  42. package/dist/types.js.map +1 -0
  43. 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)