@stonyx/orm 0.3.2-beta.157 → 0.3.2-beta.159
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 +268 -1
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/orm-request.js +182 -31
- package/dist/record.d.ts +16 -0
- package/dist/record.js +149 -3
- package/dist/types/orm-types.d.ts +27 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +312 -0
- package/src/index.ts +9 -0
- package/src/orm-request.ts +188 -32
- package/src/record.ts +176 -3
- package/src/types/orm-types.ts +28 -1
package/README.md
CHANGED
|
@@ -927,7 +927,143 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
927
927
|
related record without resolving that model's own access class, so a filter on
|
|
928
928
|
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
929
929
|
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
930
|
-
`include=`, related-resource routes and relationship-linkage routes.
|
|
930
|
+
`include=`, related-resource routes and relationship-linkage routes. This is
|
|
931
|
+
**membership** — whether the related resource is served at all — and it is a
|
|
932
|
+
different question from which ids a document may *name*, immediately below.
|
|
933
|
+
- **Relationship linkage is filtered on every request-bound surface that
|
|
934
|
+
serializes a record — the reads, the two writes, and `included`.** A
|
|
935
|
+
document's `relationships.*.data` used to publish the id of every related
|
|
936
|
+
record unconditionally, so a record hidden on every one of its own surfaces
|
|
937
|
+
was still named inside another model's document — with no `include=`, no
|
|
938
|
+
relationship route and no query string
|
|
939
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
|
|
940
|
+
the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
|
|
941
|
+
both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
|
|
942
|
+
`PATCH /:models/:id` **response documents**, and every record inside an
|
|
943
|
+
`?include=` **`included`** array
|
|
944
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
|
|
945
|
+
`{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
|
|
946
|
+
write route, and that is correct rather than an oversight** — the question
|
|
947
|
+
asked of the *related* model is "may this caller **read** this id", not "may
|
|
948
|
+
they update it". An access class that grants `['create']` but not `['read']`
|
|
949
|
+
on the related model therefore denies that linkage on its own `POST`
|
|
950
|
+
response; that is the fail-closed direction. Do **not** wire these handlers to
|
|
951
|
+
`methodAccessMap[request.method]`: it would ask a different question on a
|
|
952
|
+
write route than on a read route, which is the two-vocabularies failure
|
|
953
|
+
`createLinkageFilter` exists to prevent. An unresolvable class
|
|
954
|
+
(`getAccess()` → `undefined`) and a predicate that throws both **deny**.
|
|
955
|
+
|
|
956
|
+
**What the two write surfaces cost before #235, measured rather than
|
|
957
|
+
described:** one HTTP verb defeated the filter on the same record. On
|
|
958
|
+
`dev @ 8dda5d6`, seconds apart, with no query string and no relationship
|
|
959
|
+
route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
|
|
960
|
+
returned **200 naming angela**. Any caller who could read a record could also
|
|
961
|
+
write it and be handed the id the read withheld. That consequence is kept here
|
|
962
|
+
after the fix, and stated as a measurement, because **naming the two handlers
|
|
963
|
+
is not a substitute for it** — a reader who is told only that `POST` and
|
|
964
|
+
`PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
|
|
965
|
+
whether the fix addressed it. A
|
|
966
|
+
filtered-out relationship is **indistinguishable from a genuinely empty one** —
|
|
967
|
+
an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
|
|
968
|
+
both **keeping their `links`**, which are built from the serialized record's
|
|
969
|
+
own id and never from the related one. On the two **write** surfaces there are
|
|
970
|
+
no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
|
|
971
|
+
genuinely-empty relationship are both a bare `{ "data": … }` there. That is
|
|
972
|
+
pre-existing and deliberate — adding `baseUrl` to the write handlers would be
|
|
973
|
+
an unrelated change to their response shape. Nothing errors and no status changes,
|
|
974
|
+
because throwing here would be an existence oracle *and* would throw out of
|
|
975
|
+
the enclosing `JSON.stringify`.
|
|
976
|
+
|
|
977
|
+
**That resolves the right class; it does not guarantee a model-correct
|
|
978
|
+
answer, and the failure direction is not the safe one.** Only a predicate that
|
|
979
|
+
*reads* `context.model` can answer about the model it was asked about — see
|
|
980
|
+
[Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
|
|
981
|
+
above. A **single-argument predicate remains the default in every consumer
|
|
982
|
+
tree**, it identifies its collection from the request, and asked about
|
|
983
|
+
`owner` on a request dispatched to `/animals` it answers about **animals**.
|
|
984
|
+
Measured against this repo's own fixture with an arity-1 predicate registered
|
|
985
|
+
for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
|
|
986
|
+
`GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
|
|
987
|
+
#234 defect, on the #234 surface, after the #234 fix. This is not a
|
|
988
|
+
regression (the id was published unconditionally before), it cannot be fixed
|
|
989
|
+
from this side, and the signal that surfaces such a predicate is
|
|
990
|
+
[#221](https://github.com/abofs/stonyx-orm/issues/221) /
|
|
991
|
+
[#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
|
|
992
|
+
predicates to read the context before relying on this filter.** A migrated,
|
|
993
|
+
context-reading predicate degrades the other way — it can over-deny a
|
|
994
|
+
*permitted* related record, which is recorded in the release notes as a
|
|
995
|
+
breaking change.
|
|
996
|
+
|
|
997
|
+
**Not yet covered by #235. Each still publishes ids the surfaces above
|
|
998
|
+
withhold, except where its own owning issue has since closed it — the first
|
|
999
|
+
entry names an issue that is in flight as this is written:**
|
|
1000
|
+
|
|
1001
|
+
- **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
|
|
1002
|
+
to report rather than this entry's.**
|
|
1003
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
|
|
1004
|
+
relationships-linkage route. Its *primary data* is linkage,
|
|
1005
|
+
so filtering it is a **membership** decision — which is why it is the filed
|
|
1006
|
+
child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
|
|
1007
|
+
#234. The route builds its `{type, id}` objects by hand and never calls
|
|
1008
|
+
`toJSON`, so the `linkage` **option** never reaches it; whatever that route
|
|
1009
|
+
filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
|
|
1010
|
+
#235 branched from: `GET /animals/1/relationships/owner` answered
|
|
1011
|
+
`{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
|
|
1012
|
+
measurement is pinned to a commit on purpose, so that it does not quietly
|
|
1013
|
+
become a false claim about `dev`. **PR
|
|
1014
|
+
[#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
|
|
1015
|
+
this entry**; if it has landed, this route is covered and the bullet #247
|
|
1016
|
+
adds above supersedes this one.
|
|
1017
|
+
- **Whether a related resource appears in `included` at all.**
|
|
1018
|
+
[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
|
|
1019
|
+
related resource appears in `included`. #235 filters what a record
|
|
1020
|
+
*already in* `included` may **name**; a hidden record is still a
|
|
1021
|
+
**member** of that array. The two are different questions and neither closes
|
|
1022
|
+
the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
|
|
1023
|
+
`owner.data: null` on every permitted animal it sideloads **and still
|
|
1024
|
+
includes the hidden owner as a resource**.
|
|
1025
|
+
- **A computed attribute that interpolates a related record's id.**
|
|
1026
|
+
[#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
|
|
1027
|
+
and **it is open as this is written**. `relationships.*.data` is a structure
|
|
1028
|
+
this module builds, so it can be filtered; a computed property is arbitrary
|
|
1029
|
+
consumer code returning an arbitrary value. Whether that makes the channel a
|
|
1030
|
+
**framework defect** the ORM should close — by handing computed getters a
|
|
1031
|
+
verdict, or by refusing to run them while a filter is in force — or a
|
|
1032
|
+
**consumer contract** the ORM should only document, is the question #245
|
|
1033
|
+
must decide. **This README does not decide it; neither reading should be
|
|
1034
|
+
read out of the text here.** Measured on this repo's own fixture, where the
|
|
1035
|
+
`animal` model has a `get tag()` that interpolates `owner.id`: **every**
|
|
1036
|
+
animal document on **every** surface — including the ones above — carries
|
|
1037
|
+
`attributes.tag: "angela's small dog"` for an owner that answers `404`. That
|
|
1038
|
+
measurement is where #245 starts, and it holds whichever way the decision
|
|
1039
|
+
lands. Until it lands, if your access rules hide a record, audit your
|
|
1040
|
+
computed properties for its identifiers.
|
|
1041
|
+
- **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
|
|
1042
|
+
`Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
|
|
1043
|
+
request, and the documented `access()` contract permits a predicate to read
|
|
1044
|
+
one — the sample in this README does, for its sub-path rule — so a filter
|
|
1045
|
+
resolved inside `toJSON()` denies *permitted* records rather than hidden ones
|
|
1046
|
+
(measured: 967 → 964, all three failures over-denials). `toJSON` is also the
|
|
1047
|
+
`JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
|
|
1048
|
+
`console.log(JSON.stringify(record))` reach it with a **string** in the
|
|
1049
|
+
options slot and have no syntactic place to pass a verdict. The no-argument
|
|
1050
|
+
call therefore returns the pre-#234 document unchanged. Fail-closed by default
|
|
1051
|
+
is not available either: `Orm.instance.accessFunctions` is `{}` in any process
|
|
1052
|
+
that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
|
|
1053
|
+
it would empty every relationship on every document in processes with no REST
|
|
1054
|
+
surface to protect. Closing the residual means moving JSON:API serialization
|
|
1055
|
+
**off** the `toJSON` name, tracked as
|
|
1056
|
+
[#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
|
|
1057
|
+
to an untrusted consumer, serialize it through the REST layer, or resolve a
|
|
1058
|
+
verdict with the **exported** `createLinkageFilter(request)` and pass it as
|
|
1059
|
+
the `linkage` option — do not write your own reading of `access()`. This is a
|
|
1060
|
+
consumer obligation with no signal when it lapses; it is stated once, in full,
|
|
1061
|
+
under [Consumer Contracts](#consumer-contracts) below.
|
|
1062
|
+
- **`format()` and `serialize()` are deliberately not filtered, and must stay
|
|
1063
|
+
that way.** `format()` is the **persistence** path — its output is what
|
|
1064
|
+
`Orm.db.save()` writes to disk — so applying an access filter there would
|
|
1065
|
+
write a truncated database. That is **data loss**, not disclosure prevention.
|
|
1066
|
+
Neither method appears anywhere in the REST response path.
|
|
931
1067
|
- **A before-hook that returns a value short-circuits the request.** On write
|
|
932
1068
|
operations addressed to a record the filter is consulted first, so a hook
|
|
933
1069
|
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
@@ -986,6 +1122,128 @@ per-record filter. An input you cannot identify must **deny**.
|
|
|
986
1122
|
back, and without the guard a denied `403` would delete a record the request
|
|
987
1123
|
did not create.
|
|
988
1124
|
|
|
1125
|
+
### Consumer Contracts
|
|
1126
|
+
|
|
1127
|
+
Obligations this package **cannot enforce**, where nothing fails, warns or
|
|
1128
|
+
changes shape when a consumer omits them. One place, findable, per
|
|
1129
|
+
`quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
|
|
1130
|
+
read all of these.
|
|
1131
|
+
|
|
1132
|
+
#### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
|
|
1133
|
+
|
|
1134
|
+
**The framework resolves a verdict for you on every request-bound surface that
|
|
1135
|
+
serializes a record through `toJSON()`. You own it everywhere else.**
|
|
1136
|
+
|
|
1137
|
+
Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
|
|
1138
|
+
`GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
|
|
1139
|
+
**response documents**, and every record inside an `?include=` **`included`**
|
|
1140
|
+
array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
|
|
1141
|
+
reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
|
|
1142
|
+
writes and `included`). Each resolves a linkage verdict and passes it to
|
|
1143
|
+
`toJSON()` for you.
|
|
1144
|
+
|
|
1145
|
+
**`GET /:models/:id/relationships/{relationship}` is not on that list, and its
|
|
1146
|
+
state is not this section's to report.** It builds its `{ type, id }` objects by
|
|
1147
|
+
hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
|
|
1148
|
+
— whatever that route filters, it filters itself. And because its linkage *is*
|
|
1149
|
+
its primary data, filtering it is a **membership** decision rather than a
|
|
1150
|
+
linkage one. Membership on both relationship route families is owned by
|
|
1151
|
+
[#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
|
|
1152
|
+
[#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
|
|
1153
|
+
written); read that issue for its state rather than inferring it here, because
|
|
1154
|
+
this section describes only what `toJSON()` filters.
|
|
1155
|
+
|
|
1156
|
+
Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
|
|
1157
|
+
`console.log(record)`, a custom route, a queue payload, a websocket frame —
|
|
1158
|
+
calls `toJSON()` with no verdict, and **the no-verdict document names every
|
|
1159
|
+
related id, including records hidden on every one of their own surfaces**
|
|
1160
|
+
([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
|
|
1161
|
+
deliberate and cannot be inverted; the reasons are in
|
|
1162
|
+
[Known limitations](#known-limitations) above.
|
|
1163
|
+
|
|
1164
|
+
**There is no signal when you omit it.** `linkage` is optional, absent is the
|
|
1165
|
+
default, the default is the unfiltered document, and a filtered relationship is
|
|
1166
|
+
byte-identical to a genuinely empty one — so nothing on the wire distinguishes
|
|
1167
|
+
"filtered" from "forgotten".
|
|
1168
|
+
|
|
1169
|
+
Do this:
|
|
1170
|
+
|
|
1171
|
+
```js
|
|
1172
|
+
import { createLinkageFilter } from '@stonyx/orm';
|
|
1173
|
+
|
|
1174
|
+
// `request` is the live request the caller was authorised against. The verdict
|
|
1175
|
+
// is REQUEST-SCOPED: build one per request and never cache it across requests,
|
|
1176
|
+
// or a second caller is answered with the first caller's authorization.
|
|
1177
|
+
const linkage = createLinkageFilter(request);
|
|
1178
|
+
|
|
1179
|
+
res.json({ data: record.toJSON({ baseUrl, linkage }) });
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
Not this:
|
|
1183
|
+
|
|
1184
|
+
```js
|
|
1185
|
+
// A second, unreviewed reading of access(). It will drift from the one in
|
|
1186
|
+
// src/access-verdict.ts, and it will drift in consumer code where no reviewer
|
|
1187
|
+
// of this repository will ever see it.
|
|
1188
|
+
const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
**`createLinkageFilter` requires a live request, and there is no safe call
|
|
1192
|
+
without one.** `request` is the only authorization input the filter has — it is
|
|
1193
|
+
handed straight to your `access()` predicates, and a predicate that does not
|
|
1194
|
+
*read* it cannot fail closed when it is missing. Passing `undefined`, `null` or
|
|
1195
|
+
any non-object therefore denies **all** linkage and logs, once, at construction.
|
|
1196
|
+
Measured before that guard existed, `createLinkageFilter(undefined)` granted
|
|
1197
|
+
four of the five models in this repository's own fixture, silently.
|
|
1198
|
+
|
|
1199
|
+
**This is the catch for the request-less contexts named above.** In a queue
|
|
1200
|
+
consumer or a websocket handler there is no live request, so there is nothing to
|
|
1201
|
+
authorize against and nothing this package can resolve for you. Either carry the
|
|
1202
|
+
originating request through to the point of serialization, or publish no linkage
|
|
1203
|
+
at all — `record.toJSON({ linkage: () => false })` emits the document with every
|
|
1204
|
+
relationship empty. A stand-in is **not** a substitute: `{}` is an object
|
|
1205
|
+
and passes the guard, and any predicate that ignores its request will grant.
|
|
1206
|
+
|
|
1207
|
+
**`linkage` itself is validated, and an unusable value DENIES.** `undefined`
|
|
1208
|
+
means "no verdict supplied" and emits today's document. Anything else must be a
|
|
1209
|
+
**synchronous function that answers with a boolean**. Each of the following
|
|
1210
|
+
drops **all** linkage on that document and logs once:
|
|
1211
|
+
|
|
1212
|
+
- **A non-function** — `null`, `0`, `false`, `''`, `true`, a string, an object.
|
|
1213
|
+
`null` is the natural return of a resolver that could not resolve a session:
|
|
1214
|
+
it used to be read as "absent" and emit the full document silently.
|
|
1215
|
+
- **An `async` function, a generator function, or any predicate that returns a
|
|
1216
|
+
promise or thenable.** `toJSON` is the `JSON.stringify` hook and cannot await
|
|
1217
|
+
a verdict, and **an `async` resolver returns a promise, a promise is
|
|
1218
|
+
truthy**, so every related id was published, silently, exactly as if this fix
|
|
1219
|
+
were not here. If your
|
|
1220
|
+
authorization lookup is asynchronous, `await` it *before* you serialize and
|
|
1221
|
+
close over the result.
|
|
1222
|
+
- **Any answer that is not a boolean** — `{}`, `'no'`, `1`, `undefined`. A
|
|
1223
|
+
non-boolean is a resolver that did not answer, and a truthy one granted.
|
|
1224
|
+
- **A predicate that throws**, including a `class` passed by mistake. It is
|
|
1225
|
+
caught and denied; it used to escape the enclosing `JSON.stringify` and take
|
|
1226
|
+
the rest of that serialization down with it.
|
|
1227
|
+
|
|
1228
|
+
#### A predicate that ignores `context.model` makes cross-model resolution GRANT
|
|
1229
|
+
|
|
1230
|
+
The linkage filter above asks the **related** model's access class the
|
|
1231
|
+
model-correct question, but only a predicate that *reads*
|
|
1232
|
+
[`context.model`](#the-access-context-second-argument) can give a model-correct
|
|
1233
|
+
answer. A single-argument predicate identifies its collection from the request
|
|
1234
|
+
and therefore answers about the collection the request was *addressed to* —
|
|
1235
|
+
which is the direction that **grants**. Measured, and worked through in
|
|
1236
|
+
[Known limitations](#known-limitations). There is no boot-time warning yet
|
|
1237
|
+
([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
|
|
1238
|
+
predicates to the two-argument contract.**
|
|
1239
|
+
|
|
1240
|
+
#### `format()` and `serialize()` are never filtered, by design
|
|
1241
|
+
|
|
1242
|
+
They are the persistence path. Do not hand their output to an untrusted
|
|
1243
|
+
consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
|
|
1244
|
+
output to disk, so filtering there is data loss rather than disclosure
|
|
1245
|
+
prevention.
|
|
1246
|
+
|
|
989
1247
|
### Breaking changes
|
|
990
1248
|
|
|
991
1249
|
These land in the next published build. There is no changelog or release-notes channel yet
|
|
@@ -1195,6 +1453,15 @@ GET /animals/1
|
|
|
1195
1453
|
#### Limitations
|
|
1196
1454
|
|
|
1197
1455
|
- Only available on GET endpoints (not POST/PATCH)
|
|
1456
|
+
- **`included` is access-filtered on one of the two questions, not both.** What
|
|
1457
|
+
a record already in `included` may **name** in its own
|
|
1458
|
+
`relationships.*.data` is filtered
|
|
1459
|
+
([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
|
|
1460
|
+
longer republishes ids the primary document withholds. Whether a resource
|
|
1461
|
+
appears in `included` **at all** is *membership* and is still unfiltered
|
|
1462
|
+
([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
|
|
1463
|
+
404 on its own routes is still served as an `included` resource, attributes
|
|
1464
|
+
and all. See [Consumer Contracts](#consumer-contracts).
|
|
1198
1465
|
|
|
1199
1466
|
## Lifecycle Hooks
|
|
1200
1467
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AccessMethod, AccessOperation, LinkageFilter } 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
|
+
* Classify one `access()` return value. Extracted verbatim from `auth()`, which
|
|
16
|
+
* now calls this; the branch ORDER is load-bearing and is preserved exactly.
|
|
17
|
+
*
|
|
18
|
+
* `operation` is the verb being authorised. `undefined` -- reachable, because
|
|
19
|
+
* express delivers HEAD to the GET handler and `methodAccessMap` has no entry
|
|
20
|
+
* for it -- falls through `permitted.includes(undefined)` to a denial, which is
|
|
21
|
+
* the same answer `auth()` gave before the extraction.
|
|
22
|
+
*/
|
|
23
|
+
export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
|
|
24
|
+
/**
|
|
25
|
+
* Build a request-scoped linkage filter.
|
|
26
|
+
*
|
|
27
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
28
|
+
*
|
|
29
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
30
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
31
|
+
* already had to guard for throwing.
|
|
32
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
33
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
34
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
35
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
36
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
37
|
+
*
|
|
38
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
39
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
40
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
41
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
42
|
+
* record with the first record's verdict.
|
|
43
|
+
*
|
|
44
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
45
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
46
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
47
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
48
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
49
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
50
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
51
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
52
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
53
|
+
* `animal` ids are numbers.
|
|
54
|
+
*
|
|
55
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
56
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
57
|
+
* first caller's authorization.
|
|
58
|
+
*
|
|
59
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
60
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
61
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
62
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
63
|
+
* caller to authorise against, and this file's header already says so: the
|
|
64
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
65
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
66
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
67
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
68
|
+
*
|
|
69
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
70
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
71
|
+
*
|
|
72
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
73
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
74
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
75
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
76
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
77
|
+
* goes on to serialize nothing.
|
|
78
|
+
*
|
|
79
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
80
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
81
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
82
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
83
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createLinkageFilter(request: unknown): LinkageFilter;
|
|
@@ -0,0 +1,284 @@
|
|
|
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 -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
|
|
106
|
+
* The predicate is asked about `type` while the request in hand was dispatched
|
|
107
|
+
* to a DIFFERENT model's route. This function makes another model's class
|
|
108
|
+
* REACHABLE and asks it the model-correct question (`{ model: type }`); whether
|
|
109
|
+
* the ANSWER is model-correct is the CONSUMER's, because only a predicate that
|
|
110
|
+
* READS `context.model` can give one. Since #222 this repo's fixture does. A
|
|
111
|
+
* consumer's arity-1 predicate does not, and there is no supported way to tell
|
|
112
|
+
* which kind was resolved (the boot-time arity warning is
|
|
113
|
+
* abofs/stonyx-orm#213/#221, unshipped).
|
|
114
|
+
*
|
|
115
|
+
* BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
|
|
116
|
+
* measured, not reasoned:
|
|
117
|
+
*
|
|
118
|
+
* - CLOSED. The migrated fixture's surviving `request.path` read means asking
|
|
119
|
+
* the OWNER predicate on a request dispatched to `GET /animals/archived`
|
|
120
|
+
* returns a bare `false` -- a whole-request deny bleeding across models,
|
|
121
|
+
* treated here as "deny this linkage", not as an error. That over-denies a
|
|
122
|
+
* PERMITTED record.
|
|
123
|
+
* - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
|
|
124
|
+
* still declares valid and the README calls the default in every consumer
|
|
125
|
+
* tree -- identifies its collection from the request, so asked about
|
|
126
|
+
* `owner` on a request dispatched to `/animals` it answers about ANIMALS.
|
|
127
|
+
* Measured against this repo's own fixture with `reg.owner` replaced by an
|
|
128
|
+
* arity-1 predicate that hides angela on `/owners`:
|
|
129
|
+
*
|
|
130
|
+
* GET /owners -> ["gina","michael","bob"] angela hidden, correctly
|
|
131
|
+
* GET /animals -> owners named: [angela, ...] LEAK
|
|
132
|
+
* GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
|
|
133
|
+
*
|
|
134
|
+
* That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
|
|
135
|
+
* #234 was filed for, AFTER this fix. It is not a regression -- dev
|
|
136
|
+
* published the same id unconditionally -- and this file cannot close it,
|
|
137
|
+
* because the arity signal is #213/#221. Do NOT write, here or anywhere
|
|
138
|
+
* else, that the cross-model ask degrades closed. The standing rule this
|
|
139
|
+
* paragraph is held to is in docs/project-structure.md.
|
|
140
|
+
*/
|
|
141
|
+
function resolveVerdict(request, type) {
|
|
142
|
+
const predicate = Orm.instance?.getAccess?.(type);
|
|
143
|
+
if (typeof predicate !== 'function')
|
|
144
|
+
return DENIED;
|
|
145
|
+
let access;
|
|
146
|
+
try {
|
|
147
|
+
// `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
|
|
148
|
+
// IS RIGHT THERE, so this is pinned by assertion as well as by comment --
|
|
149
|
+
// test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
|
|
150
|
+
//
|
|
151
|
+
// `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
|
|
152
|
+
// #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
|
|
153
|
+
// the model being authorised", and `null` means "addressed to no record".
|
|
154
|
+
// The id sitting on the request in hand names the PRIMARY record, which
|
|
155
|
+
// belongs to a DIFFERENT model -- `GET /owners/gina` carries
|
|
156
|
+
// `params.id === 'gina'`, and the ask being made HERE is about `animal` or
|
|
157
|
+
// `trait`. Filling this in from the request would hand the related model's
|
|
158
|
+
// predicate an id belonging to another model, which is byte-for-byte the
|
|
159
|
+
// cross-model confusion abofs/stonyx-orm#202 introduced this context to
|
|
160
|
+
// eliminate: the predicate would compare an owner's id against its own
|
|
161
|
+
// records and answer a question nobody asked. There is no record of THIS
|
|
162
|
+
// model addressed by this request, so `null` is the honest value -- the
|
|
163
|
+
// same spelling `auth()` uses for a collection route.
|
|
164
|
+
//
|
|
165
|
+
// NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
|
|
166
|
+
// verdict is resolved ONCE PER TYPE and cached in `byType` below, before
|
|
167
|
+
// any record has been looked at; there is no per-record `AccessContext`
|
|
168
|
+
// built anywhere on this path. Seeding it from the first record of a type
|
|
169
|
+
// would let that record's identity answer for every later record of the
|
|
170
|
+
// same type -- the same "one record's verdict answers for another" defect
|
|
171
|
+
// the `decisions` raw-key argument below exists to prevent, just one level
|
|
172
|
+
// coarser. And it is unnecessary: `AccessContext` deliberately carries no
|
|
173
|
+
// `record` because auth-time and record-time are separate decision points,
|
|
174
|
+
// and the per-record point already receives the WHOLE record, id included,
|
|
175
|
+
// through `verdict.filter(record)`. A predicate that wants a record's id
|
|
176
|
+
// has the contract's own channel for it.
|
|
177
|
+
access = predicate(request, { model: type, operation: 'read', recordId: null });
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
return DENIED;
|
|
182
|
+
}
|
|
183
|
+
return interpretAccess(access, 'read');
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build a request-scoped linkage filter.
|
|
187
|
+
*
|
|
188
|
+
* TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
|
|
189
|
+
*
|
|
190
|
+
* - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
|
|
191
|
+
* which is arbitrary code with arbitrary cost and which the module has
|
|
192
|
+
* already had to guard for throwing.
|
|
193
|
+
* - one decision per `(type, id)`. `included` is deduplicated by
|
|
194
|
+
* `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
|
|
195
|
+
* per record. Measured on a bare `GET /animals` with no `include=`:
|
|
196
|
+
* 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
|
|
197
|
+
* a 6.9x reduction and 41 predicate calls saved.
|
|
198
|
+
*
|
|
199
|
+
* The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
|
|
200
|
+
* template-string composite. `Map` compares with SameValueZero, so the numeric
|
|
201
|
+
* id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
|
|
202
|
+
* or a bare `String(id)` -- collapses them onto one entry and answers the second
|
|
203
|
+
* record with the first record's verdict.
|
|
204
|
+
*
|
|
205
|
+
* WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
|
|
206
|
+
* already partitioned per type by `byType`, so a composite key inside a per-type
|
|
207
|
+
* map is one-to-one with the raw one and no owner's verdict could ever answer
|
|
208
|
+
* for an animal -- the claim that once stood here. The real exposure is narrower
|
|
209
|
+
* and entirely WITHIN one model: two records of the same type whose ids differ
|
|
210
|
+
* only by JavaScript type, which a per-record predicate may legitimately answer
|
|
211
|
+
* differently about (an id read off a JSON body is a string; the same id
|
|
212
|
+
* assigned by the server is a number). Pinned by unit assertion, because this
|
|
213
|
+
* fixture cannot produce the collision on its own -- `owner` ids are strings and
|
|
214
|
+
* `animal` ids are numbers.
|
|
215
|
+
*
|
|
216
|
+
* SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
|
|
217
|
+
* it -- a verdict cached across requests would answer a second caller with the
|
|
218
|
+
* first caller's authorization.
|
|
219
|
+
*
|
|
220
|
+
* A REQUEST IS REQUIRED, AND ITS ABSENCE IS CHECKED HERE RATHER THAN DELEGATED.
|
|
221
|
+
* This function is EXPORTED (src/index.ts), and the README's Consumer Contracts
|
|
222
|
+
* section points consumers at exactly the contexts that have no live request --
|
|
223
|
+
* a queue payload, a websocket frame, a custom route. Without one there is no
|
|
224
|
+
* caller to authorise against, and this file's header already says so: the
|
|
225
|
+
* shipped sample reads `request.path` and fail-closes when it is absent, so
|
|
226
|
+
* `getAccess('owner')(undefined, ...)` is `false`, while
|
|
227
|
+
* `getAccess('animal')(undefined, ...)` returns a per-record predicate and
|
|
228
|
+
* GRANTS. Measured on this repo's own fixture before this guard existed:
|
|
229
|
+
*
|
|
230
|
+
* createLinkageFilter(undefined | null | {} | 'x' | 0)
|
|
231
|
+
* -> owner=false animal=TRUE trait=TRUE category=TRUE phone-number=TRUE
|
|
232
|
+
*
|
|
233
|
+
* Four of five claimed models granted, with no log, because whether an absent
|
|
234
|
+
* request fails closed was left ENTIRELY to consumer predicates -- and a
|
|
235
|
+
* predicate that ignores its request cannot fail closed on one that is missing.
|
|
236
|
+
* A nullish or primitive `request` therefore denies every model outright and
|
|
237
|
+
* says so once, at construction, so the signal exists even for a caller that
|
|
238
|
+
* goes on to serialize nothing.
|
|
239
|
+
*
|
|
240
|
+
* WHAT THIS CANNOT CHECK: `{}` is an object and passes. There is no request
|
|
241
|
+
* contract this module owns -- `auth()` reads `.method`, the shipped sample
|
|
242
|
+
* reads `.path`, a consumer's reads whatever it likes -- so anything past
|
|
243
|
+
* "is it an object" would be this module inventing a shape for someone else's
|
|
244
|
+
* framework. The residual is documented in the README under Consumer Contracts.
|
|
245
|
+
*/
|
|
246
|
+
export function createLinkageFilter(request) {
|
|
247
|
+
if (typeof request !== 'object' || request === null) {
|
|
248
|
+
log.error?.(`[@stonyx/orm] createLinkageFilter() was called with no request (received ${request === null ? 'null' : typeof request}) -- there is no caller to authorise against, so ALL relationship linkage it is asked about is denied.`);
|
|
249
|
+
return function isLinkable(_type, _record) {
|
|
250
|
+
return false;
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const byType = new Map();
|
|
254
|
+
return function isLinkable(type, record) {
|
|
255
|
+
let entry = byType.get(type);
|
|
256
|
+
if (!entry) {
|
|
257
|
+
entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
|
|
258
|
+
byType.set(type, entry);
|
|
259
|
+
}
|
|
260
|
+
const { verdict, decisions } = entry;
|
|
261
|
+
if (!verdict.granted)
|
|
262
|
+
return false;
|
|
263
|
+
if (!verdict.filter)
|
|
264
|
+
return true;
|
|
265
|
+
const id = record?.id;
|
|
266
|
+
const cached = decisions.get(id);
|
|
267
|
+
if (cached !== undefined)
|
|
268
|
+
return cached;
|
|
269
|
+
let allowed;
|
|
270
|
+
try {
|
|
271
|
+
allowed = Boolean(verdict.filter(record));
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
// A predicate that throws is a denial -- the same reading `isDenied` uses
|
|
275
|
+
// one layer down. Logged, because a predicate that throws on every record
|
|
276
|
+
// empties every relationship and, silently, that is indistinguishable
|
|
277
|
+
// from a database with no relationships in it.
|
|
278
|
+
log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
+
allowed = false;
|
|
280
|
+
}
|
|
281
|
+
decisions.set(id, allowed);
|
|
282
|
+
return allowed;
|
|
283
|
+
};
|
|
284
|
+
}
|