@stonyx/orm 0.3.2-alpha.50 → 0.3.2-alpha.52
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 +214 -121
- package/dist/orm-request.d.ts +36 -13
- package/dist/orm-request.js +181 -44
- package/package.json +1 -1
- package/src/orm-request.ts +186 -46
package/README.md
CHANGED
|
@@ -311,57 +311,71 @@ await setupRestServer('/', './access');
|
|
|
311
311
|
|
|
312
312
|
Access classes define models and provide custom filtering/authorization logic.
|
|
313
313
|
|
|
314
|
-
> **
|
|
315
|
-
> [
|
|
316
|
-
>
|
|
317
|
-
>
|
|
318
|
-
>
|
|
319
|
-
>
|
|
320
|
-
>
|
|
314
|
+
> **Do not reconstruct the request path inside `access()`. Read
|
|
315
|
+
> [Identifying the collection](#identifying-the-collection) before copying this.**
|
|
316
|
+
> Every attempt to identify the collection by parsing the request target has
|
|
317
|
+
> failed **open** — five distinct variants of this same example, each found only
|
|
318
|
+
> after the previous was fixed, by five different people. The sample below does
|
|
319
|
+
> not parse anything: it reads `request.baseUrl`, the mount Express actually
|
|
320
|
+
> matched.
|
|
321
321
|
>
|
|
322
|
-
> **The real fix is
|
|
322
|
+
> That is still a stopgap. **The real fix is
|
|
323
323
|
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
324
|
-
> receive the model, the operation and the record, so there is
|
|
325
|
-
>
|
|
326
|
-
>
|
|
327
|
-
>
|
|
328
|
-
>
|
|
329
|
-
>
|
|
324
|
+
> receive the model, the operation and the record, so there is nothing to
|
|
325
|
+
> identify. Until it lands, prefer the array shape (`['read']`) or `false` where
|
|
326
|
+
> you can: the **function** shape is the one that requires any matching at all.
|
|
327
|
+
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
328
|
+
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
329
|
+
> README and that source header are the two copies a consumer sees.
|
|
330
|
+
>
|
|
331
|
+
> This sample is the same code as the shipped test fixture, and a test asserts
|
|
332
|
+
> the two `access()` bodies are identical line for line. For four rounds they
|
|
333
|
+
> were two independently written copies — and the fifth fail-open variant was
|
|
334
|
+
> found in the one nothing was mutating.
|
|
330
335
|
|
|
331
336
|
```js
|
|
332
|
-
import config from 'stonyx/config';
|
|
333
|
-
|
|
334
|
-
// Build the mount prefix from the SAME value the ORM mounts under, and compare
|
|
335
|
-
// lower-cased. Both are load-bearing — see "Matching the url".
|
|
336
|
-
function collectionPrefix(name) {
|
|
337
|
-
const route = config.orm.restServer.route ?? '/';
|
|
338
|
-
const trimmed = String(route).replace(/^\/+|\/+$/g, '');
|
|
339
|
-
|
|
340
|
-
return `${trimmed === '' ? '' : `/${trimmed}`}/${name}`.toLowerCase();
|
|
341
|
-
}
|
|
342
|
-
|
|
343
337
|
export default class GlobalAccess {
|
|
344
338
|
models = ['owner', 'animal'];
|
|
345
339
|
|
|
346
340
|
access(request) {
|
|
347
|
-
// `
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
341
|
+
// `request.baseUrl` is the mount Express matched — `/owners`, or
|
|
342
|
+
// `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
|
|
343
|
+
// the raw request target and can be absolute-form.
|
|
344
|
+
const mount = request.baseUrl;
|
|
345
|
+
|
|
346
|
+
// FAIL CLOSED. If Express did not tell us what it matched we are not behind
|
|
347
|
+
// the mount we think we are, and an unidentifiable request denies rather
|
|
348
|
+
// than falling through to the CRUD grant at the bottom.
|
|
349
|
+
if (typeof mount !== 'string' || mount === '') return false;
|
|
350
|
+
|
|
351
|
+
// Lower-cased because the router matched case-INSENSITIVELY, and a matcher
|
|
352
|
+
// stricter than the router that dispatched the request can be stepped
|
|
353
|
+
// around. The PATH only — record ids stay at their real case below.
|
|
354
|
+
const collection = mount.toLowerCase();
|
|
355
|
+
|
|
356
|
+
// `request.path` is mount-relative and query-free, so sub-path rules need no
|
|
357
|
+
// prefix arithmetic either. false → 403 for the whole request.
|
|
358
|
+
const path = String(request.path ?? '').toLowerCase();
|
|
359
|
+
|
|
360
|
+
if (collection.endsWith('/owners')) {
|
|
361
|
+
if (path === '/archived' || path.startsWith('/archived/')) return false;
|
|
362
|
+
|
|
363
|
+
// Returning a function plugs it in as a per-record filter, and it is
|
|
364
|
+
// enforced on every surface addressed to one of these records:
|
|
365
|
+
// /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
|
|
366
|
+
// A rejected record is 404 on record routes — the same status as a record
|
|
367
|
+
// that does not exist — so the filter is not an existence oracle.
|
|
368
|
+
return record => record.id !== 'angela' && record.id !== 'restricted';
|
|
363
369
|
}
|
|
364
370
|
|
|
371
|
+
// `record.owner` resolves to an OrmRecord, not to the owner's id string —
|
|
372
|
+
// comparing it directly against a string is the bug that made this predicate
|
|
373
|
+
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
374
|
+
// shape as well as the resolved one would absorb a resolution regression
|
|
375
|
+
// silently, which is exactly what blinded this fixture before.
|
|
376
|
+
if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
|
|
377
|
+
|
|
378
|
+
// Allows full access to all calls that don't match any of the above conditions
|
|
365
379
|
return ['read', 'create', 'update', 'delete'];
|
|
366
380
|
}
|
|
367
381
|
}
|
|
@@ -383,7 +397,13 @@ A `throw` inside `access()` is a **denial**, not a 500.
|
|
|
383
397
|
### Filter functions
|
|
384
398
|
|
|
385
399
|
A function return value is a **per-record predicate**, and it is enforced on
|
|
386
|
-
every endpoint that is addressed to a record — not only on the collection
|
|
400
|
+
every endpoint that is addressed to a record — not only on the collection.
|
|
401
|
+
|
|
402
|
+
It is evaluated against the record the route is *addressed to*, **on that model
|
|
403
|
+
only**. It is not a guarantee that a hidden record cannot be reached or modified:
|
|
404
|
+
a write to a *different* collection can still re-parent one. See
|
|
405
|
+
[Known limitations](#known-limitations) and
|
|
406
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207).
|
|
387
407
|
|
|
388
408
|
| Endpoint | A record the predicate rejects |
|
|
389
409
|
|---|---|
|
|
@@ -393,7 +413,7 @@ every endpoint that is addressed to a record — not only on the collection:
|
|
|
393
413
|
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
394
414
|
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
395
415
|
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
396
|
-
| `POST /:models` | `403`, and
|
|
416
|
+
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
|
|
397
417
|
|
|
398
418
|
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
399
419
|
is the property most easily "improved" away. 403 would confirm that the record
|
|
@@ -431,72 +451,81 @@ Let the server assign the id and read it back from the response. Callers with no
|
|
|
431
451
|
function-style filter are unaffected: `409` on a duplicate id and `200` on a free
|
|
432
452
|
one both behave exactly as before.
|
|
433
453
|
|
|
434
|
-
###
|
|
435
|
-
|
|
436
|
-
**This section describes a pattern the framework should not be asking you to
|
|
437
|
-
implement.** It has produced four separate fail-open defects, listed below,
|
|
438
|
-
each found only after the previous was fixed, and there is no reason to believe
|
|
439
|
-
the list is complete. [#202](https://github.com/abofs/stonyx-orm/issues/202)
|
|
440
|
-
replaces it. Until then, all four rules apply and each one, omitted, fails
|
|
441
|
-
**open**.
|
|
454
|
+
### Identifying the collection
|
|
442
455
|
|
|
443
|
-
**
|
|
444
|
-
|
|
445
|
-
|
|
456
|
+
**Do not reconstruct the request path.** Every version of this sample that tried
|
|
457
|
+
to has failed **open**, and each variant was found only after the previous one
|
|
458
|
+
was fixed:
|
|
446
459
|
|
|
447
|
-
|
|
|
460
|
+
| # | Variant | Why it fails open |
|
|
448
461
|
|---|---|---|
|
|
449
|
-
| `GET /owners`
|
|
450
|
-
| `
|
|
451
|
-
| `GET /owners/angela
|
|
452
|
-
|
|
453
|
-
`request.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
**
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
**
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
`/owners` and looks correct; for `ORM_REST_ROUTE=/api` it evaluates to
|
|
488
|
-
**`/apiowners`**, so a reader who followed the correction exactly still failed
|
|
489
|
-
open and believed they had handled it. Join on `/` and collapse the duplicate,
|
|
490
|
-
as `collectionPrefix()` above does.
|
|
462
|
+
| 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
|
|
463
|
+
| 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
|
|
464
|
+
| 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` → 404 but `GET /OwNeRs/angela` → 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
|
|
465
|
+
| 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
|
|
466
|
+
| 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
|
|
467
|
+
|
|
468
|
+
**The fix is not a sixth rule.** It is to stop parsing:
|
|
469
|
+
|
|
470
|
+
**Use `request.baseUrl`.** It is the mount Express *actually matched* when it
|
|
471
|
+
dispatched the request. It carries no query string (variant 2), it is not
|
|
472
|
+
mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
|
|
473
|
+
prefix (variant 4 — there is nothing left to derive, so `/apiowners` is
|
|
474
|
+
unconstructible), and it is unaffected by an absolute-form target (variant 5).
|
|
475
|
+
|
|
476
|
+
| request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
|
|
477
|
+
|---|---|---|---|---|
|
|
478
|
+
| `GET /owners` | `/` | `/owners` | `/owners` | `/` |
|
|
479
|
+
| `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
|
|
480
|
+
| `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
|
|
481
|
+
| `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
|
|
482
|
+
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
483
|
+
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
484
|
+
|
|
485
|
+
Two rules remain, and they are the whole list:
|
|
486
|
+
|
|
487
|
+
**1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
|
|
488
|
+
registered mount — `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
|
|
489
|
+
case-insensitively, so a case-sensitive comparison here is stricter than the
|
|
490
|
+
router and can be walked past. Lower-case the **mount and path only**; record ids
|
|
491
|
+
are case-sensitive and must be compared at their real case.
|
|
492
|
+
|
|
493
|
+
**2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
|
|
494
|
+
was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
|
|
495
|
+
empty string matches no collection, so `access()` fell through to the permission
|
|
496
|
+
array and granted full CRUD. An input you cannot identify must **deny**.
|
|
497
|
+
|
|
498
|
+
Use `request.path` — mount-relative and query-free — if you need to distinguish
|
|
499
|
+
sub-paths beneath the mount, as the `/archived` deny above does.
|
|
491
500
|
|
|
492
501
|
### Known limitations
|
|
493
502
|
|
|
494
|
-
- **
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
503
|
+
- **A function-style filter is not a guarantee that a hidden record cannot be
|
|
504
|
+
modified.** A write to a *different* collection can re-parent one and de-hide
|
|
505
|
+
it: `POST /owners` (or `PATCH /owners/{id}`) carrying
|
|
506
|
+
`relationships: { pets: { data: { id: 21 } } }` — or
|
|
507
|
+
`attributes: { pets: [21, 22] } `, which never enters the relationships loop at
|
|
508
|
+
all — re-parents animal 21 onto an owner the caller is permitted to write. The
|
|
509
|
+
animal's `owner` is the field the `/animals` predicate reads, so the record
|
|
510
|
+
stops being rejected: it becomes readable through `GET /animals/21` and
|
|
511
|
+
deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
|
|
512
|
+
one collection is writable and another is filtered on a field the first can
|
|
513
|
+
set. Blocking it requires checking animal 21 against the **animal** model's
|
|
514
|
+
predicate while servicing an **owners** route — cross-model access resolution,
|
|
515
|
+
which the current contract cannot express: `access()` never receives the model
|
|
516
|
+
structurally ([#202](https://github.com/abofs/stonyx-orm/issues/202)) and
|
|
517
|
+
`setup-rest-server.ts` discards the model→predicate map at boot
|
|
518
|
+
([#196](https://github.com/abofs/stonyx-orm/issues/196)). Tracked as
|
|
519
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207), blocked on that chain
|
|
520
|
+
(#202 → #196 → #207). Until it lands, do not rely on a filter to keep a record
|
|
521
|
+
unmodifiable; keep the *writable* collections' predicates as tight as the
|
|
522
|
+
hidden ones.
|
|
523
|
+
- **Authorization by identifying the collection is a consumer-side
|
|
524
|
+
reconstruction of information the framework already holds.** `access()`
|
|
525
|
+
receives a transport artifact and is asked to work out which model, which
|
|
526
|
+
operation and which record the request addresses. The five variants above are
|
|
527
|
+
the five ways that has been observed to fail open so far. Tracked as
|
|
528
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
500
529
|
- **Related and included records are not filtered.** The predicate is evaluated
|
|
501
530
|
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
502
531
|
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
@@ -526,19 +555,34 @@ as `collectionPrefix()` above does.
|
|
|
526
555
|
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
527
556
|
change 3 above; the general form is tracked as
|
|
528
557
|
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
529
|
-
- **A
|
|
530
|
-
on an unfiltered collection.** The
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
558
|
+
- **A `POST` body `id` that the duplicate check cannot resolve can still
|
|
559
|
+
overwrite a different record on an unfiltered collection.** The lookup is
|
|
560
|
+
correct and deliberately does not coerce: `"9105h"` is rejected as a string
|
|
561
|
+
rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
|
|
562
|
+
themselves. The model's id transform then coerces anyway — a bare `parseInt`
|
|
563
|
+
with no such guard — so the create lands on `9105` (or on `NaN`) and
|
|
564
|
+
overwrites whatever is there. **This is not string-only**: any body id whose
|
|
565
|
+
transform output differs from its lookup key is the same defect. Filtered
|
|
566
|
+
collections are unaffected — breaking change 3 refuses any client-supplied id
|
|
567
|
+
— so this reaches consumers with **no** function-style filter. Tracked as
|
|
535
568
|
[#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
|
|
536
569
|
[#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
|
|
537
570
|
a create can land on an id nobody named.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
571
|
+
- **A denied `POST` rolls back only a record it *inserted*.** The rollback
|
|
572
|
+
requires the store to have grown, because removing by id alone is a write
|
|
573
|
+
primitive keyed by a caller-supplied value. When `assignRecordId` lands a
|
|
574
|
+
**server-assigned** id on an occupied slot
|
|
575
|
+
([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
|
|
576
|
+
last-*inserted* + 1, not max + 1, so a store whose insertion order is not
|
|
577
|
+
ascending collides), `createRecord` updates that record **in place**: the map
|
|
578
|
+
does not grow, the rollback correctly declines to remove a record this request
|
|
579
|
+
did not create, and the `403` leaves the caller's attributes on someone else's
|
|
580
|
+
record. Narrow — it needs a non-ascending insertion order — but it is the
|
|
581
|
+
reachability condition, so it is stated rather than implied.
|
|
582
|
+
|
|
583
|
+
### Breaking changes
|
|
584
|
+
|
|
585
|
+
These land in the next published build. There is no changelog or release-notes channel yet
|
|
542
586
|
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
543
587
|
they are recorded here.
|
|
544
588
|
|
|
@@ -548,8 +592,10 @@ they are recorded here.
|
|
|
548
592
|
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
549
593
|
delete returned 404 while a missing one returned 204, the pair would be a
|
|
550
594
|
perfect existence oracle and the filter would be worthless.
|
|
551
|
-
2. **After-hooks no longer fire for a
|
|
552
|
-
`400` or `409`.
|
|
595
|
+
2. **After-hooks no longer fire for a request that failed** — denied, missing,
|
|
596
|
+
`400` or `409`. The gate is on the handler's status, not on the operation, so
|
|
597
|
+
it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
|
|
598
|
+
hook either. Previously `afterHook('delete', ...)` ran with a populated
|
|
553
599
|
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
554
600
|
destroyed children behind a 404.
|
|
555
601
|
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
@@ -572,6 +618,11 @@ they are recorded here.
|
|
|
572
618
|
previously reachable by id despite being filtered from the collection now
|
|
573
619
|
return 404. Only affects function-style `access` users, for whom the old
|
|
574
620
|
behaviour was the bypass.
|
|
621
|
+
|
|
622
|
+
"Seven surfaces" means the seven endpoints of **the filtered model**. A write
|
|
623
|
+
to another collection can still reach one of its records through a
|
|
624
|
+
relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
|
|
625
|
+
is **not** closed here.
|
|
575
626
|
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
576
627
|
Express's default 500 handler. So is an `access()` that throws.
|
|
577
628
|
6. **`access()` returning a bare string is one permission, not full access.**
|
|
@@ -579,6 +630,14 @@ they are recorded here.
|
|
|
579
630
|
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
580
631
|
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
581
632
|
a number) now returns `403` rather than granting full access.
|
|
633
|
+
7. **`POST` with a client-supplied `id` normalises the body id before the
|
|
634
|
+
duplicate check**, so an id shape that previously *missed* the store's key
|
|
635
|
+
now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
|
|
636
|
+
where they answered `200`, and the `200` was not a success: the lookup missed,
|
|
637
|
+
the duplicate check was skipped, and `createRecord` overwrote the colliding
|
|
638
|
+
record in place. **This one reaches consumers with no filter at all** — the
|
|
639
|
+
population breaking changes 3 and 4 explicitly exempt. If you were relying on
|
|
640
|
+
a hex-shaped or whitespace-padded id creating a second record, it never did.
|
|
582
641
|
|
|
583
642
|
### Include Parameter (Sideloading Relationships)
|
|
584
643
|
|
|
@@ -853,7 +912,19 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
853
912
|
// Additional access control - halt with 403 if unauthorized
|
|
854
913
|
beforeHook('delete', 'animal', (context) => {
|
|
855
914
|
const user = context.state.currentUser;
|
|
856
|
-
|
|
915
|
+
|
|
916
|
+
// `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
|
|
917
|
+
// has already fetched the record (and already applied the access filter to
|
|
918
|
+
// it) before this hook runs, so re-fetching it here is a fourth id coercion
|
|
919
|
+
// that has to agree with three others.
|
|
920
|
+
//
|
|
921
|
+
// And it would not agree. `context.params.id` is the raw url segment, always
|
|
922
|
+
// a string, while the store keys numeric-id models by NUMBER — so
|
|
923
|
+
// `store.get('animal', '21')` misses the record held under `21`, and so does
|
|
924
|
+
// `store.find('animal', '21')`: neither coerces. The miss reads as "no such
|
|
925
|
+
// record", which in an authorization hook fails whichever way your code
|
|
926
|
+
// happens to handle a null.
|
|
927
|
+
const animal = context.oldState;
|
|
857
928
|
|
|
858
929
|
if (animal.owner !== user.id && !user.isAdmin) {
|
|
859
930
|
return 403; // Forbidden
|
|
@@ -861,6 +932,10 @@ beforeHook('delete', 'animal', (context) => {
|
|
|
861
932
|
});
|
|
862
933
|
```
|
|
863
934
|
|
|
935
|
+
> If you do need a lookup for some *other* model inside a hook, coerce the id
|
|
936
|
+
> yourself to the type that model's `id` attribute declares — the store is a
|
|
937
|
+
> `Map` and `'21'` and `21` are different keys.
|
|
938
|
+
|
|
864
939
|
#### Auditing
|
|
865
940
|
|
|
866
941
|
```javascript
|
|
@@ -994,11 +1069,29 @@ beforeHook('create', 'post', (context) => {
|
|
|
994
1069
|
|
|
995
1070
|
### Hook Execution Order
|
|
996
1071
|
|
|
997
|
-
1. **
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1072
|
+
1. **Authorization is evaluated first for `update` and `delete`.** A record the
|
|
1073
|
+
access filter rejects returns `404` **before any before-hook runs**, so a
|
|
1074
|
+
hook never sees a record — or a `context.oldState` — that the caller is not
|
|
1075
|
+
allowed to read. `create` is the exception: there is no record to test until
|
|
1076
|
+
the handler has built one, so `beforeHook('create', ...)` **does** fire for a
|
|
1077
|
+
`POST` that goes on to answer `403`.
|
|
1078
|
+
2. **Before hooks** fire next (sequentially, in registration order).
|
|
1079
|
+
3. **Main operation** executes (if no before hook halted).
|
|
1080
|
+
4. **After hooks** fire last (sequentially, in registration order) — **only if
|
|
1081
|
+
the request succeeded.**
|
|
1082
|
+
|
|
1083
|
+
Before hooks can halt the operation by returning a value, and that value becomes
|
|
1084
|
+
the response.
|
|
1085
|
+
|
|
1086
|
+
**After hooks do not run for a failed request.** Any status `>= 400` — denied,
|
|
1087
|
+
missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
|
|
1088
|
+
persistence and `onUpdate` autosave. This is a behaviour change; see
|
|
1089
|
+
[Breaking changes](#breaking-changes). It applies to the samples above: the
|
|
1090
|
+
`afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
|
|
1091
|
+
that answered `404`, and the `afterHook('update', ...)` change-tracking hook
|
|
1092
|
+
writes none for a `PATCH` that answered `404` or `400`. If you need a record of
|
|
1093
|
+
refused requests, log them from a before-hook or from your own middleware —
|
|
1094
|
+
`after<operation>` fires only for an operation that actually happened.
|
|
1002
1095
|
|
|
1003
1096
|
### Best Practices
|
|
1004
1097
|
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
-
* THE
|
|
5
|
+
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
6
|
* ---------------------------------------------------------------------------
|
|
7
7
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
-
* you to
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* fixed, by four different people:
|
|
8
|
+
* you to work out which collection it addresses. Every attempt to do that by
|
|
9
|
+
* parsing the request target has failed OPEN. Five distinct variants of the
|
|
10
|
+
* same three-line example have now been found, each after the previous was
|
|
11
|
+
* fixed, by five different people:
|
|
13
12
|
*
|
|
14
13
|
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
14
|
* prefix match against it is ALWAYS false.
|
|
@@ -20,20 +19,44 @@
|
|
|
20
19
|
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
20
|
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
21
|
* nothing -- environment-specifically, which is worse.
|
|
22
|
+
* 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
|
|
23
|
+
* `parseurl(req).pathname`, but `originalUrl` is the raw target, so
|
|
24
|
+
* `GET http://anything.example/owners/angela` reaches the handler with
|
|
25
|
+
* `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
|
|
26
|
+
* prefix match is false, `access()` falls through to whatever it returns
|
|
27
|
+
* last, and the record comes back in full. It walks past a hard
|
|
28
|
+
* `return false` deny the same way.
|
|
23
29
|
*
|
|
24
|
-
* The
|
|
25
|
-
* safe against the four variants we happen to have found, and there is no
|
|
26
|
-
* reason to believe the list is complete.
|
|
30
|
+
* The fix is not a sixth rule. It is to stop parsing:
|
|
27
31
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
|
|
33
|
+
* the request. It carries no query string, it is not mount-relative, it is
|
|
34
|
+
* unaffected by absolute-form, and it already includes the configured
|
|
35
|
+
* `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
|
|
36
|
+
* join. Compare it lower-cased (the router matched case-insensitively) and
|
|
37
|
+
* fail CLOSED when it is absent. Use `request.path` -- mount-relative and
|
|
38
|
+
* query-free -- if you need to distinguish sub-paths.
|
|
39
|
+
*
|
|
40
|
+
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
41
|
+
* string, which matches no collection, which falls through to the permission
|
|
42
|
+
* array -- a total grant. An input you cannot identify must DENY.
|
|
43
|
+
*
|
|
44
|
+
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
45
|
+
* transport artifact being asked to stand in for a structural fact.
|
|
46
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
47
|
+
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
48
|
+
* until #202 lands; the function shape is what requires any matching at all.
|
|
32
49
|
*
|
|
33
50
|
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
51
|
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
52
|
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
53
|
* to work out which predicate to return.
|
|
54
|
+
*
|
|
55
|
+
* AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
|
|
56
|
+
* It is evaluated against the record the route is ADDRESSED TO, on that model
|
|
57
|
+
* only. A write to a DIFFERENT collection can still re-parent a hidden record
|
|
58
|
+
* and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
|
|
59
|
+
* See `### Known limitations` in README.
|
|
37
60
|
*/
|
|
38
61
|
import { Request } from '@stonyx/rest-server';
|
|
39
62
|
interface OrmRequest$ extends Request {
|
package/dist/orm-request.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
-
* THE
|
|
5
|
+
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
6
|
* ---------------------------------------------------------------------------
|
|
7
7
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
-
* you to
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* fixed, by four different people:
|
|
8
|
+
* you to work out which collection it addresses. Every attempt to do that by
|
|
9
|
+
* parsing the request target has failed OPEN. Five distinct variants of the
|
|
10
|
+
* same three-line example have now been found, each after the previous was
|
|
11
|
+
* fixed, by five different people:
|
|
13
12
|
*
|
|
14
13
|
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
14
|
* prefix match against it is ALWAYS false.
|
|
@@ -20,20 +19,44 @@
|
|
|
20
19
|
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
20
|
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
21
|
* nothing -- environment-specifically, which is worse.
|
|
22
|
+
* 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
|
|
23
|
+
* `parseurl(req).pathname`, but `originalUrl` is the raw target, so
|
|
24
|
+
* `GET http://anything.example/owners/angela` reaches the handler with
|
|
25
|
+
* `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
|
|
26
|
+
* prefix match is false, `access()` falls through to whatever it returns
|
|
27
|
+
* last, and the record comes back in full. It walks past a hard
|
|
28
|
+
* `return false` deny the same way.
|
|
23
29
|
*
|
|
24
|
-
* The
|
|
25
|
-
* safe against the four variants we happen to have found, and there is no
|
|
26
|
-
* reason to believe the list is complete.
|
|
30
|
+
* The fix is not a sixth rule. It is to stop parsing:
|
|
27
31
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
|
|
33
|
+
* the request. It carries no query string, it is not mount-relative, it is
|
|
34
|
+
* unaffected by absolute-form, and it already includes the configured
|
|
35
|
+
* `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
|
|
36
|
+
* join. Compare it lower-cased (the router matched case-insensitively) and
|
|
37
|
+
* fail CLOSED when it is absent. Use `request.path` -- mount-relative and
|
|
38
|
+
* query-free -- if you need to distinguish sub-paths.
|
|
39
|
+
*
|
|
40
|
+
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
41
|
+
* string, which matches no collection, which falls through to the permission
|
|
42
|
+
* array -- a total grant. An input you cannot identify must DENY.
|
|
43
|
+
*
|
|
44
|
+
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
45
|
+
* transport artifact being asked to stand in for a structural fact.
|
|
46
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
47
|
+
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
48
|
+
* until #202 lands; the function shape is what requires any matching at all.
|
|
32
49
|
*
|
|
33
50
|
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
51
|
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
52
|
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
53
|
* to work out which predicate to return.
|
|
54
|
+
*
|
|
55
|
+
* AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
|
|
56
|
+
* It is evaluated against the record the route is ADDRESSED TO, on that model
|
|
57
|
+
* only. A write to a DIFFERENT collection can still re-parent a hidden record
|
|
58
|
+
* and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
|
|
59
|
+
* See `### Known limitations` in README.
|
|
37
60
|
*/
|
|
38
61
|
import { Request } from '@stonyx/rest-server';
|
|
39
62
|
import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
|
|
@@ -41,6 +64,7 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
41
64
|
import { getPluralName } from './plural-registry.js';
|
|
42
65
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
43
66
|
import config from 'stonyx/config';
|
|
67
|
+
import log from 'stonyx/log';
|
|
44
68
|
import { isOrmRecord } from './utils.js';
|
|
45
69
|
const methodAccessMap = {
|
|
46
70
|
GET: 'read',
|
|
@@ -86,28 +110,47 @@ function getBaseUrl(request) {
|
|
|
86
110
|
return `${protocol}://${host}`;
|
|
87
111
|
}
|
|
88
112
|
/**
|
|
89
|
-
* The ONE coercion from a caller-supplied id
|
|
90
|
-
*
|
|
113
|
+
* The ONE coercion from a caller-supplied id to the key the store holds it
|
|
114
|
+
* under. Every id-bearing surface in this file goes through it, and none has a
|
|
115
|
+
* copy: `getId()` (URL params), `normalizeBodyId()` (JSON body), and the
|
|
116
|
+
* post-create `context.record` lookup in `_withHooks`.
|
|
91
117
|
*
|
|
92
|
-
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* is a two-record difference --
|
|
118
|
+
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` and `normalizeBodyId()`
|
|
119
|
+
* each had their own arithmetic, and they disagreed: `parseInt(id)` versus
|
|
120
|
+
* `parseInt(id, 10)`. On a hex-shaped id that is a two-record difference --
|
|
96
121
|
*
|
|
97
122
|
* GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
|
|
98
123
|
* POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
|
|
99
124
|
* -> a MISS, so the duplicate check was skipped and
|
|
100
125
|
* createRecord OVERWROTE 9105 in place, answering 200
|
|
101
126
|
*
|
|
102
|
-
* --
|
|
103
|
-
*
|
|
104
|
-
* that must agree cannot be kept in agreement by review; they have to
|
|
105
|
-
* function. Pinned by assertion 43.
|
|
127
|
+
* -- a narrower form of the raw-versus-normalised divergence that the body-id
|
|
128
|
+
* normalisation was added to close, reintroduced by the fix for it. Two
|
|
129
|
+
* coercions that must agree cannot be kept in agreement by review; they have to
|
|
130
|
+
* be one function. Pinned by assertion 43.
|
|
131
|
+
*
|
|
132
|
+
* The third copy was found later and in a quieter place: `_withHooks` populated
|
|
133
|
+
* `context.record` for `create` with `isNaN(id) ? id : parseInt(id)` -- this
|
|
134
|
+
* function's body, inlined verbatim, feeding `store.get`. It was equivalent on
|
|
135
|
+
* every input reachable there, which is exactly what the two that DID diverge
|
|
136
|
+
* looked like until someone tried a hex id.
|
|
137
|
+
*
|
|
138
|
+
* `parseInt` and not `Number`, deliberately, and the anchor is NOT `getId`.
|
|
139
|
+
* It is `src/transforms.ts:7` -- `number: (value) => parseInt(value as string)`,
|
|
140
|
+
* also radix-less -- because that transform is what actually produces the store
|
|
141
|
+
* KEY a record is filed under. `getId` merely agrees with it. They differ from
|
|
142
|
+
* `Number` on `'1e3'` (1 vs 1000) and `'9105.5'` (9105 vs 9105.5), so switching
|
|
143
|
+
* this function to `Number` would make the lookup key disagree with the landing
|
|
144
|
+
* key on those shapes.
|
|
145
|
+
*
|
|
146
|
+
* THE CHANGE THAT WOULD BREAK THIS: adding a radix to `transforms.number`
|
|
147
|
+
* (`parseInt(value, 10)`). That is a one-word edit in a file with no connection
|
|
148
|
+
* to authorization, it would silently reopen the hex divergence in the other
|
|
149
|
+
* direction, and this comment would still read as correct. Assertion 45 pins
|
|
150
|
+
* the transform's radix-less shape directly, so that edit turns a test red
|
|
151
|
+
* rather than shipping.
|
|
106
152
|
*
|
|
107
|
-
* `parseInt`
|
|
108
|
-
* and `'9105.5'` (9105 vs 9105.5), and `getId` -- which decides which record an
|
|
109
|
-
* id ADDRESSES -- is the reference, so `Number` would trade one divergence for
|
|
110
|
-
* two. The reason `parseInt` is safe here is the `isNaN` gate in front of it:
|
|
153
|
+
* The reason `parseInt` is safe here is the `isNaN` gate in front of it:
|
|
111
154
|
* `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
|
|
112
155
|
* DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
|
|
113
156
|
* rejects it as a string instead, so nothing is ever truncated. That gate, not
|
|
@@ -322,7 +365,13 @@ function isDenied(filter, record) {
|
|
|
322
365
|
try {
|
|
323
366
|
return !filter(record);
|
|
324
367
|
}
|
|
325
|
-
catch {
|
|
368
|
+
catch (error) {
|
|
369
|
+
// Denied, but not silently. A consumer predicate that throws on every
|
|
370
|
+
// record turns the whole collection into a 404 wall, and with no
|
|
371
|
+
// diagnostic that is indistinguishable from an empty database. `stonyx/log`
|
|
372
|
+
// is the module convention (see setup-rest-server.ts); optional-call
|
|
373
|
+
// because a consumer may not have configured the log types.
|
|
374
|
+
log.error?.(`[@stonyx/orm] access filter threw for model -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
326
375
|
return true;
|
|
327
376
|
}
|
|
328
377
|
}
|
|
@@ -426,9 +475,24 @@ export default class OrmRequest extends Request {
|
|
|
426
475
|
// the oracle exists for: with no per-record filter there are no hidden
|
|
427
476
|
// records, and 409 discloses nothing GET /:id does not already.
|
|
428
477
|
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
478
|
+
// RESIDUALS, stated rather than implied.
|
|
479
|
+
//
|
|
480
|
+
// - a caller can still learn that a collection HAS a per-record filter
|
|
481
|
+
// (403 rather than 409/200 for an id-bearing POST). That discloses a
|
|
482
|
+
// configuration fact, not a record.
|
|
483
|
+
// - this gate is about ids arriving on THIS model's create route. It
|
|
484
|
+
// says nothing about a write to ANOTHER collection: a `POST /owners`
|
|
485
|
+
// carrying `relationships: {pets: {data: {id: 21}}}` -- or
|
|
486
|
+
// `attributes: {pets: [21, 22]}`, which never enters the
|
|
487
|
+
// relationships loop at all -- re-parents hidden animal 21 onto an
|
|
488
|
+
// owner the caller may write, which changes the very field the
|
|
489
|
+
// animals predicate reads and DE-HIDES it. Blocking that needs animal
|
|
490
|
+
// 21 checked against the ANIMAL model's predicate while servicing an
|
|
491
|
+
// OWNERS route, i.e. cross-model access resolution: abofs/stonyx-orm
|
|
492
|
+
// #207, blocked on #202 (`access` receives the model structurally)
|
|
493
|
+
// and #196 (setup-rest-server discards the model->predicate map at
|
|
494
|
+
// boot). NOT closed here, and no comment in this file may say it is.
|
|
495
|
+
//
|
|
432
496
|
// See README `### Known limitations`.
|
|
433
497
|
if (id !== undefined) {
|
|
434
498
|
if (typeof filter === 'function')
|
|
@@ -457,8 +521,11 @@ export default class OrmRequest extends Request {
|
|
|
457
521
|
// on the create surface. Pinned by assertion 39.
|
|
458
522
|
//
|
|
459
523
|
// The `id` member of the resource object is now the ONLY channel a caller
|
|
460
|
-
// id can arrive on, which is what makes
|
|
461
|
-
// rather than merely asserted.
|
|
524
|
+
// id can arrive on FOR THIS MODEL'S OWN CREATE ROUTE, which is what makes
|
|
525
|
+
// GATE 0's guarantee checkable rather than merely asserted. It is not a
|
|
526
|
+
// statement about the record's reachability in general -- a relationship
|
|
527
|
+
// write on another collection reaches it without ever touching this
|
|
528
|
+
// handler (abofs/stonyx-orm#207). INHERITED from `dev`, which carries this
|
|
462
529
|
// loop verbatim; the general form -- the loop accepts any key, not just
|
|
463
530
|
// `id`, so a body key that is not a declared relationship is still
|
|
464
531
|
// mass-assigned -- is abofs/stonyx-orm#204.
|
|
@@ -530,9 +597,11 @@ export default class OrmRequest extends Request {
|
|
|
530
597
|
// silently re-armed when that code moves.
|
|
531
598
|
// SO IT BECOMES REACHABLE IF AN `await` IS
|
|
532
599
|
// INTRODUCED HERE, which is the change a future
|
|
533
|
-
// editor would actually make.
|
|
534
|
-
//
|
|
535
|
-
//
|
|
600
|
+
// editor would actually make. Stated here rather
|
|
601
|
+
// than by reference: `docs/` is not in `files`, so
|
|
602
|
+
// a pointer into it resolves to nothing for anyone
|
|
603
|
+
// who installed this package. README carries the
|
|
604
|
+
// consumer-facing half.
|
|
536
605
|
if (createdNewSlot && store.get(model, record.id) === record) {
|
|
537
606
|
store.remove(model, record.id, { _skipAutoPersist: true });
|
|
538
607
|
}
|
|
@@ -600,7 +669,12 @@ export default class OrmRequest extends Request {
|
|
|
600
669
|
return { data: record.toJSON?.() };
|
|
601
670
|
};
|
|
602
671
|
const deleteHandler = async ({ params }, { filter }) => {
|
|
603
|
-
|
|
672
|
+
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
673
|
+
// the record and once to remove it -- and a coercion evaluated repeatedly
|
|
674
|
+
// is a coercion that can be edited in one place and not the other, which
|
|
675
|
+
// is the defect `coerceId` exists to prevent.
|
|
676
|
+
const recordId = getId(params);
|
|
677
|
+
const record = await store.find(model, recordId);
|
|
604
678
|
// BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
|
|
605
679
|
// returned 204 before this change. It now returns 404, matching the
|
|
606
680
|
// denied case below. This is deliberate and load-bearing -- if a denied
|
|
@@ -615,7 +689,10 @@ export default class OrmRequest extends Request {
|
|
|
615
689
|
// turns a 404 into a destroyed record.
|
|
616
690
|
if (isDenied(filter, record))
|
|
617
691
|
return 404;
|
|
618
|
-
|
|
692
|
+
// Removed by the id of the record actually fetched, not by re-deriving it
|
|
693
|
+
// from the params a second time: the record the filter tested and the
|
|
694
|
+
// record removed are then provably the same one.
|
|
695
|
+
store.remove(model, record.id, { _skipAutoPersist: true });
|
|
619
696
|
return 204;
|
|
620
697
|
};
|
|
621
698
|
// Wrap handlers with hooks
|
|
@@ -669,7 +746,34 @@ export default class OrmRequest extends Request {
|
|
|
669
746
|
// ===========================================================================
|
|
670
747
|
_withHooks(operation, handler) {
|
|
671
748
|
return async (request, state) => {
|
|
672
|
-
|
|
749
|
+
// `|| {}` so this function behaves like the relationship routes below,
|
|
750
|
+
// which declare `state` with a `= {}` default. It is unkillable through
|
|
751
|
+
// the rest-server dispatcher, which always passes `getState(req)`; it is
|
|
752
|
+
// listed as such in the guards-redundant-by-construction table rather
|
|
753
|
+
// than left silently unkillable, and it defends the WHOLE function (the
|
|
754
|
+
// context, the snapshot and the handler call all read `callState`) rather
|
|
755
|
+
// than one destructure that the next line would throw past anyway.
|
|
756
|
+
const callState = (state || {});
|
|
757
|
+
// ---------------------------------------------------------------------
|
|
758
|
+
// THE AUTHORIZATION SNAPSHOT. Read ONCE, here, before any consumer code
|
|
759
|
+
// can run.
|
|
760
|
+
//
|
|
761
|
+
// `callState` is the object `auth()` planted the filter in, and it is
|
|
762
|
+
// also handed to every before-hook as `context.state` -- a published,
|
|
763
|
+
// WRITABLE extension point. So `state.filter` is an INPUT to the
|
|
764
|
+
// authorization decision, not only an output channel, and re-reading it
|
|
765
|
+
// after the hook loop lets a consumer hook disarm the filter:
|
|
766
|
+
//
|
|
767
|
+
// beforeHook('get', 'animal', ctx => { delete ctx.state.filter })
|
|
768
|
+
// -> GET /animals/21 turned 404 into 200
|
|
769
|
+
// -> GET /animals turned 20 records into 22
|
|
770
|
+
//
|
|
771
|
+
// GATE 1 already used this snapshot, so writes held; the READ handlers
|
|
772
|
+
// re-destructured `filter` from the live bag and did not. Everything
|
|
773
|
+
// downstream now reads `filter` from here, and the handler is handed
|
|
774
|
+
// `handlerState` below -- never `callState`.
|
|
775
|
+
// ---------------------------------------------------------------------
|
|
776
|
+
const { filter } = callState;
|
|
673
777
|
// Build context object for hooks
|
|
674
778
|
const context = {
|
|
675
779
|
model: this.model,
|
|
@@ -678,7 +782,11 @@ export default class OrmRequest extends Request {
|
|
|
678
782
|
params: request.params,
|
|
679
783
|
body: request.body,
|
|
680
784
|
query: request.query,
|
|
681
|
-
|
|
785
|
+
// Deliberately the LIVE object: `redirect` and `pipe` are read back off
|
|
786
|
+
// it by @stonyx/rest-server after the handler returns, so hooks must be
|
|
787
|
+
// able to write to it. What must not happen is the authorization
|
|
788
|
+
// decision reading it back, which is what the snapshot above prevents.
|
|
789
|
+
state: callState,
|
|
682
790
|
};
|
|
683
791
|
// Capture old state for operations that modify data
|
|
684
792
|
if (operation === 'update' || operation === 'delete') {
|
|
@@ -720,7 +828,14 @@ export default class OrmRequest extends Request {
|
|
|
720
828
|
}
|
|
721
829
|
}
|
|
722
830
|
// Execute main handler
|
|
723
|
-
|
|
831
|
+
// The handler receives the SNAPSHOT, never the live bag. `filter` is
|
|
832
|
+
// assigned LAST so it wins over anything a before-hook wrote to
|
|
833
|
+
// `callState.filter` -- including a `delete`, which the spread would
|
|
834
|
+
// otherwise carry through as an absent key. Every other key a hook adds
|
|
835
|
+
// is still visible to the handler; only the authorization input is
|
|
836
|
+
// pinned.
|
|
837
|
+
const handlerState = { ...callState, filter };
|
|
838
|
+
const response = await handler(request, handlerState);
|
|
724
839
|
// Set context.record for update BEFORE persist so SQL drivers can read it
|
|
725
840
|
if (operation === 'update' && response?.data) {
|
|
726
841
|
context.record = store.get(this.model, getId(request.params));
|
|
@@ -732,6 +847,16 @@ export default class OrmRequest extends Request {
|
|
|
732
847
|
// authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
|
|
733
848
|
// are equally requests in which nothing happened, and a persist or a
|
|
734
849
|
// cascade hook for one of them is just as wrong.
|
|
850
|
+
// `Number.isInteger` is a TYPE guard, not a behaviour guard, and it is
|
|
851
|
+
// unkillable TODAY: the only non-integer a handler in this file can
|
|
852
|
+
// return is a `{ data, links }` object, and `{} >= 400` is `false` by JS
|
|
853
|
+
// coercion, so dropping it changes no reachable outcome. It is kept
|
|
854
|
+
// because `>=` coerces rather than rejects, and the shapes it coerces
|
|
855
|
+
// are not obvious -- `[500] >= 400` is TRUE, so a handler that one day
|
|
856
|
+
// returned an array would have every response read as a denial. Listed
|
|
857
|
+
// as an equivalent mutant rather than left to read as coverage; it
|
|
858
|
+
// becomes killable the moment a handler returns anything array-like or
|
|
859
|
+
// numeric-string-like.
|
|
735
860
|
const denied = Number.isInteger(response) && response >= 400;
|
|
736
861
|
// EXECUTOR 1 -- SQL persistence, for all write operations.
|
|
737
862
|
//
|
|
@@ -759,8 +884,16 @@ export default class OrmRequest extends Request {
|
|
|
759
884
|
else if (operation === 'create' && response?.data && (response.data.id)) {
|
|
760
885
|
// For create, get the record from store using the ID from the response
|
|
761
886
|
const responseData = response.data;
|
|
762
|
-
|
|
763
|
-
|
|
887
|
+
// `normalizeBodyId`, not a copy of its body. This line WAS
|
|
888
|
+
// `isNaN(id) ? id : parseInt(id)` -- `coerceId` inlined verbatim, a
|
|
889
|
+
// third coercion feeding a store lookup, sitting under a docblock that
|
|
890
|
+
// said neither surface had a copy. Equivalent on every input that can
|
|
891
|
+
// reach this truthy-guarded branch (`21`->`21`, `'21'`->`21`,
|
|
892
|
+
// `'angela'`->`'angela'`; `''` and `0` cannot reach it), so this is a
|
|
893
|
+
// de-duplication rather than a behaviour change -- and that is the
|
|
894
|
+
// point: the two that disagreed were equivalent on every input anyone
|
|
895
|
+
// checked, too.
|
|
896
|
+
context.record = store.get(this.model, normalizeBodyId(responseData.id));
|
|
764
897
|
}
|
|
765
898
|
else if (operation === 'delete') {
|
|
766
899
|
// For delete, the record may no longer exist, but we have oldState
|
|
@@ -897,7 +1030,11 @@ export default class OrmRequest extends Request {
|
|
|
897
1030
|
try {
|
|
898
1031
|
access = this.access(request);
|
|
899
1032
|
}
|
|
900
|
-
catch {
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
// Same reasoning as `isDenied`: fail closed, but say so. An `access()`
|
|
1035
|
+
// that throws denies EVERY request to the collection, and a silent 403
|
|
1036
|
+
// wall is the hardest possible thing to diagnose from the outside.
|
|
1037
|
+
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
901
1038
|
return 403; // Forbidden
|
|
902
1039
|
}
|
|
903
1040
|
if (!access)
|
package/package.json
CHANGED
package/src/orm-request.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
3
|
*
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
|
-
* THE
|
|
5
|
+
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
6
|
* ---------------------------------------------------------------------------
|
|
7
7
|
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
8
|
-
* you to
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* fixed, by four different people:
|
|
8
|
+
* you to work out which collection it addresses. Every attempt to do that by
|
|
9
|
+
* parsing the request target has failed OPEN. Five distinct variants of the
|
|
10
|
+
* same three-line example have now been found, each after the previous was
|
|
11
|
+
* fixed, by five different people:
|
|
13
12
|
*
|
|
14
13
|
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
15
14
|
* prefix match against it is ALWAYS false.
|
|
@@ -20,20 +19,44 @@
|
|
|
20
19
|
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
21
20
|
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
22
21
|
* nothing -- environment-specifically, which is worse.
|
|
22
|
+
* 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
|
|
23
|
+
* `parseurl(req).pathname`, but `originalUrl` is the raw target, so
|
|
24
|
+
* `GET http://anything.example/owners/angela` reaches the handler with
|
|
25
|
+
* `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
|
|
26
|
+
* prefix match is false, `access()` falls through to whatever it returns
|
|
27
|
+
* last, and the record comes back in full. It walks past a hard
|
|
28
|
+
* `return false` deny the same way.
|
|
23
29
|
*
|
|
24
|
-
* The
|
|
25
|
-
* safe against the four variants we happen to have found, and there is no
|
|
26
|
-
* reason to believe the list is complete.
|
|
30
|
+
* The fix is not a sixth rule. It is to stop parsing:
|
|
27
31
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
|
|
33
|
+
* the request. It carries no query string, it is not mount-relative, it is
|
|
34
|
+
* unaffected by absolute-form, and it already includes the configured
|
|
35
|
+
* `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
|
|
36
|
+
* join. Compare it lower-cased (the router matched case-insensitively) and
|
|
37
|
+
* fail CLOSED when it is absent. Use `request.path` -- mount-relative and
|
|
38
|
+
* query-free -- if you need to distinguish sub-paths.
|
|
39
|
+
*
|
|
40
|
+
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
41
|
+
* string, which matches no collection, which falls through to the permission
|
|
42
|
+
* array -- a total grant. An input you cannot identify must DENY.
|
|
43
|
+
*
|
|
44
|
+
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
45
|
+
* transport artifact being asked to stand in for a structural fact.
|
|
46
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
47
|
+
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
48
|
+
* until #202 lands; the function shape is what requires any matching at all.
|
|
32
49
|
*
|
|
33
50
|
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
34
51
|
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
35
52
|
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
36
53
|
* to work out which predicate to return.
|
|
54
|
+
*
|
|
55
|
+
* AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
|
|
56
|
+
* It is evaluated against the record the route is ADDRESSED TO, on that model
|
|
57
|
+
* only. A write to a DIFFERENT collection can still re-parent a hidden record
|
|
58
|
+
* and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
|
|
59
|
+
* See `### Known limitations` in README.
|
|
37
60
|
*/
|
|
38
61
|
import { Request } from '@stonyx/rest-server';
|
|
39
62
|
import Orm, { store, createRecord, updateRecord } from '@stonyx/orm';
|
|
@@ -42,6 +65,7 @@ import { getPluralName } from './plural-registry.js';
|
|
|
42
65
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
43
66
|
import type { HookContext } from './hooks.js';
|
|
44
67
|
import config from 'stonyx/config';
|
|
68
|
+
import log from 'stonyx/log';
|
|
45
69
|
import type { OrmRecord } from './types/orm-types.js';
|
|
46
70
|
import { isOrmRecord } from './utils.js';
|
|
47
71
|
|
|
@@ -122,28 +146,47 @@ function getBaseUrl(request: OrmRequest$): string {
|
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
/**
|
|
125
|
-
* The ONE coercion from a caller-supplied id
|
|
126
|
-
*
|
|
149
|
+
* The ONE coercion from a caller-supplied id to the key the store holds it
|
|
150
|
+
* under. Every id-bearing surface in this file goes through it, and none has a
|
|
151
|
+
* copy: `getId()` (URL params), `normalizeBodyId()` (JSON body), and the
|
|
152
|
+
* post-create `context.record` lookup in `_withHooks`.
|
|
127
153
|
*
|
|
128
|
-
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` (
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* is a two-record difference --
|
|
154
|
+
* WHY IT IS SHARED RATHER THAN DUPLICATED. `getId()` and `normalizeBodyId()`
|
|
155
|
+
* each had their own arithmetic, and they disagreed: `parseInt(id)` versus
|
|
156
|
+
* `parseInt(id, 10)`. On a hex-shaped id that is a two-record difference --
|
|
132
157
|
*
|
|
133
158
|
* GET /animals/0x2391 -> record 9105 (getId -> parseInt('0x2391') = 9105)
|
|
134
159
|
* POST /animals {"id":"0x2391"} -> lookup under 0 (normalize -> parseInt('0x2391',10) = 0)
|
|
135
160
|
* -> a MISS, so the duplicate check was skipped and
|
|
136
161
|
* createRecord OVERWROTE 9105 in place, answering 200
|
|
137
162
|
*
|
|
138
|
-
* --
|
|
139
|
-
*
|
|
140
|
-
* that must agree cannot be kept in agreement by review; they have to
|
|
141
|
-
* function. Pinned by assertion 43.
|
|
163
|
+
* -- a narrower form of the raw-versus-normalised divergence that the body-id
|
|
164
|
+
* normalisation was added to close, reintroduced by the fix for it. Two
|
|
165
|
+
* coercions that must agree cannot be kept in agreement by review; they have to
|
|
166
|
+
* be one function. Pinned by assertion 43.
|
|
167
|
+
*
|
|
168
|
+
* The third copy was found later and in a quieter place: `_withHooks` populated
|
|
169
|
+
* `context.record` for `create` with `isNaN(id) ? id : parseInt(id)` -- this
|
|
170
|
+
* function's body, inlined verbatim, feeding `store.get`. It was equivalent on
|
|
171
|
+
* every input reachable there, which is exactly what the two that DID diverge
|
|
172
|
+
* looked like until someone tried a hex id.
|
|
142
173
|
*
|
|
143
|
-
* `parseInt` and not `Number`, deliberately
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
174
|
+
* `parseInt` and not `Number`, deliberately, and the anchor is NOT `getId`.
|
|
175
|
+
* It is `src/transforms.ts:7` -- `number: (value) => parseInt(value as string)`,
|
|
176
|
+
* also radix-less -- because that transform is what actually produces the store
|
|
177
|
+
* KEY a record is filed under. `getId` merely agrees with it. They differ from
|
|
178
|
+
* `Number` on `'1e3'` (1 vs 1000) and `'9105.5'` (9105 vs 9105.5), so switching
|
|
179
|
+
* this function to `Number` would make the lookup key disagree with the landing
|
|
180
|
+
* key on those shapes.
|
|
181
|
+
*
|
|
182
|
+
* THE CHANGE THAT WOULD BREAK THIS: adding a radix to `transforms.number`
|
|
183
|
+
* (`parseInt(value, 10)`). That is a one-word edit in a file with no connection
|
|
184
|
+
* to authorization, it would silently reopen the hex divergence in the other
|
|
185
|
+
* direction, and this comment would still read as correct. Assertion 45 pins
|
|
186
|
+
* the transform's radix-less shape directly, so that edit turns a test red
|
|
187
|
+
* rather than shipping.
|
|
188
|
+
*
|
|
189
|
+
* The reason `parseInt` is safe here is the `isNaN` gate in front of it:
|
|
147
190
|
* `parseInt('9105h')` is `9105`, and truncating a partially-valid id into a
|
|
148
191
|
* DIFFERENT VALID key is exactly how a collision lookup gets skipped. The gate
|
|
149
192
|
* rejects it as a string instead, so nothing is ever truncated. That gate, not
|
|
@@ -388,7 +431,14 @@ function isDenied(filter: unknown, record: unknown): boolean {
|
|
|
388
431
|
// hands back the oracle this whole change exists to close.
|
|
389
432
|
try {
|
|
390
433
|
return !(filter as (record: unknown) => boolean)(record);
|
|
391
|
-
} catch {
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// Denied, but not silently. A consumer predicate that throws on every
|
|
436
|
+
// record turns the whole collection into a 404 wall, and with no
|
|
437
|
+
// diagnostic that is indistinguishable from an empty database. `stonyx/log`
|
|
438
|
+
// is the module convention (see setup-rest-server.ts); optional-call
|
|
439
|
+
// because a consumer may not have configured the log types.
|
|
440
|
+
log.error?.(`[@stonyx/orm] access filter threw for model -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
441
|
+
|
|
392
442
|
return true;
|
|
393
443
|
}
|
|
394
444
|
}
|
|
@@ -508,9 +558,24 @@ export default class OrmRequest extends Request {
|
|
|
508
558
|
// the oracle exists for: with no per-record filter there are no hidden
|
|
509
559
|
// records, and 409 discloses nothing GET /:id does not already.
|
|
510
560
|
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
561
|
+
// RESIDUALS, stated rather than implied.
|
|
562
|
+
//
|
|
563
|
+
// - a caller can still learn that a collection HAS a per-record filter
|
|
564
|
+
// (403 rather than 409/200 for an id-bearing POST). That discloses a
|
|
565
|
+
// configuration fact, not a record.
|
|
566
|
+
// - this gate is about ids arriving on THIS model's create route. It
|
|
567
|
+
// says nothing about a write to ANOTHER collection: a `POST /owners`
|
|
568
|
+
// carrying `relationships: {pets: {data: {id: 21}}}` -- or
|
|
569
|
+
// `attributes: {pets: [21, 22]}`, which never enters the
|
|
570
|
+
// relationships loop at all -- re-parents hidden animal 21 onto an
|
|
571
|
+
// owner the caller may write, which changes the very field the
|
|
572
|
+
// animals predicate reads and DE-HIDES it. Blocking that needs animal
|
|
573
|
+
// 21 checked against the ANIMAL model's predicate while servicing an
|
|
574
|
+
// OWNERS route, i.e. cross-model access resolution: abofs/stonyx-orm
|
|
575
|
+
// #207, blocked on #202 (`access` receives the model structurally)
|
|
576
|
+
// and #196 (setup-rest-server discards the model->predicate map at
|
|
577
|
+
// boot). NOT closed here, and no comment in this file may say it is.
|
|
578
|
+
//
|
|
514
579
|
// See README `### Known limitations`.
|
|
515
580
|
if (id !== undefined) {
|
|
516
581
|
if (typeof filter === 'function') return 403; // Forbidden
|
|
@@ -540,8 +605,11 @@ export default class OrmRequest extends Request {
|
|
|
540
605
|
// on the create surface. Pinned by assertion 39.
|
|
541
606
|
//
|
|
542
607
|
// The `id` member of the resource object is now the ONLY channel a caller
|
|
543
|
-
// id can arrive on, which is what makes
|
|
544
|
-
// rather than merely asserted.
|
|
608
|
+
// id can arrive on FOR THIS MODEL'S OWN CREATE ROUTE, which is what makes
|
|
609
|
+
// GATE 0's guarantee checkable rather than merely asserted. It is not a
|
|
610
|
+
// statement about the record's reachability in general -- a relationship
|
|
611
|
+
// write on another collection reaches it without ever touching this
|
|
612
|
+
// handler (abofs/stonyx-orm#207). INHERITED from `dev`, which carries this
|
|
545
613
|
// loop verbatim; the general form -- the loop accepts any key, not just
|
|
546
614
|
// `id`, so a body key that is not a declared relationship is still
|
|
547
615
|
// mass-assigned -- is abofs/stonyx-orm#204.
|
|
@@ -565,13 +633,13 @@ export default class OrmRequest extends Request {
|
|
|
565
633
|
// mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
|
|
566
634
|
// is true for a record the request did not create. The map's size is the
|
|
567
635
|
// only O(1) signal that distinguishes an insert from an overwrite.
|
|
568
|
-
const slotsBefore =
|
|
636
|
+
const slotsBefore = store.get(model)?.size ?? 0;
|
|
569
637
|
|
|
570
638
|
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
|
|
571
639
|
const record = isOrmRecord(created) ? created : null;
|
|
572
640
|
if (!record) return 500;
|
|
573
641
|
|
|
574
|
-
const createdNewSlot = (
|
|
642
|
+
const createdNewSlot = (store.get(model)?.size ?? 0) > slotsBefore;
|
|
575
643
|
|
|
576
644
|
// 403 here, NOT 404. The oracle argument does not apply to create: there
|
|
577
645
|
// is no pre-existing record whose existence could leak, the caller
|
|
@@ -617,9 +685,11 @@ export default class OrmRequest extends Request {
|
|
|
617
685
|
// silently re-armed when that code moves.
|
|
618
686
|
// SO IT BECOMES REACHABLE IF AN `await` IS
|
|
619
687
|
// INTRODUCED HERE, which is the change a future
|
|
620
|
-
// editor would actually make.
|
|
621
|
-
//
|
|
622
|
-
//
|
|
688
|
+
// editor would actually make. Stated here rather
|
|
689
|
+
// than by reference: `docs/` is not in `files`, so
|
|
690
|
+
// a pointer into it resolves to nothing for anyone
|
|
691
|
+
// who installed this package. README carries the
|
|
692
|
+
// consumer-facing half.
|
|
623
693
|
if (createdNewSlot && store.get(model, record.id as string | number) === record) {
|
|
624
694
|
store.remove(model, record.id as string | number, { _skipAutoPersist: true });
|
|
625
695
|
}
|
|
@@ -693,7 +763,12 @@ export default class OrmRequest extends Request {
|
|
|
693
763
|
};
|
|
694
764
|
|
|
695
765
|
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
696
|
-
|
|
766
|
+
// Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
|
|
767
|
+
// the record and once to remove it -- and a coercion evaluated repeatedly
|
|
768
|
+
// is a coercion that can be edited in one place and not the other, which
|
|
769
|
+
// is the defect `coerceId` exists to prevent.
|
|
770
|
+
const recordId = getId(params);
|
|
771
|
+
const record = await store.find(model, recordId) as OrmRecord | undefined;
|
|
697
772
|
|
|
698
773
|
// BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
|
|
699
774
|
// returned 204 before this change. It now returns 404, matching the
|
|
@@ -708,7 +783,10 @@ export default class OrmRequest extends Request {
|
|
|
708
783
|
// turns a 404 into a destroyed record.
|
|
709
784
|
if (isDenied(filter, record)) return 404;
|
|
710
785
|
|
|
711
|
-
|
|
786
|
+
// Removed by the id of the record actually fetched, not by re-deriving it
|
|
787
|
+
// from the params a second time: the record the filter tested and the
|
|
788
|
+
// record removed are then provably the same one.
|
|
789
|
+
store.remove(model, record.id as string | number, { _skipAutoPersist: true });
|
|
712
790
|
return 204;
|
|
713
791
|
};
|
|
714
792
|
|
|
@@ -766,7 +844,35 @@ export default class OrmRequest extends Request {
|
|
|
766
844
|
// ===========================================================================
|
|
767
845
|
private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
|
|
768
846
|
return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
|
|
769
|
-
|
|
847
|
+
// `|| {}` so this function behaves like the relationship routes below,
|
|
848
|
+
// which declare `state` with a `= {}` default. It is unkillable through
|
|
849
|
+
// the rest-server dispatcher, which always passes `getState(req)`; it is
|
|
850
|
+
// listed as such in the guards-redundant-by-construction table rather
|
|
851
|
+
// than left silently unkillable, and it defends the WHOLE function (the
|
|
852
|
+
// context, the snapshot and the handler call all read `callState`) rather
|
|
853
|
+
// than one destructure that the next line would throw past anyway.
|
|
854
|
+
const callState = (state || {}) as { [key: string]: unknown };
|
|
855
|
+
|
|
856
|
+
// ---------------------------------------------------------------------
|
|
857
|
+
// THE AUTHORIZATION SNAPSHOT. Read ONCE, here, before any consumer code
|
|
858
|
+
// can run.
|
|
859
|
+
//
|
|
860
|
+
// `callState` is the object `auth()` planted the filter in, and it is
|
|
861
|
+
// also handed to every before-hook as `context.state` -- a published,
|
|
862
|
+
// WRITABLE extension point. So `state.filter` is an INPUT to the
|
|
863
|
+
// authorization decision, not only an output channel, and re-reading it
|
|
864
|
+
// after the hook loop lets a consumer hook disarm the filter:
|
|
865
|
+
//
|
|
866
|
+
// beforeHook('get', 'animal', ctx => { delete ctx.state.filter })
|
|
867
|
+
// -> GET /animals/21 turned 404 into 200
|
|
868
|
+
// -> GET /animals turned 20 records into 22
|
|
869
|
+
//
|
|
870
|
+
// GATE 1 already used this snapshot, so writes held; the READ handlers
|
|
871
|
+
// re-destructured `filter` from the live bag and did not. Everything
|
|
872
|
+
// downstream now reads `filter` from here, and the handler is handed
|
|
873
|
+
// `handlerState` below -- never `callState`.
|
|
874
|
+
// ---------------------------------------------------------------------
|
|
875
|
+
const { filter } = callState as { filter?: unknown };
|
|
770
876
|
|
|
771
877
|
// Build context object for hooks
|
|
772
878
|
const context: HookContext = {
|
|
@@ -776,7 +882,11 @@ export default class OrmRequest extends Request {
|
|
|
776
882
|
params: request.params,
|
|
777
883
|
body: request.body,
|
|
778
884
|
query: request.query,
|
|
779
|
-
|
|
885
|
+
// Deliberately the LIVE object: `redirect` and `pipe` are read back off
|
|
886
|
+
// it by @stonyx/rest-server after the handler returns, so hooks must be
|
|
887
|
+
// able to write to it. What must not happen is the authorization
|
|
888
|
+
// decision reading it back, which is what the snapshot above prevents.
|
|
889
|
+
state: callState,
|
|
780
890
|
};
|
|
781
891
|
|
|
782
892
|
// Capture old state for operations that modify data
|
|
@@ -822,7 +932,14 @@ export default class OrmRequest extends Request {
|
|
|
822
932
|
}
|
|
823
933
|
|
|
824
934
|
// Execute main handler
|
|
825
|
-
|
|
935
|
+
// The handler receives the SNAPSHOT, never the live bag. `filter` is
|
|
936
|
+
// assigned LAST so it wins over anything a before-hook wrote to
|
|
937
|
+
// `callState.filter` -- including a `delete`, which the spread would
|
|
938
|
+
// otherwise carry through as an absent key. Every other key a hook adds
|
|
939
|
+
// is still visible to the handler; only the authorization input is
|
|
940
|
+
// pinned.
|
|
941
|
+
const handlerState = { ...callState, filter };
|
|
942
|
+
const response = await handler(request, handlerState);
|
|
826
943
|
|
|
827
944
|
// Set context.record for update BEFORE persist so SQL drivers can read it
|
|
828
945
|
if (operation === 'update' && (response as JsonApiResponse)?.data) {
|
|
@@ -836,6 +953,16 @@ export default class OrmRequest extends Request {
|
|
|
836
953
|
// authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
|
|
837
954
|
// are equally requests in which nothing happened, and a persist or a
|
|
838
955
|
// cascade hook for one of them is just as wrong.
|
|
956
|
+
// `Number.isInteger` is a TYPE guard, not a behaviour guard, and it is
|
|
957
|
+
// unkillable TODAY: the only non-integer a handler in this file can
|
|
958
|
+
// return is a `{ data, links }` object, and `{} >= 400` is `false` by JS
|
|
959
|
+
// coercion, so dropping it changes no reachable outcome. It is kept
|
|
960
|
+
// because `>=` coerces rather than rejects, and the shapes it coerces
|
|
961
|
+
// are not obvious -- `[500] >= 400` is TRUE, so a handler that one day
|
|
962
|
+
// returned an array would have every response read as a denial. Listed
|
|
963
|
+
// as an equivalent mutant rather than left to read as coverage; it
|
|
964
|
+
// becomes killable the moment a handler returns anything array-like or
|
|
965
|
+
// numeric-string-like.
|
|
839
966
|
const denied = Number.isInteger(response) && (response as number) >= 400;
|
|
840
967
|
|
|
841
968
|
// EXECUTOR 1 -- SQL persistence, for all write operations.
|
|
@@ -864,8 +991,16 @@ export default class OrmRequest extends Request {
|
|
|
864
991
|
} else if (operation === 'create' && (response as JsonApiResponse)?.data && ((response as { data: { id?: unknown } }).data.id)) {
|
|
865
992
|
// For create, get the record from store using the ID from the response
|
|
866
993
|
const responseData = (response as { data: { id: string | number } }).data;
|
|
867
|
-
|
|
868
|
-
|
|
994
|
+
// `normalizeBodyId`, not a copy of its body. This line WAS
|
|
995
|
+
// `isNaN(id) ? id : parseInt(id)` -- `coerceId` inlined verbatim, a
|
|
996
|
+
// third coercion feeding a store lookup, sitting under a docblock that
|
|
997
|
+
// said neither surface had a copy. Equivalent on every input that can
|
|
998
|
+
// reach this truthy-guarded branch (`21`->`21`, `'21'`->`21`,
|
|
999
|
+
// `'angela'`->`'angela'`; `''` and `0` cannot reach it), so this is a
|
|
1000
|
+
// de-duplication rather than a behaviour change -- and that is the
|
|
1001
|
+
// point: the two that disagreed were equivalent on every input anyone
|
|
1002
|
+
// checked, too.
|
|
1003
|
+
context.record = store.get(this.model, normalizeBodyId(responseData.id) as string | number);
|
|
869
1004
|
} else if (operation === 'delete') {
|
|
870
1005
|
// For delete, the record may no longer exist, but we have oldState
|
|
871
1006
|
context.recordId = getId(request.params);
|
|
@@ -1013,7 +1148,12 @@ export default class OrmRequest extends Request {
|
|
|
1013
1148
|
let access: AccessMethod;
|
|
1014
1149
|
try {
|
|
1015
1150
|
access = this.access(request);
|
|
1016
|
-
} catch {
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
// Same reasoning as `isDenied`: fail closed, but say so. An `access()`
|
|
1153
|
+
// that throws denies EVERY request to the collection, and a silent 403
|
|
1154
|
+
// wall is the hardest possible thing to diagnose from the outside.
|
|
1155
|
+
log.error?.(`[@stonyx/orm] access() threw for model "${this.model}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
1156
|
+
|
|
1017
1157
|
return 403; // Forbidden
|
|
1018
1158
|
}
|
|
1019
1159
|
|