@siremzam/sentinel 0.3.1 → 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.
Files changed (59) hide show
  1. package/README.md +573 -177
  2. package/dist/engine-C6IASR5F.d.cts +283 -0
  3. package/dist/engine-C6IASR5F.d.ts +283 -0
  4. package/dist/index.cjs +877 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +58 -0
  7. package/dist/index.d.ts +58 -10
  8. package/dist/index.js +838 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/middleware/express.cjs +58 -0
  11. package/dist/middleware/express.cjs.map +1 -0
  12. package/dist/middleware/express.d.cts +35 -0
  13. package/dist/middleware/express.d.ts +6 -6
  14. package/dist/middleware/express.js +31 -39
  15. package/dist/middleware/express.js.map +1 -1
  16. package/dist/middleware/fastify.cjs +59 -0
  17. package/dist/middleware/fastify.cjs.map +1 -0
  18. package/dist/middleware/fastify.d.cts +29 -0
  19. package/dist/middleware/fastify.d.ts +6 -6
  20. package/dist/middleware/fastify.js +32 -39
  21. package/dist/middleware/fastify.js.map +1 -1
  22. package/dist/middleware/nestjs.cjs +84 -0
  23. package/dist/middleware/nestjs.cjs.map +1 -0
  24. package/dist/middleware/nestjs.d.cts +67 -0
  25. package/dist/middleware/nestjs.d.ts +9 -9
  26. package/dist/middleware/nestjs.js +51 -76
  27. package/dist/middleware/nestjs.js.map +1 -1
  28. package/dist/server.cjs +184 -0
  29. package/dist/server.cjs.map +1 -0
  30. package/dist/server.d.cts +54 -0
  31. package/dist/server.d.ts +10 -8
  32. package/dist/server.js +149 -153
  33. package/dist/server.js.map +1 -1
  34. package/package.json +22 -9
  35. package/dist/engine.d.ts +0 -70
  36. package/dist/engine.d.ts.map +0 -1
  37. package/dist/engine.js +0 -562
  38. package/dist/engine.js.map +0 -1
  39. package/dist/index.d.ts.map +0 -1
  40. package/dist/middleware/express.d.ts.map +0 -1
  41. package/dist/middleware/fastify.d.ts.map +0 -1
  42. package/dist/middleware/nestjs.d.ts.map +0 -1
  43. package/dist/policy-builder.d.ts +0 -39
  44. package/dist/policy-builder.d.ts.map +0 -1
  45. package/dist/policy-builder.js +0 -92
  46. package/dist/policy-builder.js.map +0 -1
  47. package/dist/role-hierarchy.d.ts +0 -42
  48. package/dist/role-hierarchy.d.ts.map +0 -1
  49. package/dist/role-hierarchy.js +0 -87
  50. package/dist/role-hierarchy.js.map +0 -1
  51. package/dist/serialization.d.ts +0 -52
  52. package/dist/serialization.d.ts.map +0 -1
  53. package/dist/serialization.js +0 -144
  54. package/dist/serialization.js.map +0 -1
  55. package/dist/server.d.ts.map +0 -1
  56. package/dist/types.d.ts +0 -137
  57. package/dist/types.d.ts.map +0 -1
  58. package/dist/types.js +0 -27
  59. package/dist/types.js.map +0 -1
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @siremzam/sentinel
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@siremzam/sentinel)](https://www.npmjs.com/package/@siremzam/sentinel)
4
+ [![CI](https://github.com/vegtelenseg/sentinel/actions/workflows/ci.yml/badge.svg)](https://github.com/vegtelenseg/sentinel/actions/workflows/ci.yml)
5
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/@siremzam/sentinel)
6
+ [![license](https://img.shields.io/npm/l/@siremzam/sentinel)](./LICENSE)
7
+
3
8
  **TypeScript-first, domain-driven authorization engine for modern SaaS apps.**
4
9
 
5
10
  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`.
@@ -14,6 +19,64 @@ This library was built from a different starting point:
14
19
 
15
20
  **Zero runtime dependencies. ~1,800 lines. 1:1 test-to-code ratio.**
16
21
 
22
+ > ### Try it live
23
+ >
24
+ > **[Open the interactive playground →](https://vegtelenseg.github.io/sentinel-example/)**
25
+ >
26
+ > Policy editor, multi-tenant evaluation, explain traces, and audit log — all in the browser.
27
+ > ([source](https://github.com/vegtelenseg/sentinel-example))
28
+
29
+ ---
30
+
31
+ ### What's New in 0.3.2
32
+
33
+ - README rewrite: evaluation walkthrough, concepts glossary, patterns & recipes, migration guide, benchmark data
34
+ - Standalone example (`examples/standalone/`) — no HTTP server needed
35
+ - "When NOT to Use This" and "Testing Your Policies" sections
36
+
37
+ See the full [CHANGELOG](./CHANGELOG.md).
38
+
39
+ ---
40
+
41
+ ## Table of Contents
42
+
43
+ - [How It Compares](#how-it-compares)
44
+ - [Install](#install)
45
+ - [Quick Start](#quick-start)
46
+ - [How Evaluation Works](#how-evaluation-works)
47
+ - [Concepts](#concepts)
48
+ - [Core Features](#core-features)
49
+ - [Policy Factory](#policy-factory)
50
+ - [Conditions (ABAC)](#conditions-abac)
51
+ - [Async Conditions](#async-conditions)
52
+ - [Wildcard Action Patterns](#wildcard-action-patterns)
53
+ - [Role Hierarchy](#role-hierarchy)
54
+ - [Priority and Deny Resolution](#priority-and-deny-resolution)
55
+ - [Multitenancy](#multitenancy)
56
+ - [Strict Tenancy](#strict-tenancy)
57
+ - [Condition Error Handling](#condition-error-handling)
58
+ - [Observability](#observability)
59
+ - [Decision Events](#decision-events)
60
+ - [explain() — Debug Authorization](#explain--debug-authorization)
61
+ - [toAuditEntry()](#toauditentry)
62
+ - [permitted() — UI Rendering](#permitted--ui-rendering)
63
+ - [Integration](#integration)
64
+ - [Middleware (Express, Fastify, NestJS)](#middleware)
65
+ - [Server Mode](#server-mode)
66
+ - [JSON Policy Serialization](#json-policy-serialization)
67
+ - [Performance](#performance)
68
+ - [Evaluation Cache](#evaluation-cache)
69
+ - [Benchmarks](#benchmarks)
70
+ - [Patterns and Recipes](#patterns-and-recipes)
71
+ - [Testing Your Policies](#testing-your-policies)
72
+ - [Migration Guide](#migration-guide)
73
+ - [When NOT to Use This](#when-not-to-use-this)
74
+ - [Security](#security)
75
+ - [API Reference](#api-reference)
76
+ - [Philosophy](#philosophy)
77
+ - [Contributing](#contributing)
78
+ - [License](#license)
79
+
17
80
  ---
18
81
 
19
82
  ## How It Compares
@@ -77,7 +140,11 @@ const { allow, deny } = createPolicyFactory<MySchema>();
77
140
  const engine = new AccessEngine<MySchema>({
78
141
  schema: {} as MySchema,
79
142
  });
143
+ ```
144
+
145
+ > **Why `{} as MySchema`?** The `schema` field exists purely for TypeScript inference — it carries your type information through the engine at compile time. The runtime value is never read. Think of it as a type witness, not data.
80
146
 
147
+ ```typescript
81
148
  engine.addRules(
82
149
  allow()
83
150
  .id("admin-full-access")
@@ -164,13 +231,72 @@ const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
164
231
  unsubscribe(); // when done
165
232
  ```
166
233
 
234
+ > **[Open the playground →](https://vegtelenseg.github.io/sentinel-example/)** to see all of this running interactively.
235
+
167
236
  ---
168
237
 
169
- ## Features
238
+ ## How Evaluation Works
170
239
 
171
- ### createPolicyFactory
240
+ When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`, the engine runs this algorithm:
172
241
 
173
- Eliminates the `<MySchema>` generic parameter on every rule:
242
+ ```
243
+ 1. Resolve the subject's roles
244
+ └─ Filter role assignments by tenantId (if provided)
245
+ └─ Expand via role hierarchy (if configured)
246
+
247
+ 2. Find candidate rules
248
+ └─ For each rule: does role match? action match? resource match?
249
+ └─ Wildcard patterns ("invoice:*") are pre-compiled to regex at addRule() time
250
+
251
+ 3. Sort candidates
252
+ └─ Higher priority first
253
+ └─ At equal priority, deny rules sort before allow rules
254
+
255
+ 4. Evaluate candidates in order (first match wins)
256
+ └─ No conditions? → rule matches immediately
257
+ └─ Has conditions? → all conditions must return true
258
+ └─ Condition throws? → treated as false (fail-closed)
259
+
260
+ 5. Return decision
261
+ └─ Matched rule found → use its effect (allow or deny)
262
+ └─ No match → default deny
263
+ ```
264
+
265
+ Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context. Decisions with only unconditional rule matches are eligible for LRU caching.
266
+
267
+ ---
268
+
269
+ ## Concepts
270
+
271
+ If you're new to authorization systems, here's a quick glossary:
272
+
273
+ <details>
274
+ <summary><strong>Expand glossary</strong></summary>
275
+
276
+ | Term | Meaning |
277
+ |---|---|
278
+ | **RBAC** | Role-Based Access Control. Permissions are assigned to roles, users are assigned roles. |
279
+ | **ABAC** | Attribute-Based Access Control. Permissions depend on attributes of the subject, resource, or environment — expressed as conditions. |
280
+ | **Subject** | The entity requesting access — typically a user. Has an `id` and an array of `roles`. |
281
+ | **Resource** | The thing being accessed — `"invoice"`, `"project"`, `"user"`. |
282
+ | **Action** | What the subject wants to do to the resource — `"invoice:approve"`, `"project:archive"`. Uses `resource:verb` format. |
283
+ | **Policy Rule** | A single authorization rule: "allow managers to perform invoice:approve on invoice." Has an effect (`allow` or `deny`), and optionally conditions, priority, and a description. |
284
+ | **Condition** | A function attached to a rule that receives the evaluation context and returns `true` or `false`. Used for ABAC — e.g., "only if the user owns the resource." |
285
+ | **Tenant** | An organizational unit in a multi-tenant system (e.g., a company). Users can have different roles in different tenants. |
286
+ | **Decision** | The result of evaluating a request — contains `allowed`, the matched rule, timing, and a human-readable reason. |
287
+ | **Effect** | Either `"allow"` or `"deny"`. Determines what happens when a rule matches. |
288
+ | **Priority** | A number (default 0) that determines rule evaluation order. Higher priority rules are checked first. |
289
+ | **Role Hierarchy** | A definition that one role inherits all permissions of another — e.g., `admin` inherits from `manager`. |
290
+
291
+ </details>
292
+
293
+ ---
294
+
295
+ ## Core Features
296
+
297
+ ### Policy Factory
298
+
299
+ `createPolicyFactory` eliminates the `<MySchema>` generic parameter on every rule:
174
300
 
175
301
  ```typescript
176
302
  import { createPolicyFactory } from "@siremzam/sentinel";
@@ -181,6 +307,129 @@ allow().roles("admin").anyAction().anyResource().build();
181
307
  deny().roles("viewer").actions("report:export").on("report").build();
182
308
  ```
183
309
 
310
+ ### Conditions (ABAC)
311
+
312
+ Attach predicates to any rule. All conditions on a rule must pass for it to match:
313
+
314
+ ```typescript
315
+ allow()
316
+ .roles("member")
317
+ .actions("invoice:update")
318
+ .on("invoice")
319
+ .when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
320
+ .when(ctx => ctx.resourceContext.status !== "finalized")
321
+ .build();
322
+ ```
323
+
324
+ Conditions receive the full `EvaluationContext` — subject, action, resource, resourceContext, and tenantId. Stack multiple `.when()` calls; they are AND'd together.
325
+
326
+ ### Async Conditions
327
+
328
+ For conditions that need database lookups or API calls:
329
+
330
+ ```typescript
331
+ const engine = new AccessEngine<MySchema>({
332
+ schema: {} as MySchema,
333
+ asyncConditions: true,
334
+ });
335
+
336
+ engine.addRule(
337
+ allow()
338
+ .roles("member")
339
+ .actions("report:export")
340
+ .on("report")
341
+ .when(async (ctx) => {
342
+ const quota = await db.getExportQuota(ctx.subject.id);
343
+ return quota.remaining > 0;
344
+ })
345
+ .build(),
346
+ );
347
+
348
+ const decision = await engine.evaluateAsync(user, "report:export", "report");
349
+ ```
350
+
351
+ When `asyncConditions` is enabled, use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` instead of their synchronous counterparts.
352
+
353
+ ### Wildcard Action Patterns
354
+
355
+ Use `*` in action patterns to match groups of actions:
356
+
357
+ ```typescript
358
+ // Match all invoice actions
359
+ allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
360
+
361
+ // Match all read actions across resources
362
+ allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();
363
+ ```
364
+
365
+ Wildcard patterns are pre-compiled to regexes at `addRule()` time — no per-evaluation regex cost.
366
+
367
+ ### Role Hierarchy
368
+
369
+ Define that higher roles inherit all permissions of lower roles:
370
+
371
+ ```typescript
372
+ import { RoleHierarchy } from "@siremzam/sentinel";
373
+
374
+ const hierarchy = new RoleHierarchy<MySchema>()
375
+ .define("owner", ["admin"])
376
+ .define("admin", ["manager"])
377
+ .define("manager", ["member"])
378
+ .define("member", ["viewer"]);
379
+
380
+ const engine = new AccessEngine<MySchema>({
381
+ schema: {} as MySchema,
382
+ roleHierarchy: hierarchy,
383
+ });
384
+
385
+ engine.addRules(
386
+ allow().id("viewer-read").roles("viewer").actions("invoice:read").on("invoice").build(),
387
+ allow().id("member-create").roles("member").actions("invoice:create").on("invoice").build(),
388
+ allow().id("admin-approve").roles("admin").actions("invoice:approve").on("invoice").build(),
389
+ );
390
+
391
+ // Admins can read (inherited from viewer), create (from member), AND approve (their own)
392
+ // Members can read (from viewer) and create, but NOT approve
393
+ // Viewers can only read
394
+ ```
395
+
396
+ Cycles are detected at definition time and throw immediately.
397
+
398
+ ### Priority and Deny Resolution
399
+
400
+ - Higher `priority` wins (default: 0)
401
+ - At equal priority, `deny` wins over `allow`
402
+ - This lets you create broad deny rules with targeted allow overrides
403
+
404
+ ```typescript
405
+ // Deny impersonation for everyone at priority 0
406
+ deny().anyRole().actions("user:impersonate").on("user").build();
407
+
408
+ // Allow it for owners at priority 10 — this wins
409
+ allow().roles("owner").actions("user:impersonate").on("user").priority(10).build();
410
+ ```
411
+
412
+ ### Multitenancy
413
+
414
+ Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles assigned to that tenant (or globally, with no tenantId) are considered:
415
+
416
+ ```typescript
417
+ const user: Subject<MySchema> = {
418
+ id: "user-1",
419
+ roles: [
420
+ { role: "admin", tenantId: "acme-corp" },
421
+ { role: "viewer", tenantId: "globex" },
422
+ { role: "member" }, // global — applies in any tenant
423
+ ],
424
+ };
425
+
426
+ // In acme-corp context: user has admin + member roles
427
+ engine.evaluate(user, "invoice:approve", "invoice", {}, "acme-corp");
428
+
429
+ // In globex context: user has viewer + member roles
430
+ engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
431
+ ```
432
+
184
433
  ### Strict Tenancy
185
434
 
186
435
  Prevents accidental cross-tenant access by requiring explicit `tenantId` when the subject has tenant-scoped roles:
@@ -211,22 +460,29 @@ const engine = new AccessEngine<MySchema>({
211
460
  });
212
461
  ```
213
462
 
214
- ### permitted() — UI Rendering
463
+ ---
215
464
 
216
- Ask "what can this user do?" to drive button visibility and menu items:
465
+ ## Observability
466
+
467
+ ### Decision Events
468
+
469
+ Every evaluation emits a structured `Decision` event. Subscribe at construction or at runtime:
217
470
 
218
471
  ```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
- ```
472
+ import { toAuditEntry } from "@siremzam/sentinel";
228
473
 
229
- For async conditions, use `engine.permittedAsync()`.
474
+ const engine = new AccessEngine<MySchema>({
475
+ schema: {} as MySchema,
476
+ onDecision: (decision) => {
477
+ const entry = toAuditEntry(decision);
478
+ auditLog.write(entry);
479
+ },
480
+ });
481
+
482
+ // Or at runtime
483
+ const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
484
+ unsubscribe(); // when done
485
+ ```
230
486
 
231
487
  ### explain() — Debug Authorization
232
488
 
@@ -264,50 +520,121 @@ const entry = toAuditEntry(decision);
264
520
  // Safe to JSON.stringify — no functions, no circular references
265
521
  ```
266
522
 
267
- ### Role Hierarchy
523
+ ### permitted() — UI Rendering
268
524
 
269
- Define that higher roles inherit all permissions of lower roles:
525
+ Ask "what can this user do?" to drive button visibility and menu items:
270
526
 
271
527
  ```typescript
272
- import { RoleHierarchy } from "@siremzam/sentinel";
528
+ const actions = engine.permitted(
529
+ user,
530
+ "invoice",
531
+ ["invoice:create", "invoice:read", "invoice:approve", "invoice:send"],
532
+ { ownerId: user.id },
533
+ "tenant-a",
534
+ );
535
+ // Set { "invoice:create", "invoice:read" }
536
+ ```
273
537
 
274
- const hierarchy = new RoleHierarchy<MySchema>()
275
- .define("owner", ["admin"])
276
- .define("admin", ["manager"])
277
- .define("manager", ["member"])
278
- .define("member", ["viewer"]);
538
+ For async conditions, use `engine.permittedAsync()`.
279
539
 
280
- const engine = new AccessEngine<MySchema>({
281
- schema: {} as MySchema,
282
- roleHierarchy: hierarchy,
283
- });
540
+ ---
284
541
 
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(),
542
+ ## Integration
543
+
544
+ ### Middleware
545
+
546
+ **Express:**
547
+
548
+ ```typescript
549
+ import { guard } from "@siremzam/sentinel/middleware/express";
550
+
551
+ app.post(
552
+ "/invoices/:id/approve",
553
+ guard(engine, "invoice:approve", "invoice", {
554
+ getSubject: (req) => req.user,
555
+ getResourceContext: (req) => ({ id: req.params.id }),
556
+ getTenantId: (req) => req.headers["x-tenant-id"],
557
+ }),
558
+ handler,
289
559
  );
560
+ ```
290
561
 
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
562
+ **Fastify:**
563
+
564
+ ```typescript
565
+ import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
566
+
567
+ fastify.post("/invoices/:id/approve", {
568
+ preHandler: fastifyGuard(engine, "invoice:approve", "invoice", {
569
+ getSubject: (req) => req.user,
570
+ getResourceContext: (req) => ({ id: req.params.id }),
571
+ getTenantId: (req) => req.headers["x-tenant-id"],
572
+ }),
573
+ }, handler);
294
574
  ```
295
575
 
296
- Cycles are detected at definition time and throw immediately.
576
+ **NestJS:**
297
577
 
298
- ### Wildcard Action Patterns
578
+ ```typescript
579
+ import {
580
+ createAuthorizeDecorator,
581
+ createAuthGuard,
582
+ } from "@siremzam/sentinel/middleware/nestjs";
299
583
 
300
- Use `*` in action patterns to match groups of actions:
584
+ const Authorize = createAuthorizeDecorator<MySchema>();
585
+
586
+ const AuthGuard = createAuthGuard<MySchema>({
587
+ engine,
588
+ getSubject: (req) => req.user as Subject<MySchema>,
589
+ getTenantId: (req) => req.headers["x-tenant-id"] as string,
590
+ });
591
+
592
+ @Controller("invoices")
593
+ class InvoiceController {
594
+ @Post(":id/approve")
595
+ @Authorize("invoice:approve", "invoice")
596
+ approve(@Param("id") id: string) {
597
+ return { approved: true };
598
+ }
599
+ }
600
+
601
+ app.useGlobalGuards(new AuthGuard());
602
+ ```
603
+
604
+ No dependency on `@nestjs/common` or `reflect-metadata`. Uses a WeakMap for metadata storage.
605
+
606
+ ### Server Mode
607
+
608
+ Run the engine as a standalone HTTP authorization microservice. This is useful when authorization logic needs to be shared across polyglot services (e.g., a Go API and a Python worker both calling the same policy engine), or when you want to decouple authorization decisions from your application servers entirely.
301
609
 
302
610
  ```typescript
303
- // Match all invoice actions
304
- allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
611
+ import { AccessEngine } from "@siremzam/sentinel";
612
+ import { createAuthServer } from "@siremzam/sentinel/server";
305
613
 
306
- // Match all read actions across resources
307
- allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();
614
+ const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
615
+ engine.addRules(/* ... */);
616
+
617
+ const server = createAuthServer({
618
+ engine,
619
+ port: 3100,
620
+ authenticate: (req) => {
621
+ return req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY;
622
+ },
623
+ maxBodyBytes: 1024 * 1024, // 1 MB (default)
624
+ });
625
+
626
+ await server.start();
308
627
  ```
309
628
 
310
- Wildcard patterns are pre-compiled to regexes at `addRule()` time for performance.
629
+ **Endpoints:**
630
+
631
+ | Endpoint | Method | Description |
632
+ |---|---|---|
633
+ | `/health` | GET | Health check with rules count and uptime |
634
+ | `/rules` | GET | List loaded rules (serialization-safe) |
635
+ | `/evaluate` | POST | Evaluate an authorization request |
636
+
637
+ Zero dependencies. Uses Node's built-in `http` module.
311
638
 
312
639
  ### JSON Policy Serialization
313
640
 
@@ -340,6 +667,10 @@ const rules = importRulesFromJson<MySchema>(json, conditions);
340
667
 
341
668
  Unknown condition names throw with a helpful error listing available conditions.
342
669
 
670
+ ---
671
+
672
+ ## Performance
673
+
343
674
  ### Evaluation Cache
344
675
 
345
676
  For hot paths where the same subject/action/resource is checked repeatedly:
@@ -360,100 +691,233 @@ engine.cacheStats; // { size: 0, maxSize: 1000 }
360
691
 
361
692
  Only unconditional rule evaluations are cached — conditional results are always re-evaluated because they depend on `resourceContext`.
362
693
 
363
- ### Server Mode
694
+ ### Benchmarks
364
695
 
365
- Run the engine as a standalone HTTP authorization microservice:
696
+ Measured on Node v18.18.0, Apple Silicon (ARM64). Run `npm run benchmark` to reproduce.
366
697
 
367
- ```typescript
368
- import { AccessEngine } from "@siremzam/sentinel";
369
- import { createAuthServer } from "@siremzam/sentinel/server";
698
+ | Scenario | 100 rules | 1,000 rules | 10,000 rules |
699
+ |---|---|---|---|
700
+ | `evaluate` (no cache) | 4.3 µs / 231k ops/s | 42.6 µs / 23k ops/s | 1,091 µs / 917 ops/s |
701
+ | `evaluate` (cache hit) | 0.6 µs / 1.66M ops/s | 1.8 µs / 553k ops/s | 29.1 µs / 34k ops/s |
702
+ | `evaluate` (all conditional) | 3.4 µs / 292k ops/s | 40.2 µs / 25k ops/s | 1,064 µs / 940 ops/s |
703
+ | `permitted` (18 actions) | 60.2 µs / 17k ops/s | 718 µs / 1.4k ops/s | 18,924 µs / 53 ops/s |
704
+ | `explain` (full trace) | 22.4 µs / 45k ops/s | 564 µs / 1.8k ops/s | 6,444 µs / 155 ops/s |
370
705
 
371
- const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
372
- engine.addRules(/* ... */);
706
+ Most SaaS apps have 10–50 rules. At 100 rules, a single evaluation takes **4.3 µs** — you can run 230,000 authorization checks per second on a single core. With caching enabled, that drops to **0.6 µs**.
373
707
 
374
- const server = createAuthServer({
375
- engine,
376
- port: 3100,
377
- authenticate: (req) => {
378
- return req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY;
379
- },
380
- maxBodyBytes: 1024 * 1024, // 1 MB (default)
381
- });
708
+ ---
382
709
 
383
- await server.start();
384
- ```
710
+ ## Patterns and Recipes
385
711
 
386
- **Endpoints:**
712
+ Real-world authorization scenarios and how to model them.
387
713
 
388
- | Endpoint | Method | Description |
389
- |---|---|---|
390
- | `/health` | GET | Health check with rules count and uptime |
391
- | `/rules` | GET | List loaded rules (serialization-safe) |
392
- | `/evaluate` | POST | Evaluate an authorization request |
714
+ ### Ownership "Users can only edit their own resources"
393
715
 
394
- Zero dependencies. Uses Node's built-in `http` module.
716
+ ```typescript
717
+ allow()
718
+ .id("edit-own-invoice")
719
+ .roles("member")
720
+ .actions("invoice:update")
721
+ .on("invoice")
722
+ .when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
723
+ .describe("Members can edit their own invoices")
724
+ .build();
725
+ ```
395
726
 
396
- ### Middleware
727
+ ### Time-Gated Access — "Trial expires after 14 days"
397
728
 
398
- **Express:**
729
+ ```typescript
730
+ allow()
731
+ .id("trial-access")
732
+ .roles("trial")
733
+ .actions("report:export")
734
+ .on("report")
735
+ .when(ctx => {
736
+ const createdAt = new Date(ctx.resourceContext.trialStartedAt as string);
737
+ const daysSince = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
738
+ return daysSince <= 14;
739
+ })
740
+ .describe("Trial users can export for 14 days")
741
+ .build();
742
+ ```
743
+
744
+ ### Feature Flags — "Beta feature for specific tenants"
399
745
 
400
746
  ```typescript
401
- import { guard } from "@siremzam/sentinel/middleware/express";
747
+ const BETA_TENANTS = new Set(["acme-corp", "initech"]);
402
748
 
403
- app.post(
404
- "/invoices/:id/approve",
405
- guard(engine, "invoice:approve", "invoice", {
406
- getSubject: (req) => req.user,
407
- getResourceContext: (req) => ({ id: req.params.id }),
408
- getTenantId: (req) => req.headers["x-tenant-id"],
409
- }),
410
- handler,
411
- );
749
+ allow()
750
+ .id("beta-analytics")
751
+ .anyRole()
752
+ .actions("analytics:view")
753
+ .on("analytics")
754
+ .when(ctx => BETA_TENANTS.has(ctx.tenantId ?? ""))
755
+ .describe("Analytics dashboard is in beta for select tenants")
756
+ .build();
412
757
  ```
413
758
 
414
- **Fastify:**
759
+ ### Async Conditions — "Check external quota service"
415
760
 
416
761
  ```typescript
417
- import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
762
+ allow()
763
+ .id("api-rate-limit")
764
+ .roles("member")
765
+ .actions("api:call")
766
+ .on("api")
767
+ .when(async ctx => {
768
+ const usage = await rateLimiter.check(ctx.subject.id);
769
+ return usage.remaining > 0;
770
+ })
771
+ .describe("Members can call API within rate limit")
772
+ .build();
773
+ ```
418
774
 
419
- fastify.post("/invoices/:id/approve", {
420
- preHandler: fastifyGuard(engine, "invoice:approve", "invoice", {
421
- getSubject: (req) => req.user,
422
- getResourceContext: (req) => ({ id: req.params.id }),
423
- getTenantId: (req) => req.headers["x-tenant-id"],
424
- }),
425
- }, handler);
775
+ ### Broad Deny with Targeted Override
776
+
777
+ ```typescript
778
+ // Deny all destructive actions at priority 0
779
+ deny()
780
+ .id("freeze-destructive")
781
+ .anyRole()
782
+ .actions("project:delete", "project:archive")
783
+ .on("project")
784
+ .describe("Destructive project actions are frozen")
785
+ .build();
786
+
787
+ // Allow owners to override at priority 10
788
+ allow()
789
+ .id("owner-override")
790
+ .roles("owner")
791
+ .actions("project:delete", "project:archive")
792
+ .on("project")
793
+ .priority(10)
794
+ .describe("Owners can still delete/archive their projects")
795
+ .build();
426
796
  ```
427
797
 
428
- **NestJS:**
798
+ ### IP-Based Restriction via Async Condition
429
799
 
430
800
  ```typescript
431
- import {
432
- createAuthorizeDecorator,
433
- createAuthGuard,
434
- } from "@siremzam/sentinel/middleware/nestjs";
801
+ allow()
802
+ .id("admin-from-office")
803
+ .roles("admin")
804
+ .actions("settings:update")
805
+ .on("settings")
806
+ .when(async ctx => {
807
+ const ip = ctx.resourceContext.clientIp as string;
808
+ const geo = await geoService.lookup(ip);
809
+ return geo.isOfficeNetwork;
810
+ })
811
+ .describe("Admin settings changes only from office network")
812
+ .build();
813
+ ```
435
814
 
436
- const Authorize = createAuthorizeDecorator<MySchema>();
815
+ ---
437
816
 
438
- const AuthGuard = createAuthGuard<MySchema>({
439
- engine,
440
- getSubject: (req) => req.user as Subject<MySchema>,
441
- getTenantId: (req) => req.headers["x-tenant-id"] as string,
442
- });
817
+ ## Testing Your Policies
443
818
 
444
- @Controller("invoices")
445
- class InvoiceController {
446
- @Post(":id/approve")
447
- @Authorize("invoice:approve", "invoice")
448
- approve(@Param("id") id: string) {
449
- return { approved: true };
450
- }
451
- }
819
+ Authorization policies are security-critical code — they should be tested like any other business logic. The `explain()` method is purpose-built for this:
452
820
 
453
- app.useGlobalGuards(new AuthGuard());
821
+ ```typescript
822
+ import { describe, it, expect } from "vitest";
823
+
824
+ describe("invoice policies", () => {
825
+ it("allows managers to approve invoices in their tenant", () => {
826
+ const result = engine.explain(manager, "invoice:approve", "invoice", {}, "acme");
827
+
828
+ expect(result.allowed).toBe(true);
829
+ expect(result.reason).toContain("manager-invoices");
830
+ });
831
+
832
+ it("denies viewers from approving invoices", () => {
833
+ const result = engine.explain(viewer, "invoice:approve", "invoice", {}, "acme");
834
+
835
+ expect(result.allowed).toBe(false);
836
+ expect(result.reason).toBe("No matching rule — default deny");
837
+ });
838
+
839
+ it("respects ownership conditions", () => {
840
+ const result = engine.explain(
841
+ member,
842
+ "invoice:read",
843
+ "invoice",
844
+ { ownerId: "someone-else" },
845
+ "acme",
846
+ );
847
+
848
+ // Find the ownership rule and verify the condition failed
849
+ const ownershipRule = result.evaluatedRules.find(
850
+ e => e.rule.id === "member-own-invoices",
851
+ );
852
+ expect(ownershipRule?.conditionResults[0]?.passed).toBe(false);
853
+ });
854
+
855
+ it("prevents cross-tenant access", () => {
856
+ // User is admin in acme, viewer in globex
857
+ const resultAcme = engine.evaluate(user, "invoice:approve", "invoice", {}, "acme");
858
+ const resultGlobex = engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
859
+
860
+ expect(resultAcme.allowed).toBe(true);
861
+ expect(resultGlobex.allowed).toBe(false);
862
+ });
863
+ });
454
864
  ```
455
865
 
456
- No dependency on `@nestjs/common` or `reflect-metadata`. Uses a WeakMap for metadata storage.
866
+ `explain()` returns per-rule evaluation details — which rules matched on role, action, and resource, and which conditions passed or failed. This makes your tests self-documenting: when a test fails, the explain trace tells you exactly *why* the decision changed.
867
+
868
+ ---
869
+
870
+ ## Migration Guide
871
+
872
+ ### Coming from CASL
873
+
874
+ | CASL | Sentinel |
875
+ |---|---|
876
+ | `defineAbility(can => { can('read', 'Article') })` | `allow().actions("article:read").on("article").build()` |
877
+ | `ability.can('read', 'Article')` | `engine.evaluate(user, "article:read", "article")` |
878
+ | `subject('Article', article)` | Actions use `resource:verb` format natively — no wrapper needed |
879
+ | `conditions: { authorId: user.id }` | `.when(ctx => ctx.subject.id === ctx.resourceContext.authorId)` |
880
+ | No multi-tenancy | Built-in: `{ role: "admin", tenantId: "acme" }` |
881
+ | No explain/debug | `engine.explain()` gives per-rule trace |
882
+
883
+ **Key difference:** CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions, which gives you full TypeScript expressiveness — async calls, date math, external lookups — with compile-time type safety on the context.
884
+
885
+ ### Coming from Casbin
886
+
887
+ | Casbin | Sentinel |
888
+ |---|---|
889
+ | Model file (`model.conf`) | Pure TypeScript schema interface |
890
+ | Policy file (`policy.csv`) | Fluent builder API or JSON import |
891
+ | `e.Enforce("alice", "data1", "read")` | `engine.evaluate(user, "data:read", "data")` |
892
+ | Custom matchers for ABAC | `.when()` conditions with full TypeScript |
893
+ | Role manager | `RoleHierarchy` with cycle detection |
894
+
895
+ **Key difference:** Casbin requires learning its own DSL for model definitions. Sentinel is pure TypeScript — your IDE autocompletes everything, and your policy definitions are type-checked at compile time.
896
+
897
+ ### Coming from accesscontrol
898
+
899
+ | accesscontrol | Sentinel |
900
+ |---|---|
901
+ | `ac.grant('admin').createAny('video')` | `allow().roles("admin").actions("video:create").on("video").build()` |
902
+ | CRUD only: create, read, update, delete | Domain verbs: `invoice:approve`, `order:ship` |
903
+ | `ac.can('admin').createAny('video')` | `engine.evaluate(user, "video:create", "video")` |
904
+ | No conditions/ABAC | Full ABAC with `.when()` conditions |
905
+ | No multi-tenancy | Built-in per-tenant role assignments |
906
+
907
+ **Key difference:** accesscontrol is locked into CRUD semantics. If your app has domain-specific actions (approve, archive, impersonate, ship), you'll fight the library. Sentinel treats domain verbs as first-class.
908
+
909
+ ---
910
+
911
+ ## When NOT to Use This
912
+
913
+ Being honest about boundaries:
914
+
915
+ - **You need a full policy language.** If you want Rego (OPA), Cedar (AWS), or a declarative DSL, this isn't that. Sentinel policies are TypeScript code, not a separate language.
916
+ - **You need a Zanzibar-style relationship graph.** If your authorization model is "who can access this Google Doc?" with deeply nested sharing relationships, use [SpiceDB](https://authzed.com/) or [OpenFGA](https://openfga.dev/).
917
+ - **You need a hosted authorization service.** If you want a managed SaaS solution rather than an embedded library, look at [Permit.io](https://permit.io/) or [Oso Cloud](https://www.osohq.com/).
918
+ - **Your model is truly just CRUD on REST resources.** If `create`, `read`, `update`, `delete` is all you need and you don't have tenants, simpler libraries like [accesscontrol](https://www.npmjs.com/package/accesscontrol) may be sufficient.
919
+
920
+ Sentinel is built for TypeScript-first SaaS applications with domain-specific actions, multi-tenant requirements, and a need for observable, testable authorization logic.
457
921
 
458
922
  ---
459
923
 
@@ -502,7 +966,7 @@ See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
502
966
 
503
967
  | Option | Description |
504
968
  |---|---|
505
- | `schema` | Your schema type (used for type inference) |
969
+ | `schema` | Your schema type (used for type inference, not read at runtime) |
506
970
  | `defaultEffect` | `"deny"` (default) or `"allow"` |
507
971
  | `onDecision` | Listener called on every evaluation |
508
972
  | `onConditionError` | Called when a condition throws (fail-closed) |
@@ -574,74 +1038,6 @@ Returned by `engine.explain()`:
574
1038
 
575
1039
  ---
576
1040
 
577
- ## Key Concepts
578
-
579
- ### Domain Actions, Not CRUD
580
-
581
- Actions use `resource:verb` format: `invoice:approve`, `order:ship`, `user:impersonate`. Your domain language, not generic CRUD.
582
-
583
- ### Conditions (ABAC)
584
-
585
- Attach predicates to any rule. All conditions on a rule must pass for it to match:
586
-
587
- ```typescript
588
- allow()
589
- .roles("member")
590
- .actions("invoice:update")
591
- .on("invoice")
592
- .when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
593
- .when(ctx => ctx.resourceContext.status !== "finalized")
594
- .build();
595
- ```
596
-
597
- ### Async Conditions
598
-
599
- For conditions that need database lookups or API calls:
600
-
601
- ```typescript
602
- const engine = new AccessEngine<MySchema>({
603
- schema: {} as MySchema,
604
- asyncConditions: true,
605
- });
606
-
607
- engine.addRule(
608
- allow()
609
- .roles("member")
610
- .actions("report:export")
611
- .on("report")
612
- .when(async (ctx) => {
613
- const quota = await db.getExportQuota(ctx.subject.id);
614
- return quota.remaining > 0;
615
- })
616
- .build(),
617
- );
618
-
619
- const decision = await engine.evaluateAsync(user, "report:export", "report");
620
- ```
621
-
622
- ### Priority & Deny Resolution
623
-
624
- - Higher `priority` wins (default: 0)
625
- - At equal priority, `deny` wins over `allow`
626
- - This lets you create broad deny rules with targeted allow overrides
627
-
628
- ### Multitenancy
629
-
630
- Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles assigned to that tenant (or globally, with no tenantId) are considered:
631
-
632
- ```typescript
633
- const user: Subject<MySchema> = {
634
- id: "user-1",
635
- roles: [
636
- { role: "admin", tenantId: "acme-corp" },
637
- { role: "viewer", tenantId: "globex" },
638
- { role: "member" }, // global — applies in any tenant
639
- ],
640
- };
641
- ```
642
-
643
- ---
644
-
645
1041
  ## Philosophy
646
1042
 
647
1043
  1. **Policies belong in one place.** Not scattered across middleware, handlers, and services.