@stonyx/orm 0.3.2-alpha.70 → 0.3.2-alpha.71
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 +218 -120
- package/dist/access-verdict.d.ts +59 -0
- package/dist/access-verdict.js +222 -0
- package/dist/hooks.d.ts +1 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.d.ts +0 -48
- package/dist/orm-request.js +57 -114
- package/dist/record.d.ts +16 -0
- package/dist/record.js +57 -3
- package/dist/types/orm-types.d.ts +27 -95
- package/package.json +1 -1
- package/src/access-verdict.ts +248 -0
- package/src/hooks.ts +1 -15
- package/src/index.ts +9 -0
- package/src/orm-request.ts +61 -113
- package/src/record.ts +76 -3
- package/src/types/orm-types.ts +28 -97
package/dist/record.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { store } from '@stonyx/orm';
|
|
2
|
+
import log from 'stonyx/log';
|
|
2
3
|
import { getComputedProperties } from "./serializer.js";
|
|
3
4
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
5
|
import { getPluralName } from './plural-registry.js';
|
|
@@ -65,7 +66,13 @@ export default class Record {
|
|
|
65
66
|
toJSON(options = {}) {
|
|
66
67
|
if (!this.__serialized)
|
|
67
68
|
throw new Error('Record must be serialized before being converted to JSON');
|
|
68
|
-
|
|
69
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
70
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
71
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
72
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
73
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
74
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
75
|
+
const { fields, baseUrl, linkage } = options;
|
|
69
76
|
const { __data: data } = this;
|
|
70
77
|
const modelName = this.__model.__name;
|
|
71
78
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -84,12 +91,59 @@ export default class Record {
|
|
|
84
91
|
continue;
|
|
85
92
|
attributes[key] = getter.call(this);
|
|
86
93
|
}
|
|
94
|
+
// `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
|
|
95
|
+
// (src/types/orm-types.ts) and the README tells consumers to pass one -- so
|
|
96
|
+
// it arrives from outside this package and may be ANY value. Three
|
|
97
|
+
// readings, and the difference between the second and the third is a
|
|
98
|
+
// security decision:
|
|
99
|
+
//
|
|
100
|
+
// ABSENT (`undefined`). No verdict was supplied. Emit today's document.
|
|
101
|
+
// Load-bearing and asserted (AC5/AC5b): `toJSON` is also the
|
|
102
|
+
// `JSON.stringify` hook, so the implicit caller arrives as
|
|
103
|
+
// `toJSON('data')` -- a STRING, which destructures to `undefined` here
|
|
104
|
+
// (abofs/stonyx-orm#230).
|
|
105
|
+
//
|
|
106
|
+
// A FUNCTION. Apply it per related record.
|
|
107
|
+
//
|
|
108
|
+
// ANYTHING ELSE -- `null`, `0`, `false`, `''`, `true`, a string, an
|
|
109
|
+
// object. DENY, and say so. Neither of the two obvious alternatives is
|
|
110
|
+
// available. Reading it as absent is what `!linkage ||` did, and a
|
|
111
|
+
// resolver returning `null` because it could not resolve a session is the
|
|
112
|
+
// natural shape of that value and the fail-closed INTENT -- measured,
|
|
113
|
+
// `toJSON({ linkage: null })` emitted the full pre-#234 linkage with no
|
|
114
|
+
// signal, byte-identical to unpatched dev. Reading it as a function
|
|
115
|
+
// raises `TypeError: linkage is not a function` out of the enclosing
|
|
116
|
+
// `JSON.stringify` -- measured on `true`, `'x'` and `{}` -- which is
|
|
117
|
+
// exactly the outcome the comment below promises cannot happen.
|
|
118
|
+
//
|
|
119
|
+
// Logged once per DOCUMENT, not once per relationship key or per related
|
|
120
|
+
// record: an emptied relationship is deliberately indistinguishable from a
|
|
121
|
+
// genuinely empty one on the wire, so the log is the only signal a consumer
|
|
122
|
+
// whose resolver silently returned `null` will ever get.
|
|
123
|
+
const linkageSupplied = linkage !== undefined;
|
|
124
|
+
const linkageVerdict = !linkageSupplied
|
|
125
|
+
? undefined
|
|
126
|
+
: typeof linkage === 'function' ? linkage : () => false;
|
|
127
|
+
if (linkageSupplied && typeof linkage !== 'function') {
|
|
128
|
+
log.error?.(`[@stonyx/orm] toJSON() received a \`linkage\` option of type ${linkage === null ? 'null' : typeof linkage} -- it must be a function, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
|
|
129
|
+
}
|
|
87
130
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
88
131
|
if (fields && !fields.has(key))
|
|
89
132
|
continue;
|
|
133
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
134
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
135
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
136
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
137
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
138
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
139
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
140
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
141
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
142
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
143
|
+
const isLinkable = (r) => !linkageVerdict || linkageVerdict(r.__model.__name, r);
|
|
90
144
|
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;
|
|
145
|
+
? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
|
|
146
|
+
: (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
93
147
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
94
148
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
95
149
|
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?: LinkageFilter;
|
|
93
102
|
}): Record<string, unknown>;
|
|
94
103
|
[key: string]: unknown;
|
|
95
104
|
}
|
|
@@ -242,101 +251,6 @@ export interface AccessContext {
|
|
|
242
251
|
* from one that classified the request and found nothing.
|
|
243
252
|
*/
|
|
244
253
|
operation: AccessOperation | undefined;
|
|
245
|
-
/**
|
|
246
|
-
* The record this route was addressed to, as the store key -- or `null` on a
|
|
247
|
-
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
248
|
-
*
|
|
249
|
-
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
250
|
-
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
251
|
-
* `request.path` against a literal compares an undecoded string against a
|
|
252
|
-
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
253
|
-
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
254
|
-
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
255
|
-
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
256
|
-
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
257
|
-
*
|
|
258
|
-
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
259
|
-
*
|
|
260
|
-
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
261
|
-
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
262
|
-
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
263
|
-
* decoded until stable would deny a record it was never asked about.
|
|
264
|
-
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
265
|
-
* and express's `case sensitive routing` governs literal segments only.
|
|
266
|
-
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
267
|
-
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
268
|
-
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
269
|
-
* allow, on that same record).
|
|
270
|
-
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
271
|
-
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
272
|
-
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
273
|
-
* `archived/x` -- was measured over-denied 403.
|
|
274
|
-
*
|
|
275
|
-
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
276
|
-
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
277
|
-
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
278
|
-
* therefore cannot disagree about which record a request addresses. Handing
|
|
279
|
-
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
280
|
-
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
281
|
-
*
|
|
282
|
-
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
283
|
-
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
284
|
-
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
285
|
-
* exists to buy; it is not a defect to repair here.
|
|
286
|
-
*
|
|
287
|
-
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
288
|
-
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
289
|
-
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
290
|
-
* hand-assembled by a caller resolving the predicate through
|
|
291
|
-
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
292
|
-
* signal only because the framework never produces it.
|
|
293
|
-
*
|
|
294
|
-
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
295
|
-
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
296
|
-
* key on an identically-shaped context object, which is the exact
|
|
297
|
-
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
298
|
-
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
299
|
-
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
300
|
-
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
301
|
-
* direction, and it is corrected here rather than deleted.
|
|
302
|
-
*
|
|
303
|
-
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
304
|
-
* operations on one model:
|
|
305
|
-
*
|
|
306
|
-
* before:list key ABSENT ('recordId' in context === false)
|
|
307
|
-
* before:get key ABSENT params={"id":"visible1"}
|
|
308
|
-
* before:create key ABSENT
|
|
309
|
-
* before:update key ABSENT params={"id":"visible2"}
|
|
310
|
-
* before:delete recordId="visible3"
|
|
311
|
-
* after:delete recordId="visible3"
|
|
312
|
-
*
|
|
313
|
-
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
314
|
-
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
315
|
-
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
316
|
-
* a hook context the key is absent for get, list, create and update, while
|
|
317
|
-
* this key is present on every route `auth()` classifies. The absence
|
|
318
|
-
* spelling is the smaller half of the difference, not the whole of it.
|
|
319
|
-
*
|
|
320
|
-
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
321
|
-
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
322
|
-
* On a hook context it means "this is a get / list / create / update" -- an
|
|
323
|
-
* ordinary request. A consumer who writes the hook-side half of the same
|
|
324
|
-
* rule --
|
|
325
|
-
*
|
|
326
|
-
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
327
|
-
*
|
|
328
|
-
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
329
|
-
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
330
|
-
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
331
|
-
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
332
|
-
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
333
|
-
*
|
|
334
|
-
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
335
|
-
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
336
|
-
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
337
|
-
* and is untouched by this key.
|
|
338
|
-
*/
|
|
339
|
-
recordId: string | number | null;
|
|
340
254
|
}
|
|
341
255
|
/**
|
|
342
256
|
* A consumer `access()` predicate.
|
|
@@ -358,3 +272,21 @@ export interface AccessContext {
|
|
|
358
272
|
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
359
273
|
*/
|
|
360
274
|
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
275
|
+
/**
|
|
276
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
277
|
+
* NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
|
|
278
|
+
*
|
|
279
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
280
|
+
* a consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
281
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
282
|
+
* key inside `createLinkageFilter`, not the input.
|
|
283
|
+
*
|
|
284
|
+
* DECLARED HERE, with the rest of the access vocabulary, and imported by every
|
|
285
|
+
* site that names it. It had three structurally-identical hand-written copies
|
|
286
|
+
* (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
|
|
287
|
+
* other by nothing, so a drift in nullability or a widening of `type` would
|
|
288
|
+
* have landed on one and not the others -- which is the same "second,
|
|
289
|
+
* unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
|
|
290
|
+
* level up in the type system.
|
|
291
|
+
*/
|
|
292
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
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, LinkageFilter } 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
|
+
const DENIED: AccessVerdict = Object.freeze({ granted: false });
|
|
75
|
+
const GRANTED: AccessVerdict = Object.freeze({ granted: true });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
79
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
80
|
+
*
|
|
81
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
82
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
83
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
84
|
+
* the same answer `auth()` gave before the extraction.
|
|
85
|
+
*/
|
|
86
|
+
export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
|
|
87
|
+
if (!access) return DENIED;
|
|
88
|
+
|
|
89
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
90
|
+
// carry the predicate; the caller applies it per record.
|
|
91
|
+
if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
|
|
92
|
+
|
|
93
|
+
if (access === true) return GRANTED;
|
|
94
|
+
|
|
95
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
96
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
97
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
98
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
99
|
+
|
|
100
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
101
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
102
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
103
|
+
if (!Array.isArray(permitted)) return DENIED;
|
|
104
|
+
if (!permitted.includes(operation as string)) return DENIED;
|
|
105
|
+
|
|
106
|
+
return GRANTED;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
111
|
+
*
|
|
112
|
+
* Fails closed on both ambiguous inputs:
|
|
113
|
+
*
|
|
114
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
115
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
116
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
117
|
+
* both "no access class claims this model" and "the class that claims it
|
|
118
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
119
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
120
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
121
|
+
*
|
|
122
|
+
* NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
|
|
123
|
+
* The predicate is asked about `type` while the request in hand was dispatched
|
|
124
|
+
* to a DIFFERENT model's route. This function makes another model's class
|
|
125
|
+
* REACHABLE and asks it the model-correct question (`{ model: type }`); whether
|
|
126
|
+
* the ANSWER is model-correct is the CONSUMER's, because only a predicate that
|
|
127
|
+
* READS `context.model` can give one. Since #222 this repo's fixture does. A
|
|
128
|
+
* consumer's arity-1 predicate does not, and there is no supported way to tell
|
|
129
|
+
* which kind was resolved (the boot-time arity warning is
|
|
130
|
+
* abofs/stonyx-orm#213/#221, unshipped).
|
|
131
|
+
*
|
|
132
|
+
* BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
|
|
133
|
+
* measured, not reasoned:
|
|
134
|
+
*
|
|
135
|
+
* - CLOSED. The migrated fixture's surviving `request.path` read means asking
|
|
136
|
+
* the OWNER predicate on a request dispatched to `GET /animals/archived`
|
|
137
|
+
* returns a bare `false` -- a whole-request deny bleeding across models,
|
|
138
|
+
* treated here as "deny this linkage", not as an error. That over-denies a
|
|
139
|
+
* PERMITTED record.
|
|
140
|
+
* - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
|
|
141
|
+
* still declares valid and the README calls the default in every consumer
|
|
142
|
+
* tree -- identifies its collection from the request, so asked about
|
|
143
|
+
* `owner` on a request dispatched to `/animals` it answers about ANIMALS.
|
|
144
|
+
* Measured against this repo's own fixture with `reg.owner` replaced by an
|
|
145
|
+
* arity-1 predicate that hides angela on `/owners`:
|
|
146
|
+
*
|
|
147
|
+
* GET /owners -> ["gina","michael","bob"] angela hidden, correctly
|
|
148
|
+
* GET /animals -> owners named: [angela, ...] LEAK
|
|
149
|
+
* GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
|
|
150
|
+
*
|
|
151
|
+
* That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
|
|
152
|
+
* #234 was filed for, AFTER this fix. It is not a regression -- dev
|
|
153
|
+
* published the same id unconditionally -- and this file cannot close it,
|
|
154
|
+
* because the arity signal is #213/#221. Do NOT write, here or anywhere
|
|
155
|
+
* else, that the cross-model ask degrades closed. The standing rule this
|
|
156
|
+
* paragraph is held to is in docs/project-structure.md.
|
|
157
|
+
*/
|
|
158
|
+
function resolveVerdict(request: unknown, type: string): AccessVerdict {
|
|
159
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
160
|
+
if (typeof predicate !== 'function') return DENIED;
|
|
161
|
+
|
|
162
|
+
let access: AccessMethod;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
|
|
169
|
+
return DENIED;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return interpretAccess(access, 'read');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build a request-scoped linkage filter.
|
|
177
|
+
*
|
|
178
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
179
|
+
*
|
|
180
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
181
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
182
|
+
* already had to guard for throwing.
|
|
183
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
184
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
185
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
186
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
187
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
188
|
+
*
|
|
189
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
190
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
191
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
192
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
193
|
+
* record with the first record's verdict.
|
|
194
|
+
*
|
|
195
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
196
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
197
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
198
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
199
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
200
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
201
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
202
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
203
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
204
|
+
* `animal` ids are numbers.
|
|
205
|
+
*
|
|
206
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
207
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
208
|
+
* first caller's authorization.
|
|
209
|
+
*/
|
|
210
|
+
export function createLinkageFilter(request: unknown): LinkageFilter {
|
|
211
|
+
const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
|
|
212
|
+
|
|
213
|
+
return function isLinkable(type: string, record: unknown): boolean {
|
|
214
|
+
let entry = byType.get(type);
|
|
215
|
+
|
|
216
|
+
if (!entry) {
|
|
217
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
218
|
+
byType.set(type, entry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { verdict, decisions } = entry;
|
|
222
|
+
|
|
223
|
+
if (!verdict.granted) return false;
|
|
224
|
+
if (!verdict.filter) return true;
|
|
225
|
+
|
|
226
|
+
const id = (record as { id?: unknown } | null)?.id;
|
|
227
|
+
const cached = decisions.get(id);
|
|
228
|
+
if (cached !== undefined) return cached;
|
|
229
|
+
|
|
230
|
+
let allowed: boolean;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
allowed = Boolean(verdict.filter(record));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
236
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
237
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
238
|
+
// from a database with no relationships in it.
|
|
239
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
240
|
+
|
|
241
|
+
allowed = false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
decisions.set(id, allowed);
|
|
245
|
+
|
|
246
|
+
return allowed;
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -37,21 +37,7 @@ export interface HookContext {
|
|
|
37
37
|
state?: Record<string, unknown>;
|
|
38
38
|
/** Previous record state (available in update hooks). */
|
|
39
39
|
oldState?: unknown;
|
|
40
|
-
/**
|
|
41
|
-
* Target record ID for single-record operations.
|
|
42
|
-
*
|
|
43
|
-
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
44
|
-
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
45
|
-
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
46
|
-
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
47
|
-
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
48
|
-
*
|
|
49
|
-
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
50
|
-
* key on an identically-shaped context object, and NOT interchangeable with
|
|
51
|
-
* this one: it is present on every route `auth()` classifies, and spells
|
|
52
|
-
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
53
|
-
* of five operations, not only in the absence spelling.
|
|
54
|
-
*/
|
|
40
|
+
/** Target record ID for single-record operations. */
|
|
55
41
|
recordId?: string | number;
|
|
56
42
|
/** Response data (available in after hooks). */
|
|
57
43
|
response?: unknown;
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,15 @@ export { default } from './main.js';
|
|
|
28
28
|
export { store, relationships } from './main.js';
|
|
29
29
|
export type { PersistErrorDetail } from './main.js';
|
|
30
30
|
export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js'; // access() contract (#202)
|
|
31
|
+
export type { LinkageFilter } from './types/orm-types.js'; // linkage verdict contract (#234)
|
|
32
|
+
// The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
|
|
33
|
+
// README tells a consumer serializing a `Record` outside the REST layer to pass
|
|
34
|
+
// their own resolved `linkage` option, and without an exported factory the only
|
|
35
|
+
// way to follow that advice is to write a SECOND reading of `access()` in
|
|
36
|
+
// consumer code -- the exact "unreviewed second authorization vocabulary" that
|
|
37
|
+
// src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
|
|
38
|
+
// drift. Give them the one interpreter instead of an invitation to fork it.
|
|
39
|
+
export { createLinkageFilter } from './access-verdict.js';
|
|
31
40
|
export { Model, View, Serializer }; // base classes
|
|
32
41
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
33
42
|
export { count, avg, sum, min, max }; // aggregate helpers
|