Skip to content

Commit 5870716

Browse files
fix(service-analytics): declare the measured deny on a kernel with no security service, pin it on both in-repo kernels, give the reconcile runner an explicit security double (#22276)
Fixes #22235 Clause-②: no Executes the maintainer's ruling B (`6056824332`): deny, as measured, becomes the declaration. On a kernel that registers no `security` service, the analytics read bridges deny, fail-closed, as they already did. The comments and the test header now say so, a pin holds it on both in-repo kernels, and the one in-repo producer registers an explicit security double. No line that decides an answer moves, on any context. ## What changed - `packages/services/service-analytics/src/plugin.ts`: **comments only.** The resolution blocks of the row-scope bridge (near `:556`), the object-level admission bridge (near `:700`) and the field-level and query-side bridges now give the measured classification. ABSENT is reached only by a context that answers a miss with nothing: the package's test doubles do, and no in-repo kernel does. `ObjectKernel` and `LiteKernel` throw on a never-registered name, so the bridges take UNUSABLE and refuse with a located `error`. The same change is made in the two `catch` comments, the two ABSENT-branch comments, the per-query report comment (the block PR #22234 last changed) and the two init-time comments. Proof that only comments moved: a comment-stripped transpile (`ts.transpileModule`, `removeComments`) of the base file and of this head is byte-identical (sha256 prefix `c1a4301c498f8087` both sides, 30086 bytes). - `packages/services/service-analytics/src/__tests__/admission-bridge-resolution.test.ts`: - The header is rewritten to the ruling. The sentence that called a deny on absence wrong is gone. - The ABSENT case keeps its assertions. Its title and inline comment now describe the context it actually models, a lookup that answers nothing. - A new pin covers both kernels (`describe.each` over `ObjectKernel` and `LiteKernel`), each booted with no `security` service ever registered. It asserts that the kernel's own lookup throws, and that the dataset query is refused with `code: PERMISSION_DENIED`, `status: 403` and `object: employer_member`. It also asserts that nothing was read, and that exactly one `error` line from the object-level bridge names the object and the failed `security` lookup. Finally, it asserts that the ABSENT branch's WARN did not fire. - `scripts/analytics-reconcile/boot.ts` registers an explicit, OPEN `security` test double as a named plugin. It answers `canReadObject` true, `getReadFilter` undefined (no row restriction) and `getReadableFields` undefined (no answer). The runner compares numbers, not access: its legacy `engine.aggregate()` path runs with no caller context on an engine with no security middleware, so the dataset path gets the same posture, declared by name. ## Hypotheses, measured on `origin/main` `3513ac77` - **H1, confirmed.** The ABSENT rows at `plugin.ts:556` and `:691` stated "admits" and called a kernel shipping no plugin-security a legitimate configuration. The same claim repeated at the two `catch` comments, the two ABSENT-branch comments, the report block, the field-level and query-side rows, and both init-time comments. All are rewritten, comments only. - **H2, done.** The header is rewritten and the pin is added. The pin's ablation is below. - **H3, confirmed, with a refinement.** Driven through `boot.ts`'s own `reconcileApp()` with one dual-form widget (`todo_task` by `status`, `task_metrics` / `task_count`), the composition is denied before this change. The dataset query is refused with `Access denied` and 0 rows, and the bridge logs the UNUSABLE `error`. After the change, the dataset query answers 4 groups whose counts equal the legacy path's (2, 1, 4, 1). Refinement: on `3513ac77` none of the three shipping runners reaches `queryDataset`. `app-todo.ts` boots and skips all 13 widgets and reports as not dual-form, and it exits 0 before and after this change. `app-crm.ts` and `app-showcase.ts` fail `MODULE_NOT_FOUND` on example files that no longer exist (noted below). - **H4, listed, not edited.** The release-owned sentences are listed below for the docs card. - **H5** is raised as an open question in the seat report, not built. ## Pin and ablation (head `183709e70c`) The pin's subject is imported from source (`../plugin.js`), so no `dist` leg applies. - Green: `vitest run` over the two admission files gives 2 files and 13 tests passed. The new pin is 2 cases. - Ablation A1 makes the object-level bridge's `catch` classify a throw as ABSENT. It went through `scripts/ablation-replace.mjs`: anchor x1 to x0, replacement x0 to x1, blob `ccecf120ec1d` to `1d21d50d6cea`, with an outer `trap` restore. - Result: 4 failed, 9 passed. Both new pins (`ObjectKernel`, `LiteKernel`) are red, together with the double-based throw case here and `admission-absence-report.test.ts`'s real-kernel case. - Observed direction: red. The object gate admitted, and the row-scope bridge then refused the same query with `READ_SCOPE_COMPILE_FAILED` / 500, so the declared `PERMISSION_DENIED` / 403 envelope naming the object was lost. - Restore: blob `ccecf120ec1d` equals `HEAD:plugin.ts`, and `git diff HEAD` is empty. ## Local verification (head `183709e70c`) - `@objectstack/service-analytics`: - The build exits 0. - `typecheck` (`tsc --noEmit`) exits 0. The program includes the test file: `--listFiles` lists `admission-bridge-resolution.test.ts`. - `vitest run --maxWorkers=2`: Test Files 180 passed (180). Tests 4443 passed, 262 skipped (4705). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 66 families for this change set. 65 exit 0. `pnpm check:dual-build-cjs-loads` exits 3, `PREREQUISITE NOT MET`, because 37 workspace packages have no `dist/` in this worktree. NOT MEASURED. A targeted substitute reading, a `require()` of this package's `dist/index.cjs`, loads. `dispatch-gates --ran`: 66 derived, 65 run, 1 NOT-MEASURED, 0 UNRUN. - `check:type-check-debt`: 26 raw tsc errors, none above the recorded number. `boot.ts` carries the same 8 `TS2307` it carried before this change, and no other diagnostic. - Lint, narrowed: `eslint --no-inline-config --format json` over the 3 touched files reports 3 files, 0 errors and 0 warnings. The config lints every TS/JS file outside its never-linted list, so all 3 are in scope. It never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on a file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Changeset: none, `skip-changeset` Nothing this package publishes changes behaviour. Its `files` are `dist`, `README.md` and `CHANGELOG.md`. After a rebuild, three of the new comment phrases occur in 0 files under `dist/`, while the positive control (a runtime string from the same file) occurs in 2. The test file and `scripts/` are not published. ## Released sentences that state "absent admits": for the docs card, not edited here `packages/services/service-analytics/CHANGELOG.md`: - `:583-584`: "A deployment with no security service applies no field-level check, as on the data API." - `:645-647`: "A deployment with no security service applies no object-level check, as on the data API." - `:1211-1213`: "A deployment with no security service, and an object the security service gives no field answer for, apply no field-level check, as before." - `:1926`: "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." - `:1930`: "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)" - `:1936`: "The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`." - `:1952`: "**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` ...) ... ⛔ Deliberately not tightened: refusing here would break every such deployment." - `:2773`: "a deployment with NO security service still runs unscoped exactly as before." The same lockstep entry repeats, with the `:1926` and `:1930` sentences, in: - `packages/plugins/plugin-security/CHANGELOG.md:1675` and `:1679` - `packages/spec/CHANGELOG.md:13374` and `:13378` - `packages/verify/CHANGELOG.md:571` and `:575` `content/docs/releases/v17/17-5.mdx:482-484`: "A deployment with no `security` service keeps its old behaviour and warns at init; a registered one that cannot be used denies." ## Acceptance notes - The same declaration repeats outside this card's file surface, in `service-analytics`: - the `read-admission.ts` module header (`:59-65`); - the `admitObjectRead` and `getReadableFields` docs on `AnalyticsServiceConfig` (`analytics-service.ts` near `:842` and `:862`); - the `assertReadAdmitted` doc (near `:1897`); - `field-read-admission.ts:339-341`. Each equates "no provider wired" with "a deployment with no security service". Through the plugin a provider is always wired. Reported to the seat with the docs card's list, not edited here. - On a kernel with no security service, the UNUSABLE `error` line reads "A security service is wired on this deployment". The `assertObjectsReadable` denial line that follows reads "the same verdict GET /data/OBJECT reaches". Neither holds on that deployment (measured through `boot.ts` before this change). These are runtime strings, outside this card's comments-only fence, and are reported to the seat. - `scripts/analytics-reconcile/app-crm.ts` and `app-showcase.ts` import example files that no longer exist (`MODULE_NOT_FOUND`), and `app-todo.ts` has no dual-form widget. So no shipping runner exercises the dataset path. Driving one synthetic dual-form widget showed that the reconcile tool keys a select dimension by stored value on the legacy path and by option label on the dataset path. Counts agree, keys differ. Noted, not filed: no runner reaches it. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81bd9fa commit 5870716

3 files changed

Lines changed: 240 additions & 97 deletions

File tree

‎packages/services/service-analytics/src/__tests__/admission-bridge-resolution.test.ts‎

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,45 @@
88
* bridge brings back, and the bridge has three outcomes that are easy to
99
* collapse into one:
1010
*
11-
* - the `security` service is ABSENT — this deployment has no object-level
12-
* gate anywhere, `GET /data/<object>` included, because that gate IS the
13-
* absent middleware. The two doors agree, which is the equivalence property
14-
* the card asks for, so the query is ADMITTED and the state is reported
15-
* once, by the first query that finds it (`admission-absence-report.test.ts`
16-
* pins when, and at what level);
17-
* - resolving the service THREW — a security service exists on this
18-
* deployment and could not be reached;
19-
* - the service resolved but exposes NEITHER `canReadObject` NOR `explain` —
20-
* it exists and cannot answer.
11+
* - the lookup answers NOTHING (ABSENT) — the context returns no service for
12+
* `security`. Only a context that answers a miss with nothing reaches this
13+
* branch: this file's double does, and no in-repo kernel does. The bridge
14+
* ADMITS there and reports it once, by the first query that finds it
15+
* (`admission-absence-report.test.ts` pins when, and at what level);
16+
* - resolving the service THREW (UNUSABLE), whatever the cause: a security
17+
* service that is wired and could not be reached, or — on the in-repo
18+
* kernels, `ObjectKernel` and `LiteKernel` — a `security` service nothing
19+
* ever registered, because their synchronous `getService` throws on a miss;
20+
* - the service resolved but exposes NEITHER `canReadObject` NOR `explain`
21+
* (UNUSABLE) — it exists and cannot answer.
2122
*
22-
* The last two are wired-but-broken providers. `/data`'s middleware does not
23-
* fall open in either state, so admitting here would reopen exactly the
24-
* divergence between the two doors that this gate closes — and would do it
25-
* silently, which is worse than the original defect: the original at least had
26-
* a shape a reader could find in the code. Both DENY, and both say why at
27-
* `error`.
23+
* Both UNUSABLE corners DENY, fail-closed, before anything is read, and both say
24+
* why at `error`. For a wired-but-broken provider, `/data`'s middleware does not
25+
* fall open either, so admitting here would reopen exactly the divergence
26+
* between the two doors that this gate closes — and would do it silently.
2827
*
29-
* ⛔ The absent case is not a bug to be tightened away. It is the negative
30-
* control that keeps the two deny cases honest: a bridge that denied on absence
31-
* too would refuse every analytics query on every deployment that ships no
32-
* `plugin-security`, which is a strictly different (and wrong) answer from the
33-
* one `/data` gives on that same deployment.
28+
* ## The declaration: no security service registered ⇒ analytics DENIES
29+
*
30+
* [#22235] A deployment that registers no security service gets its analytics
31+
* read queries REFUSED, fail-closed: the kernel's lookup throws, the bridge
32+
* takes UNUSABLE, the caller gets `PERMISSION_DENIED` / 403 naming the object,
33+
* and the operator gets an `error` line naming the object and the failed
34+
* lookup. That is the declared answer, not an accident of how the lookup fails,
35+
* and it holds even though `/data` carries no object-level gate on such a
36+
* deployment: a loud deny is preferred over a silent admit. A composition that
37+
* wants analytics to answer registers a security service, or its host supplies
38+
* its own `admitObjectRead`. The last block of this file pins it on both
39+
* in-repo kernels.
40+
*
41+
* ⛔ So the ABSENT case below is not a statement about deployments. It pins
42+
* what the bridge does for a context that answers a miss with nothing, and it
43+
* stays the negative control for the two UNUSABLE cases: same double, same
44+
* probe, only the lookup's answer differs, so a bridge that refused everything
45+
* could not pass this file.
3446
*/
3547

3648
import { describe, it, expect, vi } from 'vitest';
49+
import { LiteKernel, ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
3750
import { DatasetSchema } from '@objectstack/spec/ui';
3851
import type { ExecutionContext } from '@objectstack/spec/kernel';
3952
import { AnalyticsServicePlugin } from '../plugin.js';
@@ -171,13 +184,13 @@ describe('analytics admission bridge — resolving the "security" service', () =
171184
);
172185
});
173186

174-
// ── The negative control: absence is a different state and still ADMITS ────
187+
// ── The negative control: a lookup that answers NOTHING is a different state ─
175188

176-
it('ADMITS when NO "security" service is registered at all', async () => {
177-
// ⛔ Not a corner to tighten. On this deployment `/data` has no
178-
// object-level gate either, so the two doors still agree — which is the
179-
// property being defended. Tightening this to a denial would refuse every
180-
// analytics query on every deployment shipping no `plugin-security`.
189+
it('ADMITS when the context answers the "security" lookup with nothing (ABSENT)', async () => {
190+
// The ABSENT branch: this double answers the lookup with nothing, which no
191+
// in-repo kernel does — they throw on a miss (the last block of this file).
192+
// Kept as the negative control for the two deny cases above; whether this
193+
// branch should deny too is a separate question, not settled here.
181194
const { service, reads } = await bootAnalytics(undefined);
182195

183196
const result = await runProbe(service);
@@ -224,3 +237,62 @@ describe('analytics admission bridge — resolving the "security" service', () =
224237
expect(refused.reads).toEqual([]);
225238
});
226239
});
240+
241+
// ── The declaration, on the in-repo kernels: no security service ⇒ DENY ──────
242+
243+
/**
244+
* Boots a REAL kernel with a `data` provider and this plugin, and NO security
245+
* service, ever: nothing registers one at init or at start. The first plugin
246+
* taps the kernel's shared logger so the bridge's own lines are seen.
247+
*/
248+
async function bootKernelWithoutSecurity(kernel: ObjectKernel | LiteKernel) {
249+
const errors: string[] = [];
250+
const warns: string[] = [];
251+
const { engine, reads } = fakeEngine();
252+
const tap: Plugin = {
253+
name: 'test.log-tap',
254+
init: async (ctx: PluginContext) => {
255+
vi.spyOn(ctx.logger, 'error').mockImplementation((message: unknown) => { errors.push(String(message)); });
256+
vi.spyOn(ctx.logger, 'warn').mockImplementation((message: unknown) => { warns.push(String(message)); });
257+
},
258+
};
259+
const data: Plugin = {
260+
name: 'test.data',
261+
init: async (ctx: PluginContext) => { ctx.registerService('data', engine); },
262+
};
263+
await kernel.use(tap);
264+
await kernel.use(data);
265+
await kernel.use(new AnalyticsServicePlugin({ queryCapabilities: nativeSql }));
266+
await kernel.bootstrap();
267+
return { kernel, service: kernel.getService<AnalyticsService>('analytics'), reads, errors, warns };
268+
}
269+
270+
describe.each([
271+
['ObjectKernel', () => new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false })],
272+
['LiteKernel', () => new LiteKernel({ logger: { level: 'silent' } })],
273+
] as const)('analytics admission bridge — a real %s that never registers a "security" service', (_name, makeKernel) => {
274+
it('DENIES the dataset query fail-closed, before any read, with an error located on the object and the lookup', async () => {
275+
const { kernel, service, reads, errors, warns } = await bootKernelWithoutSecurity(makeKernel());
276+
277+
// The mechanism the declaration rests on: this kernel answers a lookup for
278+
// a never-registered name by THROWING, never with nothing.
279+
expect(() => kernel.getService('security')).toThrow(/'security'/);
280+
281+
// The caller's refusal: the declared envelope, naming the object.
282+
await expect(runProbe(service)).rejects.toMatchObject({
283+
code: 'PERMISSION_DENIED',
284+
status: 403,
285+
object: 'employer_member',
286+
});
287+
// Refused BEFORE the statement ran.
288+
expect(reads).toEqual([]);
289+
// The operator's line: exactly one, from the object-level bridge, naming
290+
// the object and the failed `security` lookup as the cause.
291+
const bridgeErrors = errors.filter((l) => l.includes('object-level read admission'));
292+
expect(bridgeErrors).toHaveLength(1);
293+
expect(bridgeErrors[0]).toContain('"employer_member"');
294+
expect(bridgeErrors[0]).toContain('resolving the "security" service threw');
295+
// Not the ABSENT branch: that one admits, and WARNs instead.
296+
expect(warns.filter((l) => l.includes('no "security" service registered'))).toEqual([]);
297+
});
298+
});

0 commit comments

Comments
 (0)