@siremzam/sentinel 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +573 -176
- package/dist/engine-C6IASR5F.d.cts +283 -0
- package/dist/engine-C6IASR5F.d.ts +283 -0
- package/dist/index.cjs +877 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +58 -0
- package/dist/index.d.ts +58 -11
- package/dist/index.js +838 -6
- package/dist/index.js.map +1 -1
- package/dist/middleware/express.cjs +58 -0
- package/dist/middleware/express.cjs.map +1 -0
- package/dist/middleware/express.d.cts +35 -0
- package/dist/middleware/express.d.ts +6 -6
- package/dist/middleware/express.js +31 -39
- package/dist/middleware/express.js.map +1 -1
- package/dist/middleware/fastify.cjs +59 -0
- package/dist/middleware/fastify.cjs.map +1 -0
- package/dist/middleware/fastify.d.cts +29 -0
- package/dist/middleware/fastify.d.ts +6 -6
- package/dist/middleware/fastify.js +32 -39
- package/dist/middleware/fastify.js.map +1 -1
- package/dist/middleware/nestjs.cjs +84 -0
- package/dist/middleware/nestjs.cjs.map +1 -0
- package/dist/middleware/nestjs.d.cts +67 -0
- package/dist/middleware/nestjs.d.ts +9 -9
- package/dist/middleware/nestjs.js +51 -76
- package/dist/middleware/nestjs.js.map +1 -1
- package/dist/server.cjs +184 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +54 -0
- package/dist/server.d.ts +10 -8
- package/dist/server.js +149 -153
- package/dist/server.js.map +1 -1
- package/package.json +22 -9
- package/dist/engine.d.ts +0 -70
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -562
- package/dist/engine.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/middleware/express.d.ts.map +0 -1
- package/dist/middleware/fastify.d.ts.map +0 -1
- package/dist/middleware/nestjs.d.ts.map +0 -1
- package/dist/policy-builder.d.ts +0 -39
- package/dist/policy-builder.d.ts.map +0 -1
- package/dist/policy-builder.js +0 -92
- package/dist/policy-builder.js.map +0 -1
- package/dist/role-hierarchy.d.ts +0 -42
- package/dist/role-hierarchy.d.ts.map +0 -1
- package/dist/role-hierarchy.js +0 -87
- package/dist/role-hierarchy.js.map +0 -1
- package/dist/serialization.d.ts +0 -52
- package/dist/serialization.d.ts.map +0 -1
- package/dist/serialization.js +0 -144
- package/dist/serialization.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/types.d.ts +0 -137
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -27
- package/dist/types.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @siremzam/sentinel
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@siremzam/sentinel)
|
|
4
|
+
[](https://github.com/vegtelenseg/sentinel/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/@siremzam/sentinel)
|
|
6
|
+
[](./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
|
-
##
|
|
238
|
+
## How Evaluation Works
|
|
170
239
|
|
|
171
|
-
|
|
240
|
+
When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`, the engine runs this algorithm:
|
|
172
241
|
|
|
173
|
-
|
|
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
|
-
|
|
463
|
+
---
|
|
215
464
|
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
523
|
+
### permitted() — UI Rendering
|
|
268
524
|
|
|
269
|
-
|
|
525
|
+
Ask "what can this user do?" to drive button visibility and menu items:
|
|
270
526
|
|
|
271
527
|
```typescript
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
schema: {} as MySchema,
|
|
282
|
-
roleHierarchy: hierarchy,
|
|
283
|
-
});
|
|
540
|
+
---
|
|
284
541
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
576
|
+
**NestJS:**
|
|
297
577
|
|
|
298
|
-
|
|
578
|
+
```typescript
|
|
579
|
+
import {
|
|
580
|
+
createAuthorizeDecorator,
|
|
581
|
+
createAuthGuard,
|
|
582
|
+
} from "@siremzam/sentinel/middleware/nestjs";
|
|
299
583
|
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
611
|
+
import { AccessEngine } from "@siremzam/sentinel";
|
|
612
|
+
import { createAuthServer } from "@siremzam/sentinel/server";
|
|
305
613
|
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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,99 +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
|
-
###
|
|
694
|
+
### Benchmarks
|
|
364
695
|
|
|
365
|
-
|
|
696
|
+
Measured on Node v18.18.0, Apple Silicon (ARM64). Run `npm run benchmark` to reproduce.
|
|
366
697
|
|
|
367
|
-
|
|
368
|
-
|
|
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 |
|
|
369
705
|
|
|
370
|
-
|
|
371
|
-
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**.
|
|
372
707
|
|
|
373
|
-
|
|
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
|
-
});
|
|
708
|
+
---
|
|
381
709
|
|
|
382
|
-
|
|
383
|
-
```
|
|
710
|
+
## Patterns and Recipes
|
|
384
711
|
|
|
385
|
-
|
|
712
|
+
Real-world authorization scenarios and how to model them.
|
|
386
713
|
|
|
387
|
-
|
|
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 |
|
|
714
|
+
### Ownership — "Users can only edit their own resources"
|
|
392
715
|
|
|
393
|
-
|
|
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
|
+
```
|
|
394
726
|
|
|
395
|
-
###
|
|
727
|
+
### Time-Gated Access — "Trial expires after 14 days"
|
|
396
728
|
|
|
397
|
-
|
|
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"
|
|
398
745
|
|
|
399
746
|
```typescript
|
|
400
|
-
|
|
747
|
+
const BETA_TENANTS = new Set(["acme-corp", "initech"]);
|
|
401
748
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
);
|
|
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();
|
|
411
757
|
```
|
|
412
758
|
|
|
413
|
-
|
|
759
|
+
### Async Conditions — "Check external quota service"
|
|
414
760
|
|
|
415
761
|
```typescript
|
|
416
|
-
|
|
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
|
+
```
|
|
417
774
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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();
|
|
425
796
|
```
|
|
426
797
|
|
|
427
|
-
|
|
798
|
+
### IP-Based Restriction via Async Condition
|
|
428
799
|
|
|
429
800
|
```typescript
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
```
|
|
434
814
|
|
|
435
|
-
|
|
815
|
+
---
|
|
436
816
|
|
|
437
|
-
|
|
438
|
-
engine,
|
|
439
|
-
getSubject: (req) => req.user as Subject<MySchema>,
|
|
440
|
-
getTenantId: (req) => req.headers["x-tenant-id"] as string,
|
|
441
|
-
});
|
|
817
|
+
## Testing Your Policies
|
|
442
818
|
|
|
443
|
-
|
|
444
|
-
class InvoiceController {
|
|
445
|
-
@Post(":id/approve")
|
|
446
|
-
@Authorize("invoice:approve", "invoice")
|
|
447
|
-
approve(@Param("id") id: string) {
|
|
448
|
-
return { approved: true };
|
|
449
|
-
}
|
|
450
|
-
}
|
|
819
|
+
Authorization policies are security-critical code — they should be tested like any other business logic. The `explain()` method is purpose-built for this:
|
|
451
820
|
|
|
452
|
-
|
|
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
|
+
});
|
|
453
864
|
```
|
|
454
865
|
|
|
455
|
-
|
|
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.
|
|
456
921
|
|
|
457
922
|
---
|
|
458
923
|
|
|
@@ -501,7 +966,7 @@ See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
|
|
|
501
966
|
|
|
502
967
|
| Option | Description |
|
|
503
968
|
|---|---|
|
|
504
|
-
| `schema` | Your schema type (used for type inference) |
|
|
969
|
+
| `schema` | Your schema type (used for type inference, not read at runtime) |
|
|
505
970
|
| `defaultEffect` | `"deny"` (default) or `"allow"` |
|
|
506
971
|
| `onDecision` | Listener called on every evaluation |
|
|
507
972
|
| `onConditionError` | Called when a condition throws (fail-closed) |
|
|
@@ -573,74 +1038,6 @@ Returned by `engine.explain()`:
|
|
|
573
1038
|
|
|
574
1039
|
---
|
|
575
1040
|
|
|
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
1041
|
## Philosophy
|
|
645
1042
|
|
|
646
1043
|
1. **Policies belong in one place.** Not scattered across middleware, handlers, and services.
|