@stonyx/orm 0.3.2-alpha.66 → 0.3.2-alpha.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -17
- package/dist/access-verdict.d.ts +57 -0
- package/dist/access-verdict.js +185 -0
- package/dist/manage-record.js +234 -9
- package/dist/orm-request.js +99 -30
- package/dist/record.d.ts +12 -0
- package/dist/record.js +20 -3
- package/dist/standalone-db.js +17 -5
- package/dist/types/orm-types.d.ts +9 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +222 -0
- package/src/manage-record.ts +253 -9
- package/src/orm-request.ts +105 -29
- package/src/record.ts +33 -3
- package/src/standalone-db.ts +17 -6
- package/src/types/orm-types.ts +9 -1
- package/src/utils.ts +50 -0
package/README.md
CHANGED
|
@@ -641,7 +641,7 @@ a write to a *different* collection can still re-parent one. See
|
|
|
641
641
|
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
642
642
|
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
643
643
|
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
644
|
-
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the
|
|
644
|
+
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
|
|
645
645
|
|
|
646
646
|
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
647
647
|
is the property most easily "improved" away. 403 would confirm that the record
|
|
@@ -675,9 +675,64 @@ chooses the id and learns whether the create succeeded — so under a filter the
|
|
|
675
675
|
caller does not choose the id. The refusal happens before any store lookup, so
|
|
676
676
|
neither the status nor the response time depends on whether the id exists.
|
|
677
677
|
|
|
678
|
-
Let the server assign the id and read it back from the response
|
|
679
|
-
|
|
680
|
-
|
|
678
|
+
Let the server assign the id and read it back from the response — and read it
|
|
679
|
+
back rather than predicting it, because the value it returns is documented but
|
|
680
|
+
not stable across model kinds: a numeric-id model gets `max + 1` (or, at the
|
|
681
|
+
numeric ceiling, the lowest free integer), and a string-id model gets
|
|
682
|
+
`<model>-<n>`. See breaking change 8.
|
|
683
|
+
|
|
684
|
+
**What a server-assigned id is not.** It is not a secret. On a string-id
|
|
685
|
+
collection it is dense and enumerable from `1`, where previously it inherited
|
|
686
|
+
whatever entropy the last-inserted id happened to carry — a UUID-seeded store
|
|
687
|
+
answered a UUID-derived key. If a collection has **no** `access` config its
|
|
688
|
+
record-level routes are ungated, so the id was the only thing standing between
|
|
689
|
+
an unauthenticated caller and `GET`/`PATCH`/`DELETE` on a record. That was never
|
|
690
|
+
a control and must not become one; configure `access`.
|
|
691
|
+
|
|
692
|
+
**And the id itself is an occupancy signal — on both model kinds.**
|
|
693
|
+
`assignRecordId` reads the whole store, not the caller's filtered view — it
|
|
694
|
+
never sees `state.filter` — so the id it returns is a function of records the
|
|
695
|
+
caller may not be permitted to read. **This applies to numeric-id collections
|
|
696
|
+
as well as string-id ones**, and the conditions differ, so read both:
|
|
697
|
+
|
|
698
|
+
- **String-id collections, always.** The assigned `n` is the smallest positive
|
|
699
|
+
integer whose landing key is free, which tells the caller that every key
|
|
700
|
+
below it is taken, hidden or not.
|
|
701
|
+
- **Numeric-id collections, once one record sits at the numeric ceiling.** The
|
|
702
|
+
normal answer is `max + 1`, which discloses only the maximum. But `max + 1`
|
|
703
|
+
is not representable at or above 2^53, so the walk restarts from `1` (see
|
|
704
|
+
breaking change 8) and the assigned id becomes the smallest free integer —
|
|
705
|
+
the same occupancy predicate, now over arbitrary low keys. Each subsequent
|
|
706
|
+
no-id `POST` names the next free one, so a caller can enumerate the holes in
|
|
707
|
+
a range it cannot read.
|
|
708
|
+
|
|
709
|
+
**A ceiling record reaches a filter-protected collection even though `POST`
|
|
710
|
+
refuses caller ids on one.** Breaking change 3 makes
|
|
711
|
+
`POST /animals {"id": 9007199254740992}` answer `403`, but the same id lands
|
|
712
|
+
through a *relationship write on another collection* —
|
|
713
|
+
`POST /owners` carrying `attributes: { pets: [{ "id": 9007199254740992 }] }`
|
|
714
|
+
creates the animal under that key
|
|
715
|
+
([#207](https://github.com/abofs/stonyx-orm/issues/207), the same channel the
|
|
716
|
+
**Known limitations** re-parenting note describes). So the precondition is
|
|
717
|
+
reachable by an unauthenticated caller on exactly the collections `access`
|
|
718
|
+
exists to protect. Measured on the sample fixture, with every animal hidden by
|
|
719
|
+
the `/animals` predicate and keys 4 and 7 deleted:
|
|
720
|
+
|
|
721
|
+
```
|
|
722
|
+
GET /animals -> 200 [] (nothing visible)
|
|
723
|
+
GET /animals/4 -> 404 (free — indistinguishable from hidden)
|
|
724
|
+
POST /animals {"id":4} -> 403 (breaking change 3)
|
|
725
|
+
POST /owners {... pets:[{"id":9007199254740992}]} -> 200 (#207 plants the ceiling record)
|
|
726
|
+
POST /animals (no id) -> 200 id=4 <- names a hole in the hidden range
|
|
727
|
+
POST /animals (no id) -> 200 id=7 <- and the other one
|
|
728
|
+
POST /animals (no id) -> 200 id=13
|
|
729
|
+
POST /animals (no id) -> 200 id=14
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
Closing this requires the assignment to be filter-aware, which is a change to
|
|
733
|
+
the `access` contract rather than a fix; it is stated here rather than left to
|
|
734
|
+
be discovered. Callers with no function-style filter are unaffected — there are
|
|
735
|
+
no hidden records to disclose.
|
|
681
736
|
|
|
682
737
|
### Identifying the collection
|
|
683
738
|
|
|
@@ -809,7 +864,50 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
809
864
|
related record without resolving that model's own access class, so a filter on
|
|
810
865
|
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
811
866
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
812
|
-
`include=`, related-resource routes and relationship-linkage routes.
|
|
867
|
+
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
868
|
+
**membership** — whether the related resource is served at all — and it is a
|
|
869
|
+
different question from which ids a document may *name*, immediately below.
|
|
870
|
+
- **Relationship linkage is filtered on the four request-bound read surfaces,
|
|
871
|
+
and only there.** A document's `relationships.*.data` used to publish the id
|
|
872
|
+
of every related record unconditionally, so a record hidden on every one of
|
|
873
|
+
its own surfaces was still named inside another model's document — with no
|
|
874
|
+
`include=`, no relationship route and no query string
|
|
875
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). It is now filtered
|
|
876
|
+
through the related model's own access class on `GET /:models`,
|
|
877
|
+
`GET /:models/:id` and both `GET /:models/:id/{relationship}` shapes. A
|
|
878
|
+
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
879
|
+
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
880
|
+
both **keeping their `links`**, which are built from the serialized record's
|
|
881
|
+
own id and never from the related one. Nothing errors and no status changes,
|
|
882
|
+
because throwing here would be an existence oracle *and* would throw out of
|
|
883
|
+
the enclosing `JSON.stringify`. **Not yet covered:** `included`
|
|
884
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
885
|
+
resource appears there at all), the `POST`/`PATCH` response documents, and
|
|
886
|
+
`GET /:models/:id/relationships/{relationship}`, whose *primary data* is
|
|
887
|
+
linkage ([#196](https://github.com/abofs/stonyx-orm/issues/196)).
|
|
888
|
+
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
889
|
+
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
890
|
+
request, and the documented `access()` contract permits a predicate to read
|
|
891
|
+
one — the sample in this README does, for its sub-path rule — so a filter
|
|
892
|
+
resolved inside `toJSON()` denies *permitted* records rather than hidden ones
|
|
893
|
+
(measured: 967 → 964, all three failures over-denials). `toJSON` is also the
|
|
894
|
+
`JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
|
|
895
|
+
`console.log(JSON.stringify(record))` reach it with a **string** in the
|
|
896
|
+
options slot and have no syntactic place to pass a verdict. The no-argument
|
|
897
|
+
call therefore returns the pre-#234 document unchanged. Fail-closed by default
|
|
898
|
+
is not available either: `Orm.instance.accessFunctions` is `{}` in any process
|
|
899
|
+
that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
|
|
900
|
+
it would empty every relationship on every document in processes with no REST
|
|
901
|
+
surface to protect. Closing the residual means moving JSON:API serialization
|
|
902
|
+
**off** the `toJSON` name, tracked as
|
|
903
|
+
[#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
|
|
904
|
+
to an untrusted consumer, serialize it through the REST layer or pass your own
|
|
905
|
+
resolved `linkage` option.
|
|
906
|
+
- **`format()` and `serialize()` are deliberately not filtered, and must stay
|
|
907
|
+
that way.** `format()` is the **persistence** path — its output is what
|
|
908
|
+
`Orm.db.save()` writes to disk — so applying an access filter there would
|
|
909
|
+
write a truncated database. That is **data loss**, not disclosure prevention.
|
|
910
|
+
Neither method appears anywhere in the REST response path.
|
|
813
911
|
- **A before-hook that returns a value short-circuits the request.** On write
|
|
814
912
|
operations addressed to a record the filter is consulted first, so a hook
|
|
815
913
|
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
@@ -842,9 +940,10 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
842
940
|
transform output differs from its lookup key is the same defect. Filtered
|
|
843
941
|
collections are unaffected — breaking change 3 refuses any client-supplied id
|
|
844
942
|
— so this reaches consumers with **no** function-style filter. Tracked as
|
|
845
|
-
[#205](https://github.com/abofs/stonyx-orm/issues/205)
|
|
846
|
-
[#203](https://github.com/abofs/stonyx-orm/issues/203)
|
|
847
|
-
|
|
943
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205). Its sibling
|
|
944
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203) — a **server-assigned**
|
|
945
|
+
id landing on an occupied slot — is **fixed**; see breaking change 8. #205 is
|
|
946
|
+
the client-supplied half and is still open.
|
|
848
947
|
- **`context.record` is `undefined` for an after-`create` hook when a string-id
|
|
849
948
|
model is given a numeric-looking id.** The post-create lookup uses the same id
|
|
850
949
|
coercion as every other surface, which resolves `'9107'` to the number `9107`,
|
|
@@ -854,15 +953,18 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
854
953
|
[#209](https://github.com/abofs/stonyx-orm/issues/209).
|
|
855
954
|
- **A denied `POST` rolls back only a record it *inserted*.** The rollback
|
|
856
955
|
requires the store to have grown, because removing by id alone is a write
|
|
857
|
-
primitive keyed by a caller-supplied value.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
last-*inserted* + 1,
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
956
|
+
primitive keyed by a caller-supplied value. **The reachability condition this
|
|
957
|
+
bullet used to state is gone**: it was
|
|
958
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203) — `assignRecordId`
|
|
959
|
+
returned last-*inserted* + 1, so a server-assigned id could land on an
|
|
960
|
+
occupied slot and `createRecord` would update it in place — and #203 is fixed
|
|
961
|
+
(breaking change 8). A server-assigned create can no longer overwrite, so on a
|
|
962
|
+
collection whose only id channel is `createHandler` this guard has no
|
|
963
|
+
observable effect today. It is kept because a caller-supplied id reaching
|
|
964
|
+
`createRecord` from another route — a relationship write,
|
|
965
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207) — puts the condition
|
|
966
|
+
back, and without the guard a denied `403` would delete a record the request
|
|
967
|
+
did not create.
|
|
866
968
|
|
|
867
969
|
### Breaking changes
|
|
868
970
|
|
|
@@ -923,6 +1025,64 @@ they are recorded here.
|
|
|
923
1025
|
population breaking changes 3 and 4 explicitly exempt. If you were relying on
|
|
924
1026
|
a hex-shaped or whitespace-padded id creating a second record, it never did.
|
|
925
1027
|
|
|
1028
|
+
8. **Server-assigned ids change value on string-id models, numeric ids stop
|
|
1029
|
+
being monotonic at the numeric ceiling, and the create route gains a
|
|
1030
|
+
`409`.** Three consumer-visible changes from
|
|
1031
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203).
|
|
1032
|
+
|
|
1033
|
+
**The value.** A `POST` with no `id` against a model declaring
|
|
1034
|
+
`id = attr('string')` previously produced the *last-inserted* id with `1`
|
|
1035
|
+
concatenated onto it — an owner store holding `['gina', 'bob']` answered
|
|
1036
|
+
`'bob1'`. It now answers `'owner-1'`: the model name, a hyphen, and the
|
|
1037
|
+
lowest positive integer whose landing key is free. **No test in this repo
|
|
1038
|
+
pinned the old value**, so a consumer relying on it gets no failing test, no
|
|
1039
|
+
deprecation and no other signal — which is why it is recorded here. Numeric
|
|
1040
|
+
id models (`id = attr('number')`, the default) are unaffected in shape: they
|
|
1041
|
+
still get an integer, but it is now the **maximum** existing id plus one
|
|
1042
|
+
rather than the last-inserted id plus one, which is the defect #203 is about.
|
|
1043
|
+
They are **not** unaffected in *sequence* — see the monotonicity half below.
|
|
1044
|
+
|
|
1045
|
+
The value is deliberately **not** numeric-looking, and that is not cosmetic.
|
|
1046
|
+
Every id-bearing surface resolves a numeric-looking string id to a **number**
|
|
1047
|
+
(`GET /owners/1` looks up `1`), while a string-id model files its records
|
|
1048
|
+
under the **string** key `'1'`. A server-assigned `'1'` would therefore be a
|
|
1049
|
+
record that was created successfully and could not be fetched, updated or
|
|
1050
|
+
deleted by id, and whose after-`create` hook received
|
|
1051
|
+
`context.record === undefined`
|
|
1052
|
+
([#209](https://github.com/abofs/stonyx-orm/issues/209)).
|
|
1053
|
+
|
|
1054
|
+
**Numeric ids are no longer monotonic, and deleted ids can be re-issued.**
|
|
1055
|
+
The precondition is narrow but it is reachable, and there is no signal when
|
|
1056
|
+
it is met: **one record filed at or above 2^53** (`9007199254740992`). `max
|
|
1057
|
+
+ 1` is not representable there, so assignment restarts from `1` and walks
|
|
1058
|
+
up to the lowest free key — which means the id of a *deleted* record is
|
|
1059
|
+
handed to the next `POST`. Both `dev` and every prior release were strictly
|
|
1060
|
+
monotonic and never re-issued a numeric id, so a consumer that relied on
|
|
1061
|
+
that — audit rows, cursors, cached authorization decisions, external
|
|
1062
|
+
references keyed on the id — now has a stale reference that silently points
|
|
1063
|
+
at a **different record, created by a different caller**, rather than at a
|
|
1064
|
+
deleted one. Nothing fails; the reference simply resolves to the wrong
|
|
1065
|
+
record.
|
|
1066
|
+
|
|
1067
|
+
The restart is deliberate and is not itself optional: without it, one record
|
|
1068
|
+
at the ceiling made every subsequent server-assigned create on that
|
|
1069
|
+
collection fail permanently. Re-use is the cost of keeping the collection
|
|
1070
|
+
writable. **If you need monotonic ids, assign them yourself** rather than
|
|
1071
|
+
letting the server assign, and note that a ceiling record can be planted by
|
|
1072
|
+
an unauthenticated caller — see *And the id itself is an occupancy signal*
|
|
1073
|
+
under [Filter functions](#filter-functions) for the reachability path.
|
|
1074
|
+
String-id models are unaffected by this half: their keys are
|
|
1075
|
+
`<model>-<n>` and were never monotonic over an integer sequence.
|
|
1076
|
+
|
|
1077
|
+
**The status.** `POST /{collection}` can now answer `409` for a reason other
|
|
1078
|
+
than a duplicate id: the server could not derive a free id. That requires a
|
|
1079
|
+
**non-injective** id transform — one that maps distinct candidates onto the
|
|
1080
|
+
same store key, such as `boolean`, or anything you registered on
|
|
1081
|
+
`Orm.instance.transforms` and named as an id type. It is a configuration
|
|
1082
|
+
fault rather than a request fault; the message is logged through
|
|
1083
|
+
`stonyx/log`. Previously this case threw out of the handler and express
|
|
1084
|
+
answered `500` with a stack trace.
|
|
1085
|
+
|
|
926
1086
|
### Include Parameter (Sideloading Relationships)
|
|
927
1087
|
|
|
928
1088
|
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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The classified reading of one `access()` return value.
|
|
4
|
+
*
|
|
5
|
+
* `granted: false` is a total denial. `granted: true` with no `filter` is an
|
|
6
|
+
* unconditional grant. `granted: true` WITH a filter means "grant, subject to
|
|
7
|
+
* this per-record predicate" -- the function return shape, which is the
|
|
8
|
+
* per-record hook `AccessContext` deliberately does not provide.
|
|
9
|
+
*/
|
|
10
|
+
export interface AccessVerdict {
|
|
11
|
+
granted: boolean;
|
|
12
|
+
filter?: (record: unknown) => boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
16
|
+
* NAMED, by id, inside another model's document?
|
|
17
|
+
*
|
|
18
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
19
|
+
* the consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
20
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
21
|
+
* key, not the input.
|
|
22
|
+
*/
|
|
23
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
26
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
27
|
+
*
|
|
28
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
29
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
30
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
31
|
+
* the same answer `auth()` gave before the extraction.
|
|
32
|
+
*/
|
|
33
|
+
export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
|
|
34
|
+
/**
|
|
35
|
+
* Build a request-scoped linkage filter.
|
|
36
|
+
*
|
|
37
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
38
|
+
*
|
|
39
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
40
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
41
|
+
* already had to guard for throwing.
|
|
42
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
43
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
44
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
45
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
46
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
47
|
+
*
|
|
48
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
49
|
+
* template-string composite: `Map` compares with SameValueZero, so the numeric
|
|
50
|
+
* id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
|
|
51
|
+
* would collapse them and let one model's verdict answer for another record.
|
|
52
|
+
*
|
|
53
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
54
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
55
|
+
* first caller's authorization.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared access-verdict primitive (abofs/stonyx-orm#234).
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* A consumer `access()` may return six differently-shaped things -- `false`, a
|
|
8
|
+
* bare permission string, a permission array, `true`, a per-record function, or
|
|
9
|
+
* something the contract does not define at all -- and the reading of each one
|
|
10
|
+
* is a security decision. `auth()` has held that reading inline since #190.
|
|
11
|
+
* Every surface that needs to ask "may this caller see model X's record?" needs
|
|
12
|
+
* the SAME reading, or the second copy becomes an unreviewed second
|
|
13
|
+
* authorization vocabulary that answers differently about the same value.
|
|
14
|
+
*
|
|
15
|
+
* So `interpretAccess` is extracted here and `auth()` now calls it. It is the
|
|
16
|
+
* only place a return shape is classified, and abofs/stonyx-orm#232 and #233
|
|
17
|
+
* rebase onto it rather than re-deriving it.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
|
|
23
|
+
* style choice, it is forced, and it was measured before it was decided:
|
|
24
|
+
*
|
|
25
|
+
* INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
|
|
26
|
+
* INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
|
|
27
|
+
*
|
|
28
|
+
* and all three reds were over-denial of PERMITTED records, not the leak. Two
|
|
29
|
+
* independent reasons:
|
|
30
|
+
*
|
|
31
|
+
* 1. `toJSON()` has no request. The shipped, documented sample reads
|
|
32
|
+
* `request.path` for its `/archived` sub-path rule -- the one read of
|
|
33
|
+
* argument one the README sanctions -- and fail-closes when it is absent.
|
|
34
|
+
* Measured against the live registry:
|
|
35
|
+
*
|
|
36
|
+
* getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
|
|
37
|
+
* getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
|
|
38
|
+
*
|
|
39
|
+
* Same predicate object, two models, two different degradation modes,
|
|
40
|
+
* chosen by the consumer. Without a request there is no trustworthy
|
|
41
|
+
* answer to get.
|
|
42
|
+
*
|
|
43
|
+
* 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
|
|
44
|
+
* record})` calls `record.toJSON('data')` -- a STRING in the options slot.
|
|
45
|
+
* An implicit caller has no syntactic place to pass anything
|
|
46
|
+
* (abofs/stonyx-orm#230). The no-argument document must therefore stay
|
|
47
|
+
* byte-identical to what shipped, which also rules out fail-closed by
|
|
48
|
+
* default: `Orm.instance.accessFunctions` is `{}` in any process that
|
|
49
|
+
* never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
|
|
50
|
+
* fail-closed default would empty every relationship on every document in
|
|
51
|
+
* processes that have no REST surface to protect.
|
|
52
|
+
*
|
|
53
|
+
* The caller -- which still holds the request -- resolves the predicate,
|
|
54
|
+
* interprets it here, caches the answer, and hands `toJSON()` an already-decided
|
|
55
|
+
* `(type, record) => boolean`.
|
|
56
|
+
*/
|
|
57
|
+
import Orm from '@stonyx/orm';
|
|
58
|
+
import log from 'stonyx/log';
|
|
59
|
+
const DENIED = Object.freeze({ granted: false });
|
|
60
|
+
const GRANTED = Object.freeze({ granted: true });
|
|
61
|
+
/**
|
|
62
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
63
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
64
|
+
*
|
|
65
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
66
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
67
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
68
|
+
* the same answer `auth()` gave before the extraction.
|
|
69
|
+
*/
|
|
70
|
+
export function interpretAccess(access, operation) {
|
|
71
|
+
if (!access)
|
|
72
|
+
return DENIED;
|
|
73
|
+
// The function return shape IS the per-record hook. Grant the request and
|
|
74
|
+
// carry the predicate; the caller applies it per record.
|
|
75
|
+
if (typeof access === 'function')
|
|
76
|
+
return { granted: true, filter: access };
|
|
77
|
+
if (access === true)
|
|
78
|
+
return GRANTED;
|
|
79
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
80
|
+
// above. A bare string is ONE permission, not a grant of all four -- reading
|
|
81
|
+
// it as a full grant is what once let `return 'read'` authorise DELETE.
|
|
82
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
83
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
84
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
85
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
86
|
+
if (!Array.isArray(permitted))
|
|
87
|
+
return DENIED;
|
|
88
|
+
if (!permitted.includes(operation))
|
|
89
|
+
return DENIED;
|
|
90
|
+
return GRANTED;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve model `type`'s verdict for a read, against the live `request`.
|
|
94
|
+
*
|
|
95
|
+
* Fails closed on both ambiguous inputs:
|
|
96
|
+
*
|
|
97
|
+
* - `getAccess(type)` -> `undefined`. That is NOT "this model is
|
|
98
|
+
* unrestricted". `setup-rest-server` catches an access-class load failure,
|
|
99
|
+
* warns, and publishes whatever PARTIAL map it had, so `undefined` covers
|
|
100
|
+
* both "no access class claims this model" and "the class that claims it
|
|
101
|
+
* failed to load" -- and the caller cannot tell them apart. Deny.
|
|
102
|
+
* - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
|
|
103
|
+
* a throw is a denial, logged, never a 500 and never a grant.
|
|
104
|
+
*
|
|
105
|
+
* NOTE ON CROSS-MODEL ASKS. The predicate is asked about `type` while the
|
|
106
|
+
* request in hand was dispatched to a DIFFERENT model's route. Since #222 this
|
|
107
|
+
* repo's fixture reads `context.model` and answers correctly; a consumer's
|
|
108
|
+
* arity-1 predicate does not, and there is no supported way to tell which kind
|
|
109
|
+
* was resolved (the boot-time arity warning is abofs/stonyx-orm#213). A
|
|
110
|
+
* consequence to expect rather than debug: the fixture's surviving `request.path`
|
|
111
|
+
* read means asking the OWNER predicate on a request dispatched to
|
|
112
|
+
* `GET /animals/archived` returns a bare `false`. That is a whole-request deny
|
|
113
|
+
* bleeding across models -- harmless, because it is the fail-closed direction,
|
|
114
|
+
* and it is treated as "deny this linkage", not as an error.
|
|
115
|
+
*/
|
|
116
|
+
function resolveVerdict(request, type) {
|
|
117
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
118
|
+
if (typeof predicate !== 'function')
|
|
119
|
+
return DENIED;
|
|
120
|
+
let access;
|
|
121
|
+
try {
|
|
122
|
+
access = predicate(request, { model: type, operation: 'read' });
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
+
return DENIED;
|
|
127
|
+
}
|
|
128
|
+
return interpretAccess(access, 'read');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build a request-scoped linkage filter.
|
|
132
|
+
*
|
|
133
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
134
|
+
*
|
|
135
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
136
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
137
|
+
* already had to guard for throwing.
|
|
138
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
139
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
140
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
141
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
142
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
143
|
+
*
|
|
144
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
145
|
+
* template-string composite: `Map` compares with SameValueZero, so the numeric
|
|
146
|
+
* id `1` and the string id `'1'` stay distinct, where `` `${type}:${id}` ``
|
|
147
|
+
* would collapse them and let one model's verdict answer for another record.
|
|
148
|
+
*
|
|
149
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
150
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
151
|
+
* first caller's authorization.
|
|
152
|
+
*/
|
|
153
|
+
export function createLinkageFilter(request) {
|
|
154
|
+
const byType = new Map();
|
|
155
|
+
return function isLinkable(type, record) {
|
|
156
|
+
let entry = byType.get(type);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
159
|
+
byType.set(type, entry);
|
|
160
|
+
}
|
|
161
|
+
const { verdict, decisions } = entry;
|
|
162
|
+
if (!verdict.granted)
|
|
163
|
+
return false;
|
|
164
|
+
if (!verdict.filter)
|
|
165
|
+
return true;
|
|
166
|
+
const id = record?.id;
|
|
167
|
+
const cached = decisions.get(id);
|
|
168
|
+
if (cached !== undefined)
|
|
169
|
+
return cached;
|
|
170
|
+
let allowed;
|
|
171
|
+
try {
|
|
172
|
+
allowed = Boolean(verdict.filter(record));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
176
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
177
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
178
|
+
// from a database with no relationships in it.
|
|
179
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
180
|
+
allowed = false;
|
|
181
|
+
}
|
|
182
|
+
decisions.set(id, allowed);
|
|
183
|
+
return allowed;
|
|
184
|
+
};
|
|
185
|
+
}
|