@nxgt/janus 0.1.3 → 0.2.1
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 +42 -32
- package/dist/auth/context.d.ts +1 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +9 -5
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/sessions.d.ts.map +1 -1
- package/dist/auth/users.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 +27 -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 +53 -11
- package/dist/index.js.map +7 -6
- 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 +28 -28
- package/dist/permissions/model.d.ts.map +1 -1
- package/dist/permissions/resolve.d.ts.map +1 -1
- package/dist/stores/storable.d.ts +15 -0
- package/dist/stores/storable.d.ts.map +1 -0
- package/docs/guide/adapters.md +9 -4
- package/docs/guide/errors.md +3 -2
- package/docs/guide/permissions.md +287 -109
- package/docs/guide/sessions.md +3 -1
- package/docs/guide/users.md +6 -2
- package/docs/guide/vocabulary.md +23 -19
- package/docs/roadmap.md +62 -9
- package/docs/troubleshooting.md +36 -18
- 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,32 +87,38 @@ 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) |
|
|
88
120
|
|
|
89
|
-
A rule may name another permission of the same type — `view: ['
|
|
121
|
+
A rule may name another permission of the same type — `view: ['members',
|
|
90
122
|
'manage']` — but never the permission itself: `view: ['view']` adds nothing
|
|
91
123
|
and is a loop no relation ends, so your editor does not offer it and the
|
|
92
124
|
compiler refuses it. A loop through another permission (`view: ['edit'],
|
|
@@ -98,121 +130,256 @@ like a permission.
|
|
|
98
130
|
### What the compiler refuses
|
|
99
131
|
|
|
100
132
|
A relation naming a type that does not exist, a rule naming nothing, an arrow
|
|
101
|
-
to a permission its target lacks,
|
|
102
|
-
|
|
103
|
-
singular — on an object type: each is a
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"Did you mean"
|
|
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.
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
113
155
|
|
|
114
156
|
```ts
|
|
115
157
|
defineModel({
|
|
116
158
|
subjects: ['staff'],
|
|
117
159
|
types: {
|
|
118
160
|
team: {
|
|
119
|
-
// @ts-expect-error —
|
|
120
|
-
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'] },
|
|
121
163
|
},
|
|
122
164
|
},
|
|
123
165
|
});
|
|
124
166
|
```
|
|
125
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
|
+
|
|
126
177
|
### What `defineModel` refuses at run time
|
|
127
178
|
|
|
128
|
-
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`:
|
|
129
181
|
|
|
182
|
+
- `relations` or `permissions` — the keys before 0.2 — and any key other than
|
|
183
|
+
`related` and `permits`;
|
|
130
184
|
- a name that is not camelCase;
|
|
131
|
-
- 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);
|
|
132
187
|
- a permission that reaches itself without crossing a relation (`view:
|
|
133
188
|
['edit'], edit: ['view']`) — no data could ever end that loop;
|
|
134
189
|
- a subject set or an arrow that would have to read **another** object's
|
|
135
190
|
`fromField` — only the object passed to `can()` carries its data. Store that
|
|
136
191
|
relation instead.
|
|
137
192
|
|
|
138
|
-
A loop that crosses a relation — a folder viewable through its
|
|
139
|
-
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
|
|
140
203
|
|
|
141
|
-
|
|
204
|
+
A relation is what you store: `grant` writes it, `can` reads it.
|
|
142
205
|
|
|
143
206
|
```ts
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
210
|
```
|
|
148
211
|
|
|
149
|
-
A
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
153
216
|
|
|
154
217
|
```ts
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
},
|
|
161
277
|
},
|
|
162
|
-
},
|
|
278
|
+
}),
|
|
279
|
+
store: createMemoryRelations(),
|
|
163
280
|
});
|
|
164
|
-
const access = permissions({ model, store: createMemoryRelations() });
|
|
165
281
|
|
|
166
|
-
const
|
|
167
|
-
|
|
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
|
|
168
287
|
```
|
|
169
288
|
|
|
170
|
-
|
|
171
|
-
|
|
289
|
+
A deep tree crosses one relation per level; past `maxDepth` (`25`) the check
|
|
290
|
+
is `PERMISSION_DEPTH`, never `false`.
|
|
291
|
+
|
|
292
|
+
### A relation read from the object
|
|
172
293
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
294
|
+
```ts
|
|
295
|
+
function fromField(field, subjectType): FromField;
|
|
296
|
+
function fromField(field, subjectType, { lookup }): ReversibleFromField;
|
|
297
|
+
type Lookup = (subjectId: string) => Promise<readonly string[]>;
|
|
298
|
+
```
|
|
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`:
|
|
176
305
|
|
|
177
306
|
```ts
|
|
178
|
-
|
|
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);
|
|
179
310
|
```
|
|
180
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
|
+
|
|
181
321
|
A lookup is your code, and it is not guarded: one that throws rejects `list()`
|
|
182
322
|
with its own error. **Never answer `[]` for a database that could not answer**
|
|
183
323
|
— that is a denial made of an outage.
|
|
184
324
|
|
|
185
|
-
|
|
325
|
+
### A condition
|
|
186
326
|
|
|
187
327
|
```ts
|
|
188
328
|
function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
|
|
189
329
|
```
|
|
190
330
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
196
337
|
|
|
197
338
|
```ts
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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'] },
|
|
205
366
|
},
|
|
206
367
|
},
|
|
207
|
-
},
|
|
368
|
+
}),
|
|
369
|
+
store: relations,
|
|
208
370
|
});
|
|
209
|
-
const access = permissions({ model, store: createMemoryRelations() });
|
|
210
371
|
|
|
211
|
-
await
|
|
212
|
-
|
|
213
|
-
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
|
|
214
378
|
```
|
|
215
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
|
+
|
|
216
383
|
## `permissions()`
|
|
217
384
|
|
|
218
385
|
```ts
|
|
@@ -243,14 +410,12 @@ can(subject, permission, object, options?): Promise<boolean>;
|
|
|
243
410
|
|
|
244
411
|
Your editor completes `permission` with the names of the object's type once
|
|
245
412
|
the object is written. **Before, it offers the names of every type**: the
|
|
246
|
-
permission comes before the object, and nothing yet says which type it is.
|
|
247
|
-
relation named after a type — `team: ['team']` — is offered as a name; it is
|
|
248
|
-
the relation, not the type.
|
|
413
|
+
permission comes before the object, and nothing yet says which type it is.
|
|
249
414
|
|
|
250
415
|
```ts
|
|
251
|
-
await access.can(grace, 'view', { type: 'record', ...record });
|
|
252
|
-
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } });
|
|
253
|
-
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
|
|
254
419
|
```
|
|
255
420
|
|
|
256
421
|
**A denial is `false`; a failure throws.** A store that cannot answer is
|
|
@@ -267,19 +432,30 @@ list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
|
|
|
267
432
|
|
|
268
433
|
The ids of the objects of `objectType` on which `subject` holds `permission`,
|
|
269
434
|
ascending, by pages — what `can()` answers `true` for, found without naming
|
|
270
|
-
them.
|
|
435
|
+
them. Page with `limit` and `after`, passing each `nextCursor` back as it
|
|
436
|
+
came, until it is `null`:
|
|
271
437
|
|
|
272
438
|
```ts
|
|
273
439
|
const page = await access.list(grace, 'view', 'record', { limit: 50 });
|
|
274
|
-
page.items; // readonly string[]
|
|
440
|
+
page.items; // readonly string[]: the records grace can view — hers, and her teams'
|
|
275
441
|
page.nextCursor; // string | null
|
|
276
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);
|
|
277
451
|
```
|
|
278
452
|
|
|
453
|
+
`CursorPage` is exported from `@nxgt/janus`.
|
|
454
|
+
|
|
279
455
|
`limit` is 20 by default and at most 100 — a larger one is capped, and one
|
|
280
456
|
that is not a positive integer is a `TypeError`, so parse a limit read from a
|
|
281
457
|
request first ([troubleshooting](../troubleshooting.md#call-limit-must-be-an-integer-of-at-least-1-or-absent)).
|
|
282
|
-
`ctx` is required as for `can`.
|
|
458
|
+
`ctx` is required as for `can` — `list(grace, 'edit', 'record', { ctx })`.
|
|
283
459
|
Your editor completes `permission` with what `list()` can answer only: a name
|
|
284
460
|
reaching a `fromField` with no `lookup` is neither offered nor accepted.
|
|
285
461
|
`null` answers an empty page before any store call.
|
|
@@ -297,7 +473,18 @@ revoke(object, relation, subject): Promise<void>;
|
|
|
297
473
|
```
|
|
298
474
|
|
|
299
475
|
Both are typed from the model: only a stored relation (never a `fromField`),
|
|
300
|
-
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.
|
|
301
488
|
|
|
302
489
|
The same rule holds when reading: `can()` and `list()` follow only the holders
|
|
303
490
|
a relation admits. A tuple stored past `grant()` — by an older model, or by
|
|
@@ -306,36 +493,21 @@ it refuses to `grant()` it, so remove it with the store:
|
|
|
306
493
|
|
|
307
494
|
```ts
|
|
308
495
|
await relations.write({
|
|
309
|
-
remove: [
|
|
310
|
-
{
|
|
311
|
-
object: { type: 'record', id: 'r1' },
|
|
312
|
-
relation: 'viewer',
|
|
313
|
-
subject: { type: 'team', id: 't1' },
|
|
314
|
-
},
|
|
315
|
-
],
|
|
496
|
+
remove: [{ object: { type: 'record', id: 'r1' }, relation: 'teams', subject: { type: 'team', id: 't1' } }],
|
|
316
497
|
});
|
|
317
498
|
```
|
|
318
499
|
|
|
319
500
|
Narrowing a model therefore hides the tuples it no longer admits; it does not
|
|
320
501
|
delete them, and widening it again brings them back.
|
|
321
502
|
|
|
322
|
-
```ts
|
|
323
|
-
await access.grant({ type: 'team', id: 't1' }, 'member', grace);
|
|
324
|
-
await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
|
|
325
|
-
await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
|
|
326
|
-
await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
Both are idempotent: granting what is held, or revoking what is not, is not an
|
|
330
|
-
error. Each writes one tuple.
|
|
331
|
-
|
|
332
503
|
### Deleting
|
|
333
504
|
|
|
334
|
-
Wire the relation store into `janus({ relations })`,
|
|
335
|
-
deletes every tuple naming them. Deleting an object's tuples
|
|
336
|
-
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:
|
|
337
508
|
|
|
338
509
|
```ts
|
|
510
|
+
await auth.staff.delete(grace); // grace, her sessions, and every tuple naming her
|
|
339
511
|
await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
|
|
340
512
|
```
|
|
341
513
|
|
|
@@ -361,11 +533,16 @@ export async function getRecord(request: Request, id: string): Promise<Response>
|
|
|
361
533
|
}
|
|
362
534
|
```
|
|
363
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
|
+
|
|
364
540
|
## Subjects and the notation
|
|
365
541
|
|
|
366
542
|
A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
|
|
367
543
|
`{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
|
|
368
|
-
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).
|
|
369
546
|
|
|
370
547
|
## The relation store
|
|
371
548
|
|
|
@@ -380,3 +557,4 @@ rest.
|
|
|
380
557
|
- [Users](users.md) — `janus({ relations })`, and user types as subject types
|
|
381
558
|
- [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
|
|
382
559
|
- [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
|
|
560
|
+
- [Troubleshooting](../troubleshooting.md#definemodel-) — every `defineModel` message
|
package/docs/guide/sessions.md
CHANGED
|
@@ -147,7 +147,9 @@ export async function signOut(request: Request): Promise<Response> {
|
|
|
147
147
|
|
|
148
148
|
`signOutEverywhere(user, { except })` revokes every standing session of a
|
|
149
149
|
user, but the one named, and answers how many it revoked — "sign out
|
|
150
|
-
everywhere else"
|
|
150
|
+
everywhere else". An `except` that names no session of theirs — an unknown
|
|
151
|
+
id, or anything that is not an id at all — keeps none: every session goes,
|
|
152
|
+
the current one included.
|
|
151
153
|
|
|
152
154
|
```ts
|
|
153
155
|
const current = await auth.authenticate(request);
|
package/docs/guide/users.md
CHANGED
|
@@ -66,7 +66,11 @@ time. A schema is any [Standard Schema](https://standardschema.dev) (Zod 4,
|
|
|
66
66
|
Valibot, ArkType), and its output must be JSON: a schema that produces a
|
|
67
67
|
`Date` is refused at compile time, because a `Date` round-trips through one
|
|
68
68
|
database and not the next. An optional field left `undefined` is dropped
|
|
69
|
-
before the store sees it.
|
|
69
|
+
before the store sees it. A string or a key holding a NUL character (`\u0000`)
|
|
70
|
+
or a lone surrogate is refused with `USER_INVALID`, on every adapter: PostgreSQL
|
|
71
|
+
keeps neither, so janus refuses them everywhere rather than fail on one. So is
|
|
72
|
+
a login your own `password.normalize` function turns into one — a `slice`
|
|
73
|
+
that cuts an emoji in half, say.
|
|
70
74
|
|
|
71
75
|
## Options
|
|
72
76
|
|
|
@@ -179,7 +183,7 @@ With a `password`, besides:
|
|
|
179
183
|
| --- | --- | --- |
|
|
180
184
|
| `signUp(fields & { password })` | `{ user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
181
185
|
| `signIn({ [login]: string, password })` | `{ user, session, token }` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
182
|
-
| `findByLogin(login)` | the user, or `null`; the login is normalised first | |
|
|
186
|
+
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
183
187
|
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
|
|
184
188
|
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
185
189
|
|