@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.
@@ -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.