@stonyx/orm 0.3.2-beta.145 → 0.3.2-beta.147
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 +366 -8
- package/dist/orm-request.d.ts +60 -0
- package/dist/orm-request.js +640 -44
- package/package.json +3 -3
- package/src/orm-request.ts +649 -43
package/README.md
CHANGED
|
@@ -309,19 +309,343 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
|
|
|
309
309
|
await setupRestServer('/', './access');
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
-
Access classes define models and provide custom filtering/authorization logic
|
|
312
|
+
Access classes define models and provide custom filtering/authorization logic.
|
|
313
|
+
|
|
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
|
+
>
|
|
322
|
+
> That is still a stopgap. **The real fix is
|
|
323
|
+
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
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.
|
|
313
335
|
|
|
314
336
|
```js
|
|
315
337
|
export default class GlobalAccess {
|
|
316
338
|
models = ['owner', 'animal'];
|
|
317
339
|
|
|
318
340
|
access(request) {
|
|
319
|
-
|
|
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';
|
|
369
|
+
}
|
|
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
|
|
320
379
|
return ['read', 'create', 'update', 'delete'];
|
|
321
380
|
}
|
|
322
381
|
}
|
|
323
382
|
```
|
|
324
383
|
|
|
384
|
+
### Return values
|
|
385
|
+
|
|
386
|
+
| `access()` returns | Effect |
|
|
387
|
+
|---|---|
|
|
388
|
+
| `false` (or any falsy value) | `403` for the whole request |
|
|
389
|
+
| `true` | full access, no filter |
|
|
390
|
+
| `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
|
|
391
|
+
| `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
|
|
392
|
+
| a function | a per-record filter — see below |
|
|
393
|
+
| anything else | `403` — unknown shapes fail **closed** |
|
|
394
|
+
|
|
395
|
+
A `throw` inside `access()` is a **denial**, not a 500.
|
|
396
|
+
|
|
397
|
+
### Filter functions
|
|
398
|
+
|
|
399
|
+
A function return value is a **per-record predicate**, and it is enforced on
|
|
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).
|
|
407
|
+
|
|
408
|
+
| Endpoint | A record the predicate rejects |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `GET /:models` | omitted from the collection |
|
|
411
|
+
| `GET /:models/:id` | `404` |
|
|
412
|
+
| `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
|
|
413
|
+
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
414
|
+
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
415
|
+
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
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 |
|
|
417
|
+
|
|
418
|
+
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
419
|
+
is the property most easily "improved" away. 403 would confirm that the record
|
|
420
|
+
exists to a caller who is not allowed to know that, which turns the filter into
|
|
421
|
+
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
422
|
+
it is not yours". Every status on a record route must therefore be identical for
|
|
423
|
+
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
424
|
+
a record that never existed also returns 404 rather than 204.
|
|
425
|
+
|
|
426
|
+
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
427
|
+
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
428
|
+
different failure a developer needs to diagnose.
|
|
429
|
+
|
|
430
|
+
**A client-supplied `id` on `POST` is refused with `403` whenever a function
|
|
431
|
+
filter is in force.** This is the part that keeps `POST` from being an
|
|
432
|
+
enumeration oracle, and it is worth understanding rather than working around.
|
|
433
|
+
The duplicate-id check has to run before the filter, and it sees records the
|
|
434
|
+
filter hides, so the *status* of a `POST` otherwise leaks whether an id is
|
|
435
|
+
taken:
|
|
436
|
+
|
|
437
|
+
| `POST /animals` with a payload the caller may create | before | now |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| an id held by a record the filter **hides** | `403` | `403` |
|
|
440
|
+
| an id that is **free** | `200` | `403` |
|
|
441
|
+
| an id held by a record the caller **can see** | `409` | `403` |
|
|
442
|
+
|
|
443
|
+
Three outcomes, one request per id, the whole id space. Filtering only the
|
|
444
|
+
*collision* status narrows that to callers who cannot create a record they are
|
|
445
|
+
allowed to see; it does not close it. It cannot be closed while a caller both
|
|
446
|
+
chooses the id and learns whether the create succeeded — so under a filter the
|
|
447
|
+
caller does not choose the id. The refusal happens before any store lookup, so
|
|
448
|
+
neither the status nor the response time depends on whether the id exists.
|
|
449
|
+
|
|
450
|
+
Let the server assign the id and read it back from the response. Callers with no
|
|
451
|
+
function-style filter are unaffected: `409` on a duplicate id and `200` on a free
|
|
452
|
+
one both behave exactly as before.
|
|
453
|
+
|
|
454
|
+
### Identifying the collection
|
|
455
|
+
|
|
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:
|
|
459
|
+
|
|
460
|
+
| # | Variant | Why it fails open |
|
|
461
|
+
|---|---|---|
|
|
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.
|
|
500
|
+
|
|
501
|
+
### Known limitations
|
|
502
|
+
|
|
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).
|
|
529
|
+
- **Related and included records are not filtered.** The predicate is evaluated
|
|
530
|
+
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
531
|
+
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
532
|
+
related record without resolving that model's own access class, so a filter on
|
|
533
|
+
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
534
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
535
|
+
`include=`, related-resource routes and relationship-linkage routes.
|
|
536
|
+
- **A before-hook that returns a value short-circuits the request.** On write
|
|
537
|
+
operations addressed to a record the filter is consulted first, so a hook
|
|
538
|
+
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
539
|
+
`beforeHook('get', ...)` read-through cache can answer past the filter.
|
|
540
|
+
- **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
|
|
541
|
+
`create` there is no record to test until the handler has built one, so the
|
|
542
|
+
denial is not knowable in time. Every *after*-hook is gated, and every
|
|
543
|
+
before-hook on `update` and `delete` is gated; before-`create` is the one
|
|
544
|
+
exception. A before-`create` hook must not assume the create will succeed.
|
|
545
|
+
- **A caller can still learn that a collection *has* a per-record filter**, by
|
|
546
|
+
observing `403` rather than `409`/`200` for an id-bearing `POST`. That
|
|
547
|
+
discloses a configuration fact, not the existence of any record.
|
|
548
|
+
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
549
|
+
leaves a small timing difference between a hidden record and one that never
|
|
550
|
+
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
551
|
+
- **A `relationships` key that is not a declared relationship is still applied
|
|
552
|
+
to the record.** The key comes verbatim from the request body and is checked
|
|
553
|
+
against nothing except `id`, which is stripped. On a `POST` that makes an
|
|
554
|
+
undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
|
|
555
|
+
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
556
|
+
change 3 above; the general form is tracked as
|
|
557
|
+
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
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
|
|
568
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
|
|
569
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
|
|
570
|
+
a create can land on an id nobody named.
|
|
571
|
+
- **`context.record` is `undefined` for an after-`create` hook when a string-id
|
|
572
|
+
model is given a numeric-looking id.** The post-create lookup uses the same id
|
|
573
|
+
coercion as every other surface, which resolves `'9107'` to the number `9107`,
|
|
574
|
+
while a model declaring `id = attr('string')` files the record under the string
|
|
575
|
+
key. The create itself succeeds and `context.response.data` is correct; only
|
|
576
|
+
the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
|
|
577
|
+
[#209](https://github.com/abofs/stonyx-orm/issues/209).
|
|
578
|
+
- **A denied `POST` rolls back only a record it *inserted*.** The rollback
|
|
579
|
+
requires the store to have grown, because removing by id alone is a write
|
|
580
|
+
primitive keyed by a caller-supplied value. When `assignRecordId` lands a
|
|
581
|
+
**server-assigned** id on an occupied slot
|
|
582
|
+
([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
|
|
583
|
+
last-*inserted* + 1, not max + 1, so a store whose insertion order is not
|
|
584
|
+
ascending collides), `createRecord` updates that record **in place**: the map
|
|
585
|
+
does not grow, the rollback correctly declines to remove a record this request
|
|
586
|
+
did not create, and the `403` leaves the caller's attributes on someone else's
|
|
587
|
+
record. Narrow — it needs a non-ascending insertion order — but it is the
|
|
588
|
+
reachability condition, so it is stated rather than implied.
|
|
589
|
+
|
|
590
|
+
### Breaking changes
|
|
591
|
+
|
|
592
|
+
These land in the next published build. There is no changelog or release-notes channel yet
|
|
593
|
+
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
594
|
+
they are recorded here.
|
|
595
|
+
|
|
596
|
+
1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
|
|
597
|
+
instead of `204`.** This affects **every** consumer issuing a DELETE against
|
|
598
|
+
any mounted collection, whether or not an access filter is configured, and
|
|
599
|
+
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
600
|
+
delete returned 404 while a missing one returned 204, the pair would be a
|
|
601
|
+
perfect existence oracle and the filter would be worthless.
|
|
602
|
+
2. **After-hooks no longer fire for a request that failed** — denied, missing,
|
|
603
|
+
`400` or `409`. The gate is on the handler's status, not on the operation, so
|
|
604
|
+
it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
|
|
605
|
+
hook either. Previously `afterHook('delete', ...)` ran with a populated
|
|
606
|
+
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
607
|
+
destroyed children behind a 404.
|
|
608
|
+
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
609
|
+
`access` filter is in force**, whatever the payload and whether or not the id
|
|
610
|
+
exists, and *before* any store lookup — so neither the status nor the lookup
|
|
611
|
+
cost can depend on whether that id exists. Only affects function-style
|
|
612
|
+
`access` users. See [Filter functions](#filter-functions) for why, and let the
|
|
613
|
+
server assign the id instead. `409`-on-duplicate is unchanged for everyone
|
|
614
|
+
else.
|
|
615
|
+
|
|
616
|
+
"Whatever the payload" is a statement about the **`id` member of the resource
|
|
617
|
+
object**, and it holds only because that is the sole channel a caller id can
|
|
618
|
+
arrive on. It was not always: a caller id moved into
|
|
619
|
+
`relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
|
|
620
|
+
refusal and overwrote a hidden record in place. Both strips — `attributes.id`
|
|
621
|
+
and `relationships.id` — are part of this behaviour, not tidiness. A future
|
|
622
|
+
change that adds a third channel without stripping it re-opens the oracle;
|
|
623
|
+
see [#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
624
|
+
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
625
|
+
previously reachable by id despite being filtered from the collection now
|
|
626
|
+
return 404. Only affects function-style `access` users, for whom the old
|
|
627
|
+
behaviour was the bypass.
|
|
628
|
+
|
|
629
|
+
"Seven surfaces" means the seven endpoints of **the filtered model**. A write
|
|
630
|
+
to another collection can still reach one of its records through a
|
|
631
|
+
relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
|
|
632
|
+
is **not** closed here.
|
|
633
|
+
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
634
|
+
Express's default 500 handler. So is an `access()` that throws.
|
|
635
|
+
6. **`access()` returning a bare string is one permission, not full access.**
|
|
636
|
+
`AccessMethod` declares `string` legal, and it previously fell through every
|
|
637
|
+
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
638
|
+
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
639
|
+
a number) now returns `403` rather than granting full access.
|
|
640
|
+
7. **`POST` with a client-supplied `id` normalises the body id before the
|
|
641
|
+
duplicate check**, so an id shape that previously *missed* the store's key
|
|
642
|
+
now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
|
|
643
|
+
where they answered `200`, and the `200` was not a success: the lookup missed,
|
|
644
|
+
the duplicate check was skipped, and `createRecord` overwrote the colliding
|
|
645
|
+
record in place. **This one reaches consumers with no filter at all** — the
|
|
646
|
+
population breaking changes 3 and 4 explicitly exempt. If you were relying on
|
|
647
|
+
a hex-shaped or whitespace-padded id creating a second record, it never did.
|
|
648
|
+
|
|
325
649
|
### Include Parameter (Sideloading Relationships)
|
|
326
650
|
|
|
327
651
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
|
@@ -595,7 +919,19 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
595
919
|
// Additional access control - halt with 403 if unauthorized
|
|
596
920
|
beforeHook('delete', 'animal', (context) => {
|
|
597
921
|
const user = context.state.currentUser;
|
|
598
|
-
|
|
922
|
+
|
|
923
|
+
// `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
|
|
924
|
+
// has already fetched the record (and already applied the access filter to
|
|
925
|
+
// it) before this hook runs, so re-fetching it here is a fourth id coercion
|
|
926
|
+
// that has to agree with three others.
|
|
927
|
+
//
|
|
928
|
+
// And it would not agree. `context.params.id` is the raw url segment, always
|
|
929
|
+
// a string, while the store keys numeric-id models by NUMBER — so
|
|
930
|
+
// `store.get('animal', '21')` misses the record held under `21`, and so does
|
|
931
|
+
// `store.find('animal', '21')`: neither coerces. The miss reads as "no such
|
|
932
|
+
// record", which in an authorization hook fails whichever way your code
|
|
933
|
+
// happens to handle a null.
|
|
934
|
+
const animal = context.oldState;
|
|
599
935
|
|
|
600
936
|
if (animal.owner !== user.id && !user.isAdmin) {
|
|
601
937
|
return 403; // Forbidden
|
|
@@ -603,6 +939,10 @@ beforeHook('delete', 'animal', (context) => {
|
|
|
603
939
|
});
|
|
604
940
|
```
|
|
605
941
|
|
|
942
|
+
> If you do need a lookup for some *other* model inside a hook, coerce the id
|
|
943
|
+
> yourself to the type that model's `id` attribute declares — the store is a
|
|
944
|
+
> `Map` and `'21'` and `21` are different keys.
|
|
945
|
+
|
|
606
946
|
#### Auditing
|
|
607
947
|
|
|
608
948
|
```javascript
|
|
@@ -736,11 +1076,29 @@ beforeHook('create', 'post', (context) => {
|
|
|
736
1076
|
|
|
737
1077
|
### Hook Execution Order
|
|
738
1078
|
|
|
739
|
-
1. **
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1079
|
+
1. **Authorization is evaluated first for `update` and `delete`.** A record the
|
|
1080
|
+
access filter rejects returns `404` **before any before-hook runs**, so a
|
|
1081
|
+
hook never sees a record — or a `context.oldState` — that the caller is not
|
|
1082
|
+
allowed to read. `create` is the exception: there is no record to test until
|
|
1083
|
+
the handler has built one, so `beforeHook('create', ...)` **does** fire for a
|
|
1084
|
+
`POST` that goes on to answer `403`.
|
|
1085
|
+
2. **Before hooks** fire next (sequentially, in registration order).
|
|
1086
|
+
3. **Main operation** executes (if no before hook halted).
|
|
1087
|
+
4. **After hooks** fire last (sequentially, in registration order) — **only if
|
|
1088
|
+
the request succeeded.**
|
|
1089
|
+
|
|
1090
|
+
Before hooks can halt the operation by returning a value, and that value becomes
|
|
1091
|
+
the response.
|
|
1092
|
+
|
|
1093
|
+
**After hooks do not run for a failed request.** Any status `>= 400` — denied,
|
|
1094
|
+
missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
|
|
1095
|
+
persistence and `onUpdate` autosave. This is a behaviour change; see
|
|
1096
|
+
[Breaking changes](#breaking-changes). It applies to the samples above: the
|
|
1097
|
+
`afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
|
|
1098
|
+
that answered `404`, and the `afterHook('update', ...)` change-tracking hook
|
|
1099
|
+
writes none for a `PATCH` that answered `404` or `400`. If you need a record of
|
|
1100
|
+
refused requests, log them from a before-hook or from your own middleware —
|
|
1101
|
+
`after<operation>` fires only for an operation that actually happened.
|
|
744
1102
|
|
|
745
1103
|
### Best Practices
|
|
746
1104
|
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
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:
|
|
12
|
+
*
|
|
13
|
+
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
14
|
+
* prefix match against it is ALWAYS false.
|
|
15
|
+
* 2. `request.originalUrl` carries the query string, so an anchored equality
|
|
16
|
+
* check misses `/owners?filter[age]=30`.
|
|
17
|
+
* 3. The router is a bare `express()` (`caseSensitive: false`) while a
|
|
18
|
+
* hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
|
|
19
|
+
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
20
|
+
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
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.
|
|
29
|
+
*
|
|
30
|
+
* The fix is not a sixth rule. It is to stop parsing:
|
|
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.
|
|
49
|
+
*
|
|
50
|
+
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
51
|
+
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
52
|
+
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
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.
|
|
60
|
+
*/
|
|
1
61
|
import { Request } from '@stonyx/rest-server';
|
|
2
62
|
interface OrmRequest$ extends Request {
|
|
3
63
|
protocol?: string;
|