@siremzam/sentinel 0.4.0 → 0.4.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 +195 -312
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,97 +5,45 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@siremzam/sentinel)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**All your permission logic in one place. Type-safe. Multi-tenant. Explainable.**
|
|
9
9
|
|
|
10
|
-
Most
|
|
10
|
+
Most auth libraries give you `create`, `read`, `update`, `delete` and call it a day. But your app has `invoice:approve`, your users are admin in one tenant and viewer in another, and when access breaks, nobody can tell you why without grepping the entire codebase.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Sentinel replaces scattered role checks with a single policy engine — domain actions instead of CRUD, tenant-scoped roles by default, and every decision tells you exactly which rule matched and why.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
- **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.
|
|
16
|
-
- **Your types should work for you.** TypeScript autocompletes your actions, resources, and roles everywhere — policies, checks, middleware.
|
|
17
|
-
- **Your authorization decisions should be observable.** Every `allow` and `deny` emits a structured event with timing, reason, and the matched rule.
|
|
18
|
-
- **Your policies belong in one place.** Not scattered across 47 route handlers.
|
|
19
|
-
|
|
20
|
-
**Zero runtime dependencies. ~1,800 lines. 1:1 test-to-code ratio.**
|
|
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))
|
|
14
|
+
Zero dependencies. ~1,800 lines. 1:1 test-to-code ratio.
|
|
28
15
|
|
|
29
16
|
---
|
|
30
17
|
|
|
31
|
-
###
|
|
32
|
-
|
|
33
|
-
- Hono middleware — `honoGuard()` via `@siremzam/sentinel/middleware/hono`
|
|
34
|
-
- README rewrite: evaluation walkthrough, concepts glossary, patterns & recipes, migration guide, benchmark data
|
|
35
|
-
- Standalone example (`examples/standalone/`) — no HTTP server needed
|
|
18
|
+
### Before and after
|
|
36
19
|
|
|
37
|
-
|
|
20
|
+
**Without Sentinel** — scattered, fragile, no tenant awareness:
|
|
38
21
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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, Hono, 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
|
-
|
|
80
|
-
---
|
|
22
|
+
```typescript
|
|
23
|
+
app.post("/invoices/:id/approve", async (req, res) => {
|
|
24
|
+
if (
|
|
25
|
+
user.role === "admin" ||
|
|
26
|
+
(user.role === "manager" && invoice.ownerId === user.id)
|
|
27
|
+
) {
|
|
28
|
+
// which tenant? who knows
|
|
29
|
+
// why was this allowed? good luck
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
```
|
|
81
33
|
|
|
82
|
-
|
|
34
|
+
**With Sentinel** — centralized, type-safe, explainable:
|
|
83
35
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
| Server mode (HTTP microservice) | Built-in (`createAuthServer`) | No | No | No |
|
|
96
|
-
| Middleware | Express, Fastify, Hono, NestJS | Express (community) | Express (community) | Express, NestJS |
|
|
97
|
-
| Dependencies | **0** | 2+ | 2 | 1+ |
|
|
98
|
-
| DSL required | **No** (pure TypeScript) | Yes (Casbin model) | No | No |
|
|
36
|
+
```typescript
|
|
37
|
+
app.post(
|
|
38
|
+
"/invoices/:id/approve",
|
|
39
|
+
guard(engine, "invoice:approve", "invoice", {
|
|
40
|
+
getSubject: (req) => req.user,
|
|
41
|
+
getResourceContext: (req) => ({ ownerId: req.params.id }),
|
|
42
|
+
getTenantId: (req) => req.headers["x-tenant-id"],
|
|
43
|
+
}),
|
|
44
|
+
handler,
|
|
45
|
+
);
|
|
46
|
+
```
|
|
99
47
|
|
|
100
48
|
---
|
|
101
49
|
|
|
@@ -105,14 +53,12 @@ See the full [CHANGELOG](./CHANGELOG.md).
|
|
|
105
53
|
npm install @siremzam/sentinel
|
|
106
54
|
```
|
|
107
55
|
|
|
108
|
-
---
|
|
109
|
-
|
|
110
56
|
## Quick Start
|
|
111
57
|
|
|
112
|
-
### 1. Define your schema
|
|
58
|
+
### 1. Define your schema (TypeScript does the rest)
|
|
113
59
|
|
|
114
60
|
```typescript
|
|
115
|
-
import { AccessEngine, createPolicyFactory
|
|
61
|
+
import { AccessEngine, createPolicyFactory } from "@siremzam/sentinel";
|
|
116
62
|
import type { SchemaDefinition, Subject } from "@siremzam/sentinel";
|
|
117
63
|
|
|
118
64
|
interface MySchema extends SchemaDefinition {
|
|
@@ -130,24 +76,17 @@ interface MySchema extends SchemaDefinition {
|
|
|
130
76
|
}
|
|
131
77
|
```
|
|
132
78
|
|
|
133
|
-
|
|
79
|
+
Every role, resource, and action gets autocomplete and compile-time validation. Typos are caught before your code runs.
|
|
134
80
|
|
|
135
|
-
### 2.
|
|
81
|
+
### 2. Write policies that read like English
|
|
136
82
|
|
|
137
83
|
```typescript
|
|
138
84
|
const { allow, deny } = createPolicyFactory<MySchema>();
|
|
139
85
|
|
|
140
|
-
const engine = new AccessEngine<MySchema>({
|
|
141
|
-
schema: {} as MySchema,
|
|
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.
|
|
86
|
+
const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
|
|
146
87
|
|
|
147
|
-
```typescript
|
|
148
88
|
engine.addRules(
|
|
149
89
|
allow()
|
|
150
|
-
.id("admin-full-access")
|
|
151
90
|
.roles("admin", "owner")
|
|
152
91
|
.anyAction()
|
|
153
92
|
.anyResource()
|
|
@@ -155,7 +94,6 @@ engine.addRules(
|
|
|
155
94
|
.build(),
|
|
156
95
|
|
|
157
96
|
allow()
|
|
158
|
-
.id("manager-invoices")
|
|
159
97
|
.roles("manager")
|
|
160
98
|
.actions("invoice:*" as MySchema["actions"])
|
|
161
99
|
.on("invoice")
|
|
@@ -163,34 +101,16 @@ engine.addRules(
|
|
|
163
101
|
.build(),
|
|
164
102
|
|
|
165
103
|
allow()
|
|
166
|
-
.id("member-own-invoices")
|
|
167
104
|
.roles("member")
|
|
168
105
|
.actions("invoice:read", "invoice:create")
|
|
169
106
|
.on("invoice")
|
|
170
107
|
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
|
|
171
108
|
.describe("Members can read/create their own invoices")
|
|
172
109
|
.build(),
|
|
173
|
-
|
|
174
|
-
deny()
|
|
175
|
-
.id("no-impersonation")
|
|
176
|
-
.anyRole()
|
|
177
|
-
.actions("user:impersonate")
|
|
178
|
-
.on("user")
|
|
179
|
-
.describe("Nobody can impersonate by default")
|
|
180
|
-
.build(),
|
|
181
|
-
|
|
182
|
-
allow()
|
|
183
|
-
.id("owner-impersonate")
|
|
184
|
-
.roles("owner")
|
|
185
|
-
.actions("user:impersonate")
|
|
186
|
-
.on("user")
|
|
187
|
-
.priority(10)
|
|
188
|
-
.describe("Except owners, who can impersonate")
|
|
189
|
-
.build(),
|
|
190
110
|
);
|
|
191
111
|
```
|
|
192
112
|
|
|
193
|
-
### 3. Check permissions
|
|
113
|
+
### 3. Check permissions (with tenant context)
|
|
194
114
|
|
|
195
115
|
```typescript
|
|
196
116
|
const user: Subject<MySchema> = {
|
|
@@ -201,43 +121,94 @@ const user: Subject<MySchema> = {
|
|
|
201
121
|
],
|
|
202
122
|
};
|
|
203
123
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// decision.allowed === true
|
|
124
|
+
engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-a");
|
|
125
|
+
// → allowed: true (user is admin in tenant-a)
|
|
207
126
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// d2.allowed === false (user is only a viewer in tenant-b)
|
|
127
|
+
engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-b");
|
|
128
|
+
// → allowed: false (user is only a viewer in tenant-b)
|
|
211
129
|
```
|
|
212
130
|
|
|
213
|
-
|
|
131
|
+
That's it. You're up and running.
|
|
132
|
+
|
|
133
|
+
> **[Try it live in the interactive playground →](https://vegtelenseg.github.io/sentinel-example/)**
|
|
134
|
+
>
|
|
135
|
+
> Policy editor, multi-tenant evaluation, explain traces, and audit log — all in the browser. ([source](https://github.com/vegtelenseg/sentinel-example))
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Why Sentinel?
|
|
140
|
+
|
|
141
|
+
### 1. Type-safe schema
|
|
142
|
+
|
|
143
|
+
Your IDE autocompletes `invoice:approve` and rejects `invoice:aprove`. Every policy, condition, and middleware call is type-checked at compile time. This isn't convenience — it's a security boundary.
|
|
214
144
|
|
|
215
145
|
```typescript
|
|
216
|
-
|
|
146
|
+
// TypeScript catches this at compile time, not in production
|
|
147
|
+
engine.evaluate(user, "invoice:aprove", "invoice");
|
|
148
|
+
// ^^^^^^^^^^^^^^^^ — Type error
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 2. Multi-tenancy that doesn't leak
|
|
152
|
+
|
|
153
|
+
Roles are scoped to tenants in the data model itself. No middleware hacks. No "effective role" workarounds. And `strictTenancy` mode throws if you forget the tenant ID — so cross-tenant bugs surface in development, not from a customer email on Friday.
|
|
217
154
|
|
|
155
|
+
```typescript
|
|
218
156
|
const engine = new AccessEngine<MySchema>({
|
|
219
157
|
schema: {} as MySchema,
|
|
220
|
-
|
|
221
|
-
const entry = toAuditEntry(decision);
|
|
222
|
-
auditLog.write(entry);
|
|
223
|
-
},
|
|
158
|
+
strictTenancy: true, // forget tenantId? this throws, not leaks
|
|
224
159
|
});
|
|
225
160
|
```
|
|
226
161
|
|
|
227
|
-
|
|
162
|
+
### 3. explain() — debug authorization in seconds
|
|
163
|
+
|
|
164
|
+
When a user reports "I can't access this," don't grep your codebase. Replay the decision:
|
|
228
165
|
|
|
229
166
|
```typescript
|
|
230
|
-
const
|
|
231
|
-
|
|
167
|
+
const result = engine.explain(user, "invoice:approve", "invoice", {}, "tenant-b");
|
|
168
|
+
// result.allowed → false
|
|
169
|
+
// result.reason → "No matching rule — default deny"
|
|
170
|
+
// result.evaluatedRules → per-rule trace: which matched on role, action, resource, conditions
|
|
232
171
|
```
|
|
233
172
|
|
|
234
|
-
|
|
173
|
+
Every evaluation also emits a structured audit event via `onDecision` — who asked, what for, which rule decided it, how long it took.
|
|
174
|
+
|
|
175
|
+
### 4. Domain actions, not CRUD
|
|
176
|
+
|
|
177
|
+
`invoice:approve`. `project:archive`. `user:impersonate`. Your authorization speaks your domain language, not `create`/`read`/`update`/`delete`.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
<details>
|
|
182
|
+
<summary><strong>How it compares</strong></summary>
|
|
183
|
+
|
|
184
|
+
| Feature | **Sentinel** | Casbin | accesscontrol | CASL |
|
|
185
|
+
|---|---|---|---|---|
|
|
186
|
+
| TypeScript-first (full inference) | Yes | Partial | Partial | Yes |
|
|
187
|
+
| Domain actions (`invoice:approve`) | Native | Via model config | No (CRUD only) | Via `subject` |
|
|
188
|
+
| Multi-tenancy (per-tenant roles) | Built-in | Manual | No | Manual |
|
|
189
|
+
| ABAC conditions | Sync + async | Via matchers | No | Via `conditions` |
|
|
190
|
+
| Role hierarchy | Built-in, cycle-detected | Via model | Built-in | No |
|
|
191
|
+
| Audit trail | `onDecision` + `toAuditEntry()` | Via watcher | No | No |
|
|
192
|
+
| Debug/explain | `explain()` with per-rule trace | No | No | No |
|
|
193
|
+
| UI permission set | `permitted()` returns `Set` | No | `permission.filter()` | `ability.can()` per action |
|
|
194
|
+
| JSON policy storage | `exportRules` / `importRules` | CSV / JSON adapters | No | Via `@casl/ability/extra` |
|
|
195
|
+
| Server mode (HTTP microservice) | Built-in | No | No | No |
|
|
196
|
+
| Middleware | Express, Fastify, Hono, NestJS | Express (community) | Express (community) | Express, NestJS |
|
|
197
|
+
| Dependencies | **0** | 2+ | 2 | 1+ |
|
|
198
|
+
| DSL required | **No** (pure TypeScript) | Yes | No | No |
|
|
199
|
+
|
|
200
|
+
</details>
|
|
235
201
|
|
|
236
202
|
---
|
|
237
203
|
|
|
238
|
-
##
|
|
204
|
+
## Docs
|
|
239
205
|
|
|
240
|
-
|
|
206
|
+
### Everything below is organized by topic. Click to expand what you need.
|
|
207
|
+
|
|
208
|
+
<details>
|
|
209
|
+
<summary><strong>How evaluation works</strong></summary>
|
|
210
|
+
|
|
211
|
+
When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`:
|
|
241
212
|
|
|
242
213
|
```
|
|
243
214
|
1. Resolve the subject's roles
|
|
@@ -262,16 +233,12 @@ When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`,
|
|
|
262
233
|
└─ No match → default deny
|
|
263
234
|
```
|
|
264
235
|
|
|
265
|
-
Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context.
|
|
266
|
-
|
|
267
|
-
---
|
|
268
|
-
|
|
269
|
-
## Concepts
|
|
236
|
+
Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context.
|
|
270
237
|
|
|
271
|
-
|
|
238
|
+
</details>
|
|
272
239
|
|
|
273
240
|
<details>
|
|
274
|
-
<summary><strong>
|
|
241
|
+
<summary><strong>Concepts glossary</strong></summary>
|
|
275
242
|
|
|
276
243
|
| Term | Meaning |
|
|
277
244
|
|---|---|
|
|
@@ -279,28 +246,24 @@ If you're new to authorization systems, here's a quick glossary:
|
|
|
279
246
|
| **ABAC** | Attribute-Based Access Control. Permissions depend on attributes of the subject, resource, or environment — expressed as conditions. |
|
|
280
247
|
| **Subject** | The entity requesting access — typically a user. Has an `id` and an array of `roles`. |
|
|
281
248
|
| **Resource** | The thing being accessed — `"invoice"`, `"project"`, `"user"`. |
|
|
282
|
-
| **Action** | What the subject wants to do
|
|
283
|
-
| **Policy Rule** | A single authorization rule
|
|
284
|
-
| **Condition** | A function attached to a rule that receives the evaluation context and returns `true
|
|
285
|
-
| **Tenant** | An organizational unit in a multi-tenant system
|
|
286
|
-
| **Decision** | The result of
|
|
287
|
-
| **Effect** | Either `"allow"` or `"deny"`. Determines what happens when a rule matches. |
|
|
249
|
+
| **Action** | What the subject wants to do — `"invoice:approve"`, `"project:archive"`. Uses `resource:verb` format. |
|
|
250
|
+
| **Policy Rule** | A single authorization rule with an effect (`allow` or `deny`), and optionally conditions, priority, and a description. |
|
|
251
|
+
| **Condition** | A function attached to a rule that receives the evaluation context and returns `true`/`false`. Used for ABAC. |
|
|
252
|
+
| **Tenant** | An organizational unit in a multi-tenant system. Users can have different roles in different tenants. |
|
|
253
|
+
| **Decision** | The result of an evaluation — contains `allowed`, the matched rule, timing, and a human-readable reason. |
|
|
288
254
|
| **Priority** | A number (default 0) that determines rule evaluation order. Higher priority rules are checked first. |
|
|
289
255
|
| **Role Hierarchy** | A definition that one role inherits all permissions of another — e.g., `admin` inherits from `manager`. |
|
|
290
256
|
|
|
291
257
|
</details>
|
|
292
258
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
## Core Features
|
|
259
|
+
<details>
|
|
260
|
+
<summary><strong>Core features</strong></summary>
|
|
296
261
|
|
|
297
262
|
### Policy Factory
|
|
298
263
|
|
|
299
|
-
`createPolicyFactory` eliminates the `<MySchema>` generic
|
|
264
|
+
`createPolicyFactory` eliminates the `<MySchema>` generic on every rule:
|
|
300
265
|
|
|
301
266
|
```typescript
|
|
302
|
-
import { createPolicyFactory } from "@siremzam/sentinel";
|
|
303
|
-
|
|
304
267
|
const { allow, deny } = createPolicyFactory<MySchema>();
|
|
305
268
|
|
|
306
269
|
allow().roles("admin").anyAction().anyResource().build();
|
|
@@ -309,7 +272,7 @@ deny().roles("viewer").actions("report:export").on("report").build();
|
|
|
309
272
|
|
|
310
273
|
### Conditions (ABAC)
|
|
311
274
|
|
|
312
|
-
Attach predicates to any rule. All conditions on a rule must pass
|
|
275
|
+
Attach predicates to any rule. All conditions on a rule must pass:
|
|
313
276
|
|
|
314
277
|
```typescript
|
|
315
278
|
allow()
|
|
@@ -325,13 +288,9 @@ Conditions receive the full `EvaluationContext` — subject, action, resource, r
|
|
|
325
288
|
|
|
326
289
|
### Async Conditions
|
|
327
290
|
|
|
328
|
-
For conditions that need database lookups or API calls
|
|
291
|
+
For conditions that need database lookups or API calls:
|
|
329
292
|
|
|
330
293
|
```typescript
|
|
331
|
-
const engine = new AccessEngine<MySchema>({
|
|
332
|
-
schema: {} as MySchema,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
294
|
engine.addRule(
|
|
336
295
|
allow()
|
|
337
296
|
.roles("member")
|
|
@@ -347,26 +306,19 @@ engine.addRule(
|
|
|
347
306
|
const decision = await engine.evaluateAsync(user, "report:export", "report");
|
|
348
307
|
```
|
|
349
308
|
|
|
350
|
-
Use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()`
|
|
309
|
+
Use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` for async conditions. The engine throws a clear error if you accidentally use the sync API with async conditions.
|
|
351
310
|
|
|
352
311
|
### Wildcard Action Patterns
|
|
353
312
|
|
|
354
|
-
Use `*` in action patterns to match groups of actions:
|
|
355
|
-
|
|
356
313
|
```typescript
|
|
357
|
-
// Match all invoice actions
|
|
358
314
|
allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
|
|
359
|
-
|
|
360
|
-
// Match all read actions across resources
|
|
361
315
|
allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();
|
|
362
316
|
```
|
|
363
317
|
|
|
364
|
-
|
|
318
|
+
Wildcards are pre-compiled to regexes at `addRule()` time — no per-evaluation cost.
|
|
365
319
|
|
|
366
320
|
### Role Hierarchy
|
|
367
321
|
|
|
368
|
-
Define that higher roles inherit all permissions of lower roles:
|
|
369
|
-
|
|
370
322
|
```typescript
|
|
371
323
|
import { RoleHierarchy } from "@siremzam/sentinel";
|
|
372
324
|
|
|
@@ -380,16 +332,6 @@ const engine = new AccessEngine<MySchema>({
|
|
|
380
332
|
schema: {} as MySchema,
|
|
381
333
|
roleHierarchy: hierarchy,
|
|
382
334
|
});
|
|
383
|
-
|
|
384
|
-
engine.addRules(
|
|
385
|
-
allow().id("viewer-read").roles("viewer").actions("invoice:read").on("invoice").build(),
|
|
386
|
-
allow().id("member-create").roles("member").actions("invoice:create").on("invoice").build(),
|
|
387
|
-
allow().id("admin-approve").roles("admin").actions("invoice:approve").on("invoice").build(),
|
|
388
|
-
);
|
|
389
|
-
|
|
390
|
-
// Admins can read (inherited from viewer), create (from member), AND approve (their own)
|
|
391
|
-
// Members can read (from viewer) and create, but NOT approve
|
|
392
|
-
// Viewers can only read
|
|
393
335
|
```
|
|
394
336
|
|
|
395
337
|
Cycles are detected at definition time and throw immediately.
|
|
@@ -398,19 +340,15 @@ Cycles are detected at definition time and throw immediately.
|
|
|
398
340
|
|
|
399
341
|
- Higher `priority` wins (default: 0)
|
|
400
342
|
- At equal priority, `deny` wins over `allow`
|
|
401
|
-
- This lets you create broad deny rules with targeted allow overrides
|
|
402
343
|
|
|
403
344
|
```typescript
|
|
404
|
-
// Deny impersonation for everyone at priority 0
|
|
405
345
|
deny().anyRole().actions("user:impersonate").on("user").build();
|
|
406
|
-
|
|
407
|
-
// Allow it for owners at priority 10 — this wins
|
|
408
346
|
allow().roles("owner").actions("user:impersonate").on("user").priority(10).build();
|
|
409
347
|
```
|
|
410
348
|
|
|
411
349
|
### Multitenancy
|
|
412
350
|
|
|
413
|
-
Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles
|
|
351
|
+
Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles for that tenant (or global roles) are considered:
|
|
414
352
|
|
|
415
353
|
```typescript
|
|
416
354
|
const user: Subject<MySchema> = {
|
|
@@ -422,16 +360,16 @@ const user: Subject<MySchema> = {
|
|
|
422
360
|
],
|
|
423
361
|
};
|
|
424
362
|
|
|
425
|
-
// In acme-corp context: user has admin + member roles
|
|
426
363
|
engine.evaluate(user, "invoice:approve", "invoice", {}, "acme-corp");
|
|
364
|
+
// admin + member roles
|
|
427
365
|
|
|
428
|
-
// In globex context: user has viewer + member roles
|
|
429
366
|
engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
|
|
367
|
+
// viewer + member roles
|
|
430
368
|
```
|
|
431
369
|
|
|
432
370
|
### Strict Tenancy
|
|
433
371
|
|
|
434
|
-
Prevents accidental cross-tenant access
|
|
372
|
+
Prevents accidental cross-tenant access:
|
|
435
373
|
|
|
436
374
|
```typescript
|
|
437
375
|
const engine = new AccessEngine<MySchema>({
|
|
@@ -439,16 +377,13 @@ const engine = new AccessEngine<MySchema>({
|
|
|
439
377
|
strictTenancy: true,
|
|
440
378
|
});
|
|
441
379
|
|
|
442
|
-
// THROWS — tenantId is required because user has tenant-scoped roles
|
|
443
380
|
engine.evaluate(user, "invoice:read", "invoice");
|
|
444
|
-
|
|
445
|
-
// OK — explicit tenant context
|
|
446
|
-
engine.evaluate(user, "invoice:read", "invoice", {}, "acme");
|
|
381
|
+
// THROWS — tenantId required for tenant-scoped subjects
|
|
447
382
|
```
|
|
448
383
|
|
|
449
384
|
### Condition Error Handling
|
|
450
385
|
|
|
451
|
-
Conditions that throw are treated as `false` (fail-closed)
|
|
386
|
+
Conditions that throw are treated as `false` (fail-closed):
|
|
452
387
|
|
|
453
388
|
```typescript
|
|
454
389
|
const engine = new AccessEngine<MySchema>({
|
|
@@ -459,13 +394,14 @@ const engine = new AccessEngine<MySchema>({
|
|
|
459
394
|
});
|
|
460
395
|
```
|
|
461
396
|
|
|
462
|
-
|
|
397
|
+
</details>
|
|
463
398
|
|
|
464
|
-
|
|
399
|
+
<details>
|
|
400
|
+
<summary><strong>Observability</strong></summary>
|
|
465
401
|
|
|
466
402
|
### Decision Events
|
|
467
403
|
|
|
468
|
-
Every evaluation emits a structured `Decision` event
|
|
404
|
+
Every evaluation emits a structured `Decision` event:
|
|
469
405
|
|
|
470
406
|
```typescript
|
|
471
407
|
import { toAuditEntry } from "@siremzam/sentinel";
|
|
@@ -478,9 +414,9 @@ const engine = new AccessEngine<MySchema>({
|
|
|
478
414
|
},
|
|
479
415
|
});
|
|
480
416
|
|
|
481
|
-
// Or at runtime
|
|
417
|
+
// Or subscribe at runtime
|
|
482
418
|
const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
|
|
483
|
-
unsubscribe();
|
|
419
|
+
unsubscribe();
|
|
484
420
|
```
|
|
485
421
|
|
|
486
422
|
### explain() — Debug Authorization
|
|
@@ -490,8 +426,8 @@ Full evaluation trace showing every rule, whether it matched, and why:
|
|
|
490
426
|
```typescript
|
|
491
427
|
const result = engine.explain(user, "invoice:approve", "invoice");
|
|
492
428
|
|
|
493
|
-
console.log(result.allowed);
|
|
494
|
-
console.log(result.reason);
|
|
429
|
+
console.log(result.allowed);
|
|
430
|
+
console.log(result.reason);
|
|
495
431
|
|
|
496
432
|
for (const evalRule of result.evaluatedRules) {
|
|
497
433
|
console.log({
|
|
@@ -509,11 +445,9 @@ For async conditions, use `engine.explainAsync()`.
|
|
|
509
445
|
|
|
510
446
|
### toAuditEntry()
|
|
511
447
|
|
|
512
|
-
Convert a `Decision` to a serialization-safe format
|
|
448
|
+
Convert a `Decision` to a serialization-safe format:
|
|
513
449
|
|
|
514
450
|
```typescript
|
|
515
|
-
import { toAuditEntry } from "@siremzam/sentinel";
|
|
516
|
-
|
|
517
451
|
const decision = engine.evaluate(user, "invoice:approve", "invoice");
|
|
518
452
|
const entry = toAuditEntry(decision);
|
|
519
453
|
// Safe to JSON.stringify — no functions, no circular references
|
|
@@ -521,7 +455,7 @@ const entry = toAuditEntry(decision);
|
|
|
521
455
|
|
|
522
456
|
### permitted() — UI Rendering
|
|
523
457
|
|
|
524
|
-
|
|
458
|
+
Drive button visibility and menu items:
|
|
525
459
|
|
|
526
460
|
```typescript
|
|
527
461
|
const actions = engine.permitted(
|
|
@@ -536,13 +470,12 @@ const actions = engine.permitted(
|
|
|
536
470
|
|
|
537
471
|
For async conditions, use `engine.permittedAsync()`.
|
|
538
472
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
## Integration
|
|
473
|
+
</details>
|
|
542
474
|
|
|
543
|
-
|
|
475
|
+
<details>
|
|
476
|
+
<summary><strong>Integration & middleware</strong></summary>
|
|
544
477
|
|
|
545
|
-
|
|
478
|
+
### Express
|
|
546
479
|
|
|
547
480
|
```typescript
|
|
548
481
|
import { guard } from "@siremzam/sentinel/middleware/express";
|
|
@@ -558,7 +491,7 @@ app.post(
|
|
|
558
491
|
);
|
|
559
492
|
```
|
|
560
493
|
|
|
561
|
-
|
|
494
|
+
### Fastify
|
|
562
495
|
|
|
563
496
|
```typescript
|
|
564
497
|
import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
|
|
@@ -572,7 +505,7 @@ fastify.post("/invoices/:id/approve", {
|
|
|
572
505
|
}, handler);
|
|
573
506
|
```
|
|
574
507
|
|
|
575
|
-
|
|
508
|
+
### Hono
|
|
576
509
|
|
|
577
510
|
```typescript
|
|
578
511
|
import { honoGuard } from "@siremzam/sentinel/middleware/hono";
|
|
@@ -588,7 +521,7 @@ app.post(
|
|
|
588
521
|
);
|
|
589
522
|
```
|
|
590
523
|
|
|
591
|
-
|
|
524
|
+
### NestJS
|
|
592
525
|
|
|
593
526
|
```typescript
|
|
594
527
|
import {
|
|
@@ -620,29 +553,21 @@ No dependency on `@nestjs/common` or `reflect-metadata`. Uses a WeakMap for meta
|
|
|
620
553
|
|
|
621
554
|
### Server Mode
|
|
622
555
|
|
|
623
|
-
Run the engine as a standalone HTTP authorization microservice
|
|
556
|
+
Run the engine as a standalone HTTP authorization microservice for polyglot architectures:
|
|
624
557
|
|
|
625
558
|
```typescript
|
|
626
|
-
import { AccessEngine } from "@siremzam/sentinel";
|
|
627
559
|
import { createAuthServer } from "@siremzam/sentinel/server";
|
|
628
560
|
|
|
629
|
-
const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
|
|
630
|
-
engine.addRules(/* ... */);
|
|
631
|
-
|
|
632
561
|
const server = createAuthServer({
|
|
633
562
|
engine,
|
|
634
563
|
port: 3100,
|
|
635
|
-
authenticate: (req) =>
|
|
636
|
-
|
|
637
|
-
},
|
|
638
|
-
maxBodyBytes: 1024 * 1024, // 1 MB (default)
|
|
564
|
+
authenticate: (req) => req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY,
|
|
565
|
+
maxBodyBytes: 1024 * 1024,
|
|
639
566
|
});
|
|
640
567
|
|
|
641
568
|
await server.start();
|
|
642
569
|
```
|
|
643
570
|
|
|
644
|
-
**Endpoints:**
|
|
645
|
-
|
|
646
571
|
| Endpoint | Method | Description |
|
|
647
572
|
|---|---|---|
|
|
648
573
|
| `/health` | GET | Health check with rules count and uptime |
|
|
@@ -653,7 +578,7 @@ Zero dependencies. Uses Node's built-in `http` module.
|
|
|
653
578
|
|
|
654
579
|
### JSON Policy Serialization
|
|
655
580
|
|
|
656
|
-
Store policies in a database, config file, or load
|
|
581
|
+
Store policies in a database, config file, or load from an API:
|
|
657
582
|
|
|
658
583
|
```typescript
|
|
659
584
|
import {
|
|
@@ -662,10 +587,7 @@ import {
|
|
|
662
587
|
ConditionRegistry,
|
|
663
588
|
} from "@siremzam/sentinel";
|
|
664
589
|
|
|
665
|
-
// Export rules to JSON
|
|
666
590
|
const json = exportRulesToJson(engine.getRules());
|
|
667
|
-
|
|
668
|
-
// Import rules back (validates effect and id fields)
|
|
669
591
|
const rules = importRulesFromJson<MySchema>(json);
|
|
670
592
|
engine.addRules(...rules);
|
|
671
593
|
```
|
|
@@ -675,21 +597,17 @@ Conditions use a named registry since functions can't be serialized:
|
|
|
675
597
|
```typescript
|
|
676
598
|
const conditions = new ConditionRegistry<MySchema>();
|
|
677
599
|
conditions.register("isOwner", (ctx) => ctx.subject.id === ctx.resourceContext.ownerId);
|
|
678
|
-
conditions.register("isActive", (ctx) => ctx.resourceContext.status === "active");
|
|
679
600
|
|
|
680
601
|
const rules = importRulesFromJson<MySchema>(json, conditions);
|
|
681
602
|
```
|
|
682
603
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
---
|
|
604
|
+
</details>
|
|
686
605
|
|
|
687
|
-
|
|
606
|
+
<details>
|
|
607
|
+
<summary><strong>Performance & benchmarks</strong></summary>
|
|
688
608
|
|
|
689
609
|
### Evaluation Cache
|
|
690
610
|
|
|
691
|
-
For hot paths where the same subject/action/resource is checked repeatedly:
|
|
692
|
-
|
|
693
611
|
```typescript
|
|
694
612
|
const engine = new AccessEngine<MySchema>({
|
|
695
613
|
schema: {} as MySchema,
|
|
@@ -704,7 +622,7 @@ engine.clearCache(); // manual control
|
|
|
704
622
|
engine.cacheStats; // { size: 0, maxSize: 1000 }
|
|
705
623
|
```
|
|
706
624
|
|
|
707
|
-
Only unconditional
|
|
625
|
+
Only unconditional evaluations are cached — conditional results are always re-evaluated.
|
|
708
626
|
|
|
709
627
|
### Benchmarks
|
|
710
628
|
|
|
@@ -718,24 +636,21 @@ Measured on Node v18.18.0, Apple Silicon (ARM64). Run `npm run benchmark` to rep
|
|
|
718
636
|
| `permitted` (18 actions) | 60.2 µs / 17k ops/s | 718 µs / 1.4k ops/s | 18,924 µs / 53 ops/s |
|
|
719
637
|
| `explain` (full trace) | 22.4 µs / 45k ops/s | 564 µs / 1.8k ops/s | 6,444 µs / 155 ops/s |
|
|
720
638
|
|
|
721
|
-
Most SaaS apps have 10–50 rules. At 100 rules, a single evaluation takes **4.3 µs** —
|
|
722
|
-
|
|
723
|
-
---
|
|
639
|
+
Most SaaS apps have 10–50 rules. At 100 rules, a single evaluation takes **4.3 µs** — 230,000 checks per second on a single core. With caching: **0.6 µs**.
|
|
724
640
|
|
|
725
|
-
|
|
641
|
+
</details>
|
|
726
642
|
|
|
727
|
-
|
|
643
|
+
<details>
|
|
644
|
+
<summary><strong>Patterns & recipes</strong></summary>
|
|
728
645
|
|
|
729
646
|
### Ownership — "Users can only edit their own resources"
|
|
730
647
|
|
|
731
648
|
```typescript
|
|
732
649
|
allow()
|
|
733
|
-
.id("edit-own-invoice")
|
|
734
650
|
.roles("member")
|
|
735
651
|
.actions("invoice:update")
|
|
736
652
|
.on("invoice")
|
|
737
653
|
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
|
|
738
|
-
.describe("Members can edit their own invoices")
|
|
739
654
|
.build();
|
|
740
655
|
```
|
|
741
656
|
|
|
@@ -743,7 +658,6 @@ allow()
|
|
|
743
658
|
|
|
744
659
|
```typescript
|
|
745
660
|
allow()
|
|
746
|
-
.id("trial-access")
|
|
747
661
|
.roles("trial")
|
|
748
662
|
.actions("report:export")
|
|
749
663
|
.on("report")
|
|
@@ -752,7 +666,6 @@ allow()
|
|
|
752
666
|
const daysSince = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
753
667
|
return daysSince <= 14;
|
|
754
668
|
})
|
|
755
|
-
.describe("Trial users can export for 14 days")
|
|
756
669
|
.build();
|
|
757
670
|
```
|
|
758
671
|
|
|
@@ -762,12 +675,10 @@ allow()
|
|
|
762
675
|
const BETA_TENANTS = new Set(["acme-corp", "initech"]);
|
|
763
676
|
|
|
764
677
|
allow()
|
|
765
|
-
.id("beta-analytics")
|
|
766
678
|
.anyRole()
|
|
767
679
|
.actions("analytics:view")
|
|
768
680
|
.on("analytics")
|
|
769
681
|
.when(ctx => BETA_TENANTS.has(ctx.tenantId ?? ""))
|
|
770
|
-
.describe("Analytics dashboard is in beta for select tenants")
|
|
771
682
|
.build();
|
|
772
683
|
```
|
|
773
684
|
|
|
@@ -775,7 +686,6 @@ allow()
|
|
|
775
686
|
|
|
776
687
|
```typescript
|
|
777
688
|
allow()
|
|
778
|
-
.id("api-rate-limit")
|
|
779
689
|
.roles("member")
|
|
780
690
|
.actions("api:call")
|
|
781
691
|
.on("api")
|
|
@@ -783,38 +693,30 @@ allow()
|
|
|
783
693
|
const usage = await rateLimiter.check(ctx.subject.id);
|
|
784
694
|
return usage.remaining > 0;
|
|
785
695
|
})
|
|
786
|
-
.describe("Members can call API within rate limit")
|
|
787
696
|
.build();
|
|
788
697
|
```
|
|
789
698
|
|
|
790
699
|
### Broad Deny with Targeted Override
|
|
791
700
|
|
|
792
701
|
```typescript
|
|
793
|
-
// Deny all destructive actions at priority 0
|
|
794
702
|
deny()
|
|
795
|
-
.id("freeze-destructive")
|
|
796
703
|
.anyRole()
|
|
797
704
|
.actions("project:delete", "project:archive")
|
|
798
705
|
.on("project")
|
|
799
|
-
.describe("Destructive project actions are frozen")
|
|
800
706
|
.build();
|
|
801
707
|
|
|
802
|
-
// Allow owners to override at priority 10
|
|
803
708
|
allow()
|
|
804
|
-
.id("owner-override")
|
|
805
709
|
.roles("owner")
|
|
806
710
|
.actions("project:delete", "project:archive")
|
|
807
711
|
.on("project")
|
|
808
712
|
.priority(10)
|
|
809
|
-
.describe("Owners can still delete/archive their projects")
|
|
810
713
|
.build();
|
|
811
714
|
```
|
|
812
715
|
|
|
813
|
-
### IP-Based Restriction
|
|
716
|
+
### IP-Based Restriction
|
|
814
717
|
|
|
815
718
|
```typescript
|
|
816
719
|
allow()
|
|
817
|
-
.id("admin-from-office")
|
|
818
720
|
.roles("admin")
|
|
819
721
|
.actions("settings:update")
|
|
820
722
|
.on("settings")
|
|
@@ -823,15 +725,13 @@ allow()
|
|
|
823
725
|
const geo = await geoService.lookup(ip);
|
|
824
726
|
return geo.isOfficeNetwork;
|
|
825
727
|
})
|
|
826
|
-
.describe("Admin settings changes only from office network")
|
|
827
728
|
.build();
|
|
828
729
|
```
|
|
829
730
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
## Testing Your Policies
|
|
731
|
+
</details>
|
|
833
732
|
|
|
834
|
-
|
|
733
|
+
<details>
|
|
734
|
+
<summary><strong>Testing your policies</strong></summary>
|
|
835
735
|
|
|
836
736
|
```typescript
|
|
837
737
|
import { describe, it, expect } from "vitest";
|
|
@@ -860,7 +760,6 @@ describe("invoice policies", () => {
|
|
|
860
760
|
"acme",
|
|
861
761
|
);
|
|
862
762
|
|
|
863
|
-
// Find the ownership rule and verify the condition failed
|
|
864
763
|
const ownershipRule = result.evaluatedRules.find(
|
|
865
764
|
e => e.rule.id === "member-own-invoices",
|
|
866
765
|
);
|
|
@@ -868,7 +767,6 @@ describe("invoice policies", () => {
|
|
|
868
767
|
});
|
|
869
768
|
|
|
870
769
|
it("prevents cross-tenant access", () => {
|
|
871
|
-
// User is admin in acme, viewer in globex
|
|
872
770
|
const resultAcme = engine.evaluate(user, "invoice:approve", "invoice", {}, "acme");
|
|
873
771
|
const resultGlobex = engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
|
|
874
772
|
|
|
@@ -878,11 +776,12 @@ describe("invoice policies", () => {
|
|
|
878
776
|
});
|
|
879
777
|
```
|
|
880
778
|
|
|
881
|
-
`explain()` returns per-rule evaluation details — which rules matched on role, action, and resource, and which conditions passed or failed.
|
|
779
|
+
`explain()` returns per-rule evaluation details — which rules matched on role, action, and resource, and which conditions passed or failed. When a test fails, the trace tells you exactly *why*.
|
|
882
780
|
|
|
883
|
-
|
|
781
|
+
</details>
|
|
884
782
|
|
|
885
|
-
|
|
783
|
+
<details>
|
|
784
|
+
<summary><strong>Migration guides</strong></summary>
|
|
886
785
|
|
|
887
786
|
### Coming from CASL
|
|
888
787
|
|
|
@@ -890,12 +789,12 @@ describe("invoice policies", () => {
|
|
|
890
789
|
|---|---|
|
|
891
790
|
| `defineAbility(can => { can('read', 'Article') })` | `allow().actions("article:read").on("article").build()` |
|
|
892
791
|
| `ability.can('read', 'Article')` | `engine.evaluate(user, "article:read", "article")` |
|
|
893
|
-
| `subject('Article', article)` | Actions use `resource:verb` format natively
|
|
792
|
+
| `subject('Article', article)` | Actions use `resource:verb` format natively |
|
|
894
793
|
| `conditions: { authorId: user.id }` | `.when(ctx => ctx.subject.id === ctx.resourceContext.authorId)` |
|
|
895
794
|
| No multi-tenancy | Built-in: `{ role: "admin", tenantId: "acme" }` |
|
|
896
795
|
| No explain/debug | `engine.explain()` gives per-rule trace |
|
|
897
796
|
|
|
898
|
-
**Key difference:** CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions
|
|
797
|
+
**Key difference:** CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions — async calls, date math, external lookups — with compile-time type safety.
|
|
899
798
|
|
|
900
799
|
### Coming from Casbin
|
|
901
800
|
|
|
@@ -907,54 +806,23 @@ describe("invoice policies", () => {
|
|
|
907
806
|
| Custom matchers for ABAC | `.when()` conditions with full TypeScript |
|
|
908
807
|
| Role manager | `RoleHierarchy` with cycle detection |
|
|
909
808
|
|
|
910
|
-
**Key difference:** Casbin requires
|
|
809
|
+
**Key difference:** Casbin requires its own DSL. Sentinel is pure TypeScript — your IDE autocompletes everything.
|
|
911
810
|
|
|
912
811
|
### Coming from accesscontrol
|
|
913
812
|
|
|
914
813
|
| accesscontrol | Sentinel |
|
|
915
814
|
|---|---|
|
|
916
815
|
| `ac.grant('admin').createAny('video')` | `allow().roles("admin").actions("video:create").on("video").build()` |
|
|
917
|
-
| CRUD only
|
|
918
|
-
| `ac.can('admin').createAny('video')` | `engine.evaluate(user, "video:create", "video")` |
|
|
816
|
+
| CRUD only | Domain verbs: `invoice:approve`, `order:ship` |
|
|
919
817
|
| No conditions/ABAC | Full ABAC with `.when()` conditions |
|
|
920
818
|
| No multi-tenancy | Built-in per-tenant role assignments |
|
|
921
819
|
|
|
922
|
-
**Key difference:** accesscontrol is locked into CRUD semantics.
|
|
923
|
-
|
|
924
|
-
---
|
|
925
|
-
|
|
926
|
-
## When NOT to Use This
|
|
927
|
-
|
|
928
|
-
Being honest about boundaries:
|
|
820
|
+
**Key difference:** accesscontrol is locked into CRUD semantics. Sentinel treats domain verbs as first-class.
|
|
929
821
|
|
|
930
|
-
|
|
931
|
-
- **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/).
|
|
932
|
-
- **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/).
|
|
933
|
-
- **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.
|
|
934
|
-
|
|
935
|
-
Sentinel is built for TypeScript-first SaaS applications with domain-specific actions, multi-tenant requirements, and a need for observable, testable authorization logic.
|
|
936
|
-
|
|
937
|
-
---
|
|
938
|
-
|
|
939
|
-
## Security
|
|
940
|
-
|
|
941
|
-
### Design Principles
|
|
942
|
-
|
|
943
|
-
- **Deny by default.** If no rule matches, the answer is no.
|
|
944
|
-
- **Fail closed.** If a condition throws, it evaluates to `false`. No silent privilege escalation.
|
|
945
|
-
- **Frozen rules.** Rules are `Object.freeze`'d on add. Mutation after insertion is impossible.
|
|
946
|
-
- **Cache safety.** Only unconditional rule evaluations are cached. Conditional results (which depend on `resourceContext`) are never cached, preventing stale cache entries from granting access.
|
|
947
|
-
- **Strict tenancy.** Optional mode that throws if `tenantId` is omitted for subjects with tenant-scoped roles, preventing accidental cross-tenant privilege escalation.
|
|
948
|
-
- **Import validation.** `importRulesFromJson()` validates the `effect` field and rejects invalid or missing values.
|
|
949
|
-
- **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.
|
|
950
|
-
|
|
951
|
-
### Reporting Vulnerabilities
|
|
952
|
-
|
|
953
|
-
See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
|
|
954
|
-
|
|
955
|
-
---
|
|
822
|
+
</details>
|
|
956
823
|
|
|
957
|
-
|
|
824
|
+
<details>
|
|
825
|
+
<summary><strong>API reference</strong></summary>
|
|
958
826
|
|
|
959
827
|
### AccessEngine\<S\>
|
|
960
828
|
|
|
@@ -985,7 +853,6 @@ See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
|
|
|
985
853
|
| `defaultEffect` | `"deny"` (default) or `"allow"` |
|
|
986
854
|
| `onDecision` | Listener called on every evaluation |
|
|
987
855
|
| `onConditionError` | Called when a condition throws (fail-closed) |
|
|
988
|
-
| `asyncConditions` | *(deprecated)* When true, sync methods throw immediately. Will be removed in v2. Async conditions are now detected automatically. |
|
|
989
856
|
| `strictTenancy` | Throw if tenantId is omitted for tenant-scoped subjects |
|
|
990
857
|
| `roleHierarchy` | A `RoleHierarchy` instance |
|
|
991
858
|
| `cacheSize` | LRU cache capacity (0 = disabled) |
|
|
@@ -1051,19 +918,35 @@ Returned by `engine.explain()`:
|
|
|
1051
918
|
- `allowed`, `effect`, `reason`, `durationMs`
|
|
1052
919
|
- `evaluatedRules` — array of `RuleEvaluation<S>` with per-rule and per-condition details
|
|
1053
920
|
|
|
921
|
+
</details>
|
|
922
|
+
|
|
1054
923
|
---
|
|
1055
924
|
|
|
1056
|
-
##
|
|
925
|
+
## When NOT to use this
|
|
1057
926
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
5. **Deny by default.** If no rule matches, the answer is no.
|
|
1063
|
-
6. **Fail closed.** If a condition throws, the answer is no.
|
|
1064
|
-
7. **Zero dependencies.** The core engine, server, and middleware use nothing outside Node.js built-ins.
|
|
927
|
+
- **You need a full policy language.** If you want Rego (OPA) or Cedar (AWS), this isn't that. Sentinel policies are TypeScript code.
|
|
928
|
+
- **You need Zanzibar-style relationship graphs.** For "who can access this Google Doc?" with nested sharing, use [SpiceDB](https://authzed.com/) or [OpenFGA](https://openfga.dev/).
|
|
929
|
+
- **You need a hosted authorization service.** Look at [Permit.io](https://permit.io/) or [Oso Cloud](https://www.osohq.com/).
|
|
930
|
+
- **Your model is truly just CRUD.** If `create`/`read`/`update`/`delete` is all you need with no tenants, simpler libraries may suffice.
|
|
1065
931
|
|
|
1066
|
-
|
|
932
|
+
## Security
|
|
933
|
+
|
|
934
|
+
- **Deny by default.** No rule match = no access.
|
|
935
|
+
- **Fail closed.** Condition throws = `false`. No silent privilege escalation.
|
|
936
|
+
- **Frozen rules.** Rules are `Object.freeze`'d on add. Mutation after insertion is impossible.
|
|
937
|
+
- **Cache safety.** Only unconditional evaluations are cached. Conditional results are never cached.
|
|
938
|
+
- **Strict tenancy.** Throws if `tenantId` is omitted for tenant-scoped subjects.
|
|
939
|
+
- **Import validation.** `importRulesFromJson()` validates the `effect` field and rejects invalid values.
|
|
940
|
+
- **Server hardening.** `createAuthServer` supports `authenticate` callback and configurable `maxBodyBytes`.
|
|
941
|
+
|
|
942
|
+
See [SECURITY.md](./SECURITY.md) for responsible disclosure.
|
|
943
|
+
|
|
944
|
+
## What's New in 0.4.1
|
|
945
|
+
|
|
946
|
+
- **Async conditions without opt-in** — No more `asyncConditions: true` flag. Use `evaluateAsync()`, `explainAsync()`, or `permittedAsync()` when you have async conditions; the engine detects them automatically.
|
|
947
|
+
- `asyncConditions` option deprecated (will be removed in v2)
|
|
948
|
+
|
|
949
|
+
See the full [CHANGELOG](./CHANGELOG.md).
|
|
1067
950
|
|
|
1068
951
|
## Contributing
|
|
1069
952
|
|