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
11 changes: 11 additions & 0 deletions .changeset/22279-analytics-absent-security-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/service-analytics': patch
---

docs(service-analytics): the shipped API docs no longer say a deployment with no security service keeps analytics reads open

Clause-②: no

The doc comments on `AnalyticsServiceConfig.admitObjectRead` and `AnalyticsServiceConfig.getReadableFields`, and on the service's read gates, said that a missing hook means "the deployment has no security service" and that such a deployment keeps its analytics behaviour. Both parts were wrong. `AnalyticsServicePlugin` always wires both hooks. On `ObjectKernel` and `LiteKernel`, looking up a `security` service that was never registered throws, so the bridges refuse the query, fail-closed: `PERMISSION_DENIED` / 403 naming the object, plus an `error` line. The docs now say so. A missing hook now means only a host that constructs `AnalyticsService` itself without one.

Only comments change. No behaviour, export or type moves. The text changes in the published `dist/index.d.ts` and `dist/index.d.cts`, and in the JSDoc kept in `dist/index.js` and `dist/index.cjs`.
34 changes: 21 additions & 13 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,11 @@ export interface AnalyticsServiceConfig {
*
* MAY be async. `false` refuses the query with `PERMISSION_DENIED` / 403; a
* THROW also refuses (fail-closed). When the hook is absent entirely no
* object-level gate applies — the deployment has no security service, which
* is the same deployment in which `/data` has no object-level gate either.
* object-level gate applies — a host that constructs the service without
* one. That is not a deployment with no security service: the plugin always
* wires this hook, and on the in-repo kernels its bridge DENIES when no
* `security` service was ever registered, because the lookup throws
* (`plugin.ts`).
*/
admitObjectRead?: ObjectReadAdmissionProvider;
/**
Expand All @@ -861,9 +864,11 @@ export interface AnalyticsServiceConfig {
* `getReadableFields`, so the answer is the one the engine middleware
* enforces with. MAY be async. `undefined` for an object is "no answer" and
* judges none of its fields; a THROW refuses the query (fail-closed). When
* the hook is absent entirely no field-level gate applies — the deployment
* has no security service, which is the one in which `/data` has no
* field-level security either.
* the hook is absent entirely no field-level gate applies — a host that
* constructs the service without one. That is not a deployment with no
* security service: the plugin always wires this hook, and on the in-repo
* kernels its bridge THROWS when no `security` service was ever registered,
* so the query is refused (`plugin.ts`).
*/
getReadableFields?: ReadableFieldsProvider;
/**
Expand Down Expand Up @@ -1896,9 +1901,11 @@ export class AnalyticsService implements IAnalyticsService {
* carrying its own copy of the check, which is the arrangement that produced
* the divergence in the first place.
*
* A no-op when no provider is wired: that is a deployment with no security
* service, where `/data` has no object-level gate either, so the doors still
* agree. `AnalyticsServicePlugin` reports that state at init.
* A no-op when no provider is wired — a host that constructs this service
* without `admitObjectRead`. `AnalyticsServicePlugin` always wires one, so a
* deployment with no security service is NOT this case: on the in-repo
* kernels the bridge's lookup throws and the provider denies, fail-closed
* (`plugin.ts`, `admission-bridge-resolution.test.ts`).
*/
private async assertReadAdmitted(
objects: Iterable<string>,
Expand All @@ -1919,7 +1926,8 @@ export class AnalyticsService implements IAnalyticsService {
* here too, `PERMISSION_DENIED` / 403, ahead of the field verdicts on its
* object ({@link namedQueryFields}).
*
* A no-op when no provider is wired (no security service) or when the query
* A no-op when no provider is wired (a host that constructs this service
* without `getReadableFields`; the plugin always wires one) or when the query
* names no field.
*/
/**
Expand All @@ -1932,7 +1940,7 @@ export class AnalyticsService implements IAnalyticsService {
* tier.
*
* It is the tier-independent complement of {@link assertFieldsReadable}: that
* gate is a no-op with no security service and stands down on an object its
* gate is a no-op with no field reader wired and stands down on an object its
* reader answers `undefined` for, which is exactly where caller-supplied text
* that is not a column reference reached `NativeSQLStrategy`'s statement as
* written. See `field-read-admission.ts`.
Expand Down Expand Up @@ -2058,9 +2066,9 @@ export class AnalyticsService implements IAnalyticsService {
* DECLARED, so a dimension/measure whose `field` is a raw expression resolves to
* a declared cube member whose `sql` is that expression — and #21156's
* {@link assertCallerMembersResolvable} leaves a DECLARED expression member to
* the field-level gate (#20965), which stands down with no security service and
* on an object its reader answers `undefined` for. In those tiers the
* expression reached `NativeSQLStrategy`'s statement as written.
* the field-level gate (#20965), which stands down with no field reader
* wired and on an object its reader answers `undefined` for. In those tiers
* the expression reached `NativeSQLStrategy`'s statement as written.
*
* ## Every branch that supplies the dataset
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,10 @@ export async function assertNamedFieldsReadable(
* ## Why this is separate from {@link assertNamedFieldsReadable}
*
* That gate judges READABILITY, and it is a no-op in the two tiers where the
* caller-supplied kind of {@link NamedExpression} is dangerous: a deployment
* with no security service wires no reader, and an object the reader answers
* caller-supplied kind of {@link NamedExpression} is dangerous: a host that
* wires no reader (it constructs `AnalyticsService` without one — the plugin
* always wires one, and on a deployment with no security service its bridge
* throws, so the query is refused), and an object the reader answers
* `undefined` for is skipped (`continue`). In both, a member whose text is not
* a column reference reached the native statement as written — `NativeSQLStrategy`
* emits an unrecognised `sql` verbatim, into the grouping and filter positions.
Expand Down
29 changes: 20 additions & 9 deletions packages/services/service-analytics/src/read-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,32 @@
*
* ## Fail direction
*
* The provider is access-NARROWING, so it fails CLOSED — but "closed" is a
* claim about a WIRED provider, and it is worth saying which states are which:
* The provider is access-NARROWING, so it fails CLOSED, and it is worth saying
* which states are which:
*
* - a wired provider that THROWS, or answers `false`, denies (this module);
* - a `security` service that is wired but cannot be used — resolving it
* throws, or it exposes neither `canReadObject` nor `explain` — denies at
* the bridge, because `/data`'s middleware does not fall open in those
* states either (`plugin.ts`);
* - an ABSENT provider is a different state altogether. No security service
* answered at all, which is the same deployment in which `/data` has no
* object-level gate either, so the two doors still AGREE — and agreement is
* the property being defended, not refusal for its own sake. Such a
* deployment keeps its pre-existing analytics behaviour, and
* `AnalyticsServicePlugin` logs that state loudly at init, the same posture
* it already takes for a missing `getReadScope`.
* - [#22235] a deployment that registers NO security service denies at the
* bridge too, by declaration: the in-repo kernels (`ObjectKernel`,
* `LiteKernel`) throw on a `security` service nothing ever registered, so
* the bridge takes its UNUSABLE branch and refuses with a located error.
* `/data` carries no object-level gate on such a deployment, so there the
* two doors do NOT agree: a loud deny is preferred over a silent admit. A
* composition that wants analytics to answer registers a security service,
* or its host supplies its own `admitObjectRead`;
* - a context that answers the `security` lookup with NOTHING (ABSENT) is a
* different state, reached only by such a context: the package's test
* doubles do, and no in-repo kernel does. What the bridge does there is
* documented at the bridge (`plugin.ts`); it is not a statement about
* deployments;
* - a host that constructs `AnalyticsService` itself with no provider at all
* gets no object-level gate (`assertReadAdmitted` is a no-op). That is the
* host's composition, not a deployment with no security service:
* `AnalyticsServicePlugin` always wires a provider — the host's
* `admitObjectRead`, or the bridge.
*/

import type { ExecutionContext } from '@objectstack/spec/kernel';
Expand Down
12 changes: 7 additions & 5 deletions packages/services/service-analytics/src/read-scope-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ const READ_SCOPE_COMPILE_FAILED: RegisteredErrorCode = 'READ_SCOPE_COMPILE_FAILE
* `READ_SCOPE_COMPILE_FAILED` / 500.
*
* Use it where a wired row-scope provider could not be asked or could not
* answer, and the query is therefore refused fail-closed. ⛔ Not for a caller's
* mistake (that is `dataset-refusal.ts`) and ⛔ not for an ABSENT provider,
* which is a different state entirely: a deployment with no security service is
* one where `/data` has no row-level policy either, it is reported loudly at
* init, and it must keep running unscoped exactly as before.
* answer, and the query is therefore refused fail-closed — which, on the
* in-repo kernels, includes a deployment that registers no security service:
* their lookup throws on the never-registered name, so the bridge is UNUSABLE
* (`plugin.ts`). ⛔ Not for a caller's mistake (that is `dataset-refusal.ts`)
* and ⛔ not for an ABSENT provider — no row-scope provider at all, or a
* context that answers the `security` lookup with nothing — for which the
* row-scope stage applies no scope, as it always has.
*
* The message stays whatever the refusing site says — it is for the operator's
* log, which after this change is its only destination.
Expand Down
Loading