@stonyx/orm 0.3.2-alpha.67 → 0.3.2-alpha.68
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 +44 -1
- package/dist/access-verdict.d.ts +57 -0
- package/dist/access-verdict.js +185 -0
- package/dist/orm-request.js +39 -23
- package/dist/record.d.ts +12 -0
- package/dist/record.js +20 -3
- package/dist/types/orm-types.d.ts +9 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +222 -0
- package/src/orm-request.ts +43 -22
- package/src/record.ts +33 -3
- package/src/types/orm-types.ts +9 -1
package/README.md
CHANGED
|
@@ -864,7 +864,50 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
864
864
|
related record without resolving that model's own access class, so a filter on
|
|
865
865
|
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
866
866
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
867
|
-
`include=`, related-resource routes and relationship-linkage routes.
|
|
867
|
+
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
868
|
+
**membership** — whether the related resource is served at all — and it is a
|
|
869
|
+
different question from which ids a document may *name*, immediately below.
|
|
870
|
+
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
871
|
+
and only there.** A document's `relationships.*.data` used to publish the id
|
|
872
|
+
of every related record unconditionally, so a record hidden on every one of
|
|
873
|
+
its own surfaces was still named inside another model's document — with no
|
|
874
|
+
`include=`, no relationship route and no query string
|
|
875
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). It is now filtered
|
|
876
|
+
through the related model's own access class on `GET /:models`,
|
|
877
|
+
`GET /:models/:id` and both `GET /:models/:id/{relationship}` shapes. A
|
|
878
|
+
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
879
|
+
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
880
|
+
both **keeping their `links`**, which are built from the serialized record's
|
|
881
|
+
own id and never from the related one. Nothing errors and no status changes,
|
|
882
|
+
because throwing here would be an existence oracle *and* would throw out of
|
|
883
|
+
the enclosing `JSON.stringify`. **Not yet covered:** `included`
|
|
884
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
885
|
+
resource appears there at all), the `POST`/`PATCH` response documents, and
|
|
886
|
+
`GET /:models/:id/relationships/{relationship}`, whose *primary data* is
|
|
887
|
+
linkage ([#196](https://github.com/abofs/stonyx-orm/issues/196)).
|
|
888
|
+
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
889
|
+
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
890
|
+
request, and the documented `access()` contract permits a predicate to read
|
|
891
|
+
one — the sample in this README does, for its sub-path rule — so a filter
|
|
892
|
+
resolved inside `toJSON()` denies *permitted* records rather than hidden ones
|
|
893
|
+
(measured: 967 → 964, all three failures over-denials). `toJSON` is also the
|
|
894
|
+
`JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
|
|
895
|
+
`console.log(JSON.stringify(record))` reach it with a **string** in the
|
|
896
|
+
options slot and have no syntactic place to pass a verdict. The no-argument
|
|
897
|
+
call therefore returns the pre-#234 document unchanged. Fail-closed by default
|
|
898
|
+
is not available either: `Orm.instance.accessFunctions` is `{}` in any process
|
|
899
|
+
that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
|
|
900
|
+
it would empty every relationship on every document in processes with no REST
|
|
901
|
+
surface to protect. Closing the residual means moving JSON:API serialization
|
|
902
|
+
**off** the `toJSON` name, tracked as
|
|
903
|
+
[#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
|
|
904
|
+
to an untrusted consumer, serialize it through the REST layer or pass your own
|
|
905
|
+
resolved `linkage` option.
|
|
906
|
+
- **`format()` and `serialize()` are deliberately not filtered, and must stay
|
|
907
|
+
that way.** `format()` is the **persistence** path — its output is what
|
|
908
|
+
`Orm.db.save()` writes to disk — so applying an access filter there would
|
|
909
|
+
write a truncated database. That is **data loss**, not disclosure prevention.
|
|
910
|
+
Neither method appears anywhere in the REST response path.
|
|
868
911
|
- **A before-hook that returns a value short-circuits the request.** On write
|
|
869
912
|
operations addressed to a record the filter is consulted first, so a hook
|
|
870
913
|
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The classified reading of one `access()` return value.
|
|
4
|
+
*
|
|
5
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
6
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
7
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
8
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
9
|
+
*/
|
|
10
|
+
export interface AccessVerdict {
|
|
11
|
+
granted: boolean;
|
|
12
|
+
filter?: (record: unknown) => boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
16
|
+
* NAMED, by id, inside another model's document?
|
|
17
|
+
*
|
|
18
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
19
|
+
* the consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
20
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
21
|
+
* key, not the input.
|
|
22
|
+
*/
|
|
23
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
26
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
27
|
+
*
|
|
28
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
29
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
30
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
31
|
+
* the same answer `auth()` gave before the extraction.
|
|
32
|
+
*/
|
|
33
|
+
export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
|
|
34
|
+
/**
|
|
35
|
+
* Build a request-scoped linkage filter.
|
|
36
|
+
*
|
|
37
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
38
|
+
*
|
|
39
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
40
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
41
|
+
* already had to guard for throwing.
|
|
42
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
43
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
44
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
45
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
46
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
47
|
+
*
|
|
48
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
49
|
+
* template-string composite: `Map` compares with SameValueZero, so the numeric
|
|
50
|
+
* id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
|
|
51
|
+
* would collapse them and let one model's verdict answer for another record.
|
|
52
|
+
*
|
|
53
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
54
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
55
|
+
* first caller's authorization.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared access-verdict primitive (abofs/stonyx-orm#234).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* A consumer `access()` may return six differently-shaped things -- `false`, a
|
|
8
|
+
* bare permission string, a permission array, `true`, a per-record function, or
|
|
9
|
+
* something the contract does not define at all -- and the reading of each one
|
|
10
|
+
* is a security decision. `auth()` has held that reading inline since #190.
|
|
11
|
+
* Every surface that needs to ask "may this caller see model X's record?" needs
|
|
12
|
+
* the SAME reading, or the second copy becomes an unreviewed second
|
|
13
|
+
* authorization vocabulary that answers differently about the same value.
|
|
14
|
+
*
|
|
15
|
+
* So `interpretAccess` is extracted here and `auth()` now calls it. It is the
|
|
16
|
+
* only place a return shape is classified, and abofs/stonyx-orm#232 and #233
|
|
17
|
+
* rebase onto it rather than re-deriving it.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
|
|
23
|
+
* style choice, it is forced, and it was measured before it was decided:
|
|
24
|
+
*
|
|
25
|
+
* INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
|
|
26
|
+
* INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
|
|
27
|
+
*
|
|
28
|
+
* and all three reds were over-denial of PERMITTED records, not the leak. Two
|
|
29
|
+
* independent reasons:
|
|
30
|
+
*
|
|
31
|
+
* 1. `toJSON()` has no request. The shipped, documented sample reads
|
|
32
|
+
* `request.path` for its `/archived` sub-path rule -- the one read of
|
|
33
|
+
* argument one the README sanctions -- and fail-closes when it is absent.
|
|
34
|
+
* Measured against the live registry:
|
|
35
|
+
*
|
|
36
|
+
* getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
|
|
37
|
+
* getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
|
|
38
|
+
*
|
|
39
|
+
* Same predicate object, two models, two different degradation modes,
|
|
40
|
+
* chosen by the consumer. Without a request there is no trustworthy
|
|
41
|
+
* answer to get.
|
|
42
|
+
*
|
|
43
|
+
* 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
|
|
44
|
+
* record})` calls `record.toJSON('data')` -- a STRING in the options slot.
|
|
45
|
+
* An implicit caller has no syntactic place to pass anything
|
|
46
|
+
* (abofs/stonyx-orm#230). The no-argument document must therefore stay
|
|
47
|
+
* byte-identical to what shipped, which also rules out fail-closed by
|
|
48
|
+
* default: `Orm.instance.accessFunctions` is `{}` in any process that
|
|
49
|
+
* never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
|
|
50
|
+
* fail-closed default would empty every relationship on every document in
|
|
51
|
+
* processes that have no REST surface to protect.
|
|
52
|
+
*
|
|
53
|
+
* The caller -- which still holds the request -- resolves the predicate,
|
|
54
|
+
* interprets it here, caches the answer, and hands `toJSON()` an already-decided
|
|
55
|
+
* `(type, record) => boolean`.
|
|
56
|
+
*/
|
|
57
|
+
import Orm from '@stonyx/orm';
|
|
58
|
+
import log from 'stonyx/log';
|
|
59
|
+
const DENIED = Object.freeze({ granted: false });
|
|
60
|
+
const GRANTED = Object.freeze({ granted: true });
|
|
61
|
+
/**
|
|
62
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
63
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
64
|
+
*
|
|
65
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
66
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
67
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
68
|
+
* the same answer `auth()` gave before the extraction.
|
|
69
|
+
*/
|
|
70
|
+
export function interpretAccess(access, operation) {
|
|
71
|
+
if (!access)
|
|
72
|
+
return DENIED;
|
|
73
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
74
|
+
// carry the predicate; the caller applies it per record.
|
|
75
|
+
if (typeof access === 'function')
|
|
76
|
+
return { granted: true, filter: access };
|
|
77
|
+
if (access === true)
|
|
78
|
+
return GRANTED;
|
|
79
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
80
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
81
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
82
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
83
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
84
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
85
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
86
|
+
if (!Array.isArray(permitted))
|
|
87
|
+
return DENIED;
|
|
88
|
+
if (!permitted.includes(operation))
|
|
89
|
+
return DENIED;
|
|
90
|
+
return GRANTED;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
94
|
+
*
|
|
95
|
+
* Fails closed on both ambiguous inputs:
|
|
96
|
+
*
|
|
97
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
98
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
99
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
100
|
+
* both "no access class claims this model" and "the class that claims it
|
|
101
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
102
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
103
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
104
|
+
*
|
|
105
|
+
* NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
|
|
106
|
+
* request in hand was dispatched to a DIFFERENT model's route. Since #222 this
|
|
107
|
+
* repo's fixture reads `context.model` and answers correctly; a consumer's
|
|
108
|
+
* arity-1 predicate does not, and there is no supported way to tell which kind
|
|
109
|
+
* was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
|
|
110
|
+
* consequence to expect rather than debug: the fixture's surviving `request.path`
|
|
111
|
+
* read means asking the OWNER predicate on a request dispatched to
|
|
112
|
+
* `GET /animals/archived` returns a bare `false`. That is a whole-request deny
|
|
113
|
+
* bleeding across models -- harmless, because it is the fail-closed direction,
|
|
114
|
+
* and it is treated as "deny this linkage", not as an error.
|
|
115
|
+
*/
|
|
116
|
+
function resolveVerdict(request, type) {
|
|
117
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
118
|
+
if (typeof predicate !== 'function')
|
|
119
|
+
return DENIED;
|
|
120
|
+
let access;
|
|
121
|
+
try {
|
|
122
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
+
return DENIED;
|
|
127
|
+
}
|
|
128
|
+
return interpretAccess(access, 'read');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build a request-scoped linkage filter.
|
|
132
|
+
*
|
|
133
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
134
|
+
*
|
|
135
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
136
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
137
|
+
* already had to guard for throwing.
|
|
138
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
139
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
140
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
141
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
142
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
143
|
+
*
|
|
144
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
145
|
+
* template-string composite: `Map` compares with SameValueZero, so the numeric
|
|
146
|
+
* id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
|
|
147
|
+
* would collapse them and let one model's verdict answer for another record.
|
|
148
|
+
*
|
|
149
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
150
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
151
|
+
* first caller's authorization.
|
|
152
|
+
*/
|
|
153
|
+
export function createLinkageFilter(request) {
|
|
154
|
+
const byType = new Map();
|
|
155
|
+
return function isLinkable(type, record) {
|
|
156
|
+
let entry = byType.get(type);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
159
|
+
byType.set(type, entry);
|
|
160
|
+
}
|
|
161
|
+
const { verdict, decisions } = entry;
|
|
162
|
+
if (!verdict.granted)
|
|
163
|
+
return false;
|
|
164
|
+
if (!verdict.filter)
|
|
165
|
+
return true;
|
|
166
|
+
const id = record?.id;
|
|
167
|
+
const cached = decisions.get(id);
|
|
168
|
+
if (cached !== undefined)
|
|
169
|
+
return cached;
|
|
170
|
+
let allowed;
|
|
171
|
+
try {
|
|
172
|
+
allowed = Boolean(verdict.filter(record));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
176
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
177
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
178
|
+
// from a database with no relationships in it.
|
|
179
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
180
|
+
allowed = false;
|
|
181
|
+
}
|
|
182
|
+
decisions.set(id, allowed);
|
|
183
|
+
return allowed;
|
|
184
|
+
};
|
|
185
|
+
}
|
package/dist/orm-request.js
CHANGED
|
@@ -219,6 +219,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
|
219
219
|
import config from 'stonyx/config';
|
|
220
220
|
import log from 'stonyx/log';
|
|
221
221
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
222
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
222
223
|
const methodAccessMap = {
|
|
223
224
|
GET: 'read',
|
|
224
225
|
POST: 'create',
|
|
@@ -561,7 +562,13 @@ export default class OrmRequest extends Request {
|
|
|
561
562
|
if (queryFilterPredicate)
|
|
562
563
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
563
564
|
const baseUrl = getBaseUrl(request);
|
|
564
|
-
|
|
565
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
566
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
567
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
568
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
569
|
+
// (type, id) pairs.
|
|
570
|
+
const linkage = createLinkageFilter(request);
|
|
571
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
565
572
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
566
573
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
567
574
|
baseUrl
|
|
@@ -579,7 +586,13 @@ export default class OrmRequest extends Request {
|
|
|
579
586
|
const fieldsMap = parseFields(request.query);
|
|
580
587
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
581
588
|
const baseUrl = getBaseUrl(request);
|
|
582
|
-
|
|
589
|
+
const linkage = createLinkageFilter(request);
|
|
590
|
+
// `buildResponse` is deliberately NOT given the linkage filter. It builds
|
|
591
|
+
// `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
|
|
592
|
+
// which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
|
|
593
|
+
// the ownership boundary in that issue. Only the PRIMARY document's
|
|
594
|
+
// linkage is filtered here.
|
|
595
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
583
596
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
584
597
|
baseUrl
|
|
585
598
|
});
|
|
@@ -1166,15 +1179,21 @@ export default class OrmRequest extends Request {
|
|
|
1166
1179
|
return 404;
|
|
1167
1180
|
const relatedData = record.__relationships[relationshipName];
|
|
1168
1181
|
const baseUrl = getBaseUrl(request);
|
|
1182
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1183
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1184
|
+
// the related records themselves are served -- that is the parent-only
|
|
1185
|
+
// filtering this route has done since #190, and widening it to the
|
|
1186
|
+
// related record is abofs/stonyx-orm#196.
|
|
1187
|
+
const linkage = createLinkageFilter(request);
|
|
1169
1188
|
let data;
|
|
1170
1189
|
if (info.isArray) {
|
|
1171
1190
|
// hasMany - return array
|
|
1172
1191
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1173
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1192
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1174
1193
|
}
|
|
1175
1194
|
else {
|
|
1176
1195
|
// belongsTo - return single or null
|
|
1177
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1196
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1178
1197
|
}
|
|
1179
1198
|
return {
|
|
1180
1199
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1285,26 +1304,23 @@ export default class OrmRequest extends Request {
|
|
|
1285
1304
|
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1286
1305
|
return 403; // Forbidden
|
|
1287
1306
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
1300
|
-
const permitted = typeof access === 'string' ? [access] : access;
|
|
1301
|
-
// Anything that is not a permission array by this point -- an object, a
|
|
1302
|
-
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
1303
|
-
// shape the contract does not define is a denial. Fail CLOSED.
|
|
1304
|
-
if (!Array.isArray(permitted))
|
|
1305
|
-
return 403;
|
|
1306
|
-
if (!permitted.includes(methodAccessMap[request.method]))
|
|
1307
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1308
|
+
//
|
|
1309
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1310
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1311
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1312
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1313
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1314
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1315
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1316
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1317
|
+
if (!verdict.granted)
|
|
1307
1318
|
return 403;
|
|
1319
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1320
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1321
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1322
|
+
if (verdict.filter)
|
|
1323
|
+
state.filter = verdict.filter;
|
|
1308
1324
|
return undefined;
|
|
1309
1325
|
}
|
|
1310
1326
|
}
|
package/dist/record.d.ts
CHANGED
|
@@ -2,6 +2,18 @@ import type Serializer from './serializer.js';
|
|
|
2
2
|
interface ToJSONOptions {
|
|
3
3
|
fields?: Set<string>;
|
|
4
4
|
baseUrl?: string;
|
|
5
|
+
/**
|
|
6
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
7
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
8
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
9
|
+
*
|
|
10
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
11
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
12
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
13
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
14
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
15
|
+
*/
|
|
16
|
+
linkage?: (type: string, record: unknown) => boolean;
|
|
5
17
|
}
|
|
6
18
|
interface SerializeOptions {
|
|
7
19
|
update?: boolean;
|
package/dist/record.js
CHANGED
|
@@ -65,7 +65,13 @@ export default class Record {
|
|
|
65
65
|
toJSON(options = {}) {
|
|
66
66
|
if (!this.__serialized)
|
|
67
67
|
throw new Error('Record must be serialized before being converted to JSON');
|
|
68
|
-
|
|
68
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
69
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
70
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
71
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
72
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
73
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
74
|
+
const { fields, baseUrl, linkage } = options;
|
|
69
75
|
const { __data: data } = this;
|
|
70
76
|
const modelName = this.__model.__name;
|
|
71
77
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -87,9 +93,20 @@ export default class Record {
|
|
|
87
93
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
88
94
|
if (fields && !fields.has(key))
|
|
89
95
|
continue;
|
|
96
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
97
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
98
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
99
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
100
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
101
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
102
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
103
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
104
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
105
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
106
|
+
const isLinkable = (r) => !linkage || linkage(r.__model.__name, r);
|
|
90
107
|
const relationshipData = Array.isArray(childRecord)
|
|
91
|
-
? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
92
|
-
: (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
108
|
+
? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
109
|
+
: (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
93
110
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
94
111
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
95
112
|
relationships[dasherizedKey] = { data: relationshipData };
|
|
@@ -87,9 +87,18 @@ export interface OrmRecord {
|
|
|
87
87
|
__pendingSqlId?: boolean;
|
|
88
88
|
};
|
|
89
89
|
__relationships: Record<string, unknown>;
|
|
90
|
+
/**
|
|
91
|
+
* `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
|
|
92
|
+
* the request (abofs/stonyx-orm#234): return `false` for a related record and
|
|
93
|
+
* its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
|
|
94
|
+
* the default, and the default is the pre-#234 document unchanged -- this
|
|
95
|
+
* method is also the `JSON.stringify` hook, so an implicit caller has no
|
|
96
|
+
* syntactic place to pass it (abofs/stonyx-orm#230).
|
|
97
|
+
*/
|
|
90
98
|
toJSON?(options?: {
|
|
91
99
|
fields?: Set<string>;
|
|
92
100
|
baseUrl?: string;
|
|
101
|
+
linkage?: (type: string, record: unknown) => boolean;
|
|
93
102
|
}): Record<string, unknown>;
|
|
94
103
|
[key: string]: unknown;
|
|
95
104
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared access-verdict primitive (abofs/stonyx-orm#234).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* A consumer `access()` may return six differently-shaped things -- `false`, a
|
|
8
|
+
* bare permission string, a permission array, `true`, a per-record function, or
|
|
9
|
+
* something the contract does not define at all -- and the reading of each one
|
|
10
|
+
* is a security decision. `auth()` has held that reading inline since #190.
|
|
11
|
+
* Every surface that needs to ask "may this caller see model X's record?" needs
|
|
12
|
+
* the SAME reading, or the second copy becomes an unreviewed second
|
|
13
|
+
* authorization vocabulary that answers differently about the same value.
|
|
14
|
+
*
|
|
15
|
+
* So `interpretAccess` is extracted here and `auth()` now calls it. It is the
|
|
16
|
+
* only place a return shape is classified, and abofs/stonyx-orm#232 and #233
|
|
17
|
+
* rebase onto it rather than re-deriving it.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
|
|
23
|
+
* style choice, it is forced, and it was measured before it was decided:
|
|
24
|
+
*
|
|
25
|
+
* INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
|
|
26
|
+
* INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
|
|
27
|
+
*
|
|
28
|
+
* and all three reds were over-denial of PERMITTED records, not the leak. Two
|
|
29
|
+
* independent reasons:
|
|
30
|
+
*
|
|
31
|
+
* 1. `toJSON()` has no request. The shipped, documented sample reads
|
|
32
|
+
* `request.path` for its `/archived` sub-path rule -- the one read of
|
|
33
|
+
* argument one the README sanctions -- and fail-closes when it is absent.
|
|
34
|
+
* Measured against the live registry:
|
|
35
|
+
*
|
|
36
|
+
* getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
|
|
37
|
+
* getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
|
|
38
|
+
*
|
|
39
|
+
* Same predicate object, two models, two different degradation modes,
|
|
40
|
+
* chosen by the consumer. Without a request there is no trustworthy
|
|
41
|
+
* answer to get.
|
|
42
|
+
*
|
|
43
|
+
* 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
|
|
44
|
+
* record})` calls `record.toJSON('data')` -- a STRING in the options slot.
|
|
45
|
+
* An implicit caller has no syntactic place to pass anything
|
|
46
|
+
* (abofs/stonyx-orm#230). The no-argument document must therefore stay
|
|
47
|
+
* byte-identical to what shipped, which also rules out fail-closed by
|
|
48
|
+
* default: `Orm.instance.accessFunctions` is `{}` in any process that
|
|
49
|
+
* never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
|
|
50
|
+
* fail-closed default would empty every relationship on every document in
|
|
51
|
+
* processes that have no REST surface to protect.
|
|
52
|
+
*
|
|
53
|
+
* The caller -- which still holds the request -- resolves the predicate,
|
|
54
|
+
* interprets it here, caches the answer, and hands `toJSON()` an already-decided
|
|
55
|
+
* `(type, record) => boolean`.
|
|
56
|
+
*/
|
|
57
|
+
import Orm from '@stonyx/orm';
|
|
58
|
+
import log from 'stonyx/log';
|
|
59
|
+
import type { AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The classified reading of one `access()` return value.
|
|
63
|
+
*
|
|
64
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
65
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
66
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
67
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
68
|
+
*/
|
|
69
|
+
export interface AccessVerdict {
|
|
70
|
+
granted: boolean;
|
|
71
|
+
filter?: (record: unknown) => boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
76
|
+
* NAMED, by id, inside another model's document?
|
|
77
|
+
*
|
|
78
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
79
|
+
* the consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
80
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
81
|
+
* key, not the input.
|
|
82
|
+
*/
|
|
83
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
|
84
|
+
|
|
85
|
+
const DENIED: AccessVerdict = Object.freeze({ granted: false });
|
|
86
|
+
const GRANTED: AccessVerdict = Object.freeze({ granted: true });
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
90
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
91
|
+
*
|
|
92
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
93
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
94
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
95
|
+
* the same answer `auth()` gave before the extraction.
|
|
96
|
+
*/
|
|
97
|
+
export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
|
|
98
|
+
if (!access) return DENIED;
|
|
99
|
+
|
|
100
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
101
|
+
// carry the predicate; the caller applies it per record.
|
|
102
|
+
if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
|
|
103
|
+
|
|
104
|
+
if (access === true) return GRANTED;
|
|
105
|
+
|
|
106
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
107
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
108
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
109
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
110
|
+
|
|
111
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
112
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
113
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
114
|
+
if (!Array.isArray(permitted)) return DENIED;
|
|
115
|
+
if (!permitted.includes(operation as string)) return DENIED;
|
|
116
|
+
|
|
117
|
+
return GRANTED;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
122
|
+
*
|
|
123
|
+
* Fails closed on both ambiguous inputs:
|
|
124
|
+
*
|
|
125
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
126
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
127
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
128
|
+
* both "no access class claims this model" and "the class that claims it
|
|
129
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
130
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
131
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
132
|
+
*
|
|
133
|
+
* NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
|
|
134
|
+
* request in hand was dispatched to a DIFFERENT model's route. Since #222 this
|
|
135
|
+
* repo's fixture reads `context.model` and answers correctly; a consumer's
|
|
136
|
+
* arity-1 predicate does not, and there is no supported way to tell which kind
|
|
137
|
+
* was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
|
|
138
|
+
* consequence to expect rather than debug: the fixture's surviving `request.path`
|
|
139
|
+
* read means asking the OWNER predicate on a request dispatched to
|
|
140
|
+
* `GET /animals/archived` returns a bare `false`. That is a whole-request deny
|
|
141
|
+
* bleeding across models -- harmless, because it is the fail-closed direction,
|
|
142
|
+
* and it is treated as "deny this linkage", not as an error.
|
|
143
|
+
*/
|
|
144
|
+
function resolveVerdict(request: unknown, type: string): AccessVerdict {
|
|
145
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
146
|
+
if (typeof predicate !== 'function') return DENIED;
|
|
147
|
+
|
|
148
|
+
let access: AccessMethod;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
+
|
|
155
|
+
return DENIED;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return interpretAccess(access, 'read');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build a request-scoped linkage filter.
|
|
163
|
+
*
|
|
164
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
165
|
+
*
|
|
166
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
167
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
168
|
+
* already had to guard for throwing.
|
|
169
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
170
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
171
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
172
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
173
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
174
|
+
*
|
|
175
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
176
|
+
* template-string composite: `Map` compares with SameValueZero, so the numeric
|
|
177
|
+
* id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
|
|
178
|
+
* would collapse them and let one model's verdict answer for another record.
|
|
179
|
+
*
|
|
180
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
181
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
182
|
+
* first caller's authorization.
|
|
183
|
+
*/
|
|
184
|
+
export function createLinkageFilter(request: unknown): LinkageFilter {
|
|
185
|
+
const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
|
|
186
|
+
|
|
187
|
+
return function isLinkable(type: string, record: unknown): boolean {
|
|
188
|
+
let entry = byType.get(type);
|
|
189
|
+
|
|
190
|
+
if (!entry) {
|
|
191
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
192
|
+
byType.set(type, entry);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { verdict, decisions } = entry;
|
|
196
|
+
|
|
197
|
+
if (!verdict.granted) return false;
|
|
198
|
+
if (!verdict.filter) return true;
|
|
199
|
+
|
|
200
|
+
const id = (record as { id?: unknown } | null)?.id;
|
|
201
|
+
const cached = decisions.get(id);
|
|
202
|
+
if (cached !== undefined) return cached;
|
|
203
|
+
|
|
204
|
+
let allowed: boolean;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
allowed = Boolean(verdict.filter(record));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
210
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
211
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
212
|
+
// from a database with no relationships in it.
|
|
213
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
+
|
|
215
|
+
allowed = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
decisions.set(id, allowed);
|
|
219
|
+
|
|
220
|
+
return allowed;
|
|
221
|
+
};
|
|
222
|
+
}
|
package/src/orm-request.ts
CHANGED
|
@@ -221,6 +221,7 @@ import config from 'stonyx/config';
|
|
|
221
221
|
import log from 'stonyx/log';
|
|
222
222
|
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
223
223
|
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
224
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
224
225
|
|
|
225
226
|
interface OrmRequest$ extends Request {
|
|
226
227
|
protocol?: string;
|
|
@@ -633,7 +634,14 @@ export default class OrmRequest extends Request {
|
|
|
633
634
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
634
635
|
|
|
635
636
|
const baseUrl = getBaseUrl(request);
|
|
636
|
-
|
|
637
|
+
|
|
638
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
639
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
640
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
641
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
642
|
+
// (type, id) pairs.
|
|
643
|
+
const linkage = createLinkageFilter(request);
|
|
644
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
637
645
|
|
|
638
646
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
639
647
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
@@ -653,7 +661,14 @@ export default class OrmRequest extends Request {
|
|
|
653
661
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
654
662
|
|
|
655
663
|
const baseUrl = getBaseUrl(request);
|
|
656
|
-
|
|
664
|
+
const linkage = createLinkageFilter(request);
|
|
665
|
+
|
|
666
|
+
// `buildResponse` is deliberately NOT given the linkage filter. It builds
|
|
667
|
+
// `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
|
|
668
|
+
// which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
|
|
669
|
+
// the ownership boundary in that issue. Only the PRIMARY document's
|
|
670
|
+
// linkage is filtered here.
|
|
671
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
657
672
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
658
673
|
baseUrl
|
|
659
674
|
});
|
|
@@ -1283,14 +1298,21 @@ export default class OrmRequest extends Request {
|
|
|
1283
1298
|
const relatedData = record.__relationships[relationshipName];
|
|
1284
1299
|
const baseUrl = getBaseUrl(request);
|
|
1285
1300
|
|
|
1301
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1302
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1303
|
+
// the related records themselves are served -- that is the parent-only
|
|
1304
|
+
// filtering this route has done since #190, and widening it to the
|
|
1305
|
+
// related record is abofs/stonyx-orm#196.
|
|
1306
|
+
const linkage = createLinkageFilter(request);
|
|
1307
|
+
|
|
1286
1308
|
let data: unknown;
|
|
1287
1309
|
if (info.isArray) {
|
|
1288
1310
|
// hasMany - return array
|
|
1289
1311
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1290
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1312
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1291
1313
|
} else {
|
|
1292
1314
|
// belongsTo - return single or null
|
|
1293
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1315
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1294
1316
|
}
|
|
1295
1317
|
|
|
1296
1318
|
return {
|
|
@@ -1407,24 +1429,23 @@ export default class OrmRequest extends Request {
|
|
|
1407
1429
|
return 403; // Forbidden
|
|
1408
1430
|
}
|
|
1409
1431
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
if (
|
|
1427
|
-
if (!permitted.includes(methodAccessMap[request.method])) return 403;
|
|
1432
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1433
|
+
//
|
|
1434
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1435
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1436
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1437
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1438
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1439
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1440
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1441
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1442
|
+
|
|
1443
|
+
if (!verdict.granted) return 403;
|
|
1444
|
+
|
|
1445
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1446
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1447
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1448
|
+
if (verdict.filter) state.filter = verdict.filter;
|
|
1428
1449
|
|
|
1429
1450
|
return undefined;
|
|
1430
1451
|
}
|
package/src/record.ts
CHANGED
|
@@ -7,6 +7,18 @@ import type Serializer from './serializer.js';
|
|
|
7
7
|
interface ToJSONOptions {
|
|
8
8
|
fields?: Set<string>;
|
|
9
9
|
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
12
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
13
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
14
|
+
*
|
|
15
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
16
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
17
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
18
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
19
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
20
|
+
*/
|
|
21
|
+
linkage?: (type: string, record: unknown) => boolean;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
interface SerializeOptions {
|
|
@@ -116,7 +128,13 @@ export default class Record {
|
|
|
116
128
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
117
129
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
132
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
133
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
134
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
135
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
136
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
137
|
+
const { fields, baseUrl, linkage } = options;
|
|
120
138
|
const { __data: data } = this;
|
|
121
139
|
const modelName = this.__model.__name;
|
|
122
140
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -138,9 +156,21 @@ export default class Record {
|
|
|
138
156
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
139
157
|
if (fields && !fields.has(key)) continue;
|
|
140
158
|
|
|
159
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
160
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
161
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
162
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
163
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
164
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
165
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
166
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
167
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
168
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
169
|
+
const isLinkable = (r: Record) => !linkage || linkage(r.__model.__name, r);
|
|
170
|
+
|
|
141
171
|
const relationshipData = Array.isArray(childRecord)
|
|
142
|
-
? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
143
|
-
: (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
172
|
+
? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
173
|
+
: (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
144
174
|
|
|
145
175
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
146
176
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/types/orm-types.ts
CHANGED
|
@@ -89,7 +89,15 @@ export interface OrmRecord {
|
|
|
89
89
|
__model?: { __name: string };
|
|
90
90
|
__data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
|
|
91
91
|
__relationships: Record<string, unknown>;
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
|
|
94
|
+
* the request (abofs/stonyx-orm#234): return `false` for a related record and
|
|
95
|
+
* its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
|
|
96
|
+
* the default, and the default is the pre-#234 document unchanged -- this
|
|
97
|
+
* method is also the `JSON.stringify` hook, so an implicit caller has no
|
|
98
|
+
* syntactic place to pass it (abofs/stonyx-orm#230).
|
|
99
|
+
*/
|
|
100
|
+
toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: (type: string, record: unknown) => boolean }): Record<string, unknown>;
|
|
93
101
|
[key: string]: unknown;
|
|
94
102
|
}
|
|
95
103
|
|