diff --git a/.changeset/22279-analytics-absent-security-comments.md b/.changeset/22279-analytics-absent-security-comments.md new file mode 100644 index 00000000000..380caf8e5bf --- /dev/null +++ b/.changeset/22279-analytics-absent-security-comments.md @@ -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`. diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 4388292f931..ef0103d0396 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -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; /** @@ -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; /** @@ -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, @@ -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. */ /** @@ -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`. @@ -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 * diff --git a/packages/services/service-analytics/src/field-read-admission.ts b/packages/services/service-analytics/src/field-read-admission.ts index f550d504596..0cf6be5fa3f 100644 --- a/packages/services/service-analytics/src/field-read-admission.ts +++ b/packages/services/service-analytics/src/field-read-admission.ts @@ -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. diff --git a/packages/services/service-analytics/src/read-admission.ts b/packages/services/service-analytics/src/read-admission.ts index bf385d5a11e..40f890515e9 100644 --- a/packages/services/service-analytics/src/read-admission.ts +++ b/packages/services/service-analytics/src/read-admission.ts @@ -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'; diff --git a/packages/services/service-analytics/src/read-scope-refusal.ts b/packages/services/service-analytics/src/read-scope-refusal.ts index 1571f60c1b9..fa8288ba58a 100644 --- a/packages/services/service-analytics/src/read-scope-refusal.ts +++ b/packages/services/service-analytics/src/read-scope-refusal.ts @@ -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.