@stonyx/orm 0.3.2-alpha.53 → 0.3.2-alpha.55
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 +105 -8
- package/dist/index.d.ts +1 -0
- package/dist/main.d.ts +47 -0
- package/dist/main.js +48 -0
- package/dist/orm-request.d.ts +59 -3
- package/dist/orm-request.js +89 -1
- package/dist/setup-rest-server.js +23 -1
- package/dist/types/orm-types.d.ts +67 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/main.ts +52 -0
- package/src/orm-request.ts +93 -5
- package/src/setup-rest-server.ts +32 -3
- package/src/types/orm-types.ts +71 -0
package/README.md
CHANGED
|
@@ -381,6 +381,97 @@ export default class GlobalAccess {
|
|
|
381
381
|
}
|
|
382
382
|
```
|
|
383
383
|
|
|
384
|
+
|
|
385
|
+
### The access context (second argument)
|
|
386
|
+
|
|
387
|
+
`access()` is called with **two** arguments:
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
access(request, { model, operation })
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The second is the **access context** — the structural facts about the request,
|
|
394
|
+
which the framework already holds at authorization time. Read these instead of
|
|
395
|
+
parsing anything.
|
|
396
|
+
|
|
397
|
+
| Key | Value |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `model` | The model this route was mounted for, as a **model name**: kebab-case, exactly as declared under `config.orm.paths.model` and keyed in the store — `'owner'`, `'animal'`, `'phone-number'`. **Not** the pluralized, dasherized, mount-prefixed *route* name. |
|
|
400
|
+
| `operation` | One of **`'read'`, `'create'`, `'update'`, `'delete'`** — and no second vocabulary. Never an HTTP method name like `'GET'`. `undefined` when the dispatched method has no entry in the framework's method map. |
|
|
401
|
+
|
|
402
|
+
So a predicate can be written without reference to any URL:
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
export default class OwnerAccess {
|
|
406
|
+
models = ['owner'];
|
|
407
|
+
|
|
408
|
+
access(request, { model, operation }) {
|
|
409
|
+
if (model === 'owner' && operation === 'read') {
|
|
410
|
+
return record => record.id !== 'angela';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return ['read'];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
There is no string to parse, no variant to miss, and no way to fail open through
|
|
419
|
+
a URL shape nobody anticipated. `model` is fixed at mount time and no request
|
|
420
|
+
can influence it — not a mount prefix, not a query string, not a case-varied
|
|
421
|
+
path, not an absolute-form request target.
|
|
422
|
+
|
|
423
|
+
The four `operation` values are the same four strings the permission-array
|
|
424
|
+
return shape is written in (`['read', 'create', 'update', 'delete']`), because
|
|
425
|
+
both come from one method map inside the framework. The two forms cannot
|
|
426
|
+
disagree about the same request.
|
|
427
|
+
|
|
428
|
+
**`operation` is `undefined`, never defaulted, for an unmapped method.** Express
|
|
429
|
+
delivers `HEAD` to the `GET` handler, so this is reachable. It is deliberately
|
|
430
|
+
not defaulted to `'read'`: a fabricated operation would turn an unclassified
|
|
431
|
+
request into an authorized one. Treat `undefined` as *not classified* and deny.
|
|
432
|
+
|
|
433
|
+
**The second argument is additive.** JavaScript ignores extra arguments, so an
|
|
434
|
+
existing `access(request)` predicate keeps working exactly as it did. Nothing
|
|
435
|
+
needs to be migrated to keep running — but note that argument **one** is still
|
|
436
|
+
the raw request, so the warning in
|
|
437
|
+
[Identifying the collection](#identifying-the-collection) still applies to any
|
|
438
|
+
predicate that reads it.
|
|
439
|
+
|
|
440
|
+
#### `record` is not in the context
|
|
441
|
+
|
|
442
|
+
Deliberately, and it is not an oversight. `auth()` runs after route matching but
|
|
443
|
+
**before any handler executes**, so nothing has been fetched yet. Supplying a
|
|
444
|
+
record would force a pre-fetch on every request — a second store hit, a new
|
|
445
|
+
failure mode, and an ordering change in the middle of an authorization path.
|
|
446
|
+
|
|
447
|
+
It is also unnecessary: the **function** return shape already *is* the
|
|
448
|
+
per-record hook. Return `(record) => boolean` and the handlers apply it to every
|
|
449
|
+
record the request touches. Auth-time and record-time are separate decision
|
|
450
|
+
points, and the contract keeps them separate.
|
|
451
|
+
|
|
452
|
+
#### Reaching another model's predicate
|
|
453
|
+
|
|
454
|
+
The model → predicate map is published on the ORM instance at boot, before any
|
|
455
|
+
route is mounted, so a predicate can be resolved by model name and asked about a
|
|
456
|
+
request routed to a *different* model:
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
import Orm from '@stonyx/orm';
|
|
460
|
+
|
|
461
|
+
const predicate = Orm.instance.getAccess('animal');
|
|
462
|
+
const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`Orm.instance.getAccess(modelName)` returns the predicate, or `undefined` when
|
|
466
|
+
that model has no access class. The raw map is `Orm.instance.accessFiles`, keyed
|
|
467
|
+
by model name; prefer `getAccess()`.
|
|
468
|
+
|
|
469
|
+
Passing the context explicitly is what makes the answer **model-correct**. A
|
|
470
|
+
predicate that identifies its collection from the request would otherwise answer
|
|
471
|
+
about the collection the request is *addressed to* while being asked about
|
|
472
|
+
another one — and per the five variants below, it answers wrong in the direction
|
|
473
|
+
that grants access.
|
|
474
|
+
|
|
384
475
|
### Return values
|
|
385
476
|
|
|
386
477
|
| `access()` returns | Effect |
|
|
@@ -512,14 +603,20 @@ sub-paths beneath the mount, as the `/archived` deny above does.
|
|
|
512
603
|
one collection is writable and another is filtered on a field the first can
|
|
513
604
|
set. Blocking it requires checking animal 21 against the **animal** model's
|
|
514
605
|
predicate while servicing an **owners** route — cross-model access resolution,
|
|
515
|
-
which the
|
|
516
|
-
|
|
517
|
-
`setup-rest-server.ts`
|
|
518
|
-
|
|
519
|
-
[
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
606
|
+
which the contract could not express before
|
|
607
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202): `access()` never
|
|
608
|
+
received the model structurally and `setup-rest-server.ts` discarded the
|
|
609
|
+
model→predicate map at boot. **#202 has landed and both halves now exist** —
|
|
610
|
+
see [The access context](#the-access-context-second-argument): `context.model`
|
|
611
|
+
makes the answer model-correct and `Orm.instance.getAccess(modelName)` makes
|
|
612
|
+
another model's predicate reachable. **The mechanism exists; the ORM does not
|
|
613
|
+
yet use it on this path.** The re-parenting write above is still unblocked —
|
|
614
|
+
that enforcement is
|
|
615
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196) and
|
|
616
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207), which were blocked on
|
|
617
|
+
#202 and are now unblocked. Until they land, do not rely on a filter to keep a
|
|
618
|
+
record unmodifiable; keep the *writable* collections' predicates as tight as
|
|
619
|
+
the hidden ones.
|
|
523
620
|
- **Authorization by identifying the collection is a consumer-side
|
|
524
621
|
reconstruction of information the framework already holds.** `access()`
|
|
525
622
|
receives a transport artifact and is asked to work out which model, which
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
|
|
|
9
9
|
export { default } from './main.js';
|
|
10
10
|
export { store, relationships } from './main.js';
|
|
11
11
|
export type { PersistErrorDetail } from './main.js';
|
|
12
|
+
export type { AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js';
|
|
12
13
|
export { Model, View, Serializer };
|
|
13
14
|
export { attr, belongsTo, hasMany, createRecord, updateRecord };
|
|
14
15
|
export { count, avg, sum, min, max };
|
package/dist/main.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Store from './store.js';
|
|
2
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
2
3
|
interface OrmOptions {
|
|
3
4
|
dbType?: string;
|
|
4
5
|
}
|
|
@@ -32,12 +33,58 @@ export default class Orm {
|
|
|
32
33
|
views: Record<string, unknown>;
|
|
33
34
|
transforms: Record<string, (value: unknown) => unknown>;
|
|
34
35
|
warnings: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
|
|
38
|
+
*
|
|
39
|
+
* Populated by `setup-rest-server.ts` at boot, from the access classes under
|
|
40
|
+
* `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
|
|
41
|
+
* and reachable before the first request can be served. The mapping is
|
|
42
|
+
* one-to-one by construction: setup-rest-server throws if two access classes
|
|
43
|
+
* claim the same model.
|
|
44
|
+
*
|
|
45
|
+
* Keys are model names as declared and stored (kebab-case, e.g.
|
|
46
|
+
* `'phone-number'`), NOT pluralised or mount-prefixed route names.
|
|
47
|
+
*
|
|
48
|
+
* WHY THIS EXISTS AS A FIELD. It used to be a function-local in
|
|
49
|
+
* setup-rest-server that was discarded when that function returned, so at
|
|
50
|
+
* request time there was no way to get from a model name to that model's
|
|
51
|
+
* predicate at all. Each `OrmRequest` held only its OWN model's predicate.
|
|
52
|
+
* That made cross-model authorization -- asking model X's predicate about a
|
|
53
|
+
* request routed to model Y -- inexpressible, which is the capability
|
|
54
|
+
* abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
|
|
55
|
+
*
|
|
56
|
+
* Empty when the REST server is disabled, or when no access configuration
|
|
57
|
+
* could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
|
|
58
|
+
*/
|
|
59
|
+
accessFiles: Record<string, AccessFunction>;
|
|
35
60
|
options: OrmOptions;
|
|
36
61
|
sqlDb?: SqlDb;
|
|
37
62
|
db?: OrmDB | SqlDb;
|
|
38
63
|
private _persistErrorHandler;
|
|
39
64
|
constructor(options?: OrmOptions);
|
|
40
65
|
init(): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
|
|
68
|
+
*
|
|
69
|
+
* This is the supported way to reach another model's predicate while
|
|
70
|
+
* servicing a request routed to a different model. Call it with the model
|
|
71
|
+
* name and invoke the result with the live request and an explicit context
|
|
72
|
+
* naming THAT model:
|
|
73
|
+
*
|
|
74
|
+
* ```js
|
|
75
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
76
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Passing the context is not optional in practice. A predicate that
|
|
80
|
+
* identifies its collection from the request would otherwise answer about the
|
|
81
|
+
* collection the request is ADDRESSED TO -- owners -- while being asked about
|
|
82
|
+
* animals, and per #202's thesis it answers wrong in the granting direction.
|
|
83
|
+
*
|
|
84
|
+
* @param modelName - Model name as declared and stored (kebab-case).
|
|
85
|
+
* @returns The predicate, or `undefined` when the model has no access class.
|
|
86
|
+
*/
|
|
87
|
+
getAccess(modelName: string): AccessFunction | undefined;
|
|
41
88
|
startup(): Promise<void>;
|
|
42
89
|
shutdown(): Promise<void>;
|
|
43
90
|
static get db(): OrmDB | SqlDb;
|
package/dist/main.js
CHANGED
|
@@ -38,6 +38,30 @@ export default class Orm {
|
|
|
38
38
|
views = {};
|
|
39
39
|
transforms = { ...baseTransforms };
|
|
40
40
|
warnings = new Set();
|
|
41
|
+
/**
|
|
42
|
+
* Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
|
|
43
|
+
*
|
|
44
|
+
* Populated by `setup-rest-server.ts` at boot, from the access classes under
|
|
45
|
+
* `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
|
|
46
|
+
* and reachable before the first request can be served. The mapping is
|
|
47
|
+
* one-to-one by construction: setup-rest-server throws if two access classes
|
|
48
|
+
* claim the same model.
|
|
49
|
+
*
|
|
50
|
+
* Keys are model names as declared and stored (kebab-case, e.g.
|
|
51
|
+
* `'phone-number'`), NOT pluralised or mount-prefixed route names.
|
|
52
|
+
*
|
|
53
|
+
* WHY THIS EXISTS AS A FIELD. It used to be a function-local in
|
|
54
|
+
* setup-rest-server that was discarded when that function returned, so at
|
|
55
|
+
* request time there was no way to get from a model name to that model's
|
|
56
|
+
* predicate at all. Each `OrmRequest` held only its OWN model's predicate.
|
|
57
|
+
* That made cross-model authorization -- asking model X's predicate about a
|
|
58
|
+
* request routed to model Y -- inexpressible, which is the capability
|
|
59
|
+
* abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
|
|
60
|
+
*
|
|
61
|
+
* Empty when the REST server is disabled, or when no access configuration
|
|
62
|
+
* could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
|
|
63
|
+
*/
|
|
64
|
+
accessFiles = {};
|
|
41
65
|
options;
|
|
42
66
|
sqlDb;
|
|
43
67
|
db;
|
|
@@ -145,6 +169,30 @@ export default class Orm {
|
|
|
145
169
|
Orm.ready = await Promise.all(promises);
|
|
146
170
|
Orm.initialized = true;
|
|
147
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
|
|
174
|
+
*
|
|
175
|
+
* This is the supported way to reach another model's predicate while
|
|
176
|
+
* servicing a request routed to a different model. Call it with the model
|
|
177
|
+
* name and invoke the result with the live request and an explicit context
|
|
178
|
+
* naming THAT model:
|
|
179
|
+
*
|
|
180
|
+
* ```js
|
|
181
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
182
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
183
|
+
* ```
|
|
184
|
+
*
|
|
185
|
+
* Passing the context is not optional in practice. A predicate that
|
|
186
|
+
* identifies its collection from the request would otherwise answer about the
|
|
187
|
+
* collection the request is ADDRESSED TO -- owners -- while being asked about
|
|
188
|
+
* animals, and per #202's thesis it answers wrong in the granting direction.
|
|
189
|
+
*
|
|
190
|
+
* @param modelName - Model name as declared and stored (kebab-case).
|
|
191
|
+
* @returns The predicate, or `undefined` when the model has no access class.
|
|
192
|
+
*/
|
|
193
|
+
getAccess(modelName) {
|
|
194
|
+
return this.accessFiles[modelName];
|
|
195
|
+
}
|
|
148
196
|
async startup() {
|
|
149
197
|
if (this.sqlDb)
|
|
150
198
|
await this.sqlDb.startup();
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE `access()` CONTRACT: `access(request, { model, operation })`
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` calls your predicate with TWO arguments. The second is the access
|
|
8
|
+
* CONTEXT -- the structural facts about the request, which the framework
|
|
9
|
+
* already holds and which you should read INSTEAD of parsing anything:
|
|
10
|
+
*
|
|
11
|
+
* context.model The model this route was mounted for, as a model name:
|
|
12
|
+
* kebab-case, exactly as declared under
|
|
13
|
+
* `config.orm.paths.model` and keyed in the store --
|
|
14
|
+
* `'owner'`, `'animal'`, `'phone-number'`. NOT the
|
|
15
|
+
* pluralised, dasherized, mount-prefixed ROUTE name. It is
|
|
16
|
+
* read from the OrmRequest instance, fixed at mount time,
|
|
17
|
+
* and no request can influence it.
|
|
18
|
+
*
|
|
19
|
+
* context.operation The operation being authorised. Exactly one of the four
|
|
20
|
+
* verbs `'read'`, `'create'`, `'update'`, `'delete'` --
|
|
21
|
+
* there is no second vocabulary, and it is never an HTTP
|
|
22
|
+
* method name like `'GET'`. These are the same four
|
|
23
|
+
* strings the permission-array return shape is written in
|
|
24
|
+
* (`['read', 'create']`), because both come from the one
|
|
25
|
+
* `methodAccessMap` below.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` when the dispatched method has no entry in
|
|
28
|
+
* that map. Express delivers `HEAD` to the `GET` handler,
|
|
29
|
+
* so this is reachable. It is left undefined rather than
|
|
30
|
+
* defaulted on purpose -- a fabricated `'read'` would turn
|
|
31
|
+
* an unclassified request into an authorised one. Treat
|
|
32
|
+
* `undefined` as "not classified" and deny.
|
|
33
|
+
*
|
|
34
|
+
* So a consumer writes `if (model === 'owner' && operation === 'read')`. There
|
|
35
|
+
* is no string to parse, no variant to miss, and no way to fail open through a
|
|
36
|
+
* URL shape nobody anticipated.
|
|
37
|
+
*
|
|
38
|
+
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
39
|
+
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
40
|
+
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
41
|
+
* record would force a pre-fetch on every request, a second store hit and an
|
|
42
|
+
* ordering change in the middle of an authorization path. It is also
|
|
43
|
+
* unnecessary: the FUNCTION return shape already is the per-record hook. Return
|
|
44
|
+
* `(record) => boolean` and the handlers apply it to every record the request
|
|
45
|
+
* touches. Auth-time and record-time are separate decision points.
|
|
46
|
+
*
|
|
47
|
+
* THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
|
|
48
|
+
* existing `access(request)` predicate keeps working exactly as before. The
|
|
49
|
+
* warning immediately below is therefore still live: `request` is still
|
|
50
|
+
* argument ONE, and reading it is still how predicates fail open.
|
|
51
|
+
*
|
|
52
|
+
* To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
|
|
53
|
+
* an owners route -- use the boot-time registry:
|
|
54
|
+
*
|
|
55
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
56
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
57
|
+
*
|
|
58
|
+
* Passing the context explicitly is what makes that answer model-CORRECT.
|
|
59
|
+
*
|
|
60
|
+
* ---------------------------------------------------------------------------
|
|
5
61
|
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
62
|
* ---------------------------------------------------------------------------
|
|
7
63
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
@@ -59,6 +115,7 @@
|
|
|
59
115
|
* See `### Known limitations` in README.
|
|
60
116
|
*/
|
|
61
117
|
import { Request } from '@stonyx/rest-server';
|
|
118
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
62
119
|
interface OrmRequest$ extends Request {
|
|
63
120
|
protocol?: string;
|
|
64
121
|
method: string;
|
|
@@ -73,13 +130,12 @@ interface OrmRequest$ extends Request {
|
|
|
73
130
|
};
|
|
74
131
|
get(header: string): string;
|
|
75
132
|
}
|
|
76
|
-
type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
77
133
|
type HandlerFn = (request: OrmRequest$, state: {
|
|
78
134
|
[key: string]: unknown;
|
|
79
135
|
}) => unknown | Promise<unknown>;
|
|
80
136
|
export default class OrmRequest extends Request {
|
|
81
137
|
model: string;
|
|
82
|
-
access:
|
|
138
|
+
access: AccessFunction;
|
|
83
139
|
handlers: {
|
|
84
140
|
[key: string]: {
|
|
85
141
|
[key: string]: HandlerFn;
|
|
@@ -87,7 +143,7 @@ export default class OrmRequest extends Request {
|
|
|
87
143
|
};
|
|
88
144
|
constructor({ model, access }: {
|
|
89
145
|
model: string;
|
|
90
|
-
access:
|
|
146
|
+
access: AccessFunction;
|
|
91
147
|
});
|
|
92
148
|
private _withHooks;
|
|
93
149
|
private _generateRelationshipRoutes;
|
package/dist/orm-request.js
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE `access()` CONTRACT: `access(request, { model, operation })`
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` calls your predicate with TWO arguments. The second is the access
|
|
8
|
+
* CONTEXT -- the structural facts about the request, which the framework
|
|
9
|
+
* already holds and which you should read INSTEAD of parsing anything:
|
|
10
|
+
*
|
|
11
|
+
* context.model The model this route was mounted for, as a model name:
|
|
12
|
+
* kebab-case, exactly as declared under
|
|
13
|
+
* `config.orm.paths.model` and keyed in the store --
|
|
14
|
+
* `'owner'`, `'animal'`, `'phone-number'`. NOT the
|
|
15
|
+
* pluralised, dasherized, mount-prefixed ROUTE name. It is
|
|
16
|
+
* read from the OrmRequest instance, fixed at mount time,
|
|
17
|
+
* and no request can influence it.
|
|
18
|
+
*
|
|
19
|
+
* context.operation The operation being authorised. Exactly one of the four
|
|
20
|
+
* verbs `'read'`, `'create'`, `'update'`, `'delete'` --
|
|
21
|
+
* there is no second vocabulary, and it is never an HTTP
|
|
22
|
+
* method name like `'GET'`. These are the same four
|
|
23
|
+
* strings the permission-array return shape is written in
|
|
24
|
+
* (`['read', 'create']`), because both come from the one
|
|
25
|
+
* `methodAccessMap` below.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` when the dispatched method has no entry in
|
|
28
|
+
* that map. Express delivers `HEAD` to the `GET` handler,
|
|
29
|
+
* so this is reachable. It is left undefined rather than
|
|
30
|
+
* defaulted on purpose -- a fabricated `'read'` would turn
|
|
31
|
+
* an unclassified request into an authorised one. Treat
|
|
32
|
+
* `undefined` as "not classified" and deny.
|
|
33
|
+
*
|
|
34
|
+
* So a consumer writes `if (model === 'owner' && operation === 'read')`. There
|
|
35
|
+
* is no string to parse, no variant to miss, and no way to fail open through a
|
|
36
|
+
* URL shape nobody anticipated.
|
|
37
|
+
*
|
|
38
|
+
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
39
|
+
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
40
|
+
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
41
|
+
* record would force a pre-fetch on every request, a second store hit and an
|
|
42
|
+
* ordering change in the middle of an authorization path. It is also
|
|
43
|
+
* unnecessary: the FUNCTION return shape already is the per-record hook. Return
|
|
44
|
+
* `(record) => boolean` and the handlers apply it to every record the request
|
|
45
|
+
* touches. Auth-time and record-time are separate decision points.
|
|
46
|
+
*
|
|
47
|
+
* THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
|
|
48
|
+
* existing `access(request)` predicate keeps working exactly as before. The
|
|
49
|
+
* warning immediately below is therefore still live: `request` is still
|
|
50
|
+
* argument ONE, and reading it is still how predicates fail open.
|
|
51
|
+
*
|
|
52
|
+
* To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
|
|
53
|
+
* an owners route -- use the boot-time registry:
|
|
54
|
+
*
|
|
55
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
56
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
57
|
+
*
|
|
58
|
+
* Passing the context explicitly is what makes that answer model-CORRECT.
|
|
59
|
+
*
|
|
60
|
+
* ---------------------------------------------------------------------------
|
|
5
61
|
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
62
|
* ---------------------------------------------------------------------------
|
|
7
63
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
@@ -1036,9 +1092,41 @@ export default class OrmRequest extends Request {
|
|
|
1036
1092
|
// answers 500 -- and the documented sample itself can throw
|
|
1037
1093
|
// (`request.originalUrl.split(...)` when originalUrl is absent), so the
|
|
1038
1094
|
// failure mode is reachable by following the docs.
|
|
1095
|
+
// -------------------------------------------------------------------------
|
|
1096
|
+
// #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
|
|
1097
|
+
//
|
|
1098
|
+
// Both members are already in hand here. `model` is `this.model`, the name
|
|
1099
|
+
// setup-rest-server mounted this route for; `operation` is the SAME
|
|
1100
|
+
// `methodAccessMap` lookup the permission-array branch at the bottom of
|
|
1101
|
+
// this method performs, so the predicate form and the array form cannot
|
|
1102
|
+
// answer differently about the same request.
|
|
1103
|
+
//
|
|
1104
|
+
// NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
|
|
1105
|
+
// Deriving `model` here from `request.baseUrl` (or from the mounted route
|
|
1106
|
+
// name, or from `getPluralName(this.model)`) would move all five fail-open
|
|
1107
|
+
// variants listed in this file's header OUT of the consumer and INTO the
|
|
1108
|
+
// framework, where every consumer inherits them at once. `this.model` is
|
|
1109
|
+
// assigned once at mount time and no request can influence it.
|
|
1110
|
+
//
|
|
1111
|
+
// `operation` is left UNDEFINED for a method with no entry in
|
|
1112
|
+
// `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
|
|
1113
|
+
// GET handler, so an unmapped method really does reach this line; a
|
|
1114
|
+
// `?? 'read'` here would hand the consumer a fabricated authorisation fact
|
|
1115
|
+
// and turn an unclassified request into an authorised one. Undefined is
|
|
1116
|
+
// the honest answer.
|
|
1117
|
+
//
|
|
1118
|
+
// `record` is deliberately absent -- see `AccessContext` in
|
|
1119
|
+
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1120
|
+
// lookup here would put a store read in the middle of an authorization
|
|
1121
|
+
// path. The function return shape below IS the per-record hook.
|
|
1122
|
+
// -------------------------------------------------------------------------
|
|
1123
|
+
const context = {
|
|
1124
|
+
model: this.model,
|
|
1125
|
+
operation: methodAccessMap[request.method],
|
|
1126
|
+
};
|
|
1039
1127
|
let access;
|
|
1040
1128
|
try {
|
|
1041
|
-
access = this.access(request);
|
|
1129
|
+
access = this.access(request, context);
|
|
1042
1130
|
}
|
|
1043
1131
|
catch (error) {
|
|
1044
1132
|
// Same reasoning as `isDenied`: fail closed, but say so. An `access()`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { waitForModule } from 'stonyx';
|
|
2
|
-
import { store } from '@stonyx/orm';
|
|
2
|
+
import Orm, { store } from '@stonyx/orm';
|
|
3
3
|
import OrmRequest from './orm-request.js';
|
|
4
4
|
import MetaRequest from './meta-request.js';
|
|
5
5
|
import RestServer from '@stonyx/rest-server';
|
|
@@ -35,6 +35,28 @@ export default async function (route, accessPath, metaRoute) {
|
|
|
35
35
|
log.error?.(error instanceof Error ? error.message : String(error));
|
|
36
36
|
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
37
37
|
}
|
|
38
|
+
// -------------------------------------------------------------------------
|
|
39
|
+
// #202 -- the registry has to survive this function.
|
|
40
|
+
//
|
|
41
|
+
// `accessFiles` used to be a function-local that was discarded at the return
|
|
42
|
+
// below, so the only thing that ever saw it was the mount loop. Each mounted
|
|
43
|
+
// OrmRequest then held its OWN model's predicate and nothing held the map, so
|
|
44
|
+
// at request time there was no route from a model NAME to that model's
|
|
45
|
+
// predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
|
|
46
|
+
// ask model X's predicate about a request routed to model Y.
|
|
47
|
+
//
|
|
48
|
+
// Published BEFORE `waitForModule('rest-server')` and before the mount loop,
|
|
49
|
+
// deliberately. That module may already be listening by the time it reports
|
|
50
|
+
// ready, so assigning after the mounts would leave a window in which a route
|
|
51
|
+
// exists and the registry does not.
|
|
52
|
+
//
|
|
53
|
+
// Assigned unconditionally, including when the try above failed and the map
|
|
54
|
+
// is empty or partial: the mount loop below is driven by this exact object,
|
|
55
|
+
// so whatever is reachable through `Orm.instance` is by construction the same
|
|
56
|
+
// set of predicates that is actually enforcing. A guard here that skipped the
|
|
57
|
+
// assignment would let the registry go silently missing, which is precisely
|
|
58
|
+
// the failure #202's AC8 exists to catch.
|
|
59
|
+
Orm.instance.accessFiles = accessFiles;
|
|
38
60
|
await waitForModule('rest-server');
|
|
39
61
|
// Remove "/" prefix and name mount point accordingly
|
|
40
62
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
@@ -162,3 +162,70 @@ export interface SnapshotEntry {
|
|
|
162
162
|
source?: string;
|
|
163
163
|
viewQuery?: string;
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* The shapes a consumer `access()` predicate may return.
|
|
167
|
+
*
|
|
168
|
+
* - `false` (or any falsy value) -- deny, 403.
|
|
169
|
+
* - `true` -- allow, with no per-record filter.
|
|
170
|
+
* - a permission string or array of them, drawn from the same four verbs as
|
|
171
|
+
* {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
|
|
172
|
+
* grant of all four.
|
|
173
|
+
* - a `(record) => boolean` predicate -- allow, and filter every record the
|
|
174
|
+
* request touches through it.
|
|
175
|
+
*
|
|
176
|
+
* Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
|
|
177
|
+
*/
|
|
178
|
+
export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
179
|
+
/**
|
|
180
|
+
* The structural facts about the request being authorised, handed to a consumer
|
|
181
|
+
* `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
|
|
182
|
+
*
|
|
183
|
+
* These are the facts the framework already holds at authorisation time. Before
|
|
184
|
+
* #202 a consumer had to reconstruct both of them by string-matching a URL, and
|
|
185
|
+
* five independent fail-open variants of that reconstruction were found in one
|
|
186
|
+
* three-line documented example -- each one wrong in the direction that GRANTS
|
|
187
|
+
* access. Read these instead; there is nothing to parse and no variant to miss.
|
|
188
|
+
*
|
|
189
|
+
* `record` is deliberately NOT a member. `auth()` runs after route matching but
|
|
190
|
+
* before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
|
|
191
|
+
* so nothing has been fetched yet -- carrying a record here would force a
|
|
192
|
+
* pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
|
|
193
|
+
* return shape of {@link AccessMethod} already IS the per-record hook, applied
|
|
194
|
+
* by the handlers. Auth-time and record-time are separate decision points.
|
|
195
|
+
*/
|
|
196
|
+
export interface AccessContext {
|
|
197
|
+
/**
|
|
198
|
+
* The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
|
|
199
|
+
*
|
|
200
|
+
* Model names are kebab-case, as declared under `config.orm.paths.model` and
|
|
201
|
+
* keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
|
|
202
|
+
* read from the `OrmRequest` instance and is never derived from the request
|
|
203
|
+
* target, so a mount prefix, a case-varied path, a query string or an
|
|
204
|
+
* absolute-form request-target cannot change it.
|
|
205
|
+
*/
|
|
206
|
+
model: string;
|
|
207
|
+
/**
|
|
208
|
+
* The operation being authorised: `'read'`, `'create'`, `'update'` or
|
|
209
|
+
* `'delete'`, and no second vocabulary. These are exactly the values of
|
|
210
|
+
* `methodAccessMap` in `src/orm-request.ts`, which is also what the
|
|
211
|
+
* permission-array return shape is matched against -- so the two forms cannot
|
|
212
|
+
* disagree.
|
|
213
|
+
*
|
|
214
|
+
* `undefined` when the dispatched method has no entry in that map. Express
|
|
215
|
+
* delivers `HEAD` to the `GET` handler, so this is reachable. It is left
|
|
216
|
+
* undefined rather than defaulted on purpose: a fabricated `'read'` would
|
|
217
|
+
* turn an unclassified request into an authorised one.
|
|
218
|
+
*/
|
|
219
|
+
operation?: string;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A consumer `access()` predicate.
|
|
223
|
+
*
|
|
224
|
+
* `context` is optional in the type because the second argument is ADDITIVE:
|
|
225
|
+
* JavaScript ignores extra arguments, so every pre-#202 single-argument
|
|
226
|
+
* predicate keeps working untouched. Changing the FIRST argument instead would
|
|
227
|
+
* have been the breaking form, and a predicate that can no longer identify its
|
|
228
|
+
* collection falls through to a full CRUD grant -- so the "safer" breaking
|
|
229
|
+
* change would have converted every unmigrated predicate into a fail-open.
|
|
230
|
+
*/
|
|
231
|
+
export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
|
|
|
27
27
|
export { default } from './main.js';
|
|
28
28
|
export { store, relationships } from './main.js';
|
|
29
29
|
export type { PersistErrorDetail } from './main.js';
|
|
30
|
+
export type { AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js'; // access() contract (#202)
|
|
30
31
|
export { Model, View, Serializer }; // base classes
|
|
31
32
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
32
33
|
export { count, avg, sum, min, max }; // aggregate helpers
|
package/src/main.ts
CHANGED
|
@@ -25,6 +25,7 @@ import baseTransforms from './transforms.js';
|
|
|
25
25
|
import Store from './store.js';
|
|
26
26
|
import Serializer from './serializer.js';
|
|
27
27
|
import { setup } from '@stonyx/events';
|
|
28
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
28
29
|
|
|
29
30
|
interface OrmOptions {
|
|
30
31
|
dbType?: string;
|
|
@@ -68,6 +69,32 @@ export default class Orm {
|
|
|
68
69
|
views: Record<string, unknown> = {};
|
|
69
70
|
transforms: Record<string, (value: unknown) => unknown> = { ...baseTransforms };
|
|
70
71
|
warnings: Set<string> = new Set();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Model name -> that model's `access` predicate (abofs/stonyx-orm#202).
|
|
75
|
+
*
|
|
76
|
+
* Populated by `setup-rest-server.ts` at boot, from the access classes under
|
|
77
|
+
* `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
|
|
78
|
+
* and reachable before the first request can be served. The mapping is
|
|
79
|
+
* one-to-one by construction: setup-rest-server throws if two access classes
|
|
80
|
+
* claim the same model.
|
|
81
|
+
*
|
|
82
|
+
* Keys are model names as declared and stored (kebab-case, e.g.
|
|
83
|
+
* `'phone-number'`), NOT pluralised or mount-prefixed route names.
|
|
84
|
+
*
|
|
85
|
+
* WHY THIS EXISTS AS A FIELD. It used to be a function-local in
|
|
86
|
+
* setup-rest-server that was discarded when that function returned, so at
|
|
87
|
+
* request time there was no way to get from a model name to that model's
|
|
88
|
+
* predicate at all. Each `OrmRequest` held only its OWN model's predicate.
|
|
89
|
+
* That made cross-model authorization -- asking model X's predicate about a
|
|
90
|
+
* request routed to model Y -- inexpressible, which is the capability
|
|
91
|
+
* abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
|
|
92
|
+
*
|
|
93
|
+
* Empty when the REST server is disabled, or when no access configuration
|
|
94
|
+
* could be loaded. Prefer {@link Orm#getAccess} over indexing this directly.
|
|
95
|
+
*/
|
|
96
|
+
accessFiles: Record<string, AccessFunction> = {};
|
|
97
|
+
|
|
71
98
|
options!: OrmOptions;
|
|
72
99
|
sqlDb?: SqlDb;
|
|
73
100
|
db?: OrmDB | SqlDb;
|
|
@@ -195,6 +222,31 @@ export default class Orm {
|
|
|
195
222
|
Orm.initialized = true;
|
|
196
223
|
}
|
|
197
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Resolve a model's `access` predicate by model name (abofs/stonyx-orm#202).
|
|
227
|
+
*
|
|
228
|
+
* This is the supported way to reach another model's predicate while
|
|
229
|
+
* servicing a request routed to a different model. Call it with the model
|
|
230
|
+
* name and invoke the result with the live request and an explicit context
|
|
231
|
+
* naming THAT model:
|
|
232
|
+
*
|
|
233
|
+
* ```js
|
|
234
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
235
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
236
|
+
* ```
|
|
237
|
+
*
|
|
238
|
+
* Passing the context is not optional in practice. A predicate that
|
|
239
|
+
* identifies its collection from the request would otherwise answer about the
|
|
240
|
+
* collection the request is ADDRESSED TO -- owners -- while being asked about
|
|
241
|
+
* animals, and per #202's thesis it answers wrong in the granting direction.
|
|
242
|
+
*
|
|
243
|
+
* @param modelName - Model name as declared and stored (kebab-case).
|
|
244
|
+
* @returns The predicate, or `undefined` when the model has no access class.
|
|
245
|
+
*/
|
|
246
|
+
getAccess(modelName: string): AccessFunction | undefined {
|
|
247
|
+
return this.accessFiles[modelName];
|
|
248
|
+
}
|
|
249
|
+
|
|
198
250
|
async startup(): Promise<void> {
|
|
199
251
|
if (this.sqlDb) await this.sqlDb.startup();
|
|
200
252
|
}
|
package/src/orm-request.ts
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE `access()` CONTRACT: `access(request, { model, operation })`
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` calls your predicate with TWO arguments. The second is the access
|
|
8
|
+
* CONTEXT -- the structural facts about the request, which the framework
|
|
9
|
+
* already holds and which you should read INSTEAD of parsing anything:
|
|
10
|
+
*
|
|
11
|
+
* context.model The model this route was mounted for, as a model name:
|
|
12
|
+
* kebab-case, exactly as declared under
|
|
13
|
+
* `config.orm.paths.model` and keyed in the store --
|
|
14
|
+
* `'owner'`, `'animal'`, `'phone-number'`. NOT the
|
|
15
|
+
* pluralised, dasherized, mount-prefixed ROUTE name. It is
|
|
16
|
+
* read from the OrmRequest instance, fixed at mount time,
|
|
17
|
+
* and no request can influence it.
|
|
18
|
+
*
|
|
19
|
+
* context.operation The operation being authorised. Exactly one of the four
|
|
20
|
+
* verbs `'read'`, `'create'`, `'update'`, `'delete'` --
|
|
21
|
+
* there is no second vocabulary, and it is never an HTTP
|
|
22
|
+
* method name like `'GET'`. These are the same four
|
|
23
|
+
* strings the permission-array return shape is written in
|
|
24
|
+
* (`['read', 'create']`), because both come from the one
|
|
25
|
+
* `methodAccessMap` below.
|
|
26
|
+
*
|
|
27
|
+
* `undefined` when the dispatched method has no entry in
|
|
28
|
+
* that map. Express delivers `HEAD` to the `GET` handler,
|
|
29
|
+
* so this is reachable. It is left undefined rather than
|
|
30
|
+
* defaulted on purpose -- a fabricated `'read'` would turn
|
|
31
|
+
* an unclassified request into an authorised one. Treat
|
|
32
|
+
* `undefined` as "not classified" and deny.
|
|
33
|
+
*
|
|
34
|
+
* So a consumer writes `if (model === 'owner' && operation === 'read')`. There
|
|
35
|
+
* is no string to parse, no variant to miss, and no way to fail open through a
|
|
36
|
+
* URL shape nobody anticipated.
|
|
37
|
+
*
|
|
38
|
+
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
39
|
+
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
40
|
+
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
41
|
+
* record would force a pre-fetch on every request, a second store hit and an
|
|
42
|
+
* ordering change in the middle of an authorization path. It is also
|
|
43
|
+
* unnecessary: the FUNCTION return shape already is the per-record hook. Return
|
|
44
|
+
* `(record) => boolean` and the handlers apply it to every record the request
|
|
45
|
+
* touches. Auth-time and record-time are separate decision points.
|
|
46
|
+
*
|
|
47
|
+
* THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
|
|
48
|
+
* existing `access(request)` predicate keeps working exactly as before. The
|
|
49
|
+
* warning immediately below is therefore still live: `request` is still
|
|
50
|
+
* argument ONE, and reading it is still how predicates fail open.
|
|
51
|
+
*
|
|
52
|
+
* To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
|
|
53
|
+
* an owners route -- use the boot-time registry:
|
|
54
|
+
*
|
|
55
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
56
|
+
* const verdict = predicate?.(request, { model: 'animal', operation: 'read' });
|
|
57
|
+
*
|
|
58
|
+
* Passing the context explicitly is what makes that answer model-CORRECT.
|
|
59
|
+
*
|
|
60
|
+
* ---------------------------------------------------------------------------
|
|
5
61
|
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
62
|
* ---------------------------------------------------------------------------
|
|
7
63
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
@@ -66,7 +122,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
66
122
|
import type { HookContext } from './hooks.js';
|
|
67
123
|
import config from 'stonyx/config';
|
|
68
124
|
import log from 'stonyx/log';
|
|
69
|
-
import type { OrmRecord } from './types/orm-types.js';
|
|
125
|
+
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod } from './types/orm-types.js';
|
|
70
126
|
import { isOrmRecord } from './utils.js';
|
|
71
127
|
|
|
72
128
|
interface OrmRequest$ extends Request {
|
|
@@ -94,7 +150,6 @@ interface JsonApiResponse {
|
|
|
94
150
|
included?: unknown[];
|
|
95
151
|
}
|
|
96
152
|
|
|
97
|
-
type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
98
153
|
type HandlerFn = (request: OrmRequest$, state: { [key: string]: unknown }) => unknown | Promise<unknown>;
|
|
99
154
|
|
|
100
155
|
const methodAccessMap: { [key: string]: string } = {
|
|
@@ -455,10 +510,10 @@ function isDenied(filter: unknown, record: unknown): boolean {
|
|
|
455
510
|
|
|
456
511
|
export default class OrmRequest extends Request {
|
|
457
512
|
model: string;
|
|
458
|
-
access:
|
|
513
|
+
access: AccessFunction;
|
|
459
514
|
handlers: { [key: string]: { [key: string]: HandlerFn } };
|
|
460
515
|
|
|
461
|
-
constructor({ model, access }: { model: string; access:
|
|
516
|
+
constructor({ model, access }: { model: string; access: AccessFunction }) {
|
|
462
517
|
super(...arguments as unknown as unknown[]);
|
|
463
518
|
|
|
464
519
|
this.model = model;
|
|
@@ -1155,9 +1210,42 @@ export default class OrmRequest extends Request {
|
|
|
1155
1210
|
// answers 500 -- and the documented sample itself can throw
|
|
1156
1211
|
// (`request.originalUrl.split(...)` when originalUrl is absent), so the
|
|
1157
1212
|
// failure mode is reachable by following the docs.
|
|
1213
|
+
// -------------------------------------------------------------------------
|
|
1214
|
+
// #202 -- hand the consumer the STRUCTURAL facts, not just the transport.
|
|
1215
|
+
//
|
|
1216
|
+
// Both members are already in hand here. `model` is `this.model`, the name
|
|
1217
|
+
// setup-rest-server mounted this route for; `operation` is the SAME
|
|
1218
|
+
// `methodAccessMap` lookup the permission-array branch at the bottom of
|
|
1219
|
+
// this method performs, so the predicate form and the array form cannot
|
|
1220
|
+
// answer differently about the same request.
|
|
1221
|
+
//
|
|
1222
|
+
// NEITHER IS DERIVED FROM THE REQUEST TARGET, and that is the whole point.
|
|
1223
|
+
// Deriving `model` here from `request.baseUrl` (or from the mounted route
|
|
1224
|
+
// name, or from `getPluralName(this.model)`) would move all five fail-open
|
|
1225
|
+
// variants listed in this file's header OUT of the consumer and INTO the
|
|
1226
|
+
// framework, where every consumer inherits them at once. `this.model` is
|
|
1227
|
+
// assigned once at mount time and no request can influence it.
|
|
1228
|
+
//
|
|
1229
|
+
// `operation` is left UNDEFINED for a method with no entry in
|
|
1230
|
+
// `methodAccessMap`, rather than defaulted. Express delivers HEAD to the
|
|
1231
|
+
// GET handler, so an unmapped method really does reach this line; a
|
|
1232
|
+
// `?? 'read'` here would hand the consumer a fabricated authorisation fact
|
|
1233
|
+
// and turn an unclassified request into an authorised one. Undefined is
|
|
1234
|
+
// the honest answer.
|
|
1235
|
+
//
|
|
1236
|
+
// `record` is deliberately absent -- see `AccessContext` in
|
|
1237
|
+
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1238
|
+
// lookup here would put a store read in the middle of an authorization
|
|
1239
|
+
// path. The function return shape below IS the per-record hook.
|
|
1240
|
+
// -------------------------------------------------------------------------
|
|
1241
|
+
const context: AccessContext = {
|
|
1242
|
+
model: this.model,
|
|
1243
|
+
operation: methodAccessMap[request.method],
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1158
1246
|
let access: AccessMethod;
|
|
1159
1247
|
try {
|
|
1160
|
-
access = this.access(request);
|
|
1248
|
+
access = this.access(request, context);
|
|
1161
1249
|
} catch (error) {
|
|
1162
1250
|
// Same reasoning as `isDenied`: fail closed, but say so. An `access()`
|
|
1163
1251
|
// that throws denies EVERY request to the collection, and a silent 403
|
package/src/setup-rest-server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { waitForModule } from 'stonyx';
|
|
2
|
-
import { store } from '@stonyx/orm';
|
|
2
|
+
import Orm, { store } from '@stonyx/orm';
|
|
3
3
|
import OrmRequest from './orm-request.js';
|
|
4
4
|
import MetaRequest from './meta-request.js';
|
|
5
5
|
import RestServer from '@stonyx/rest-server';
|
|
@@ -7,14 +7,20 @@ import { forEachFileImport } from '@stonyx/utils/file';
|
|
|
7
7
|
import { dbKey } from './db.js';
|
|
8
8
|
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
10
11
|
|
|
11
12
|
interface AccessInstance {
|
|
12
13
|
models: string[] | '*';
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The consumer predicate. Called as `access(request, { model, operation })`
|
|
16
|
+
* -- the second argument is additive (abofs/stonyx-orm#202), so a predicate
|
|
17
|
+
* declared with a single parameter is still valid and still works.
|
|
18
|
+
*/
|
|
19
|
+
access: AccessFunction;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
|
|
17
|
-
const accessFiles: Record<string,
|
|
23
|
+
const accessFiles: Record<string, AccessFunction> = {};
|
|
18
24
|
|
|
19
25
|
try {
|
|
20
26
|
await forEachFileImport(accessPath, (accessClass: unknown) => {
|
|
@@ -41,6 +47,29 @@ export default async function(route: string, accessPath: string, metaRoute: bool
|
|
|
41
47
|
log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// -------------------------------------------------------------------------
|
|
51
|
+
// #202 -- the registry has to survive this function.
|
|
52
|
+
//
|
|
53
|
+
// `accessFiles` used to be a function-local that was discarded at the return
|
|
54
|
+
// below, so the only thing that ever saw it was the mount loop. Each mounted
|
|
55
|
+
// OrmRequest then held its OWN model's predicate and nothing held the map, so
|
|
56
|
+
// at request time there was no route from a model NAME to that model's
|
|
57
|
+
// predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
|
|
58
|
+
// ask model X's predicate about a request routed to model Y.
|
|
59
|
+
//
|
|
60
|
+
// Published BEFORE `waitForModule('rest-server')` and before the mount loop,
|
|
61
|
+
// deliberately. That module may already be listening by the time it reports
|
|
62
|
+
// ready, so assigning after the mounts would leave a window in which a route
|
|
63
|
+
// exists and the registry does not.
|
|
64
|
+
//
|
|
65
|
+
// Assigned unconditionally, including when the try above failed and the map
|
|
66
|
+
// is empty or partial: the mount loop below is driven by this exact object,
|
|
67
|
+
// so whatever is reachable through `Orm.instance` is by construction the same
|
|
68
|
+
// set of predicates that is actually enforcing. A guard here that skipped the
|
|
69
|
+
// assignment would let the registry go silently missing, which is precisely
|
|
70
|
+
// the failure #202's AC8 exists to catch.
|
|
71
|
+
Orm.instance.accessFiles = accessFiles;
|
|
72
|
+
|
|
44
73
|
await waitForModule('rest-server');
|
|
45
74
|
|
|
46
75
|
// Remove "/" prefix and name mount point accordingly
|
package/src/types/orm-types.ts
CHANGED
|
@@ -168,3 +168,74 @@ export interface SnapshotEntry {
|
|
|
168
168
|
source?: string;
|
|
169
169
|
viewQuery?: string;
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The shapes a consumer `access()` predicate may return.
|
|
174
|
+
*
|
|
175
|
+
* - `false` (or any falsy value) -- deny, 403.
|
|
176
|
+
* - `true` -- allow, with no per-record filter.
|
|
177
|
+
* - a permission string or array of them, drawn from the same four verbs as
|
|
178
|
+
* {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
|
|
179
|
+
* grant of all four.
|
|
180
|
+
* - a `(record) => boolean` predicate -- allow, and filter every record the
|
|
181
|
+
* request touches through it.
|
|
182
|
+
*
|
|
183
|
+
* Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
|
|
184
|
+
*/
|
|
185
|
+
export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The structural facts about the request being authorised, handed to a consumer
|
|
189
|
+
* `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
|
|
190
|
+
*
|
|
191
|
+
* These are the facts the framework already holds at authorisation time. Before
|
|
192
|
+
* #202 a consumer had to reconstruct both of them by string-matching a URL, and
|
|
193
|
+
* five independent fail-open variants of that reconstruction were found in one
|
|
194
|
+
* three-line documented example -- each one wrong in the direction that GRANTS
|
|
195
|
+
* access. Read these instead; there is nothing to parse and no variant to miss.
|
|
196
|
+
*
|
|
197
|
+
* `record` is deliberately NOT a member. `auth()` runs after route matching but
|
|
198
|
+
* before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
|
|
199
|
+
* so nothing has been fetched yet -- carrying a record here would force a
|
|
200
|
+
* pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
|
|
201
|
+
* return shape of {@link AccessMethod} already IS the per-record hook, applied
|
|
202
|
+
* by the handlers. Auth-time and record-time are separate decision points.
|
|
203
|
+
*/
|
|
204
|
+
export interface AccessContext {
|
|
205
|
+
/**
|
|
206
|
+
* The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
|
|
207
|
+
*
|
|
208
|
+
* Model names are kebab-case, as declared under `config.orm.paths.model` and
|
|
209
|
+
* keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
|
|
210
|
+
* read from the `OrmRequest` instance and is never derived from the request
|
|
211
|
+
* target, so a mount prefix, a case-varied path, a query string or an
|
|
212
|
+
* absolute-form request-target cannot change it.
|
|
213
|
+
*/
|
|
214
|
+
model: string;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The operation being authorised: `'read'`, `'create'`, `'update'` or
|
|
218
|
+
* `'delete'`, and no second vocabulary. These are exactly the values of
|
|
219
|
+
* `methodAccessMap` in `src/orm-request.ts`, which is also what the
|
|
220
|
+
* permission-array return shape is matched against -- so the two forms cannot
|
|
221
|
+
* disagree.
|
|
222
|
+
*
|
|
223
|
+
* `undefined` when the dispatched method has no entry in that map. Express
|
|
224
|
+
* delivers `HEAD` to the `GET` handler, so this is reachable. It is left
|
|
225
|
+
* undefined rather than defaulted on purpose: a fabricated `'read'` would
|
|
226
|
+
* turn an unclassified request into an authorised one.
|
|
227
|
+
*/
|
|
228
|
+
operation?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A consumer `access()` predicate.
|
|
233
|
+
*
|
|
234
|
+
* `context` is optional in the type because the second argument is ADDITIVE:
|
|
235
|
+
* JavaScript ignores extra arguments, so every pre-#202 single-argument
|
|
236
|
+
* predicate keeps working untouched. Changing the FIRST argument instead would
|
|
237
|
+
* have been the breaking form, and a predicate that can no longer identify its
|
|
238
|
+
* collection falls through to a full CRUD grant -- so the "safer" breaking
|
|
239
|
+
* change would have converted every unmigrated predicate into a fail-open.
|
|
240
|
+
*/
|
|
241
|
+
export type AccessFunction = (request: unknown, context?: AccessContext) => AccessMethod;
|