diff --git a/content/docs/releases/v17/17-5.mdx b/content/docs/releases/v17/17-5.mdx index 3d014c07c49..22ae831ccde 100644 --- a/content/docs/releases/v17/17-5.mdx +++ b/content/docs/releases/v17/17-5.mdx @@ -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']` diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 329009209fd..68bb70cbe6a 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -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/` 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/` 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. diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index eefb4a159d3..bc0987d760b 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -580,8 +580,10 @@ **What is not affected.** A query that reads only fields the caller may read answers as before. A system context, and a caller with no permission sets, are unaffected, as on the data API. A host read scope (row-level policy) may still - name fields the caller cannot read. A deployment with no security service applies - no field-level check, as on the data API. A member of an authored cube whose `sql` + name fields the caller cannot read. A deployment with no security service is + unaffected too: the in-repo kernels throw on a `security` service nothing ever + registered, so its analytics queries were already refused, fail-closed, at the + object-level read gate, and still are. A member of an authored cube whose `sql` is an expression is not attributed to a field. **New hook.** `AnalyticsServiceConfig.getReadableFields(object, context)` supplies @@ -592,6 +594,8 @@ **If a widget stopped answering for some users,** it reads a field those users may not read. Grant that field's read permission to the users who need it, or build the widget on fields they can read. + + *Erratum, 2026-10-08 — this entry said "A deployment with no security service applies no field-level check, as on the data API." The sentence was false when published: in the published 17.6.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, and the analytics bridges answer that throw by refusing the query, fail-closed. One passage above is corrected in place; everything else this entry published is unchanged. (Corrected after publication, #22279.)* - 5dbeb7d: fix(service-analytics): on the engine-aggregate path, a `$not`, `$notContains` or null test over a multi-valued lookup now gets the engine's rows instead of `400 INVALID_FILTER` (#20918) Clause-②: yes @@ -643,8 +647,9 @@ **What is not affected.** A query through a related object the caller may read answers as before, within the caller's row scope. A system context, and a caller with no permission sets, are unaffected, as on the data API. A - deployment with no security service applies no object-level check, as on the - data API. + deployment with no security service is unaffected too: the in-repo kernels + throw on a `security` service nothing ever registered, so the object-level + gate already refused its analytics queries, fail-closed, and still does. **Refusals that change form.** On the ObjectQL strategy a related object the caller may not read was already refused on `POST /api/v1/analytics/query` and @@ -660,6 +665,8 @@ **If a widget stopped answering for some users,** it reads a related object those users may not read. Grant read access on that object to the users who need it, or build the widget on objects they can read. + + *Erratum, 2026-10-08 — this entry said "A deployment with no security service applies no object-level check, as on the data API." The sentence was false when published: in the published 17.6.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, and the analytics bridges answer that throw by refusing the query, fail-closed. One passage above is corrected in place; everything else this entry published is unchanged. (Corrected after publication, #22279.)* - 83480c6: fix(service-analytics)!: a field the caller is served masked is refused as a group key, an aggregate input, a filter or a sort key on every analytics face, whichever strategy serves the cube (#20935) Clause-②: yes (narrowing) @@ -818,9 +825,11 @@ **BREAKING**: this narrows what the analytics dataset door accepts. A dataset whose dimension or measure `field` is not a column reference is now refused with `403 PERMISSION_DENIED` instead of being evaluated — an inline dataset and a saved dataset queried by name alike, since both reach the same door. No shipped dataset carries a non-column `field`. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. - **What changes.** The service compiles a dataset into a cube whose members read as declared, so a dimension or measure whose `field` was a raw expression resolved to a declared cube member and was left to the field-level read gate, which stands down with no security service and on an object its reader answers `undefined` for; in those tiers the expression reached the native statement as written. The dataset's own `field` text is now judged at the dataset door, before compile and before any strategy runs, through the field-read gate's existing judge (`PERMISSION_DENIED` / 403, naming the member and never the expression text), for every caller, admin included, and with or without a security service. There is no new error code and no new admission module. + **What changes.** The service compiles a dataset into a cube whose members read as declared, so a dimension or measure whose `field` was a raw expression resolved to a declared cube member and was left to the field-level read gate, which stands down where no field reader is wired (a host that constructs `AnalyticsService` without one) and on an object its reader answers `undefined` for; in those tiers the expression reached the native statement as written. (A deployment with no security service is not such a tier: the in-repo kernels throw on a `security` service nothing ever registered, so its analytics queries are refused, fail-closed.) The dataset's own `field` text is now judged at the dataset door, before compile and before any strategy runs, through the field-read gate's existing judge (`PERMISSION_DENIED` / 403, naming the member and never the expression text), for every caller, admin included, and with or without a security service. There is no new error code and no new admission module. **What stays answerable.** Every dataset whose fields are columns or relationship paths is unchanged, inline or saved. A saved dataset whose `field` is an expression is refused the same way as an inline one; refusing such a `field` when it is authored belongs to the dataset schema's own retirement of expression fields, not to this door. The dataset's own filter, the selection's runtime filter and cube-query members are lowered into the compiled query and already judged on the query path, so they are unchanged. + + *Erratum, 2026-10-08 — this entry said the field-level read gate "stands down with no security service". The sentence was false when published: in the published 17.6.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, and the analytics bridges answer that throw by refusing the query, fail-closed. One passage above is corrected in place; everything else this entry published is unchanged. (Corrected after publication, #22279.)* - 4727fcb: fix(service-analytics)!: a grouped dimension or a `count_distinct` measure over a JSON-stored column reached through a relationship path the cube declares no join for is refused with `INVALID_FIELD` / 400 at the analytics door, as the same member over a declared join already was Clause-②: no (narrowing) @@ -1208,16 +1217,20 @@ **What is not affected.** A member that is a column reference is judged by the field it resolves to, as before. A `count` over `'*'` names no field and is - served. A deployment with no security service, and an object the security - service gives no field answer for, apply no field-level check, as before. The - spec's parse already refuses an expression member, so a cube that parses is - unaffected. + served. A host that wires no field reader, and an object the security service + gives no field answer for, apply no field-level check, as before. A deployment + with no security service is refused outright: the in-repo kernels throw on a + `security` service nothing ever registered, so its analytics queries are + refused, fail-closed. The spec's parse already refuses an expression member, so + a cube that parses is unaffected. **If a widget stopped answering,** its cube carries an expression member from before the parse refused one. Re-author the member as a column reference, or declare the derived value on an ADR-0021 dataset: a conditional count or sum is a dataset measure with its own `filter`, and a ratio of measures is `derived: { op: 'ratio', of: [...] }`. + + *Erratum, 2026-10-08 — this entry said "A deployment with no security service, and an object the security service gives no field answer for, apply no field-level check, as before." The sentence was false when published: in the published 17.6.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, and the analytics bridges answer that throw by refusing the query, fail-closed. One passage above is corrected in place; its half about an object the security service gives no field answer for stands, and everything else this entry published is unchanged. (Corrected after publication, #22279.)* - 097ef80: fix: the analytics native-SQL path aggregates with the engine's own aggregate policies, so one query answers one number whichever strategy serves it: `sum` / `avg` accumulate in double, a PostgreSQL boolean aggregand is cast, and an all-NULL `sum` answers `0`. The operand policies move from `@objectstack/driver-sql` to `@objectstack/core` (#21042) Clause-②: yes (widening) @@ -1916,24 +1929,27 @@ 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/` 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/` 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.)* - 5d12b16: fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) - `AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: + `AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT (the context answers the lookup with nothing; no in-repo kernel does) admits, THROWING (on the in-repo kernels that includes a `security` service nothing ever registered) and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: ```ts const trySecurity = () => { @@ -1949,14 +1965,16 @@ **What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: - - **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. + - **ABSENT** — the context answers the `security` lookup with nothing: **unchanged**, no row-scope provider. No in-repo kernel reaches this row: `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, so a kernel that ships no `plugin-security` takes the THROWING row below and is refused; the object-level gate of the same release refuses its query first. - **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. - **This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. + **This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service changes behaviour by so much as a byte. A deployment with none counts as broken here on the in-repo kernels, whose lookup throws on a never-registered name, so it is refused too — though the object-level gate of the same release refuses its queries first. Nothing that was refused becomes admitted. **No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. ⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. + + *Erratum, 2026-10-08 — this entry described an ABSENT `security` resolution as "a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, …)" that stays **unchanged** and is "Deliberately not tightened", and said "no deployment with none, changes behaviour by so much as a byte". 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 such a kernel took the THROWING row and was refused. Three passages above are corrected in place; everything else this entry published is unchanged. (Corrected after publication, #22279.)* - 634f23d: fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) @@ -2770,7 +2788,9 @@ - the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; - its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. - Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. + Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`. A deployment with NO security service does not run unscoped either: the in-repo kernels throw on its `security` lookup, so its queries are refused, fail-closed, as they already were. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. + + *Erratum, 2026-10-08 — this entry said "a deployment with NO security service still runs unscoped exactly as before." The sentence was false when published: in the published 17.5.0 packages, `ObjectKernel` and `LiteKernel` throw on a `security` service nothing ever registered, and the analytics bridges answer that throw by refusing the query, fail-closed. One passage above is corrected in place; everything else this entry published is unchanged. (Corrected after publication, #22279.)* - f3b28eb: Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index ef61443c743..0f884a636da 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -13364,21 +13364,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/` 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/` 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.)* - b8ec127: `defineStack`: a package of a multi-package release artifact can now grant permissions on, and seed data into, an object one of its SIBLING packages owns. **FROM** — every `permissions[].objects` key and every `data[].object` had to name an object the same stack declares. In an ADR-0130 artifact this made two accepted records contradict each other: the 2026-09-02 addendum keeps every permission set whole in the `type: app` package, so as soon as that package also owns objects of its own, its sets were refused for granting on its modules' objects (`Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects.`). The only escapes were `strict: false` for the whole package or splitting the sets per package, which contradicts the addendum. diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index cfdde042f8d..ee85359dc62 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -561,21 +561,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/` 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/` 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.)* - 611795e: verify: let `bootStack` be told which package `multiTenant: true` resolves, so the `declared-unresolvable` control can name a subject the workspace can never supply