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
14 changes: 10 additions & 4 deletions content/docs/releases/v17/17-5.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -480,10 +480,16 @@ rewrites such a cell for you.
the driver's raw `execute()` behind the row-scope layer only. The service now
asks the new optional `ISecurityService.canReadObject(object, context)` for
the base object and every joined object before choosing a strategy. A
deployment with no `security` service keeps its old behaviour and warns at
init; a registered one that cannot be used denies. **Migration:** a principal
now refused needs object-level read on that object — the grant `/data`
already requires.
registered `security` service that cannot be used denies, and so does a
deployment with no `security` service: the kernel's lookup throws, so the
query is refused, fail-closed, although `/data` has no object-level gate
there. **Migration:** a principal now refused needs object-level read on that
object — the grant `/data` already requires; a deployment with no `security`
service registers one, or its host supplies its own `admitObjectRead`.
*Erratum, 2026-10-08 — this bullet said "A deployment with no `security`
service keeps its old behaviour and warns at init". In the published 17.5.0
packages the kernel's lookup throws on a `security` service nothing ever
registered, so analytics refused the query, fail-closed (#22279).*
- **The saved-report stack is removed** (`8d1f7ab`, #20125). Its eight routes
answer the standard unmatched-route `404`; nine `REPORTS_*` / `SCHEDULE_*`
error codes leave the ledger; `defineStack` refuses `requires: ['reports']`
Expand Down
11 changes: 7 additions & 4 deletions packages/plugins/plugin-security/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1665,21 +1665,24 @@
lockstep convention: during the window the bump level is not the carrier, this
banner and the disposition above are). Nothing that was already admitted
becomes refused **except** the requests `GET /data/<object>` refuses today for
the same principal, which is the defect. Nothing that was refused becomes
admitted.
the same principal, which is the defect, and every analytics read on a
deployment that registers no `security` service (below). Nothing that was
refused becomes admitted.

`POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/<object>` reach one admission verdict on every driver.

The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one.

**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended.
**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. A deployment with no `security` service registered is refused too, fail-closed: `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, so the bridge below gets no verdict and denies, naming the object. `/data` carries no object-level gate on that deployment, so there analytics refuses reads that `/data` serves. A composition that wants analytics to answer registers a security service, or its host supplies its own `admitObjectRead`.

- **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional.
- **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service.
- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states.
- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: a context that answers the `security` lookup with nothing (ABSENT) admits, and no in-repo kernel answers that way; a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`. Resolving it throws on `ObjectKernel` and `LiteKernel` when no `security` service was ever registered, so a deployment that ships no `plugin-security` (or any other security service) is refused here, although `/data` carries no object-level gate on it; for a wired service that cannot be used, `/data`'s middleware does not fall open either.
- **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree.

The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named.

*Erratum, 2026-10-08 — this entry said that a deployment with no `security` service registered "keeps its previous analytics behaviour by design", that "an ABSENT `security` service admits", and that this "keeps a deployment shipping no `plugin-security` working as before". The sentences were false when published: in the published 17.5.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, so the bridge took its cannot-be-USED branch and refused the query, fail-closed. ABSENT is reached only by a context that answers the lookup with nothing, and no in-repo kernel does. Two passages above are corrected in place and the BREAKING banner now names that deployment among the requests that become refused; everything else this entry published is unchanged. (Corrected after publication, #22279.)*
- 2266438: Re-run the seed-ownership claim when the seed settles, and report whether each pass was final.

`claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised.
Expand Down
Loading
Loading