@alxia/janus 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/device.d.ts +21 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +15 -0
- package/dist/lookup.d.ts +9 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/permission-options.d.ts +45 -0
- package/dist/permission-options.d.ts.map +1 -0
- package/dist/permission.d.ts +52 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/send.d.ts +34 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/errors.md +176 -0
- package/docs/guide/permissions.md +362 -0
- package/docs/guide/sessions.md +350 -0
- package/docs/guide/sign-in-and-out.md +340 -0
- package/docs/roadmap.md +68 -0
- package/docs/troubleshooting.md +600 -0
- package/package.json +55 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Permissions
|
|
2
|
+
|
|
3
|
+
This page covers `permission()`: a guard on `@nxgt/janus/permissions` that
|
|
4
|
+
lets the routes after it run only when the subject holds a permission on
|
|
5
|
+
the object they are about, loads that object once, and hands it to them.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { byParam, janusErrors, permission, session } from '@alxia/janus';
|
|
10
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
11
|
+
import { createMemoryRelations, defineModel, permissions } from '@nxgt/janus/permissions';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
const relations = createMemoryRelations();
|
|
15
|
+
const accounts = janus({
|
|
16
|
+
user: z.object({ email: z.email() }),
|
|
17
|
+
password: { login: 'email' },
|
|
18
|
+
store: createMemoryStores(),
|
|
19
|
+
relations,
|
|
20
|
+
hasher: scryptHasher(),
|
|
21
|
+
});
|
|
22
|
+
const access = permissions({
|
|
23
|
+
model: defineModel({
|
|
24
|
+
subjects: accounts.types,
|
|
25
|
+
types: {
|
|
26
|
+
record: {
|
|
27
|
+
related: { owners: ['user'] },
|
|
28
|
+
permits: { view: ['owners'] },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
store: relations,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const records = new Map([['r1', { id: 'r1', title: 'Blood test' }]]);
|
|
36
|
+
const findRecord = (id: string) => records.get(id) ?? null;
|
|
37
|
+
|
|
38
|
+
const app = alxia()
|
|
39
|
+
.use(janusErrors())
|
|
40
|
+
.use(session(accounts))
|
|
41
|
+
.group('/records/:id', (record) =>
|
|
42
|
+
record
|
|
43
|
+
.use(permission(access, 'view', 'record', byParam('id', findRecord)))
|
|
44
|
+
.get('/', ({ object, reply }) => reply(200, { title: object.title })),
|
|
45
|
+
);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`object` is what `findRecord` answered, typed as such. Granting access is
|
|
49
|
+
`@nxgt/janus`'s:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await access.grant({ type: 'record', id: 'r1' }, 'owners', user); // user: what janus() answered
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## `permission(access, permission, type, load, options?)`
|
|
56
|
+
|
|
57
|
+
| Argument | Type | Effect |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `access` | what `permissions()` answered | only its `can` is called |
|
|
60
|
+
| `permission` | a permission of `type` in the model | what the subject must hold; a name the model does not define does not compile |
|
|
61
|
+
| `type` | an object type of the model | the type `can` checks the object as |
|
|
62
|
+
| `load` | `(ctx: BaseContext) => O \| null \| Promise<O \| null>` | finds the object; `null` is a 404 |
|
|
63
|
+
| `options.subject` | `(ctx: BaseContext) => SubjectRef \| null \| Promise<…>` | who asks; default: the `user` `session()` derived |
|
|
64
|
+
| `options.ctx` | `(ctx: BaseContext, object: O) => …` | the context a condition of the permission reads; **required exactly when the permission has one**, refused otherwise |
|
|
65
|
+
|
|
66
|
+
Each callback reads the request's `BaseContext`, and, when its parameter is
|
|
67
|
+
annotated, what an earlier plugin added: see
|
|
68
|
+
[Reading the app's context](#reading-the-apps-context).
|
|
69
|
+
|
|
70
|
+
It runs once per request, before the route:
|
|
71
|
+
|
|
72
|
+
1. the subject — `null` is a 401, **before** anything is loaded;
|
|
73
|
+
2. `load(ctx)` — `null` is a 404;
|
|
74
|
+
3. `access.can(subject, permission, object, { ctx })` — `false` is a 403;
|
|
75
|
+
4. the route runs, with `object` set.
|
|
76
|
+
|
|
77
|
+
## Scope it with `group`
|
|
78
|
+
|
|
79
|
+
The guard applies to every route declared after it, in the app or group it
|
|
80
|
+
is used in. Use it inside a `group` so it guards only the routes about that
|
|
81
|
+
object: a guard used at the top of the app would run for `/health` too,
|
|
82
|
+
and answer it 401 or 404.
|
|
83
|
+
|
|
84
|
+
## Refusals
|
|
85
|
+
|
|
86
|
+
Each refusal is in the type of the guarded routes, with the body
|
|
87
|
+
`PermissionRefusedBody`:
|
|
88
|
+
|
|
89
|
+
| The request | Answered |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| anonymous | `401 {"error":"unauthenticated"}` |
|
|
92
|
+
| `load` answers `null` | `404 {"error":"not_found"}` |
|
|
93
|
+
| `can` answers `false` | `403 {"error":"forbidden"}` |
|
|
94
|
+
| allowed | the route runs, `object` set |
|
|
95
|
+
|
|
96
|
+
**A failure throws.** A relation store that cannot answer throws
|
|
97
|
+
`STORE_FAILED`, never a 403; a check that walks past `maxDepth` throws
|
|
98
|
+
`PERMISSION_DEPTH`. Both are `JanusError`s: [`janusErrors()`](errors.md)
|
|
99
|
+
answers them 503 and 500. An error `load` or `ctx` throws is not caught
|
|
100
|
+
either: a `JanusError` reaches `janusErrors()`, anything else is the app's
|
|
101
|
+
500.
|
|
102
|
+
|
|
103
|
+
## Loading the object: `byParam`
|
|
104
|
+
|
|
105
|
+
`byParam(name, find)` is a `load` that reads one path parameter and calls
|
|
106
|
+
`find` with it:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
byParam('id', (id) => db.records.findOne({ id })); // find may be async
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The parameter is the string from the path, decoded (`/records/a%20b` reads
|
|
113
|
+
`'a b'`), before the route's `params` schema: a guard runs before the route
|
|
114
|
+
validates anything. A group whose path has no such parameter answers 404 to
|
|
115
|
+
every request — a typo in `name` is not a compile error.
|
|
116
|
+
|
|
117
|
+
Any other `load` is a function of the context:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
permission(access, 'view', 'record', async (ctx) => {
|
|
121
|
+
const id = ctx.url.searchParams.get('record');
|
|
122
|
+
return id === null ? null : findRecord(id);
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### What the object must carry
|
|
127
|
+
|
|
128
|
+
`can` reads the object as `{ type, id, … }`: the guard adds `type`, and
|
|
129
|
+
every other field is read from the object `load` answered. So the object
|
|
130
|
+
must hold an `id`, and **every field a `fromField` relation of its type
|
|
131
|
+
reads**, as a `string` or `null` — `ObjectData` says so, and a `load`
|
|
132
|
+
answering an object without one does not compile:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { createMemoryRelations, defineModel, fromField, permissions } from '@nxgt/janus/permissions';
|
|
136
|
+
|
|
137
|
+
const access = permissions({
|
|
138
|
+
model: defineModel({
|
|
139
|
+
subjects: accounts.types,
|
|
140
|
+
types: {
|
|
141
|
+
record: {
|
|
142
|
+
related: { owners: ['user'], doctors: fromField('doctorId', 'user') },
|
|
143
|
+
permits: { view: ['owners', 'doctors'] },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
store: createMemoryRelations(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const records = new Map([['r1', { id: 'r1', doctorId: null as string | null, title: 'Blood test' }]]);
|
|
151
|
+
|
|
152
|
+
permission(access, 'view', 'record', byParam('id', (id) => records.get(id) ?? null)); // doctorId: present
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The user whose id is in `doctorId` may view the record, with no tuple
|
|
156
|
+
granted.
|
|
157
|
+
|
|
158
|
+
## A condition: `options.ctx`
|
|
159
|
+
|
|
160
|
+
A permission whose rule is a `when(…)` condition needs a context to decide.
|
|
161
|
+
`ctx` builds it from the request and the loaded object, and the types
|
|
162
|
+
require it for that permission, and refuse it for one without a condition:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { when } from '@nxgt/janus/permissions';
|
|
166
|
+
|
|
167
|
+
const model = defineModel({
|
|
168
|
+
subjects: accounts.types,
|
|
169
|
+
types: {
|
|
170
|
+
record: {
|
|
171
|
+
related: { owners: ['user'] },
|
|
172
|
+
permits: {
|
|
173
|
+
view: ['owners'],
|
|
174
|
+
edit: [when('owners', (ctx: { locked: boolean }) => !ctx.locked)],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const access = permissions({ model, store: relations });
|
|
180
|
+
|
|
181
|
+
const records = new Map([['r1', { id: 'r1', title: 'Blood test', locked: false }]]);
|
|
182
|
+
|
|
183
|
+
const app = alxia()
|
|
184
|
+
.use(janusErrors())
|
|
185
|
+
.use(session(accounts))
|
|
186
|
+
.group('/records/:id', (record) =>
|
|
187
|
+
record
|
|
188
|
+
.use(
|
|
189
|
+
permission(access, 'edit', 'record', byParam('id', (id) => records.get(id) ?? null), {
|
|
190
|
+
ctx: (_ctx, object) => ({ locked: object.locked }), // object: the loaded record
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
.put('/', ({ object, reply }) => reply(200, { title: object.title })),
|
|
194
|
+
);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
An owner gets 200 while the record is unlocked, and 403 once it is.
|
|
198
|
+
|
|
199
|
+
## Another subject: `options.subject`
|
|
200
|
+
|
|
201
|
+
By default the subject is the `user` [`session()`](sessions.md) derived,
|
|
202
|
+
so `session()` must be used before the guard: without it, every guarded
|
|
203
|
+
request throws a `TypeError` and answers 500
|
|
204
|
+
([troubleshooting](../troubleshooting.md#typeerror-permission-no-user-in-the-context--use-sessionauth-before-it-or-pass--subject-)).
|
|
205
|
+
|
|
206
|
+
`subject` names another — an API key's service account, a user read from a
|
|
207
|
+
header set by a gateway. It reads the `BaseContext`, so the request, the
|
|
208
|
+
URL and the path parameters, not the route's validated headers — and, when
|
|
209
|
+
its parameter is annotated, what an earlier plugin added
|
|
210
|
+
([Reading the app's context](#reading-the-apps-context)). `null` is
|
|
211
|
+
anonymous, a 401:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
permission(access, 'view', 'record', byParam('id', findRecord), {
|
|
215
|
+
subject: (ctx) => {
|
|
216
|
+
const id = ctx.request.headers.get('x-user');
|
|
217
|
+
return id === null ? null : { type: 'user' as const, id };
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Reading the app's context
|
|
223
|
+
|
|
224
|
+
`load`, `subject` and `ctx` read `BaseContext` — the request, the URL, the
|
|
225
|
+
path parameters. To read what an earlier plugin added — a tenant, a member,
|
|
226
|
+
a feature flag — annotate the callback's parameter. `permission()` infers
|
|
227
|
+
what each one reads from its annotation, while `access`, `permission`,
|
|
228
|
+
`type` and the object stay inferred as before, and the guard is a
|
|
229
|
+
[`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
|
|
230
|
+
plugin: an app that does not give it before the guard cannot use it.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { alxia, type BaseContext } from '@alxia/core';
|
|
234
|
+
|
|
235
|
+
interface Tenant {
|
|
236
|
+
readonly records: ReadonlyMap<string, { readonly id: string; readonly title: string; readonly locked: boolean }>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const edit = permission(
|
|
240
|
+
access,
|
|
241
|
+
'edit',
|
|
242
|
+
'record',
|
|
243
|
+
({ tenant, pathParams }: BaseContext & { tenant: Tenant }) => tenant.records.get(pathParams['id'] ?? '') ?? null,
|
|
244
|
+
{
|
|
245
|
+
subject: ({ member }: { member: { type: 'user'; id: string } | null }) => member,
|
|
246
|
+
ctx: (_ctx, object) => ({ locked: object.locked }),
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
// requires { tenant: Tenant; member: { type: 'user'; id: string } | null }
|
|
250
|
+
|
|
251
|
+
const app = alxia()
|
|
252
|
+
.use(janusErrors())
|
|
253
|
+
.use(tenancy) // derives tenant and member
|
|
254
|
+
.group('/records/:id', (record) =>
|
|
255
|
+
record.use(edit).put('/', ({ object, reply }) => reply(200, { title: object.title })),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
alxia().use(edit);
|
|
259
|
+
// error, one message per missing key: the plugin reads "tenant", which this app's context does not give: use the plugin that adds it first
|
|
260
|
+
// | the plugin reads "member", which this app's context does not give: use the plugin that adds it first
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
What the guard requires is the union of what the three annotations read.
|
|
264
|
+
An annotation may be `BaseContext & { member: Member }` or `{ member: Member }`
|
|
265
|
+
alone. An app whose `member` has a type that does not fit is refused too —
|
|
266
|
+
`the plugin reads "member", which this app's context gives with another type`
|
|
267
|
+
— while a narrower one passes. A key `BaseContext` already has, annotated
|
|
268
|
+
with a type it does not give — `({ pathParams }: { pathParams: string })` —
|
|
269
|
+
is refused the same way.
|
|
270
|
+
A callback left unannotated reads `BaseContext` only, and adds nothing to
|
|
271
|
+
what the guard requires.
|
|
272
|
+
Annotated `unknown` or `object`, it adds nothing either. Annotated `any`,
|
|
273
|
+
it would read anything and require nothing, so the guard is refused on
|
|
274
|
+
every app instead, with a message naming `load`, `subject` or `ctx`:
|
|
275
|
+
[`the plugin's load reads its context as any: annotate what it reads, or leave it unannotated`](../troubleshooting.md#the-plugins-load-reads-its-context-as-any-annotate-what-it-reads-or-leave-it-unannotated).
|
|
276
|
+
|
|
277
|
+
The default subject — no `subject` option — reads the `user`
|
|
278
|
+
[`session()`](sessions.md) derived at runtime, and is not part of what the
|
|
279
|
+
guard requires: without `session()` before it, each guarded request throws
|
|
280
|
+
([troubleshooting](../troubleshooting.md#typeerror-permission-no-user-in-the-context--use-sessionauth-before-it-or-pass--subject-)).
|
|
281
|
+
To have the types check it, annotate a `subject` that reads it:
|
|
282
|
+
`subject: ({ user }: { user: User | null }) => user`.
|
|
283
|
+
|
|
284
|
+
## Testing it
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { expect, test } from 'bun:test';
|
|
288
|
+
|
|
289
|
+
test('403 to a denial, 404 to nothing loaded, the object once granted', async () => {
|
|
290
|
+
const { token, user } = await accounts.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
291
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
292
|
+
|
|
293
|
+
expect((await app.request('/records/r1', { headers })).status).toBe(403);
|
|
294
|
+
expect((await app.request('/records/gone', { headers })).status).toBe(404);
|
|
295
|
+
expect((await app.request('/records/r1')).status).toBe(401);
|
|
296
|
+
|
|
297
|
+
await access.grant({ type: 'record', id: 'r1' }, 'owners', user);
|
|
298
|
+
expect(await (await app.request('/records/r1', { headers })).json()).toEqual({ title: 'Blood test' });
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Signatures
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
function permission<
|
|
306
|
+
C extends ModelConfig,
|
|
307
|
+
const T extends ObjectTypeOf<C>,
|
|
308
|
+
const P extends CheckableOf<C, T>,
|
|
309
|
+
O extends ObjectData<C, T>,
|
|
310
|
+
LoadCtx extends object = BaseContext,
|
|
311
|
+
SubjectCtx extends object = BaseContext,
|
|
312
|
+
CheckCtx extends object = BaseContext,
|
|
313
|
+
>(
|
|
314
|
+
access: Pick<Permissions<C>, 'can'>,
|
|
315
|
+
permission: P,
|
|
316
|
+
type: T,
|
|
317
|
+
load: (ctx: BaseContext & LoadCtx) => Awaitable<O | null>,
|
|
318
|
+
...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>
|
|
319
|
+
): Alxia<
|
|
320
|
+
RequiresOf<LoadCtx & SubjectCtx & CheckCtx> & { object: O },
|
|
321
|
+
Empty,
|
|
322
|
+
'',
|
|
323
|
+
Reply<401, PermissionRefusedBody> | Reply<404, PermissionRefusedBody> | Reply<403, PermissionRefusedBody>
|
|
324
|
+
> &
|
|
325
|
+
Requiring<RequiresOf<LoadCtx & SubjectCtx & CheckCtx>>;
|
|
326
|
+
|
|
327
|
+
function byParam<O>(name: string, find: (id: string) => Awaitable<O | null>): (ctx: BaseContext) => Awaitable<O | null>;
|
|
328
|
+
|
|
329
|
+
type PermissionOptions<C, T, P, O, SubjectCtx = BaseContext, CheckCtx = BaseContext> = {
|
|
330
|
+
readonly subject?: (ctx: BaseContext & SubjectCtx) => Awaitable<SubjectRef<C> | null>;
|
|
331
|
+
} & ([CtxOf<C, T, P>] extends [never]
|
|
332
|
+
? { readonly ctx?: never }
|
|
333
|
+
: { readonly ctx: (ctx: BaseContext & CheckCtx, object: O) => Awaitable<CtxOf<C, T, P>> });
|
|
334
|
+
|
|
335
|
+
type ObjectData<C extends ModelConfig, T extends ObjectTypeOf<C>> = {
|
|
336
|
+
readonly id: string;
|
|
337
|
+
} & { readonly [F in FieldsOf<C, T>]: string | null };
|
|
338
|
+
|
|
339
|
+
interface PermissionRefusedBody {
|
|
340
|
+
readonly error: 'unauthenticated' | 'not_found' | 'forbidden';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
type Awaitable<V> = V | Promise<V>;
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`RequiresOf` and `Requiring` are `@alxia/core`'s. `LoadCtx`, `SubjectCtx`
|
|
347
|
+
and `CheckCtx` are inferred from the types `load`, `subject` and `ctx`'s
|
|
348
|
+
parameters are annotated with, `BaseContext` when they are not. `RequiresOf<X>` is what `X` adds to `BaseContext` — `{ tenant: Tenant }`
|
|
349
|
+
for `BaseContext & { tenant: Tenant }` — and `Empty` when it adds nothing;
|
|
350
|
+
see [Reading the app's context](#reading-the-apps-context).
|
|
351
|
+
|
|
352
|
+
`OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>` is the rest of the
|
|
353
|
+
arguments, exported for a wrapper that forwards them:
|
|
354
|
+
`[options?: PermissionOptions<…>]`, or `[options: PermissionOptions<…>]`
|
|
355
|
+
when the permission has a condition — and a looser `[options?]` when `type`
|
|
356
|
+
or `permission` is typed as the union of several, not one literal. Its last
|
|
357
|
+
two parameters default to `BaseContext`, so `OptionsArgs<C, T, P, O>` is
|
|
358
|
+
the unannotated one.
|
|
359
|
+
|
|
360
|
+
`ModelConfig`, `ObjectTypeOf`, `CheckableOf`, `CtxOf`, `FieldsOf`,
|
|
361
|
+
`SubjectRef` and `Permissions` are `@nxgt/janus/permissions`'s; its
|
|
362
|
+
permissions guide covers the model itself.
|