Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/20156-alternate-door-read-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/rest": patch
---

**The read doors beside `GET /api/v1/meta/:type/:name` now apply the plain read's per-caller gates to docs, books and object schemas.** The plain read withholds a document per caller in several ways. On `doc` and `book` it applies the documentation audience: a `{ permissionSet }`-gated doc or book is `403 PERMISSION_DENIED` to a non-holder and `401 UNAUTHENTICATED` to an anonymous caller. On `app` it applies the navigation filter: an app whose `requiredPermissions` the caller lacks is `403`, an unpublished app is `404` to a non-builder, and gated entries are left out. On `object` schemas it applies the field mask. It also applies the optional-service widget gate on `dashboard`. The doors beside it served the same stored document with none of those gates:

- `GET …/:name/layers`, and the deprecated `GET …/:name?layers=true`, served every layer. This exposed a gated doc's or book's body to any signed-in member. Through `?layers=true`, which sits on the route anonymous callers may reach for public docs, it also exposed any doc or book to a caller who was not signed in.
- `GET …/:name/published` served a gated doc's or book's body, a gated or unpublished app whole, a dashboard's widgets bound to an optional service this deployment lacks, and an object schema's unreadable fields.
- `GET …/:name/diff` served both compared versions' values, including a doc's content, an app's navigation and an object's fields.
- `GET …/:name/history` and `GET …/:name/audit` served the change log and audit trail of a doc, book or app the caller may not open.

What each door answers now:

- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read.
- `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits.
- **One case is unchanged in this release: an app the plain read would serve a caller only in part.** That is an app the caller may open, but with navigation entries withheld from them by `requiredPermissions` or the documentation audience. On `/layers`, `?layers=true` and `/diff`, such a caller still receives the stored app with those entries included, as before. No other caller does: an app the plain read refuses whole is refused on these doors too. What these doors should answer a partial caller is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included.
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body.

Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except the `app` row of `/layers`, `?layers=true` and `/diff` for a caller the plain read serves only part of the app. To read a gated doc or book through any of these doors, hold the permission set its book names.
14 changes: 14 additions & 0 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,20 @@ enforced identically in both trees: a gated entry — with its `objectName` /
collapses; an area *authored* empty is passed through untouched, because
filtering reports what the caller may not see rather than tidying the metadata.

The single-app reads — `GET /api/v1/meta/app/<name>` and its `/published`
twin — serve that filtered body, and every door refuses an app the caller may
not open: one whose app-level `requiredPermissions` they lack (`403`), or an
unpublished app to a non-builder (`404`). The doors that serve an app's
*stored* versions for authoring — the layered view (`/layers`, and the
deprecated `?layers=true`) and `/diff` — do **not** yet prune the entries of an
app the caller may open: a caller from whom `requiredPermissions` or the docs
audience withholds some entries still receives the stored app there, those
entries included. What these doors should answer that caller is pending a
decision. Pruning a stored app the designer saves back would delete the
withheld entries, and refusing it would lock out any author who lacks one
entry's permission, platform admins included. Until that is decided, such an
entry is absent from the app's served body but not from these authoring views.

`visible` did **not** move server-side with them, and that asymmetry is
deliberate: CEL is evaluated in the browser because server-side evaluation needs
a bound `user` context the read layer does not have. So `visible` is a
Expand Down
33 changes: 23 additions & 10 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,20 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('69 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('70 invocation sites, 93 mentions — the thread\'s two control numbers hold', () => {
// [#20156] 69 → 70 sites / 92 → 93 mentions — one net site, and it is
// a MOVE plus one. The plain read's app and dashboard gates each
// resolved the context inline (two same-line CAUGHT sites); both moved
// into `metaItemReadGate`, the one per-caller read gate the plain read
// and every door beside it now ask, where they stay same-line CAUGHT.
// The one new site is `fetchCurrentMetaDocument`, which the event doors
// and `/diff` use to judge the item the plain read would judge — the
// plain read's org partition, so it resolves the context too. Same-line
// CAUGHT, like the two it sits beside: none of these doors is behind the
// shared anonymous floor's `enforceAuth` line, and they already carried
// `rethrowAuthzStoreUnavailable`. No prose mention was added, so the two
// numbers move together (+1 / +1).
//
// [#20102] 77 → 69 sites / 100 → 92 mentions. The saved-report
// `/reports` family was retired whole with the saved-report stack, and
// its eight routes each carried ONE bare site (`resolveExecCtx` then
Expand Down Expand Up @@ -410,26 +423,26 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// naming the seam is the point of the sentence — and the sentence
// moving only the mention count is this control working: a site was not
// added, and the number that tracks sites did not move.
expect(SITES.length).toBe(69);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(70);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(93);
});

it('the split is 24 locally caught / 45 bare — NOT 16 / 45, which does not add to 69', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
it('the split is 25 locally caught / 45 bare — NOT 17 / 45, which does not add to 70', () => {
// 17 sites spell the catch on the invocation line; 8 more spell it on
// the continuation line. A single-line grep sees 17 and the arithmetic
// silently loses eight sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(24);
expect(sameLine.length).toBe(17);
expect(CAUGHT.length).toBe(25);
expect(BARE.length).toBe(45);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 24 caught ones is', () => {
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 25 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading