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