@nxgt/janus 0.1.2 → 0.2.0
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 +41 -31
- package/dist/auth/port/types.d.ts +6 -4
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/chunks/{index-658b6mr2.js → index-0xarpm7z.js} +3 -3
- package/dist/chunks/index-0xarpm7z.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js.map +2 -2
- package/dist/conformance/cases/sessions.d.ts.map +1 -1
- package/dist/conformance/cases/users.d.ts.map +1 -1
- package/dist/conformance/index.js +12 -4
- package/dist/conformance/index.js.map +4 -4
- package/dist/errors/janus-error.d.ts +2 -1
- package/dist/errors/janus-error.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/permissions/engine.d.ts +1 -1
- package/dist/permissions/index.js +17 -13
- package/dist/permissions/index.js.map +5 -5
- package/dist/permissions/model.d.ts +41 -30
- package/dist/permissions/model.d.ts.map +1 -1
- package/dist/permissions/resolve.d.ts.map +1 -1
- package/docs/guide/adapters.md +6 -2
- package/docs/guide/errors.md +3 -2
- package/docs/guide/permissions.md +300 -104
- package/docs/guide/vocabulary.md +22 -19
- package/docs/roadmap.md +31 -8
- package/docs/troubleshooting.md +45 -19
- package/package.json +1 -1
- package/dist/chunks/index-658b6mr2.js.map +0 -11
|
@@ -11,16 +11,23 @@ it to `janus()` so the user types become the subjects, but `subjects` takes
|
|
|
11
11
|
any list of names — `defineModel({ subjects: ['user'], … })` — when your users
|
|
12
12
|
live elsewhere. Importing `@nxgt/janus/permissions` loads no identity code.
|
|
13
13
|
|
|
14
|
+
## The running example: a clinic
|
|
15
|
+
|
|
16
|
+
Every snippet on this page uses this model, and only its names. Two sections
|
|
17
|
+
need something else and say so: a [folder tree](#a-hierarchy) and
|
|
18
|
+
[permissions on a user](#permissions-on-a-user).
|
|
19
|
+
|
|
14
20
|
```ts
|
|
15
21
|
import { z } from 'zod';
|
|
16
|
-
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
17
|
-
import { createMemoryRelations, defineModel, permissions } from '@nxgt/janus/permissions';
|
|
22
|
+
import { createMemoryStores, type CursorPage, janus, scryptHasher } from '@nxgt/janus';
|
|
23
|
+
import { createMemoryRelations, defineModel, fromField, permissions, when } from '@nxgt/janus/permissions';
|
|
18
24
|
|
|
19
25
|
const relations = createMemoryRelations();
|
|
20
26
|
|
|
21
27
|
const auth = janus({
|
|
22
28
|
users: {
|
|
23
29
|
staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
|
|
30
|
+
patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
|
|
24
31
|
},
|
|
25
32
|
store: createMemoryStores(),
|
|
26
33
|
relations, // deleting a user deletes every tuple naming them
|
|
@@ -28,26 +35,45 @@ const auth = janus({
|
|
|
28
35
|
});
|
|
29
36
|
|
|
30
37
|
const model = defineModel({
|
|
31
|
-
subjects: auth.types, // 'staff': a user type is a subject type
|
|
38
|
+
subjects: auth.types, // 'staff' | 'patient': a user type is a subject type
|
|
32
39
|
types: {
|
|
33
40
|
team: {
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
related: { members: ['staff', 'team#members'], leads: ['staff'] },
|
|
42
|
+
permits: { manage: ['leads'], view: ['members', 'manage'] },
|
|
43
|
+
},
|
|
44
|
+
record: {
|
|
45
|
+
related: {
|
|
46
|
+
doctors: fromField('doctorId', 'staff', { lookup: (staffId) => db.records.idsByDoctor(staffId) }),
|
|
47
|
+
patients: fromField('patientId', 'patient', { lookup: (patientId) => db.records.idsByPatient(patientId) }),
|
|
48
|
+
teams: ['team'],
|
|
49
|
+
},
|
|
50
|
+
permits: {
|
|
51
|
+
view: ['doctors', 'patients', 'teams->view'],
|
|
52
|
+
review: ['teams->leads'],
|
|
53
|
+
edit: [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)],
|
|
54
|
+
},
|
|
36
55
|
},
|
|
37
56
|
},
|
|
38
57
|
});
|
|
39
58
|
|
|
40
59
|
const access = permissions({ model, store: relations });
|
|
41
60
|
|
|
42
|
-
const grace = await auth.staff.create({ username: 'grace' });
|
|
61
|
+
const grace = await auth.staff.create({ username: 'grace' }); // a doctor
|
|
62
|
+
const ada = await auth.staff.create({ username: 'ada' }); // a team lead
|
|
43
63
|
const team = { type: 'team', id: 't1' } as const;
|
|
44
|
-
|
|
45
|
-
await access.grant(team, 'lead', grace);
|
|
46
|
-
await access.can(grace, 'view', team); // true: view includes manage, which includes lead
|
|
47
|
-
await access.revoke(team, 'lead', grace);
|
|
48
|
-
await access.can(grace, 'view', team); // false
|
|
64
|
+
const record = { id: 'r1', doctorId: grace.id, patientId: 'p1', title: 'Chart' }; // as your database answered it
|
|
49
65
|
```
|
|
50
66
|
|
|
67
|
+
`db` is your database; the two `lookup`s answer the ids of the records whose
|
|
68
|
+
field names the subject — see [`fromField`](#a-relation-read-from-the-object).
|
|
69
|
+
|
|
70
|
+
In words: a team has **members** — staff, or every member of another team —
|
|
71
|
+
and **leads**; whoever leads it may **manage** it, and members and managers
|
|
72
|
+
may **view** it. A record's **doctors** and **patients** are read from its own
|
|
73
|
+
fields, its **teams** are stored; its doctors, its patients and whoever can
|
|
74
|
+
view one of its teams may **view** it, the leads of its teams may **review**
|
|
75
|
+
it, and its doctors may **edit** it while on shift.
|
|
76
|
+
|
|
51
77
|
## The model
|
|
52
78
|
|
|
53
79
|
```ts
|
|
@@ -61,30 +87,42 @@ interface ModelConfig {
|
|
|
61
87
|
readonly subjects: readonly string[]; // pass auth.types
|
|
62
88
|
readonly types: {
|
|
63
89
|
readonly [objectType: string]: {
|
|
64
|
-
readonly
|
|
65
|
-
readonly
|
|
90
|
+
readonly related?: { readonly [relation: string]: readonly string[] | FromField };
|
|
91
|
+
readonly permits?: { readonly [permission: string]: readonly (string | When)[] };
|
|
66
92
|
};
|
|
67
93
|
};
|
|
68
94
|
}
|
|
69
95
|
```
|
|
70
96
|
|
|
97
|
+
An object type has two keys, Keto's words: **`related`**, who may hold each
|
|
98
|
+
relation, and **`permits`**, what each permission is made of. Relation names
|
|
99
|
+
are plural by convention — `members`, `doctors`, `teams` — because a relation
|
|
100
|
+
holds many; `defineModel` does not enforce it.
|
|
101
|
+
|
|
71
102
|
**A relation** lists who may hold it:
|
|
72
103
|
|
|
73
104
|
| Holder | Means |
|
|
74
105
|
| --- | --- |
|
|
75
106
|
| `'staff'` | one user of type `staff` |
|
|
76
107
|
| `'team'` | one object of type `team` — what an arrow follows |
|
|
77
|
-
| `'team#
|
|
78
|
-
| `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#
|
|
108
|
+
| `'team#members'` | a **subject set**: every member of a team |
|
|
109
|
+
| `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#a-relation-read-from-the-object) |
|
|
79
110
|
|
|
80
111
|
**A permission** is the union of its rules:
|
|
81
112
|
|
|
82
113
|
| Rule | Means |
|
|
83
114
|
| --- | --- |
|
|
84
|
-
| `'
|
|
115
|
+
| `'members'` | a relation of the same object |
|
|
85
116
|
| `'manage'` | another permission of the same object |
|
|
86
|
-
| `'
|
|
87
|
-
| `
|
|
117
|
+
| `'teams->view'` | an **arrow**: whoever holds `view` on one of the object's `teams` |
|
|
118
|
+
| `'teams->leads'` | an arrow to a relation: whoever leads one of the object's `teams` |
|
|
119
|
+
| `when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)` | a rule under a condition — see [`when`](#a-condition) |
|
|
120
|
+
|
|
121
|
+
A rule may name another permission of the same type — `view: ['members',
|
|
122
|
+
'manage']` — but never the permission itself: `view: ['view']` adds nothing
|
|
123
|
+
and is a loop no relation ends, so your editor does not offer it and the
|
|
124
|
+
compiler refuses it. A loop through another permission (`view: ['edit'],
|
|
125
|
+
edit: ['view']`) is refused by `defineModel` when it runs.
|
|
88
126
|
|
|
89
127
|
A permission may be asked of `can` and `list` like a relation, and a relation
|
|
90
128
|
like a permission.
|
|
@@ -92,120 +130,256 @@ like a permission.
|
|
|
92
130
|
### What the compiler refuses
|
|
93
131
|
|
|
94
132
|
A relation naming a type that does not exist, a rule naming nothing, an arrow
|
|
95
|
-
to a permission its target lacks,
|
|
96
|
-
|
|
97
|
-
`
|
|
98
|
-
|
|
99
|
-
|
|
133
|
+
to a permission its target lacks, an arrow through a relation that can hold a
|
|
134
|
+
subject set, a name that is both a relation and a permission, a key other than
|
|
135
|
+
`related` and `permits` — `permit:`, singular — on an object type: each is a
|
|
136
|
+
compile error **on the offending name** — on the whole `fromField(…)` or
|
|
137
|
+
`when(…)` call for those two. Except for the name used twice, which says to
|
|
138
|
+
rename one, the error lists what you could have written, with "Did you mean"
|
|
139
|
+
when one is close.
|
|
100
140
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
141
|
+
```ts
|
|
142
|
+
defineModel({
|
|
143
|
+
subjects: ['staff'],
|
|
144
|
+
types: {
|
|
145
|
+
team: {
|
|
146
|
+
// @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#members"'. Did you mean '"staff"'?
|
|
147
|
+
related: { members: ['staf'] },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**The keys before 0.2**, `relations` and `permissions`, are refused with the
|
|
154
|
+
name that replaced them — rename the key, nothing else changes:
|
|
106
155
|
|
|
107
156
|
```ts
|
|
108
157
|
defineModel({
|
|
109
158
|
subjects: ['staff'],
|
|
110
159
|
types: {
|
|
111
160
|
team: {
|
|
112
|
-
// @ts-expect-error —
|
|
113
|
-
relations: {
|
|
161
|
+
// @ts-expect-error — 'members' does not exist in type 'Refusal<"team.relations is now related: rename the key", never>'
|
|
162
|
+
relations: { members: ['staff'] },
|
|
114
163
|
},
|
|
115
164
|
},
|
|
116
165
|
});
|
|
117
166
|
```
|
|
118
167
|
|
|
168
|
+
`permissions:` is refused the same way: `team.permissions is now permits:
|
|
169
|
+
rename the key`.
|
|
170
|
+
|
|
171
|
+
Your editor offers those names as you type — subject types and subject sets
|
|
172
|
+
in a relation, subject types in `fromField`, relations, permissions and arrows
|
|
173
|
+
in a rule and in `when` — because `defineModel` types its `types` with a
|
|
174
|
+
constraint an editor reads, not only with a check. A spec asks the TypeScript
|
|
175
|
+
language service what it completes, so a change that loses it fails.
|
|
176
|
+
|
|
119
177
|
### What `defineModel` refuses at run time
|
|
120
178
|
|
|
121
|
-
With a `TypeError`, when the model is defined — never at a check
|
|
179
|
+
With a `TypeError`, when the model is defined — never at a check. Each names
|
|
180
|
+
the key you wrote, `types.record.permits.view` or `types.team.related.members`:
|
|
122
181
|
|
|
182
|
+
- `relations` or `permissions` — the keys before 0.2 — and any key other than
|
|
183
|
+
`related` and `permits`;
|
|
123
184
|
- a name that is not camelCase;
|
|
124
|
-
- an object type named like a user type
|
|
185
|
+
- an object type named like a user type — see
|
|
186
|
+
[permissions on a user](#permissions-on-a-user);
|
|
125
187
|
- a permission that reaches itself without crossing a relation (`view:
|
|
126
188
|
['edit'], edit: ['view']`) — no data could ever end that loop;
|
|
127
189
|
- a subject set or an arrow that would have to read **another** object's
|
|
128
190
|
`fromField` — only the object passed to `can()` carries its data. Store that
|
|
129
191
|
relation instead.
|
|
130
192
|
|
|
131
|
-
A loop that crosses a relation — a folder viewable through its
|
|
132
|
-
fine: the data ends it.
|
|
193
|
+
A loop that crosses a relation — a folder viewable through its parents — is
|
|
194
|
+
fine: the data ends it. See [a hierarchy](#a-hierarchy).
|
|
195
|
+
|
|
196
|
+
## Use cases
|
|
197
|
+
|
|
198
|
+
Each case below runs against the clinic above, in order — except the two that
|
|
199
|
+
say otherwise: a folder tree for a hierarchy, and an `account` for
|
|
200
|
+
permissions on a user.
|
|
201
|
+
|
|
202
|
+
### A direct relation
|
|
133
203
|
|
|
134
|
-
|
|
204
|
+
A relation is what you store: `grant` writes it, `can` reads it.
|
|
135
205
|
|
|
136
206
|
```ts
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
207
|
+
await access.grant(team, 'leads', ada);
|
|
208
|
+
await access.can(ada, 'leads', team); // true: a relation may be asked like a permission
|
|
209
|
+
await access.can(grace, 'leads', team); // false
|
|
140
210
|
```
|
|
141
211
|
|
|
142
|
-
A
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
212
|
+
### A permission naming a permission
|
|
213
|
+
|
|
214
|
+
`view: ['members', 'manage']` includes whoever holds `manage`, which is
|
|
215
|
+
`['leads']` — so a lead can view the team without being a member.
|
|
146
216
|
|
|
147
217
|
```ts
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
218
|
+
await access.can(ada, 'manage', team); // true: ada leads t1
|
|
219
|
+
await access.can(ada, 'view', team); // true: view includes manage
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### A subject set
|
|
223
|
+
|
|
224
|
+
`members: ['staff', 'team#members']` admits a staff member, or **every member
|
|
225
|
+
of another team** at once. Grant the set with its `relation`:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const cardiology = { type: 'team', id: 't2' } as const;
|
|
229
|
+
await access.grant(cardiology, 'members', grace);
|
|
230
|
+
await access.grant(team, 'members', { type: 'team', id: 't2', relation: 'members' });
|
|
231
|
+
await access.can(grace, 'view', team); // true: grace is a member of t2, whose members are members of t1
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The set is followed as the data stands: revoke grace from `t2`, and she no
|
|
235
|
+
longer views `t1`. A team member of itself — a cycle in the data — is cut, and
|
|
236
|
+
is not an error.
|
|
237
|
+
|
|
238
|
+
### An arrow
|
|
239
|
+
|
|
240
|
+
`'teams->view'` is **whoever can view one of the record's teams**; the arrow
|
|
241
|
+
follows the objects the `teams` relation holds, and asks them `view`. Grant
|
|
242
|
+
the record its team, then everyone who views the team views the record:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
await access.grant({ type: 'record', id: record.id }, 'teams', team);
|
|
246
|
+
await access.can(ada, 'view', { type: 'record', ...record }); // true: ada views t1 (she leads it)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
An arrow may end on a relation too: `review: ['teams->leads']` is whoever
|
|
250
|
+
**leads** one of the record's teams — not its members.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
await access.can(ada, 'review', { type: 'record', ...record }); // true: ada leads t1
|
|
254
|
+
await access.can(grace, 'review', { type: 'record', ...record }); // false: grace is a member, not a lead
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
An arrow follows **object types only**: through a relation that can hold a
|
|
258
|
+
subject set (`teams: ['team', 'team#members']`), `'teams->view'` is a compile
|
|
259
|
+
error.
|
|
260
|
+
|
|
261
|
+
### A hierarchy
|
|
262
|
+
|
|
263
|
+
A folder tree, one of the two models on this page besides the clinic: a folder is
|
|
264
|
+
viewable by its owners and by whoever views one of its parents. The arrow
|
|
265
|
+
names the folder's own `view` — a loop in the model, which **the data ends**:
|
|
266
|
+
the walk stops at a folder with no parents.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const files = permissions({
|
|
270
|
+
model: defineModel({
|
|
271
|
+
subjects: auth.types,
|
|
272
|
+
types: {
|
|
273
|
+
folder: {
|
|
274
|
+
related: { owners: ['staff'], parents: ['folder'] },
|
|
275
|
+
permits: { view: ['owners', 'parents->view'] },
|
|
276
|
+
},
|
|
154
277
|
},
|
|
155
|
-
},
|
|
278
|
+
}),
|
|
279
|
+
store: createMemoryRelations(),
|
|
156
280
|
});
|
|
157
|
-
const access = permissions({ model, store: createMemoryRelations() });
|
|
158
281
|
|
|
159
|
-
const
|
|
160
|
-
|
|
282
|
+
const root = { type: 'folder', id: 'root' } as const;
|
|
283
|
+
const reports = { type: 'folder', id: 'reports' } as const;
|
|
284
|
+
await files.grant(root, 'owners', grace);
|
|
285
|
+
await files.grant(reports, 'parents', root);
|
|
286
|
+
await files.can(grace, 'view', reports); // true: she owns its parent
|
|
161
287
|
```
|
|
162
288
|
|
|
163
|
-
|
|
164
|
-
|
|
289
|
+
A deep tree crosses one relation per level; past `maxDepth` (`25`) the check
|
|
290
|
+
is `PERMISSION_DEPTH`, never `false`.
|
|
165
291
|
|
|
166
|
-
|
|
167
|
-
for the ids of the objects whose field names the subject. A `list()` that would
|
|
168
|
-
reach a `fromField` without one is a compile error:
|
|
292
|
+
### A relation read from the object
|
|
169
293
|
|
|
170
294
|
```ts
|
|
171
|
-
fromField(
|
|
295
|
+
function fromField(field, subjectType): FromField;
|
|
296
|
+
function fromField(field, subjectType, { lookup }): ReversibleFromField;
|
|
297
|
+
type Lookup = (subjectId: string) => Promise<readonly string[]>;
|
|
172
298
|
```
|
|
173
299
|
|
|
300
|
+
`doctors: fromField('doctorId', 'staff')` reads the record's own `doctorId`
|
|
301
|
+
rather than a tuple kept in sync with it. Nothing is stored, and `grant`
|
|
302
|
+
refuses it at compile time. `can()` is given the object, and the compiler
|
|
303
|
+
requires every field a `fromField` of its type reads — here `doctorId` and
|
|
304
|
+
`patientId`:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
await access.can(grace, 'view', { type: 'record', ...record }); // true: record.doctorId is grace's id
|
|
308
|
+
// @ts-expect-error — doctors is read from a field: there is nothing to grant
|
|
309
|
+
await access.grant({ type: 'record', id: record.id }, 'doctors', grace);
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Spread the loaded object.** A field missing at run time is a `TypeError`,
|
|
313
|
+
never a denial. `null` in the field holds nobody.
|
|
314
|
+
|
|
315
|
+
**`list()` needs a `lookup`.** It cannot read a field of objects it has not
|
|
316
|
+
found, so it asks `lookup` for the ids of the objects whose field names the
|
|
317
|
+
subject — in the clinic, `db.records.idsByDoctor(staffId)`. A `list()` that
|
|
318
|
+
would reach a `fromField` without one is a compile error, and your editor does
|
|
319
|
+
not offer that permission.
|
|
320
|
+
|
|
174
321
|
A lookup is your code, and it is not guarded: one that throws rejects `list()`
|
|
175
322
|
with its own error. **Never answer `[]` for a database that could not answer**
|
|
176
323
|
— that is a denial made of an outage.
|
|
177
324
|
|
|
178
|
-
|
|
325
|
+
### A condition
|
|
179
326
|
|
|
180
327
|
```ts
|
|
181
328
|
function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
|
|
182
329
|
```
|
|
183
330
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
331
|
+
`edit: [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)]` grants
|
|
332
|
+
the record's doctors, **while on shift**. The rule is any rule of the same
|
|
333
|
+
type — a relation, a permission, an arrow. The test is synchronous and pure:
|
|
334
|
+
it decides on what the caller passes, and reads nothing. Its `ctx` is what
|
|
335
|
+
`can()` and `list()` then **require** — and only for the permissions whose
|
|
336
|
+
rules reach it:
|
|
189
337
|
|
|
190
338
|
```ts
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
339
|
+
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } }); // true
|
|
340
|
+
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
|
|
341
|
+
// @ts-expect-error — ctx is required: edit reaches a condition
|
|
342
|
+
await access.can(grace, 'edit', { type: 'record', ...record });
|
|
343
|
+
|
|
344
|
+
await access.can(grace, 'view', { type: 'record', ...record }); // no ctx: view reaches no condition
|
|
345
|
+
await access.list(grace, 'edit', 'record', { ctx: { onShift: true } });
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Permissions on a user
|
|
349
|
+
|
|
350
|
+
A user type cannot also be an object type: `defineModel` refuses `staff` in
|
|
351
|
+
`types` — `"staff" names a user type and an object type`. To decide who may
|
|
352
|
+
edit a staff member's account, declare an object type **whose id is the
|
|
353
|
+
user's id**, and read it with `fromField('id', …)`:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const accounts = permissions({
|
|
357
|
+
model: defineModel({
|
|
358
|
+
subjects: auth.types,
|
|
359
|
+
types: {
|
|
360
|
+
account: {
|
|
361
|
+
related: {
|
|
362
|
+
self: fromField('id', 'staff', { lookup: async (staffId) => [staffId] }),
|
|
363
|
+
managers: ['staff'],
|
|
364
|
+
},
|
|
365
|
+
permits: { edit: ['self', 'managers'] },
|
|
198
366
|
},
|
|
199
367
|
},
|
|
200
|
-
},
|
|
368
|
+
}),
|
|
369
|
+
store: relations,
|
|
201
370
|
});
|
|
202
|
-
const access = permissions({ model, store: createMemoryRelations() });
|
|
203
371
|
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
await
|
|
372
|
+
const bob = await auth.staff.create({ username: 'bob' });
|
|
373
|
+
await accounts.can(bob, 'edit', { type: 'account', id: bob.id }); // true: his own account
|
|
374
|
+
await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // false
|
|
375
|
+
await accounts.grant({ type: 'account', id: bob.id }, 'managers', ada);
|
|
376
|
+
await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // true: she manages it
|
|
377
|
+
await accounts.list(ada, 'edit', 'account'); // her own account, and bob's
|
|
207
378
|
```
|
|
208
379
|
|
|
380
|
+
The `lookup` answers the one account whose id is the subject's, so `list()`
|
|
381
|
+
finds a user's own account too.
|
|
382
|
+
|
|
209
383
|
## `permissions()`
|
|
210
384
|
|
|
211
385
|
```ts
|
|
@@ -234,10 +408,14 @@ can(subject, permission, object, options?): Promise<boolean>;
|
|
|
234
408
|
- `object` is `{ type, id }` plus every field its `fromField`s read.
|
|
235
409
|
- `options.ctx` is required exactly when a `when` is reachable.
|
|
236
410
|
|
|
411
|
+
Your editor completes `permission` with the names of the object's type once
|
|
412
|
+
the object is written. **Before, it offers the names of every type**: the
|
|
413
|
+
permission comes before the object, and nothing yet says which type it is.
|
|
414
|
+
|
|
237
415
|
```ts
|
|
238
|
-
await access.can(grace, 'view', { type: 'record', ...record });
|
|
239
|
-
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } });
|
|
240
|
-
await access.can(null, 'view', { type: 'record', ...record });
|
|
416
|
+
await access.can(grace, 'view', { type: 'record', ...record }); // true
|
|
417
|
+
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
|
|
418
|
+
await access.can(null, 'view', { type: 'record', ...record }); // false, no store call
|
|
241
419
|
```
|
|
242
420
|
|
|
243
421
|
**A denial is `false`; a failure throws.** A store that cannot answer is
|
|
@@ -254,16 +432,32 @@ list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
|
|
|
254
432
|
|
|
255
433
|
The ids of the objects of `objectType` on which `subject` holds `permission`,
|
|
256
434
|
ascending, by pages — what `can()` answers `true` for, found without naming
|
|
257
|
-
them.
|
|
435
|
+
them. Page with `limit` and `after`, passing each `nextCursor` back as it
|
|
436
|
+
came, until it is `null`:
|
|
258
437
|
|
|
259
438
|
```ts
|
|
260
439
|
const page = await access.list(grace, 'view', 'record', { limit: 50 });
|
|
261
|
-
page.items; // readonly string[]
|
|
440
|
+
page.items; // readonly string[]: the records grace can view — hers, and her teams'
|
|
262
441
|
page.nextCursor; // string | null
|
|
263
442
|
await access.list(grace, 'view', 'record', { after: page.nextCursor, limit: 50 });
|
|
443
|
+
|
|
444
|
+
// Every page — annotate the page, or TypeScript cannot type the loop (TS7022):
|
|
445
|
+
let after: string | null = null;
|
|
446
|
+
do {
|
|
447
|
+
const next: CursorPage<string> = await access.list(grace, 'view', 'record', { after, limit: 100 });
|
|
448
|
+
for (const id of next.items) console.log(id);
|
|
449
|
+
after = next.nextCursor;
|
|
450
|
+
} while (after);
|
|
264
451
|
```
|
|
265
452
|
|
|
266
|
-
`
|
|
453
|
+
`CursorPage` is exported from `@nxgt/janus`.
|
|
454
|
+
|
|
455
|
+
`limit` is 20 by default and at most 100 — a larger one is capped, and one
|
|
456
|
+
that is not a positive integer is a `TypeError`, so parse a limit read from a
|
|
457
|
+
request first ([troubleshooting](../troubleshooting.md#call-limit-must-be-an-integer-of-at-least-1-or-absent)).
|
|
458
|
+
`ctx` is required as for `can` — `list(grace, 'edit', 'record', { ctx })`.
|
|
459
|
+
Your editor completes `permission` with what `list()` can answer only: a name
|
|
460
|
+
reaching a `fromField` with no `lookup` is neither offered nor accepted.
|
|
267
461
|
`null` answers an empty page before any store call.
|
|
268
462
|
|
|
269
463
|
`list()` walks backwards from the subject, reading every page of the reverse
|
|
@@ -279,7 +473,18 @@ revoke(object, relation, subject): Promise<void>;
|
|
|
279
473
|
```
|
|
280
474
|
|
|
281
475
|
Both are typed from the model: only a stored relation (never a `fromField`),
|
|
282
|
-
and only a holder the relation admits
|
|
476
|
+
and only a holder the relation admits — a staff member, or a team's members
|
|
477
|
+
where `'team#members'` is declared.
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
await access.grant(team, 'members', grace); // a staff member
|
|
481
|
+
await access.grant(team, 'members', { type: 'team', id: 't2', relation: 'members' }); // a subject set
|
|
482
|
+
await access.grant({ type: 'record', id: 'r1' }, 'teams', team); // what 'teams->view' follows
|
|
483
|
+
await access.revoke(team, 'members', grace);
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Both are idempotent: granting what is held, or revoking what is not, is not an
|
|
487
|
+
error. Each writes one tuple.
|
|
283
488
|
|
|
284
489
|
The same rule holds when reading: `can()` and `list()` follow only the holders
|
|
285
490
|
a relation admits. A tuple stored past `grant()` — by an older model, or by
|
|
@@ -288,36 +493,21 @@ it refuses to `grant()` it, so remove it with the store:
|
|
|
288
493
|
|
|
289
494
|
```ts
|
|
290
495
|
await relations.write({
|
|
291
|
-
remove: [
|
|
292
|
-
{
|
|
293
|
-
object: { type: 'record', id: 'r1' },
|
|
294
|
-
relation: 'viewer',
|
|
295
|
-
subject: { type: 'team', id: 't1' },
|
|
296
|
-
},
|
|
297
|
-
],
|
|
496
|
+
remove: [{ object: { type: 'record', id: 'r1' }, relation: 'teams', subject: { type: 'team', id: 't1' } }],
|
|
298
497
|
});
|
|
299
498
|
```
|
|
300
499
|
|
|
301
500
|
Narrowing a model therefore hides the tuples it no longer admits; it does not
|
|
302
501
|
delete them, and widening it again brings them back.
|
|
303
502
|
|
|
304
|
-
```ts
|
|
305
|
-
await access.grant({ type: 'team', id: 't1' }, 'member', grace);
|
|
306
|
-
await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
|
|
307
|
-
await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
|
|
308
|
-
await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
Both are idempotent: granting what is held, or revoking what is not, is not an
|
|
312
|
-
error. Each writes one tuple.
|
|
313
|
-
|
|
314
503
|
### Deleting
|
|
315
504
|
|
|
316
|
-
Wire the relation store into `janus({ relations })`,
|
|
317
|
-
deletes every tuple naming them. Deleting an object's tuples
|
|
318
|
-
your own code, when you delete the object:
|
|
505
|
+
Wire the relation store into `janus({ relations })`, as the clinic does, and
|
|
506
|
+
deleting a user deletes every tuple naming them. Deleting an object's tuples
|
|
507
|
+
is yours, from your own code, when you delete the object:
|
|
319
508
|
|
|
320
509
|
```ts
|
|
510
|
+
await auth.staff.delete(grace); // grace, her sessions, and every tuple naming her
|
|
321
511
|
await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
|
|
322
512
|
```
|
|
323
513
|
|
|
@@ -343,11 +533,16 @@ export async function getRecord(request: Request, id: string): Promise<Response>
|
|
|
343
533
|
}
|
|
344
534
|
```
|
|
345
535
|
|
|
536
|
+
Load the record first: `can()` needs its `doctorId` and `patientId`. An
|
|
537
|
+
anonymous caller is `null`, which `can()` answers `false` without a store call
|
|
538
|
+
— a `401` here, a `403` for a signed-in one.
|
|
539
|
+
|
|
346
540
|
## Subjects and the notation
|
|
347
541
|
|
|
348
542
|
A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
|
|
349
543
|
`{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
|
|
350
|
-
notation, typed —
|
|
544
|
+
notation, typed — `record:r1#teams@team:t1`, `team:t1#members@team:t2#members`
|
|
545
|
+
— see [the shared vocabulary](vocabulary.md#subjects-and-the-tuple-notation).
|
|
351
546
|
|
|
352
547
|
## The relation store
|
|
353
548
|
|
|
@@ -362,3 +557,4 @@ rest.
|
|
|
362
557
|
- [Users](users.md) — `janus({ relations })`, and user types as subject types
|
|
363
558
|
- [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
|
|
364
559
|
- [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
|
|
560
|
+
- [Troubleshooting](../troubleshooting.md#definemodel-) — every `defineModel` message
|