@diister/quick-permission 0.9.0-beta.5 → 0.9.0-beta.6
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 +170 -668
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,747 +1,249 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @diister/quick-permission
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Declarative, type-safe permissions that answer three questions at once: **may
|
|
4
|
+
this subject act?**, **on which rows?**, and **which fields come back?**
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
A check returns not just a yes or no, but the MongoDB constraints — and, when a
|
|
7
|
+
rule reaches across collections, the aggregation pipeline — needed to enforce
|
|
8
|
+
the same decision on a list query.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
await permSystem.can(user, "article.read", articleId); // Accepts ArticleId
|
|
11
|
-
await permSystem.can(user, "article.create"); // Does not accept argument
|
|
10
|
+
[](https://www.npmjs.com/package/@diister/quick-permission)
|
|
11
|
+
[](https://jsr.io/@diister/quick-permission)
|
|
12
|
+
[](LICENSE)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
const result = await permSystem.can(user, "article.read", "article:1");
|
|
15
|
-
result.output?.filter // Type: Record<string, boolean> | undefined
|
|
16
|
-
result.output?.data // Type: any
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
### 🔥 **Field-Level Permissions with Accumulation**
|
|
20
|
-
```typescript
|
|
21
|
-
// Multiple permissions can contribute
|
|
22
|
-
// Owner permission : { filter: { _id, title, owner, salary } }
|
|
23
|
-
// Public permission : { filter: { _id, title, body } }
|
|
24
|
-
// Contributor permission : { filter: { views } }
|
|
14
|
+
Runs on Bun, Node.js 20.11+ and Deno. No runtime dependencies.
|
|
25
15
|
|
|
26
|
-
|
|
27
|
-
// result.output.data = { _id, title, body, owner, salary, views }
|
|
28
|
-
// ↑ Union of all filters!
|
|
29
|
-
```
|
|
16
|
+
## Installation
|
|
30
17
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{ ...ctx, key: "article.read" },
|
|
35
|
-
{ ...ctx, key: "article.update" },
|
|
36
|
-
{ ...ctx, key: "article.comment.manage", target: [ctx.target, "*"] }
|
|
37
|
-
])
|
|
18
|
+
```bash
|
|
19
|
+
# Bun / npm / pnpm / yarn
|
|
20
|
+
bun add @diister/quick-permission
|
|
38
21
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
### 🧩 **Providers + Rules Architecture**
|
|
43
|
-
```typescript
|
|
44
|
-
const permSystem = createPermissionSystem({
|
|
45
|
-
schemas: permissionsSchemas,
|
|
46
|
-
sources: [
|
|
47
|
-
directProvider(staticPerms),
|
|
48
|
-
ownerProvider(["article.read"], "article:*"),
|
|
49
|
-
customDatabaseProvider(),
|
|
50
|
-
],
|
|
51
|
-
rules: [TimeRule(), IpRule(), WithRule()],
|
|
52
|
-
});
|
|
22
|
+
# Deno
|
|
23
|
+
deno add jsr:@diister/quick-permission
|
|
53
24
|
```
|
|
54
25
|
|
|
55
|
-
|
|
26
|
+
## Quick start
|
|
56
27
|
|
|
57
|
-
|
|
28
|
+
A **resource** says how to load a document. A **permission** says what target it
|
|
29
|
+
takes and which rules must hold. A **provider** says which grants a subject
|
|
30
|
+
carries.
|
|
58
31
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
```typescript
|
|
32
|
+
```ts
|
|
62
33
|
import {
|
|
63
|
-
|
|
34
|
+
createSystem,
|
|
64
35
|
permission,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
ownerProvider,
|
|
69
|
-
} from "./library_2/mod.ts";
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### 1. Define Permissions
|
|
73
|
-
|
|
74
|
-
```typescript
|
|
75
|
-
import { permission, intermediate, FilterRule } from "./library_2/mod.ts";
|
|
76
|
-
|
|
77
|
-
// Resource fetchers
|
|
78
|
-
async function getArticle(id: string) {
|
|
79
|
-
return db.articles.findById(id);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function getComment(ids: [string, string]) {
|
|
83
|
-
const [articleId, commentId] = ids;
|
|
84
|
-
return db.comments.findById(commentId, { articleId });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Permission schemas
|
|
88
|
-
const permissionsSchemas = {
|
|
89
|
-
// Simple permission
|
|
90
|
-
"article.create": permission(),
|
|
91
|
-
|
|
92
|
-
// Permission with context + field filtering
|
|
93
|
-
"article.read": permission(getArticle, [FilterRule()] as const),
|
|
94
|
-
|
|
95
|
-
// Permission with context but without rules
|
|
96
|
-
"article.update": permission(getArticle),
|
|
97
|
-
|
|
98
|
-
// Intermediate permission (expand to children)
|
|
99
|
-
"article.manage": intermediate(
|
|
100
|
-
(ctx) => [
|
|
101
|
-
{ subject: ctx.subject, key: "article.read", target: ctx.target },
|
|
102
|
-
{ subject: ctx.subject, key: "article.update", target: ctx.target },
|
|
103
|
-
{ subject: ctx.subject, key: "article.delete", target: ctx.target },
|
|
104
|
-
],
|
|
105
|
-
getArticle
|
|
106
|
-
),
|
|
107
|
-
|
|
108
|
-
// Permission with nested context
|
|
109
|
-
"article.comment.delete": permission(getComment),
|
|
110
|
-
};
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
### 2. Create Providers
|
|
114
|
-
|
|
115
|
-
**Providers** are sources of permissions. They return the permissions for a subject.
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
import { directProvider, ownerProvider } from "./library_2/mod.ts";
|
|
119
|
-
import type { PermissionProvider } from "./library_2/mod.ts";
|
|
120
|
-
|
|
121
|
-
// Provider 1: Static permissions
|
|
122
|
-
const staticPerms = directProvider([
|
|
123
|
-
{
|
|
124
|
-
subject: adminUser,
|
|
125
|
-
key: "article.create",
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
subject: moderatorUser,
|
|
129
|
-
key: "article.update",
|
|
130
|
-
target: "article:*",
|
|
131
|
-
},
|
|
132
|
-
]);
|
|
133
|
-
|
|
134
|
-
// Provider 2: Ownership-based permissions
|
|
135
|
-
const ownerPerms = ownerProvider(
|
|
136
|
-
["article.read", "article.update", "article.delete"],
|
|
137
|
-
"article:*",
|
|
138
|
-
{
|
|
139
|
-
filter: { _id: true, title: true, body: true, owner: true }
|
|
140
|
-
}
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
// Provider 3: Custom provider (e.g., from database)
|
|
144
|
-
function databaseProvider(): PermissionProvider {
|
|
145
|
-
return {
|
|
146
|
-
provide: async (subject, _key, _target) => {
|
|
147
|
-
const userPerms = await db.permissions.find({ userId: subject.id });
|
|
148
|
-
return userPerms.map(p => ({
|
|
149
|
-
subject,
|
|
150
|
-
key: p.permissionKey,
|
|
151
|
-
target: p.target,
|
|
152
|
-
filter: p.filter,
|
|
153
|
-
}));
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Provider 4: Conditional provider
|
|
159
|
-
function publicArticleProvider(): PermissionProvider {
|
|
160
|
-
return {
|
|
161
|
-
provide: (subject, _key, _target) => {
|
|
162
|
-
return Promise.resolve([
|
|
163
|
-
{
|
|
164
|
-
key: "article.read",
|
|
165
|
-
subject,
|
|
166
|
-
target: "*",
|
|
167
|
-
with: { public: true }, // Condition: article must be public
|
|
168
|
-
filter: { _id: true, title: true, body: true },
|
|
169
|
-
},
|
|
170
|
-
]);
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### 3. Create the Permission System
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
import { createPermissionSystem, TimeRule, IpRule, WithRule } from "./library_2/mod.ts";
|
|
180
|
-
|
|
181
|
-
const permSystem = createPermissionSystem({
|
|
182
|
-
schemas: permissionsSchemas,
|
|
183
|
-
|
|
184
|
-
sources: [
|
|
185
|
-
staticPerms,
|
|
186
|
-
ownerPerms,
|
|
187
|
-
publicArticleProvider(),
|
|
188
|
-
databaseProvider(),
|
|
189
|
-
],
|
|
190
|
-
|
|
191
|
-
// Global validation rules (applied to ALL permissions)
|
|
192
|
-
rules: [
|
|
193
|
-
TimeRule(), // Validate time constraints (startDate, endDate)
|
|
194
|
-
IpRule(), // Validate IP restrictions
|
|
195
|
-
WithRule(), // Validate resource constraints
|
|
196
|
-
] as const,
|
|
36
|
+
resource,
|
|
37
|
+
target,
|
|
38
|
+
} from "@diister/quick-permission";
|
|
197
39
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
})
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### 4. Check Permissions
|
|
204
|
-
|
|
205
|
-
```typescript
|
|
206
|
-
const user = { id: "user:123" };
|
|
207
|
-
|
|
208
|
-
// Permission without context
|
|
209
|
-
const canCreate = await permSystem.can(user, "article.create");
|
|
210
|
-
if (canCreate.ok) {
|
|
211
|
-
// User can create articles
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Permission with context (target)
|
|
215
|
-
const canRead = await permSystem.can(user, "article.read", "article:1");
|
|
216
|
-
if (canRead.ok) {
|
|
217
|
-
// User can read article:1
|
|
218
|
-
console.log("Allowed fields:", canRead.output?.filter);
|
|
219
|
-
console.log("Filtered data:", canRead.output?.data);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Create a checker with shared context + cache
|
|
223
|
-
const checker = permSystem.context({
|
|
224
|
-
subject: user,
|
|
225
|
-
checkDate: new Date("2024-01-01"),
|
|
226
|
-
ips: ["192.168.1.1"],
|
|
40
|
+
const userOf = resource({
|
|
41
|
+
id: "user",
|
|
42
|
+
fetch: ({ target }) => db.users.findOne({ _id: target[0] }),
|
|
227
43
|
});
|
|
228
44
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
## 🧪 Complete Examples
|
|
238
|
-
|
|
239
|
-
### Example 1: Blog with Field Filtering
|
|
240
|
-
|
|
241
|
-
```typescript
|
|
242
|
-
import { createPermissionSystem, permission, FilterRule, ownerProvider } from "./library_2/mod.ts";
|
|
243
|
-
|
|
244
|
-
type Article = {
|
|
245
|
-
_id: string;
|
|
246
|
-
title: string;
|
|
247
|
-
body: string;
|
|
248
|
-
owner: string;
|
|
249
|
-
salary?: number;
|
|
250
|
-
secret?: string;
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
async function getArticle(id: string): Promise<Article | undefined> {
|
|
254
|
-
return db.articles.findById(id);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const schemas = {
|
|
258
|
-
"article.read": permission(getArticle, [FilterRule()] as const),
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const permSystem = createPermissionSystem({
|
|
262
|
-
schemas,
|
|
263
|
-
sources: [
|
|
264
|
-
// Public users see basic fields
|
|
265
|
-
publicProvider({
|
|
266
|
-
filter: { _id: true, title: true, body: true }
|
|
267
|
-
}),
|
|
268
|
-
|
|
269
|
-
// Owners see more fields
|
|
270
|
-
ownerProvider(["article.read"], "article:*", {
|
|
271
|
-
filter: { _id: true, title: true, body: true, owner: true, salary: true }
|
|
272
|
-
}),
|
|
273
|
-
|
|
274
|
-
// Admins see everything
|
|
275
|
-
adminProvider({
|
|
276
|
-
filter: { _id: true, title: true, body: true, owner: true, salary: true, secret: true }
|
|
277
|
-
}),
|
|
45
|
+
const sys = createSystem({
|
|
46
|
+
schema: {
|
|
47
|
+
"users.read": permission({ target: target.required("user") })
|
|
48
|
+
.rules([userOf.match()]),
|
|
49
|
+
},
|
|
50
|
+
providers: [
|
|
51
|
+
(subject) => grantsFor(subject), // [{ key: "users.read", target: ["user:abc"] }]
|
|
278
52
|
],
|
|
279
53
|
});
|
|
280
54
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
55
|
+
const result = await sys
|
|
56
|
+
.context({ subject: { id: "user:caller" } })
|
|
57
|
+
.can("users.read", ["user:abc"]);
|
|
284
58
|
|
|
285
|
-
|
|
286
|
-
// result2.output.data = { _id, title, body, owner, salary } ← more fields
|
|
59
|
+
result.ok; // true
|
|
287
60
|
```
|
|
288
61
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
```typescript
|
|
292
|
-
const schemas = {
|
|
293
|
-
"article.read": permission(getArticle),
|
|
294
|
-
"article.update": permission(getArticle),
|
|
295
|
-
"article.delete": permission(getArticle),
|
|
296
|
-
|
|
297
|
-
// Intermediate permission
|
|
298
|
-
"article.manage": intermediate(
|
|
299
|
-
(ctx) => [
|
|
300
|
-
{ ...ctx, key: "article.read" },
|
|
301
|
-
{ ...ctx, key: "article.update" },
|
|
302
|
-
{ ...ctx, key: "article.delete" },
|
|
303
|
-
],
|
|
304
|
-
getArticle
|
|
305
|
-
),
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
const permSystem = createPermissionSystem({
|
|
309
|
-
schemas,
|
|
310
|
-
sources: [
|
|
311
|
-
directProvider([
|
|
312
|
-
{ subject: admin, key: "article.manage", target: "*" }
|
|
313
|
-
])
|
|
314
|
-
],
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// Admin has article.manage, which expands to read + update + delete
|
|
318
|
-
const canRead = await permSystem.can(admin, "article.read", "article:1");
|
|
319
|
-
// ✅ true (via article.manage)
|
|
62
|
+
## Core concepts
|
|
320
63
|
|
|
321
|
-
|
|
322
|
-
// ✅ true (via article.manage)
|
|
323
|
-
```
|
|
64
|
+
### Targets
|
|
324
65
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
import { WithRule } from "./library_2/mod.ts";
|
|
329
|
-
|
|
330
|
-
const schemas = {
|
|
331
|
-
"article.read": permission(getArticle),
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
const permSystem = createPermissionSystem({
|
|
335
|
-
schemas,
|
|
336
|
-
sources: [
|
|
337
|
-
// Only on public articles
|
|
338
|
-
{
|
|
339
|
-
provide: (subject) => Promise.resolve([{
|
|
340
|
-
subject,
|
|
341
|
-
key: "article.read",
|
|
342
|
-
target: "*",
|
|
343
|
-
with: { public: true }, // ← Condition
|
|
344
|
-
}])
|
|
345
|
-
},
|
|
346
|
-
|
|
347
|
-
// Only if user is author
|
|
348
|
-
{
|
|
349
|
-
provide: (subject) => Promise.resolve([{
|
|
350
|
-
subject,
|
|
351
|
-
key: "article.read",
|
|
352
|
-
target: "*",
|
|
353
|
-
with: { author: subject.id }, // ← Condition
|
|
354
|
-
}])
|
|
355
|
-
},
|
|
356
|
-
],
|
|
357
|
-
rules: [WithRule()] as const,
|
|
358
|
-
});
|
|
66
|
+
A permission declares the shape of what it acts on. Targets are segment tuples,
|
|
67
|
+
so `["user:abc"]` and `["org:1", "project:2"]` are both valid — for their
|
|
68
|
+
respective declarations.
|
|
359
69
|
|
|
360
|
-
|
|
70
|
+
```ts
|
|
71
|
+
target.required("user") // exactly one user segment
|
|
72
|
+
target.optional("user") // the segment may be omitted
|
|
73
|
+
target.none() // the permission acts on nothing in particular
|
|
74
|
+
target.path("org", "project") // a hierarchy
|
|
361
75
|
```
|
|
362
76
|
|
|
363
|
-
|
|
77
|
+
Grants may use `*` as a segment wildcard: a grant on `["user:*"]` matches any
|
|
78
|
+
user.
|
|
364
79
|
|
|
365
|
-
|
|
366
|
-
import { createPermissionSystem, permission, FilterRule } from "./library_2/mod.ts";
|
|
80
|
+
### Resources
|
|
367
81
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
"article.update": permission(getArticle),
|
|
371
|
-
"article.delete": permission(getArticle),
|
|
372
|
-
};
|
|
82
|
+
A resource is a named loader. The engine calls `fetch` at most once per distinct
|
|
83
|
+
target within a check, and `dedupKey` lets you widen or narrow that sharing.
|
|
373
84
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
85
|
+
```ts
|
|
86
|
+
const postOf = resource({
|
|
87
|
+
id: "post",
|
|
88
|
+
fetch: ({ subject, target, grant }) => db.posts.findOne({ _id: target[0] }),
|
|
89
|
+
activeWhen: (grant) => grant.with?.post !== undefined, // skip when irrelevant
|
|
90
|
+
dedupKey: ({ target }) => String(target[0]),
|
|
377
91
|
});
|
|
378
|
-
|
|
379
|
-
// WITHOUT context(): each permission triggers a fetch
|
|
380
|
-
await permSystem.can(user, "article.read", "article:1"); // FETCH #1
|
|
381
|
-
await permSystem.can(user, "article.update", "article:1"); // FETCH #2
|
|
382
|
-
await permSystem.can(user, "article.delete", "article:1"); // FETCH #3
|
|
383
|
-
|
|
384
|
-
// WITH context(): cache is shared between all can()
|
|
385
|
-
const checker = permSystem.context({ subject: user });
|
|
386
|
-
await checker.can("article.read", "article:1"); // FETCH #1
|
|
387
|
-
await checker.can("article.update", "article:1"); // CACHE HIT ✨
|
|
388
|
-
await checker.can("article.delete", "article:1"); // CACHE HIT ✨
|
|
389
|
-
|
|
390
|
-
// Cache also works in nested functions
|
|
391
|
-
async function checkAllPermissions(checker, articleId: string) {
|
|
392
|
-
const canRead = await checker.can("article.read", articleId); // CACHE HIT
|
|
393
|
-
const canUpdate = await checker.can("article.update", articleId); // CACHE HIT
|
|
394
|
-
return { canRead: canRead.ok, canUpdate: canUpdate.ok };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const perms = await checkAllPermissions(checker, "article:1");
|
|
398
|
-
// ↑ No new fetch! Everything is cached
|
|
399
92
|
```
|
|
400
93
|
|
|
401
|
-
|
|
402
|
-
- ✅ Cache shared between all `can()` of the same checker
|
|
403
|
-
- ✅ Custom context (dates, IPs, metadata) in all calls
|
|
404
|
-
- ✅ Elegant API: `checker.can(key, target)` instead of `can(subject, key, target)`
|
|
405
|
-
- ✅ No need for callbacks/closures (unlike AsyncContext)
|
|
94
|
+
### Rules
|
|
406
95
|
|
|
407
|
-
|
|
96
|
+
Rules are what a permission checks. Every resource carries sugar methods for the
|
|
97
|
+
common ones:
|
|
408
98
|
|
|
409
|
-
|
|
99
|
+
| Method | Holds when |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `match(extract?)` | the fetched value equals the grant's constraint |
|
|
102
|
+
| `filter(extract?)` | contributes a field filter rather than a verdict |
|
|
103
|
+
| `includes(field, extract)` | the grant's `field` is in the extracted list |
|
|
104
|
+
| `requireTruthy()` | the document exists and is truthy |
|
|
105
|
+
| `requireOwner(get, { flag })` | the subject owns the document |
|
|
106
|
+
| `requireMembership(get, { flag })` | the subject is in the extracted list |
|
|
107
|
+
| `requireCustom(predicate, opts)` | your own predicate holds |
|
|
410
108
|
|
|
411
|
-
|
|
109
|
+
For anything else, `defineRule` is the primitive they are all built on:
|
|
412
110
|
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
↓
|
|
416
|
-
2. Call all providers → [permissions]
|
|
417
|
-
↓
|
|
418
|
-
3. Resolve intermediates (recursive, max depth: 10) → [resolved permissions]
|
|
419
|
-
↓
|
|
420
|
-
4. Filter by key + target matching → [matched permissions]
|
|
421
|
-
↓
|
|
422
|
-
5. Apply global rules (Time, IP, With) → [valid permissions]
|
|
423
|
-
↓
|
|
424
|
-
6. Fetch resource (with cache if context()) ✨
|
|
425
|
-
↓
|
|
426
|
-
7. Apply output rules (FilterRule) → outputs
|
|
427
|
-
↓
|
|
428
|
-
8. Merge outputs (union strategy) → final output
|
|
429
|
-
↓
|
|
430
|
-
9. Return { ok: true, output }
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
**Note on caching**: Without `context()`, each `can()` creates its own local cache. With `context()`, all `can()` of the same checker share the same cache Map.
|
|
434
|
-
|
|
435
|
-
### Components
|
|
436
|
-
|
|
437
|
-
| Component | Role | Example |
|
|
438
|
-
|-----------|------|---------|
|
|
439
|
-
| **Permission** | Defines a permission with optional context | `permission(getArticle)` |
|
|
440
|
-
| **Intermediate** | Permission that resolves to other permissions | `intermediate((ctx) => [...])` |
|
|
441
|
-
| **Provider** | Source of permissions for a subject | `ownerProvider(...)` |
|
|
442
|
-
| **Global Rule** | Global validation (applied to all permissions) | `TimeRule()`, `IpRule()` |
|
|
443
|
-
| **Output Rule** | Generates metadata/output | `FilterRule()` |
|
|
444
|
-
|
|
445
|
-
---
|
|
446
|
-
|
|
447
|
-
## 🎨 Advanced Patterns
|
|
448
|
-
|
|
449
|
-
### Pattern 1: Multi-Tenant with Organizations
|
|
450
|
-
|
|
451
|
-
```typescript
|
|
452
|
-
const schemas = {
|
|
453
|
-
"org.member.read": permission(getOrgMember, [FilterRule()] as const),
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
function orgMemberProvider(): PermissionProvider {
|
|
457
|
-
return {
|
|
458
|
-
provide: async (subject, _key, _target) => {
|
|
459
|
-
// Get user's organizations
|
|
460
|
-
const orgs = await db.orgMembers.find({ userId: subject.id });
|
|
461
|
-
|
|
462
|
-
return orgs.map(org => ({
|
|
463
|
-
subject,
|
|
464
|
-
key: "org.member.read",
|
|
465
|
-
target: `org:${org.orgId}:member:*`,
|
|
466
|
-
filter: org.role === 'admin'
|
|
467
|
-
? { _id: true, name: true, email: true, salary: true }
|
|
468
|
-
: { _id: true, name: true, email: true },
|
|
469
|
-
}));
|
|
470
|
-
}
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
```
|
|
111
|
+
```ts
|
|
112
|
+
import { defineRule } from "@diister/quick-permission";
|
|
474
113
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
name: "rateLimit",
|
|
483
|
-
output: ({ state, currentOutput }) => {
|
|
484
|
-
const current = currentOutput?.rateLimit?.max || 0;
|
|
485
|
-
const incoming = state.rateLimit?.max || 0;
|
|
486
|
-
|
|
487
|
-
return {
|
|
488
|
-
rateLimit: {
|
|
489
|
-
max: Math.max(current, incoming), // Most permissive
|
|
490
|
-
window: state.rateLimit?.window || "1h"
|
|
491
|
-
}
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Usage
|
|
498
|
-
const schemas = {
|
|
499
|
-
"api.call": permission(undefined, [RateLimitRule()] as const),
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
const result = await permSystem.can(user, "api.call");
|
|
503
|
-
// result.output.rateLimit = { max: 1000, window: "1h" }
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
### Pattern 3: Temporary Permissions
|
|
507
|
-
|
|
508
|
-
```typescript
|
|
509
|
-
import { TimeRule } from "./library_2/mod.ts";
|
|
510
|
-
|
|
511
|
-
const permSystem = createPermissionSystem({
|
|
512
|
-
schemas,
|
|
513
|
-
sources: [
|
|
514
|
-
{
|
|
515
|
-
provide: (subject) => Promise.resolve([{
|
|
516
|
-
subject,
|
|
517
|
-
key: "article.read",
|
|
518
|
-
target: "article:1",
|
|
519
|
-
startDate: new Date("2024-01-01"),
|
|
520
|
-
endDate: new Date("2024-12-31"),
|
|
521
|
-
}])
|
|
522
|
-
}
|
|
523
|
-
],
|
|
524
|
-
rules: [TimeRule()] as const,
|
|
114
|
+
const isPublished = defineRule({
|
|
115
|
+
kind: "post.published",
|
|
116
|
+
needs: [postOf],
|
|
117
|
+
check: ([post]) =>
|
|
118
|
+
post?.status === "published"
|
|
119
|
+
? true
|
|
120
|
+
: { ok: false, reason: "post is not published" },
|
|
525
121
|
});
|
|
526
|
-
|
|
527
|
-
// Only works between 2024-01-01 and 2024-12-31
|
|
528
122
|
```
|
|
529
123
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
## 🚀 Performance
|
|
533
|
-
|
|
534
|
-
### Built-in Optimizations
|
|
124
|
+
`check` returns a boolean for a plain verdict, or `{ ok: false, reason }` when
|
|
125
|
+
the caller deserves to know why.
|
|
535
126
|
|
|
536
|
-
|
|
537
|
-
2. **Resource Fetching**: Resource is fetched **only once** per permission check
|
|
538
|
-
3. **Short-Circuit**: Global rules stop validation as soon as one fails
|
|
539
|
-
4. **Lazy Evaluation**: Intermediates are resolved only if necessary
|
|
540
|
-
|
|
541
|
-
### Tips for Best Performance
|
|
542
|
-
|
|
543
|
-
```typescript
|
|
544
|
-
// ✅ GOOD: Put most restrictive rules first
|
|
545
|
-
rules: [
|
|
546
|
-
IpRule(), // Fast, filters many
|
|
547
|
-
WithRule(), // May fetch resource
|
|
548
|
-
TimeRule(), // Fast
|
|
549
|
-
]
|
|
127
|
+
### Providers
|
|
550
128
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
alwaysAllowProvider(), // ← Useless if you have this
|
|
554
|
-
ownerProvider(...), // ← Never used
|
|
555
|
-
]
|
|
129
|
+
A provider turns a subject into grants. Several may be combined, and a subject
|
|
130
|
+
is allowed when any single grant satisfies the permission's rules.
|
|
556
131
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
132
|
+
```ts
|
|
133
|
+
providers: [
|
|
134
|
+
(subject) => db.grants.find({ userId: subject.id }).toArray(),
|
|
135
|
+
(subject) => (subject.isAdmin ? [{ key: "*", target: ["*"] }] : []),
|
|
561
136
|
]
|
|
562
137
|
```
|
|
563
138
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
## 🔧 API Reference
|
|
139
|
+
## Filtering lists, not just single documents
|
|
567
140
|
|
|
568
|
-
|
|
141
|
+
This is what the library is really for. `can()` hands back the query fragments
|
|
142
|
+
needed to apply the same decision to a list.
|
|
569
143
|
|
|
570
|
-
|
|
144
|
+
```ts
|
|
145
|
+
const result = await sys.context({ subject }).can("posts.list");
|
|
146
|
+
if (!result.ok) return [];
|
|
571
147
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const permSystem = createPermissionSystem({
|
|
576
|
-
schemas: PermissionSchemas,
|
|
577
|
-
sources: PermissionProvider[],
|
|
578
|
-
rules?: readonly PermissionRule[],
|
|
579
|
-
maxIntermediateDepth?: number,
|
|
580
|
-
});
|
|
148
|
+
const rows = result.stages
|
|
149
|
+
? await db.posts.aggregate([...result.stages]).toArray()
|
|
150
|
+
: await db.posts.find(result.constraints ?? {}).toArray();
|
|
581
151
|
```
|
|
582
152
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
```typescript
|
|
588
|
-
// No context, no rules
|
|
589
|
-
permission()
|
|
590
|
-
|
|
591
|
-
// With context
|
|
592
|
-
permission<ArticleId>(getArticle)
|
|
153
|
+
`constraints` is a plain MongoDB filter. `stages` appears instead when a rule
|
|
154
|
+
reaches through an **indirect resource** — a join the engine has to express as
|
|
155
|
+
an aggregation, because the deciding data lives in another collection.
|
|
593
156
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
#### `intermediate<C, TRules>(provide, fetchTarget?, rules?)`
|
|
599
|
-
|
|
600
|
-
Creates an intermediate permission.
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
intermediate(
|
|
604
|
-
(ctx) => [
|
|
605
|
-
{ ...ctx, key: "article.read" },
|
|
606
|
-
{ ...ctx, key: "article.update" },
|
|
607
|
-
],
|
|
608
|
-
getArticle
|
|
609
|
-
)
|
|
610
|
-
```
|
|
157
|
+
```ts
|
|
158
|
+
import { indirectResource } from "@diister/quick-permission";
|
|
611
159
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
```typescript
|
|
619
|
-
// Without target
|
|
620
|
-
await permSystem.can(user, "article.create")
|
|
621
|
-
|
|
622
|
-
// With target
|
|
623
|
-
await permSystem.can(user, "article.read", "article:1")
|
|
624
|
-
|
|
625
|
-
// Return
|
|
626
|
-
type PermissionResult<TOutput> = {
|
|
627
|
-
ok: boolean;
|
|
628
|
-
output?: TOutput; // Type inferred from rules
|
|
629
|
-
}
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
#### `permSystem.context(ctx)`
|
|
633
|
-
|
|
634
|
-
Creates a checker with shared context and cache.
|
|
635
|
-
|
|
636
|
-
```typescript
|
|
637
|
-
const checker = permSystem.context({
|
|
638
|
-
subject: user, // Required
|
|
639
|
-
checkDate: new Date(), // Optional (for TimeRule)
|
|
640
|
-
ips: ["192.168.1.1"], // Optional (for IpRule)
|
|
641
|
-
// ... other custom properties
|
|
160
|
+
const membershipsOf = indirectResource({
|
|
161
|
+
id: "memberships_of_participant",
|
|
162
|
+
from: participantOf,
|
|
163
|
+
on: { localField: "_id", foreignField: "participantId" },
|
|
164
|
+
to: { _type: "org_membership" },
|
|
165
|
+
cardinality: "many",
|
|
642
166
|
});
|
|
643
|
-
|
|
644
|
-
// All can() of the checker share the same cache
|
|
645
|
-
await checker.can("article.read", "article:1"); // FETCH
|
|
646
|
-
await checker.can("article.update", "article:1"); // CACHE HIT
|
|
647
167
|
```
|
|
648
168
|
|
|
649
|
-
|
|
169
|
+
Field-level filtering works the same way: `filter()` rules contribute a
|
|
170
|
+
`FilterSpec`, and `applyFilter` applies it to a document.
|
|
650
171
|
|
|
651
|
-
|
|
172
|
+
```ts
|
|
173
|
+
import { applyFilter } from "@diister/quick-permission";
|
|
652
174
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
Provider with static permissions.
|
|
656
|
-
|
|
657
|
-
```typescript
|
|
658
|
-
directProvider([
|
|
659
|
-
{ subject: user1, key: "article.create" },
|
|
660
|
-
{ subject: user2, key: "article.read", target: "article:*" },
|
|
661
|
-
])
|
|
175
|
+
const visible = applyFilter(post, result.data as FilterSpec);
|
|
662
176
|
```
|
|
663
177
|
|
|
664
|
-
|
|
178
|
+
## API reference
|
|
665
179
|
|
|
666
|
-
|
|
180
|
+
### `createSystem({ schema, providers })`
|
|
667
181
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
["article.read", "article.update"],
|
|
671
|
-
"article:*",
|
|
672
|
-
{ filter: { _id: true, title: true } }
|
|
673
|
-
)
|
|
674
|
-
```
|
|
182
|
+
Builds the engine. `schema` maps permission keys to `permission(...)`
|
|
183
|
+
declarations; `providers` lists the grant sources. Returns a `System`:
|
|
675
184
|
|
|
676
|
-
|
|
185
|
+
| Member | Purpose |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `context({ subject })` | bind a subject; returns the surface below |
|
|
188
|
+
| `can(key, target?)` | one-shot check without binding a context |
|
|
189
|
+
| `list()` | every permission key with its metadata |
|
|
190
|
+
| `tree()` | the same, as a hierarchy |
|
|
191
|
+
| `schema` | the declaration you passed in |
|
|
192
|
+
| `indirectResources()` | every indirect resource reachable from the schema |
|
|
193
|
+
| `indirectsUsedBy(key)` | those a given permission depends on |
|
|
677
194
|
|
|
678
|
-
|
|
195
|
+
### `context({ subject })`
|
|
679
196
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
197
|
+
| Member | Purpose |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `can(key, target?)` | run a check; resource fetches are deduplicated across it |
|
|
200
|
+
| `preseed(resourceOrId, target, value)` | supply a document you already hold |
|
|
201
|
+
| `getFetchCounters()` | how many times each resource was fetched |
|
|
202
|
+
| `clearCounters()` | reset those counters |
|
|
203
|
+
| `dumpGrants()` | the grants the providers returned, for debugging |
|
|
683
204
|
|
|
684
|
-
|
|
205
|
+
`preseed` is worth knowing: when the caller already has the document in hand,
|
|
206
|
+
seeding it skips the fetch entirely.
|
|
685
207
|
|
|
686
|
-
|
|
208
|
+
### The result of `can()`
|
|
687
209
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
210
|
+
```ts
|
|
211
|
+
type CanResult =
|
|
212
|
+
| {
|
|
213
|
+
ok: true;
|
|
214
|
+
data?: unknown; // filter spec, when filter() rules ran
|
|
215
|
+
constraints?: Record<string, unknown>; // MongoDB filter for list queries
|
|
216
|
+
stages?: readonly Record<string, unknown>[]; // aggregation, for indirect rules
|
|
217
|
+
matchedGrants?: readonly string[];
|
|
218
|
+
}
|
|
219
|
+
| { ok: false /* … */ };
|
|
697
220
|
```
|
|
698
221
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
Applies a filter to an object.
|
|
702
|
-
|
|
703
|
-
```typescript
|
|
704
|
-
applyFilter(
|
|
705
|
-
{ _id: 1, name: "John", password: "secret" },
|
|
706
|
-
{ _id: true, name: true }
|
|
707
|
-
)
|
|
708
|
-
// → { _id: 1, name: "John" }
|
|
709
|
-
```
|
|
222
|
+
### Other exports
|
|
710
223
|
|
|
711
|
-
|
|
224
|
+
`permission`, `resource`, `defineRule`, `indirectResource`, `target`, `seg`,
|
|
225
|
+
`applyFilter`, `inputMatch`, `intermediate`, `matchPath`, `pickFields`,
|
|
226
|
+
`requireSelf`, and the types they use.
|
|
712
227
|
|
|
713
|
-
|
|
228
|
+
## Development
|
|
714
229
|
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
230
|
+
```bash
|
|
231
|
+
bun install
|
|
232
|
+
bun test # 184 tests
|
|
233
|
+
bun run check # tsc
|
|
234
|
+
bun run lint # biome
|
|
235
|
+
bun run build # dist/ for npm
|
|
721
236
|
```
|
|
722
237
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
| Feature | CASL | Casbin | **Library_2** |
|
|
728
|
-
|---------|------|--------|---------------|
|
|
729
|
-
| Type Safety | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
|
|
730
|
-
| Field-level permissions | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
|
731
|
-
| Auto-filtering | ❌ | ❌ | ✅ |
|
|
732
|
-
| Multi-permission accumulation | ❌ | ❌ | ✅ |
|
|
733
|
-
| Output metadata | ⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
|
|
734
|
-
| Hierarchical permissions | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
|
735
|
-
| Flexible providers | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
|
736
|
-
|
|
737
|
-
---
|
|
738
|
-
|
|
739
|
-
## 📝 License
|
|
740
|
-
|
|
741
|
-
MIT
|
|
238
|
+
The suite targets `node:test`, so it runs unchanged under `bun test`,
|
|
239
|
+
`node --test` and `deno test` — worth it, because Bun runs JavaScriptCore while
|
|
240
|
+
Node and Deno run V8. Five cases need a MongoDB on `localhost:27017`; set
|
|
241
|
+
`MONGO_URL` to point elsewhere.
|
|
742
242
|
|
|
743
|
-
|
|
243
|
+
`sift/` is vendored from [sift.js](https://github.com/crcn/sift.js) (MIT) with
|
|
244
|
+
the `$where` operator removed, and is excluded from lint and formatting so
|
|
245
|
+
re-vendoring stays a clean diff.
|
|
744
246
|
|
|
745
|
-
##
|
|
247
|
+
## License
|
|
746
248
|
|
|
747
|
-
|
|
249
|
+
MIT — see [LICENSE](LICENSE).
|