@diister/quick-permission 0.9.0-beta.5 → 0.9.0-beta.7
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 +218 -675
- package/dist/mod.d.ts +2 -2
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +1 -1
- package/dist/mod.js.map +1 -1
- package/dist/system.d.ts +45 -70
- package/dist/system.d.ts.map +1 -1
- package/dist/system.js +264 -245
- package/dist/system.js.map +1 -1
- package/mod.ts +9 -1
- package/package.json +1 -1
- package/system.ts +349 -325
package/README.md
CHANGED
|
@@ -1,747 +1,290 @@
|
|
|
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
|
-
|
|
22
|
+
# Deno
|
|
23
|
+
deno add jsr:@diister/quick-permission
|
|
40
24
|
```
|
|
41
25
|
|
|
42
|
-
|
|
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
|
-
});
|
|
53
|
-
```
|
|
54
|
-
|
|
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
|
-
|
|
40
|
+
const userOf = resource({
|
|
41
|
+
id: "user",
|
|
42
|
+
fetch: ({ target }) => db.users.findOne({ _id: target[0] }),
|
|
200
43
|
});
|
|
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"],
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// All calls to checker.can() share the same cache!
|
|
230
|
-
const r1 = await checker.can("article.read", "article:1"); // FETCH
|
|
231
|
-
const r2 = await checker.can("article.update", "article:1"); // CACHE HIT
|
|
232
|
-
const r3 = await checker.can("article.delete", "article:1"); // CACHE HIT
|
|
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
44
|
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
});
|
|
62
|
+
## Core concepts
|
|
316
63
|
|
|
317
|
-
|
|
318
|
-
const canRead = await permSystem.can(admin, "article.read", "article:1");
|
|
319
|
-
// ✅ true (via article.manage)
|
|
64
|
+
### Targets
|
|
320
65
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
324
69
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
});
|
|
359
|
-
|
|
360
|
-
// Only works if article.public === true OR article.author === user.id
|
|
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
|
-
```
|
|
400
|
-
|
|
401
|
-
**Advantages of `context()`:**
|
|
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)
|
|
406
|
-
|
|
407
|
-
---
|
|
408
|
-
|
|
409
|
-
## 🏗️ Architecture
|
|
410
|
-
|
|
411
|
-
### Validation Flow
|
|
412
|
-
|
|
413
92
|
```
|
|
414
|
-
1. permSystem.can(subject, key, target) or checker.can(key, target)
|
|
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
93
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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,
|
|
94
|
+
### Rules
|
|
95
|
+
|
|
96
|
+
Rules are what a permission checks. Every resource carries sugar methods for the
|
|
97
|
+
common ones:
|
|
98
|
+
|
|
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 |
|
|
108
|
+
|
|
109
|
+
For anything else, `defineRule` is the primitive they are all built on:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { defineRule } from "@diister/quick-permission";
|
|
113
|
+
|
|
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
|
|
124
|
+
`check` returns a boolean for a plain verdict, or `{ ok: false, reason }` when
|
|
125
|
+
the caller deserves to know why.
|
|
533
126
|
|
|
534
|
-
###
|
|
127
|
+
### Providers
|
|
535
128
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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. A grant names
|
|
131
|
+
one permission key; there is no wildcard key, so an administrator is granted the
|
|
132
|
+
intermediate keys that expand to everything they hold.
|
|
540
133
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
IpRule(), // Fast, filters many
|
|
547
|
-
WithRule(), // May fetch resource
|
|
548
|
-
TimeRule(), // Fast
|
|
549
|
-
]
|
|
550
|
-
|
|
551
|
-
// ❌ BAD: Everyone has access, providers are useless
|
|
552
|
-
sources: [
|
|
553
|
-
alwaysAllowProvider(), // ← Useless if you have this
|
|
554
|
-
ownerProvider(...), // ← Never used
|
|
555
|
-
]
|
|
556
|
-
|
|
557
|
-
// ✅ GOOD: Specific provider
|
|
558
|
-
sources: [
|
|
559
|
-
roleBasedProvider(), // Return quickly if wrong role
|
|
560
|
-
ownerProvider(...),
|
|
134
|
+
```ts
|
|
135
|
+
providers: [
|
|
136
|
+
(subject) => db.grants.find({ userId: subject.id }).toArray(),
|
|
137
|
+
(subject) =>
|
|
138
|
+
subject.isAdmin ? [{ key: "posts.manage", target: ["post:*"] }] : [],
|
|
561
139
|
]
|
|
562
140
|
```
|
|
563
141
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
## 🔧 API Reference
|
|
567
|
-
|
|
568
|
-
### Core Functions
|
|
569
|
-
|
|
570
|
-
#### `createPermissionSystem<PS, TRules>(config)`
|
|
571
|
-
|
|
572
|
-
Creates a permission system.
|
|
573
|
-
|
|
574
|
-
```typescript
|
|
575
|
-
const permSystem = createPermissionSystem({
|
|
576
|
-
schemas: PermissionSchemas,
|
|
577
|
-
sources: PermissionProvider[],
|
|
578
|
-
rules?: readonly PermissionRule[],
|
|
579
|
-
maxIntermediateDepth?: number,
|
|
580
|
-
});
|
|
581
|
-
```
|
|
142
|
+
A provider can also be an object, which lets the engine skip it and cache it:
|
|
582
143
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
// With context
|
|
592
|
-
permission<ArticleId>(getArticle)
|
|
593
|
-
|
|
594
|
-
// With context + rules
|
|
595
|
-
permission(getArticle, [FilterRule()] as const)
|
|
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
|
-
)
|
|
144
|
+
```ts
|
|
145
|
+
{
|
|
146
|
+
name: "memberships",
|
|
147
|
+
keys: ["posts.manage"],
|
|
148
|
+
targetType: "post",
|
|
149
|
+
cacheKey: (subject) => subject.id,
|
|
150
|
+
fetch: (subject, key, target) => membershipGrants(subject.id, target?.[0]),
|
|
151
|
+
}
|
|
610
152
|
```
|
|
611
153
|
|
|
612
|
-
|
|
154
|
+
| Field | Effect |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `name` | how the provider is named in hooks and in the reasons of a deny |
|
|
157
|
+
| `keys`, `matches` | the keys the provider emits; it is consulted for those and for every key they expand to |
|
|
158
|
+
| `targetType` | consulted only when `target[0]` is one concrete id of that type (`"post:1"`, never `"post:*"`) |
|
|
159
|
+
| `cacheKey` | grants are reused within a `context()` for the same key; return `undefined` to skip the cache |
|
|
160
|
+
| `fetch` | returns the grants |
|
|
613
161
|
|
|
614
|
-
|
|
162
|
+
A provider that throws, or whose `cacheKey` throws, contributes no grant. The
|
|
163
|
+
check goes on with the others, so a failure can only narrow what a subject may
|
|
164
|
+
do. The failure reaches `hooks.onError` and, when the check is denied, its
|
|
165
|
+
`reasons`. A resource or rule that throws rejects only the grant that needed it,
|
|
166
|
+
the same way.
|
|
615
167
|
|
|
616
|
-
|
|
168
|
+
## Filtering lists, not just single documents
|
|
617
169
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
await permSystem.can(user, "article.create")
|
|
170
|
+
This is what the library is really for. `can()` hands back the query fragments
|
|
171
|
+
needed to apply the same decision to a list.
|
|
621
172
|
|
|
622
|
-
|
|
623
|
-
await
|
|
173
|
+
```ts
|
|
174
|
+
const result = await sys.context({ subject }).can("posts.list");
|
|
175
|
+
if (!result.ok) return [];
|
|
624
176
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
output?: TOutput; // Type inferred from rules
|
|
629
|
-
}
|
|
177
|
+
const rows = result.stages
|
|
178
|
+
? await db.posts.aggregate([...result.stages]).toArray()
|
|
179
|
+
: await db.posts.find(result.constraints ?? {}).toArray();
|
|
630
180
|
```
|
|
631
181
|
|
|
632
|
-
|
|
182
|
+
`constraints` is a plain MongoDB filter. `stages` appears instead when a rule
|
|
183
|
+
reaches through an **indirect resource** — a join the engine has to express as
|
|
184
|
+
an aggregation, because the deciding data lives in another collection.
|
|
633
185
|
|
|
634
|
-
|
|
186
|
+
```ts
|
|
187
|
+
import { indirectResource } from "@diister/quick-permission";
|
|
635
188
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
189
|
+
const membershipsOf = indirectResource({
|
|
190
|
+
id: "memberships_of_participant",
|
|
191
|
+
from: participantOf,
|
|
192
|
+
on: { localField: "_id", foreignField: "participantId" },
|
|
193
|
+
to: { _type: "org_membership" },
|
|
194
|
+
cardinality: "many",
|
|
642
195
|
});
|
|
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
|
-
```
|
|
648
|
-
|
|
649
|
-
**Return**: `{ can<K>(key, ...target?) => Promise<PermissionResult<...>> }`
|
|
650
|
-
|
|
651
|
-
### Built-in Providers
|
|
652
|
-
|
|
653
|
-
#### `directProvider(permissions)`
|
|
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
|
-
])
|
|
662
196
|
```
|
|
663
197
|
|
|
664
|
-
|
|
198
|
+
Field-level filtering works the same way: `filter()` rules contribute a
|
|
199
|
+
`FilterSpec`, and `applyFilter` applies it to a document.
|
|
665
200
|
|
|
666
|
-
|
|
201
|
+
```ts
|
|
202
|
+
import { applyFilter } from "@diister/quick-permission";
|
|
667
203
|
|
|
668
|
-
|
|
669
|
-
ownerProvider(
|
|
670
|
-
["article.read", "article.update"],
|
|
671
|
-
"article:*",
|
|
672
|
-
{ filter: { _id: true, title: true } }
|
|
673
|
-
)
|
|
204
|
+
const visible = applyFilter(post, result.data as FilterSpec);
|
|
674
205
|
```
|
|
675
206
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
207
|
+
## API reference
|
|
208
|
+
|
|
209
|
+
### `createSystem({ schema, providers, hooks })`
|
|
210
|
+
|
|
211
|
+
Builds the engine. `schema` maps permission keys to `permission(...)`
|
|
212
|
+
declarations; `providers` lists the grant sources; `hooks` observes them:
|
|
213
|
+
|
|
214
|
+
| Hook | Called with |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `onProviderFetch` | provider name, key, target, `durationMs`, `grantCount`, for every fetch that was not served from the cache |
|
|
217
|
+
| `onError` | `{ source: "provider", provider, error, … }` or `{ source: "rule", grantId, error, … }` |
|
|
218
|
+
|
|
219
|
+
A hook that throws never changes a decision. `createSystem` returns a `System`:
|
|
220
|
+
|
|
221
|
+
| Member | Purpose |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| `context({ subject })` | bind a subject; returns the surface below |
|
|
224
|
+
| `can(key, target?)` | one-shot check without binding a context |
|
|
225
|
+
| `list()` | every permission key with its metadata |
|
|
226
|
+
| `tree()` | the same, as a hierarchy |
|
|
227
|
+
| `schema` | the declaration you passed in |
|
|
228
|
+
| `indirectResources()` | every indirect resource reachable from the schema |
|
|
229
|
+
| `indirectsUsedBy(key)` | those a given permission depends on |
|
|
230
|
+
|
|
231
|
+
### `context({ subject })`
|
|
232
|
+
|
|
233
|
+
| Member | Purpose |
|
|
234
|
+
| --- | --- |
|
|
235
|
+
| `can(key, target?)` | run a check; resource fetches are deduplicated across it |
|
|
236
|
+
| `preseed(resourceOrId, target, value)` | supply a document you already hold |
|
|
237
|
+
| `getFetchCounters()` | how many times each resource was fetched |
|
|
238
|
+
| `clearCounters()` | reset those counters |
|
|
239
|
+
| `dumpGrants()` | the grants the providers returned, for debugging |
|
|
240
|
+
|
|
241
|
+
`preseed` is worth knowing: when the caller already has the document in hand,
|
|
242
|
+
seeding it skips the fetch entirely.
|
|
243
|
+
|
|
244
|
+
### The result of `can()`
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
type CanResult =
|
|
248
|
+
| {
|
|
249
|
+
ok: true;
|
|
250
|
+
data?: unknown; // filter spec, when filter() rules ran
|
|
251
|
+
constraints?: Record<string, unknown>; // MongoDB filter for list queries
|
|
252
|
+
stages?: readonly Record<string, unknown>[]; // aggregation, for indirect rules
|
|
253
|
+
matchedGrants?: readonly string[];
|
|
254
|
+
}
|
|
255
|
+
| { ok: false /* … */ };
|
|
697
256
|
```
|
|
698
257
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
Applies a filter to an object.
|
|
258
|
+
### Other exports
|
|
702
259
|
|
|
703
|
-
|
|
704
|
-
applyFilter
|
|
705
|
-
|
|
706
|
-
{ _id: true, name: true }
|
|
707
|
-
)
|
|
708
|
-
// → { _id: 1, name: "John" }
|
|
709
|
-
```
|
|
260
|
+
`permission`, `resource`, `defineRule`, `indirectResource`, `target`, `seg`,
|
|
261
|
+
`applyFilter`, `inputMatch`, `intermediate`, `matchPath`, `pickFields`,
|
|
262
|
+
`requireSelf`, and the types they use.
|
|
710
263
|
|
|
711
|
-
|
|
264
|
+
`isCapabilityQuery(target)`, `isWildcardSegment(segment)` and
|
|
265
|
+
`refType(segment)` expose how the engine reads a target: a query with any
|
|
266
|
+
wildcard segment (`"*"` or `"post:*"`) is a capability query, and `refType`
|
|
267
|
+
returns the type of a `"type:id"` segment.
|
|
712
268
|
|
|
713
|
-
|
|
269
|
+
## Development
|
|
714
270
|
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
271
|
+
```bash
|
|
272
|
+
bun install
|
|
273
|
+
bun test # 198 tests
|
|
274
|
+
bun run check # tsc
|
|
275
|
+
bun run lint # biome
|
|
276
|
+
bun run build # dist/ for npm
|
|
721
277
|
```
|
|
722
278
|
|
|
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
|
|
279
|
+
The suite targets `node:test`, so it runs unchanged under `bun test`,
|
|
280
|
+
`node --test` and `deno test` — worth it, because Bun runs JavaScriptCore while
|
|
281
|
+
Node and Deno run V8. Five cases need a MongoDB on `localhost:27017`; set
|
|
282
|
+
`MONGO_URL` to point elsewhere.
|
|
742
283
|
|
|
743
|
-
|
|
284
|
+
`sift/` is vendored from [sift.js](https://github.com/crcn/sift.js) (MIT) with
|
|
285
|
+
the `$where` operator removed, and is excluded from lint and formatting so
|
|
286
|
+
re-vendoring stays a clean diff.
|
|
744
287
|
|
|
745
|
-
##
|
|
288
|
+
## License
|
|
746
289
|
|
|
747
|
-
|
|
290
|
+
MIT — see [LICENSE](LICENSE).
|