@stonyx/orm 0.3.2-alpha.68 → 0.3.2-alpha.69
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 +54 -90
- package/dist/orm-request.js +66 -39
- package/dist/record.d.ts +0 -12
- package/dist/record.js +3 -20
- package/dist/types/orm-types.d.ts +66 -9
- package/package.json +1 -1
- package/src/orm-request.ts +65 -43
- package/src/record.ts +3 -33
- package/src/types/orm-types.ts +68 -9
- package/dist/access-verdict.d.ts +0 -57
- package/dist/access-verdict.js +0 -185
- package/src/access-verdict.ts +0 -222
package/README.md
CHANGED
|
@@ -350,66 +350,73 @@ Access classes define models and provide custom filtering/authorization logic.
|
|
|
350
350
|
export default class GlobalAccess {
|
|
351
351
|
models = ['owner', 'animal'];
|
|
352
352
|
|
|
353
|
-
access(request, { model, operation }) {
|
|
353
|
+
access(request, { model, operation, recordId }) {
|
|
354
354
|
// `model` is the model this route was mounted for. It is assigned once, at
|
|
355
355
|
// mount time, and no request can influence it — not a mount prefix, not a
|
|
356
356
|
// query string, not a case-varied path, not an absolute-form request
|
|
357
|
-
// target.
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
357
|
+
// target. `recordId` is the record this route was ADDRESSED TO, decoded by
|
|
358
|
+
// the router and coerced to the key the store lookup uses. Nothing below
|
|
359
|
+
// parses anything, and since abofs/stonyx-orm#236 nothing below reads
|
|
360
|
+
// argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
|
|
361
|
+
// the sub-path STRING COMPARISON that variant 3 lived in is gone too,
|
|
362
|
+
// replaced by a comparison against the decoded id. Retiring the "variant 3
|
|
363
|
+
// survives" wording at the four sites that still carry it — with the
|
|
364
|
+
// measurement that retires it, rather than by deletion — is
|
|
365
|
+
// abofs/stonyx-orm#238.
|
|
363
366
|
//
|
|
364
367
|
// `operation` is destructured to name the whole contract at the point of
|
|
365
|
-
// use. This sample's rules are per-model and per-
|
|
368
|
+
// use. This sample's rules are per-model and per-record rather than
|
|
366
369
|
// per-verb, so it does not branch on it; the permission array at the bottom
|
|
367
370
|
// is where the verb is answered.
|
|
368
371
|
|
|
369
|
-
// FAIL CLOSED ON
|
|
370
|
-
// resolved this predicate without supplying the context, and a request
|
|
371
|
-
// function cannot identify DENIES rather than falling through to the
|
|
372
|
-
// grant at the bottom. An unidentifiable input must never be the
|
|
373
|
-
// path.
|
|
374
|
-
// not cover it.
|
|
372
|
+
// FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
|
|
373
|
+
// that resolved this predicate without supplying the context, and a request
|
|
374
|
+
// this function cannot identify DENIES rather than falling through to the
|
|
375
|
+
// CRUD grant at the bottom. An unidentifiable input must never be the
|
|
376
|
+
// permissive path.
|
|
375
377
|
if (typeof model !== 'string' || model === '') return false;
|
|
376
378
|
|
|
377
379
|
if (model === 'owner') {
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
380
|
+
// FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
|
|
381
|
+
// SPELLING OF ABSENT. `auth()` ALWAYS sets the key — `null` on a
|
|
382
|
+
// collection route, which is addressed to no record — so `undefined`
|
|
383
|
+
// means the context did not come from `auth()`: it was hand-assembled by
|
|
384
|
+
// a caller resolving this predicate through the documented
|
|
385
|
+
// `Orm.instance.getAccess()` path. Letting that through would fall
|
|
386
|
+
// straight to the per-record filter below, which is a DENY becoming an
|
|
387
|
+
// ALLOW. This is the same rule the old guard on `request.path` enforced,
|
|
388
|
+
// moved to the argument this predicate now actually reads.
|
|
389
|
+
if (recordId === undefined) return false;
|
|
390
|
+
|
|
391
|
+
// THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
|
|
392
|
+
// `request.path.toLowerCase()` compared against `'/archived'`, and that
|
|
393
|
+
// was wrong in both directions at once.
|
|
383
394
|
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
395
|
+
// TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
|
|
396
|
+
// the router DECODES `:id`, so `GET /owners/%61rchived` reached the
|
|
397
|
+
// comparison as `/%61rchived`, walked past the deny and was dispatched as
|
|
398
|
+
// the record `archived` — 200 with the record in full, and DELETE
|
|
399
|
+
// answered 204 with the record DESTROYED, unauthenticated. 255
|
|
400
|
+
// non-canonical spellings of that 8-character id decode to the same key,
|
|
401
|
+
// so no deny-list of spellings was ever going to close it.
|
|
386
402
|
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// `
|
|
391
|
-
//
|
|
392
|
-
// `String(request.path ?? '')` is then `''`, which matches no sub-path
|
|
393
|
-
// rule and falls straight through to the per-record filter below. That is
|
|
394
|
-
// a DENY becoming an ALLOW. An input this function cannot identify DENIES,
|
|
395
|
-
// whichever ARGUMENT it arrived on — which is also why the `?? ''` this
|
|
396
|
-
// file's header condemns does not appear below.
|
|
397
|
-
if (typeof request?.path !== 'string' || request.path === '') return false;
|
|
398
|
-
|
|
399
|
-
// Lower-cased because the router matched case-insensitively, so a
|
|
400
|
-
// case-sensitive rule here would be stricter than the router and could be
|
|
401
|
-
// stepped around.
|
|
403
|
+
// TOO STRICT: a record id is a VALUE, not a literal route segment, and
|
|
404
|
+
// express's `case sensitive routing` governs literal segments only. With
|
|
405
|
+
// a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
|
|
406
|
+
// `GET /owners/ARCHIVED` — the wrong record — while still admitting
|
|
407
|
+
// `GET /owners/%41RCHIVED`, the same record encoded.
|
|
402
408
|
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
409
|
+
// SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
|
|
410
|
+
// which is what a route parameter means: `/owners/%2561rchived` is the
|
|
411
|
+
// legitimate id `%61rchived`, and decoding until stable would deny it. Do
|
|
412
|
+
// not case-fold it. Do not rebuild it from `request.path` — decoding the
|
|
413
|
+
// whole path decodes THEN splits while the router splits THEN decodes,
|
|
414
|
+
// which over-denies the distinct record at `/owners/archived%2fx`.
|
|
415
|
+
//
|
|
416
|
+
// THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
|
|
417
|
+
// what `recordId` bought — and it still must not be dropped. Deleting it
|
|
418
|
+
// does not remove a rule loudly, it turns a deny into an ALLOW, silently.
|
|
419
|
+
if (recordId === 'archived') return false;
|
|
413
420
|
|
|
414
421
|
// Returning a function plugs it in as a per-record filter, and it is
|
|
415
422
|
// enforced on every surface addressed to one of these records:
|
|
@@ -864,50 +871,7 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
864
871
|
related record without resolving that model's own access class, so a filter on
|
|
865
872
|
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
866
873
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
867
|
-
`include=`, related-resource routes and relationship-linkage routes.
|
|
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.
|
|
874
|
+
`include=`, related-resource routes and relationship-linkage routes.
|
|
911
875
|
- **A before-hook that returns a value short-circuits the request.** On write
|
|
912
876
|
operations addressed to a record the filter is consulted first, so a hook
|
|
913
877
|
cannot answer for a record the caller may not see. On reads it is not, so a
|
package/dist/orm-request.js
CHANGED
|
@@ -219,7 +219,6 @@ 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';
|
|
223
222
|
const methodAccessMap = {
|
|
224
223
|
GET: 'read',
|
|
225
224
|
POST: 'create',
|
|
@@ -562,13 +561,7 @@ export default class OrmRequest extends Request {
|
|
|
562
561
|
if (queryFilterPredicate)
|
|
563
562
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
564
563
|
const baseUrl = getBaseUrl(request);
|
|
565
|
-
|
|
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 }));
|
|
564
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
572
565
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
573
566
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
574
567
|
baseUrl
|
|
@@ -586,13 +579,7 @@ export default class OrmRequest extends Request {
|
|
|
586
579
|
const fieldsMap = parseFields(request.query);
|
|
587
580
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
588
581
|
const baseUrl = getBaseUrl(request);
|
|
589
|
-
|
|
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, {
|
|
582
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
596
583
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
597
584
|
baseUrl
|
|
598
585
|
});
|
|
@@ -1179,21 +1166,15 @@ export default class OrmRequest extends Request {
|
|
|
1179
1166
|
return 404;
|
|
1180
1167
|
const relatedData = record.__relationships[relationshipName];
|
|
1181
1168
|
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);
|
|
1188
1169
|
let data;
|
|
1189
1170
|
if (info.isArray) {
|
|
1190
1171
|
// hasMany - return array
|
|
1191
1172
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1192
|
-
data = related.map(r => r.toJSON?.({ baseUrl
|
|
1173
|
+
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1193
1174
|
}
|
|
1194
1175
|
else {
|
|
1195
1176
|
// belongsTo - return single or null
|
|
1196
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl
|
|
1177
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1197
1178
|
}
|
|
1198
1179
|
return {
|
|
1199
1180
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -1288,10 +1269,53 @@ export default class OrmRequest extends Request {
|
|
|
1288
1269
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1289
1270
|
// lookup here would put a store read in the middle of an authorization
|
|
1290
1271
|
// path. The function return shape below IS the per-record hook.
|
|
1272
|
+
//
|
|
1273
|
+
// -------------------------------------------------------------------------
|
|
1274
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1275
|
+
//
|
|
1276
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1277
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1278
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1279
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1280
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1281
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1282
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1283
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1284
|
+
// to the same key, so this was never a deny-list of one.
|
|
1285
|
+
//
|
|
1286
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1287
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1288
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1289
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1290
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1291
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1292
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1293
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1294
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1295
|
+
//
|
|
1296
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1297
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1298
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1299
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1300
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1301
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1302
|
+
//
|
|
1303
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1304
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1305
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1306
|
+
// guarantee `model` carries, by the same means.
|
|
1307
|
+
//
|
|
1308
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1309
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1310
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1311
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1312
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1313
|
+
// `auth()` never produces it.
|
|
1291
1314
|
// -------------------------------------------------------------------------
|
|
1292
1315
|
const context = {
|
|
1293
1316
|
model: this.model,
|
|
1294
1317
|
operation: methodAccessMap[request.method],
|
|
1318
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1295
1319
|
};
|
|
1296
1320
|
let access;
|
|
1297
1321
|
try {
|
|
@@ -1304,23 +1328,26 @@ export default class OrmRequest extends Request {
|
|
|
1304
1328
|
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1305
1329
|
return 403; // Forbidden
|
|
1306
1330
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
//
|
|
1316
|
-
|
|
1317
|
-
|
|
1331
|
+
if (!access)
|
|
1332
|
+
return 403;
|
|
1333
|
+
if (typeof access === 'function') {
|
|
1334
|
+
state.filter = access;
|
|
1335
|
+
return undefined;
|
|
1336
|
+
}
|
|
1337
|
+
if (access === true)
|
|
1338
|
+
return undefined;
|
|
1339
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
1340
|
+
// above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
|
|
1341
|
+
// is the natural reading of a type that lists `string` first, and it
|
|
1342
|
+
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
1343
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
1344
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
1345
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
1346
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
1347
|
+
if (!Array.isArray(permitted))
|
|
1348
|
+
return 403;
|
|
1349
|
+
if (!permitted.includes(methodAccessMap[request.method]))
|
|
1318
1350
|
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;
|
|
1324
1351
|
return undefined;
|
|
1325
1352
|
}
|
|
1326
1353
|
}
|
package/dist/record.d.ts
CHANGED
|
@@ -2,18 +2,6 @@ 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;
|
|
17
5
|
}
|
|
18
6
|
interface SerializeOptions {
|
|
19
7
|
update?: boolean;
|
package/dist/record.js
CHANGED
|
@@ -65,13 +65,7 @@ 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
|
-
|
|
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;
|
|
68
|
+
const { fields, baseUrl } = options;
|
|
75
69
|
const { __data: data } = this;
|
|
76
70
|
const modelName = this.__model.__name;
|
|
77
71
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -93,20 +87,9 @@ export default class Record {
|
|
|
93
87
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
94
88
|
if (fields && !fields.has(key))
|
|
95
89
|
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);
|
|
107
90
|
const relationshipData = Array.isArray(childRecord)
|
|
108
|
-
? childRecord.filter((r) => r?.__model).
|
|
109
|
-
: (childRecord && childRecord.__model
|
|
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;
|
|
110
93
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
111
94
|
const dasherizedKey = camelCaseToKebabCase(key);
|
|
112
95
|
relationships[dasherizedKey] = { data: relationshipData };
|
|
@@ -87,18 +87,9 @@ 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
|
-
*/
|
|
98
90
|
toJSON?(options?: {
|
|
99
91
|
fields?: Set<string>;
|
|
100
92
|
baseUrl?: string;
|
|
101
|
-
linkage?: (type: string, record: unknown) => boolean;
|
|
102
93
|
}): Record<string, unknown>;
|
|
103
94
|
[key: string]: unknown;
|
|
104
95
|
}
|
|
@@ -251,6 +242,72 @@ export interface AccessContext {
|
|
|
251
242
|
* from one that classified the request and found nothing.
|
|
252
243
|
*/
|
|
253
244
|
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 IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
|
|
295
|
+
* `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
|
|
296
|
+
* identically-shaped context object, which is the exact configuration that
|
|
297
|
+
* makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
|
|
298
|
+
* sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
|
|
299
|
+
* `context.recordId = getId(request.params)`, the same single coercion this
|
|
300
|
+
* key is built from. They differ in ONE way and it is the absence spelling --
|
|
301
|
+
* `HookContext.recordId` is optional and `undefined` when unset, while this
|
|
302
|
+
* key is always present and `null` on a collection route. A predicate must
|
|
303
|
+
* not read `undefined` here as "collection".
|
|
304
|
+
*
|
|
305
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
306
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
307
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
308
|
+
* and is untouched by this key.
|
|
309
|
+
*/
|
|
310
|
+
recordId: string | number | null;
|
|
254
311
|
}
|
|
255
312
|
/**
|
|
256
313
|
* A consumer `access()` predicate.
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -221,7 +221,6 @@ 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';
|
|
225
224
|
|
|
226
225
|
interface OrmRequest$ extends Request {
|
|
227
226
|
protocol?: string;
|
|
@@ -634,14 +633,7 @@ export default class OrmRequest extends Request {
|
|
|
634
633
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
635
634
|
|
|
636
635
|
const baseUrl = getBaseUrl(request);
|
|
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 }));
|
|
636
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
645
637
|
|
|
646
638
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
647
639
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
@@ -661,14 +653,7 @@ export default class OrmRequest extends Request {
|
|
|
661
653
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
662
654
|
|
|
663
655
|
const baseUrl = getBaseUrl(request);
|
|
664
|
-
|
|
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, {
|
|
656
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
672
657
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
673
658
|
baseUrl
|
|
674
659
|
});
|
|
@@ -1298,21 +1283,14 @@ export default class OrmRequest extends Request {
|
|
|
1298
1283
|
const relatedData = record.__relationships[relationshipName];
|
|
1299
1284
|
const baseUrl = getBaseUrl(request);
|
|
1300
1285
|
|
|
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
|
-
|
|
1308
1286
|
let data: unknown;
|
|
1309
1287
|
if (info.isArray) {
|
|
1310
1288
|
// hasMany - return array
|
|
1311
1289
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1312
|
-
data = related.map(r => r.toJSON?.({ baseUrl
|
|
1290
|
+
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1313
1291
|
} else {
|
|
1314
1292
|
// belongsTo - return single or null
|
|
1315
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl
|
|
1293
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1316
1294
|
}
|
|
1317
1295
|
|
|
1318
1296
|
return {
|
|
@@ -1411,10 +1389,53 @@ export default class OrmRequest extends Request {
|
|
|
1411
1389
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1412
1390
|
// lookup here would put a store read in the middle of an authorization
|
|
1413
1391
|
// path. The function return shape below IS the per-record hook.
|
|
1392
|
+
//
|
|
1393
|
+
// -------------------------------------------------------------------------
|
|
1394
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1395
|
+
//
|
|
1396
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1397
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1398
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1399
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1400
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1401
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1402
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1403
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1404
|
+
// to the same key, so this was never a deny-list of one.
|
|
1405
|
+
//
|
|
1406
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1407
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1408
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1409
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1410
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1411
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1412
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1413
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1414
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1415
|
+
//
|
|
1416
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1417
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1418
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1419
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1420
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1421
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1422
|
+
//
|
|
1423
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1424
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1425
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1426
|
+
// guarantee `model` carries, by the same means.
|
|
1427
|
+
//
|
|
1428
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1429
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1430
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1431
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1432
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1433
|
+
// `auth()` never produces it.
|
|
1414
1434
|
// -------------------------------------------------------------------------
|
|
1415
1435
|
const context: AccessContext = {
|
|
1416
1436
|
model: this.model,
|
|
1417
1437
|
operation: methodAccessMap[request.method],
|
|
1438
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1418
1439
|
};
|
|
1419
1440
|
|
|
1420
1441
|
let access: AccessMethod;
|
|
@@ -1429,23 +1450,24 @@ export default class OrmRequest extends Request {
|
|
|
1429
1450
|
return 403; // Forbidden
|
|
1430
1451
|
}
|
|
1431
1452
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
if (
|
|
1453
|
+
if (!access) return 403;
|
|
1454
|
+
if (typeof access === 'function') {
|
|
1455
|
+
state.filter = access;
|
|
1456
|
+
return undefined;
|
|
1457
|
+
}
|
|
1458
|
+
if (access === true) return undefined;
|
|
1459
|
+
|
|
1460
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
1461
|
+
// above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
|
|
1462
|
+
// is the natural reading of a type that lists `string` first, and it
|
|
1463
|
+
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
1464
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
1465
|
+
|
|
1466
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
1467
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
1468
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
1469
|
+
if (!Array.isArray(permitted)) return 403;
|
|
1470
|
+
if (!permitted.includes(methodAccessMap[request.method])) return 403;
|
|
1449
1471
|
|
|
1450
1472
|
return undefined;
|
|
1451
1473
|
}
|
package/src/record.ts
CHANGED
|
@@ -7,18 +7,6 @@ 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;
|
|
22
10
|
}
|
|
23
11
|
|
|
24
12
|
interface SerializeOptions {
|
|
@@ -128,13 +116,7 @@ export default class Record {
|
|
|
128
116
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
129
117
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
130
118
|
|
|
131
|
-
|
|
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;
|
|
119
|
+
const { fields, baseUrl } = options;
|
|
138
120
|
const { __data: data } = this;
|
|
139
121
|
const modelName = this.__model.__name;
|
|
140
122
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -156,21 +138,9 @@ export default class Record {
|
|
|
156
138
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
157
139
|
if (fields && !fields.has(key)) continue;
|
|
158
140
|
|
|
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
|
-
|
|
171
141
|
const relationshipData = Array.isArray(childRecord)
|
|
172
|
-
? childRecord.filter((r: Record) => r?.__model).
|
|
173
|
-
: (childRecord && (childRecord as Record).__model
|
|
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;
|
|
174
144
|
|
|
175
145
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
176
146
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/types/orm-types.ts
CHANGED
|
@@ -89,15 +89,7 @@ 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
|
-
|
|
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>;
|
|
92
|
+
toJSON?(options?: { fields?: Set<string>; baseUrl?: string }): Record<string, unknown>;
|
|
101
93
|
[key: string]: unknown;
|
|
102
94
|
}
|
|
103
95
|
|
|
@@ -260,6 +252,73 @@ export interface AccessContext {
|
|
|
260
252
|
* from one that classified the request and found nothing.
|
|
261
253
|
*/
|
|
262
254
|
operation: AccessOperation | undefined;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
258
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
259
|
+
*
|
|
260
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
261
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
262
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
263
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
264
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
265
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
266
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
267
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
268
|
+
*
|
|
269
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
270
|
+
*
|
|
271
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
272
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
273
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
274
|
+
* decoded until stable would deny a record it was never asked about.
|
|
275
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
276
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
277
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
278
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
279
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
280
|
+
* allow, on that same record).
|
|
281
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
282
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
283
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
284
|
+
* `archived/x` -- was measured over-denied 403.
|
|
285
|
+
*
|
|
286
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
287
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
288
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
289
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
290
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
291
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
292
|
+
*
|
|
293
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
294
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
295
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
296
|
+
* exists to buy; it is not a defect to repair here.
|
|
297
|
+
*
|
|
298
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
299
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
300
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
301
|
+
* hand-assembled by a caller resolving the predicate through
|
|
302
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
303
|
+
* signal only because the framework never produces it.
|
|
304
|
+
*
|
|
305
|
+
* IT IS THE ONE KEY THE HOOK VOCABULARY DOES *NOT* DISAGREE WITH.
|
|
306
|
+
* `HookContext.recordId` (`src/hooks.ts`) is an identically-named key on an
|
|
307
|
+
* identically-shaped context object, which is the exact configuration that
|
|
308
|
+
* makes `operation` fail-open shaped -- a hook sees `'get'` where `access()`
|
|
309
|
+
* sees `'read'`. Here they AGREE, and not by coincidence: `_withHooks` sets
|
|
310
|
+
* `context.recordId = getId(request.params)`, the same single coercion this
|
|
311
|
+
* key is built from. They differ in ONE way and it is the absence spelling --
|
|
312
|
+
* `HookContext.recordId` is optional and `undefined` when unset, while this
|
|
313
|
+
* key is always present and `null` on a collection route. A predicate must
|
|
314
|
+
* not read `undefined` here as "collection".
|
|
315
|
+
*
|
|
316
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
317
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
318
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
319
|
+
* and is untouched by this key.
|
|
320
|
+
*/
|
|
321
|
+
recordId: string | number | null;
|
|
263
322
|
}
|
|
264
323
|
|
|
265
324
|
/**
|
package/dist/access-verdict.d.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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;
|
package/dist/access-verdict.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
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/src/access-verdict.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
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
|
-
}
|