Skip to content

Commit 036bf1d

Browse files
fix(service-analytics): log the admitObjectRead bridge's init-time security absence at info; WARN once at the first query that finds none (#22234)
Fixes #22154 Clause-②: no ## What changed `packages/services/service-analytics/src/plugin.ts`, the `admitObjectRead` bridge's absence logging only: - **Init-time line: `warn` to `info`**, the same level and shape the sibling `getReadScope` line already uses for the identical situation: `[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).` - **Per-query bridge, ABSENT branch** (the plugin context answers a lookup for `security` with nothing): it still returns `true` (admit), exactly as before, and now logs ONE WARN at the first query that finds no service, naming that query's object: `[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") ...`. Once per plugin instance: a closure flag created per `init()`, deliberately not a module binding, so a second kernel in the same process gets its own report. The flag records only that the report was made; it caches no verdict, and every query still resolves the service afresh. - The UNUSABLE branch (lookup threw, or the service answers neither `canReadObject` nor `explain`), the `getReadScope` bridge, and the `translateSelectOptions` region are untouched. Tests (all in `service-analytics`): new `src/__tests__/admission-absence-report.test.ts` (5 cases); `raw-sql-object-routing.test.ts` follows the moved WARN text; `admission-bridge-resolution.test.ts` header comment. One `@objectstack/service-analytics` `patch` changeset. ## Why the init-time WARN was always false on a default boot (measured) `@objectstack/plugin-security` registers the `security` service in its `start()`: `packages/plugins/plugin-security/src/security-plugin.ts:2355`, inside `start()` (opens at `:1646`; `init()` opens at `:1461`). So at analytics' `init()` that service can never be present on a composition that ships plugin-security, whatever the plugin order, and the old branch fired on every default boot. ## PM mechanism hypotheses, measured - **H1, confirmed** at `7d7943dd`: the init-time line was `ctx.logger.warn` at `plugin.ts:1449`; the `getReadScope` sibling logs at `info` at `:1430`; the `admitObjectRead` closure built at `:754` calls `trySecurityAdmission()` (`ctx.getService('security')`) on every call. - **H2, refined.** What the per-query bridge does today when no security service resolves depends on how the context answers the miss: - a context whose `getService` returns nothing (every in-package test double): the ABSENT branch admits and, before this PR, logged nothing; - the in-repo kernels (`ObjectKernel`, `LiteKernel`) THROW on a never-registered name (`[Kernel] Service 'security' not found`), so the bridge takes the UNUSABLE branch: every query is DENIED `PERMISSION_DENIED` / 403 with an `error` line per query, and nothing is read. - This PR keeps both outcomes exactly. On the in-repo kernels, absence at query time was and stays loud at `error`; the new once-WARN lives in the ABSENT branch. The divergence between the two classifications is reported to the seat as a separate finding; it is not changed here. - **H3, green, with a caveat.** `pnpm check:startup-registry-verdict` and `pnpm check:init-service-contract` are green on this head. The startup-registry gate is ALSO green on the pre-fix tree, and it lists the analytics `security` probe as `read-only (legal) [final: no workspace plugin declares providesServices: ['security']]` both before and after. It does not see this shape, because `providesServices` is init-only by contract and plugin-security registers `security` in `start()`. Its green is not evidence for or against this fix. - **H4, pinned and ablated** (below). ## Before / after, real kernels, built `dist` A scratch probe booted a real kernel with a `data` provider and this plugin, plus optionally a security provider registered after analytics' init, and ran the same dataset query twice. Rows below are admission-related log lines and outcomes. | kernel, security | before (`7d7943dd`) | after (this head) | |---|---|---| | ObjectKernel, registered after init | `warn` at init; 2 queries admitted, 2 reads | `info` at init; 2 queries admitted, 2 reads | | LiteKernel, registered after init | `warn` at init; 2 admitted, 2 reads | `info` at init; 2 admitted, 2 reads | | ObjectKernel, never registered | `warn` at init; per query `error` + 403, 0 reads | `info` at init; per query `error` + 403, 0 reads | | LiteKernel, never registered | `warn` at init; per query `error` + 403, 0 reads | `info` at init; per query `error` + 403, 0 reads | ## Pins (`admission-absence-report.test.ts`) 1. Default composition on a real `LiteKernel`, security registered from `start()`: no admission `warn`/`error` at boot; the init line is the `info` above. 2. Same composition: the first query asks `canReadObject('employer_member', ctx)`; an admitted caller gets rows (1 read); a refusing service gives `PERMISSION_DENIED` / 403 with 0 reads. 3. Real `LiteKernel`, no security service ever: no init-time admission WARN; the query is denied `PERMISSION_DENIED` / 403, 0 reads, with the bridge's existing `error` line (unchanged outcome). 4. ABSENT resolution (context answers nothing): no WARN at init; the first query is admitted and WARNs once, naming `employer_member`; the second query is admitted and does not repeat it; no `error`. 5. Per plugin instance: two separate inits each get their own single report. ## Ablations (head `9c632fe74a`; mutation through `scripts/ablation-replace.mjs` with an anchor that must hit, an outer `trap` restore, and a blob-hash proof) The subject is imported from source (`../plugin.js`), so no `dist` leg applies. Each leg: anchor x1 to x0, replacement x0 to x1, restored blob `fc1df493483a` equal to `HEAD:plugin.ts`, `git diff HEAD` empty. - **A1**, the init line back to `ctx.logger.warn`: 6 failed / 8 passed; red on pins 1 to 5 and on `raw-sql-object-routing` "leaves default-datasource objects exactly where they were". - **A2**, once-guard removed (`if (true)`): 1 failed / 13 passed; red on pin 4 (the second query repeats the WARN). - **A3**, the query-time WARN demoted to `debug`: 3 failed / 11 passed; red on pins 4 and 5 and on the same `raw-sql-object-routing` case. ## Local verification (head `9c632fe74a`) - `pnpm --filter @objectstack/service-analytics build`: ok. `typecheck` (`tsc --noEmit`, whose program includes the new test file): exit 0. - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: Test Files 179 passed (179); Tests 4415 passed, 262 skipped (4677). - Gates: the 64 families `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives on `9c632fe74a`, plus `pnpm check:startup-registry-verdict` and `pnpm check:init-service-contract`: 66 of 66 exit 0. `dispatch-gates --ran`: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - Lint, narrowed: `eslint --no-inline-config --format json` over the 4 touched source files. The config lints every `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside its never-linted list and `packages/spec/**`, so all 4 are in scope; the JSON reports 4 files, 0 errors, 0 warnings. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - When a host supplies `getReadScope` but not `admitObjectRead`, `securityPresentAtInit` is never probed, so the admission init line reads "not registered yet at init" even when a security service is present. Before this PR the same branch was a WARN. The line is now `info`; the condition is unchanged and is noted here, not changed. - `analytics-service.ts:1901` (`assertReadAdmitted` doc) still says "`AnalyticsServicePlugin` reports that state at init." It is now reported at init at `info`, and at the first query at `warn`. That file is outside this card's file surface, so it is noted here, not edited. - `check:startup-registry-verdict` cannot see init-time verdicts about a service its provider registers in `start()` (see H3). --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73a0a6b commit 036bf1d

5 files changed

Lines changed: 291 additions & 15 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a default boot no longer warns that no "security" service is registered; the warning moves to the first query that finds none
6+
7+
Clause-②: no
8+
9+
- **What changed.** `AnalyticsServicePlugin` used to log a WARN at init, `[Analytics] No admitObjectRead configured and no "security" service registered at init …`, on every default boot. `@objectstack/plugin-security` registers the `security` service in its `start()`, after every plugin's `init()`, and the object-level read bridge resolves that service per query. So the warning described a consequence that did not apply to the running app. Init now logs the same situation at `info`, as the sibling row-scope bridge already did: `[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query`.
10+
- **Where the warning went.** When an analytics query needs the object-level gate and the plugin context returns no `security` service, the bridge logs one WARN: `[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") …`. It is logged once per plugin instance, not once per query.
11+
- **Unchanged.** What the bridge enforces. When the plugin context answers a lookup for `security` with nothing, the query is admitted, as before. A lookup that throws, or a service that answers neither `canReadObject` nor `explain`, still denies the query fail-closed and logs an `error`, as before. The in-repo kernels (`ObjectKernel`, `LiteKernel`) throw on a lookup for a service nobody registered, so on those kernels a query with no security service ever registered is still denied, with an `error` per query, as before. A deployment that supplies `admitObjectRead` sees no change.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* WHEN the analytics → `security` admission bridge reports that no security
5+
* service answers the object-level read grant, and at what level.
6+
*
7+
* The bridge resolves the `security` service PER QUERY, so plugin order is not
8+
* significant — and plugin-security registers that service in its `start()`,
9+
* after every plugin's `init()`. A WARN at init therefore asserted a verdict
10+
* the same boot contradicts (AGENTS.md "Startup registry reads — never record
11+
* a verdict the boot can still contradict"), on every default boot.
12+
*
13+
* So:
14+
* - init reports the absence at `info`, as the read-scope sibling already
15+
* does for the identical situation;
16+
* - the WARN belongs to the first query that needs the gate and finds no
17+
* service — once per bridge, not once per query;
18+
* - what the bridge ENFORCES does not move. Each case below asserts the
19+
* verdict next to the log line, so a change that quietened the log by
20+
* changing the answer cannot pass.
21+
*
22+
* The three resolutions themselves (absent / threw / cannot answer) are
23+
* `admission-bridge-resolution.test.ts`'s subject; this file is about the
24+
* report, not the verdict.
25+
*/
26+
27+
import { describe, it, expect, vi } from 'vitest';
28+
import { LiteKernel, type Plugin, type PluginContext } from '@objectstack/core';
29+
import { DatasetSchema } from '@objectstack/spec/ui';
30+
import type { ExecutionContext } from '@objectstack/spec/kernel';
31+
import { AnalyticsServicePlugin } from '../plugin.js';
32+
import type { AnalyticsService } from '../analytics-service.js';
33+
34+
const probe = DatasetSchema.parse({
35+
name: 'probe_member',
36+
label: 'probe',
37+
object: 'employer_member',
38+
dimensions: [],
39+
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
40+
});
41+
42+
const CALLER = { userId: 'u_seeker', tenantId: 'org_a' } as ExecutionContext;
43+
44+
/** The SQL posture — the path where nothing but this gate stands in the way. */
45+
const nativeSql = () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false });
46+
47+
/** Engine double; every read it serves is recorded, so "denied" means "never read". */
48+
function fakeEngine() {
49+
const reads: string[] = [];
50+
return {
51+
reads,
52+
engine: {
53+
execute: async (_sql: unknown, options?: { object?: string }) => {
54+
reads.push(`execute:${options?.object ?? '?'}`);
55+
return { rows: [{ cnt: 24 }] };
56+
},
57+
aggregate: async (object: string) => {
58+
reads.push(`aggregate:${object}`);
59+
return [{ cnt: 24 }];
60+
},
61+
hasObjectMiddleware: () => false,
62+
getObject: (name: string) =>
63+
name === 'employer_member' ? { fields: { id: { type: 'text' } } } : undefined,
64+
resolveEffectiveDatasource: () => undefined,
65+
},
66+
};
67+
}
68+
69+
/** The lines this file is about: anything naming the object-level admission. */
70+
const ADMISSION = /admitObjectRead|OBJECT-LEVEL|object-level read admission/;
71+
72+
type Lines = { info: string[]; warn: string[]; error: string[] };
73+
const about = (lines: string[]) => lines.filter((l) => ADMISSION.test(l));
74+
75+
const runProbe = (service: AnalyticsService) =>
76+
service.queryDataset(probe as never, { measures: ['cnt'] } as never, CALLER);
77+
78+
// ── A REAL kernel: the default composition, in the default order ─────────────
79+
80+
/**
81+
* Boots a real `LiteKernel` with a `data` provider, this plugin, and — when
82+
* given — a security provider that registers AFTER this plugin's `init()`,
83+
* which is what every composition shipping plugin-security produces (it
84+
* registers `security` in its `start()`). The first plugin taps the kernel's
85+
* logger so every line the analytics plugin writes, at init and at query
86+
* time, is seen.
87+
*/
88+
async function bootKernel(security?: Record<string, unknown>) {
89+
const lines: Lines = { info: [], warn: [], error: [] };
90+
const { engine, reads } = fakeEngine();
91+
const tap: Plugin = {
92+
name: 'test.log-tap',
93+
init: async (ctx: PluginContext) => {
94+
for (const level of ['info', 'warn', 'error'] as const) {
95+
vi.spyOn(ctx.logger, level).mockImplementation((message: unknown) => {
96+
lines[level].push(String(message));
97+
});
98+
}
99+
},
100+
};
101+
const data: Plugin = {
102+
name: 'test.data',
103+
init: async (ctx: PluginContext) => { ctx.registerService('data', engine); },
104+
};
105+
const kernel = new LiteKernel({ logger: { level: 'silent' } });
106+
kernel.use(tap);
107+
kernel.use(data);
108+
kernel.use(new AnalyticsServicePlugin({ queryCapabilities: nativeSql }));
109+
if (security) {
110+
// Registered from start(), the phase plugin-security registers it in.
111+
kernel.use({
112+
name: 'test.security',
113+
init: async () => {},
114+
start: async (ctx: PluginContext) => { ctx.registerService('security', security); },
115+
} satisfies Plugin);
116+
}
117+
await kernel.bootstrap();
118+
return { service: kernel.getService<AnalyticsService>('analytics'), reads, lines };
119+
}
120+
121+
/** A working security service's row-scope half; `undefined` = no row restriction. */
122+
const rowScopeOpen = { getReadFilter: async () => undefined };
123+
124+
describe('admission absence report — a default composition (security registered after analytics init)', () => {
125+
it('boots with no admitObjectRead WARN: the init-time absence is an info line', async () => {
126+
const canReadObject = vi.fn(() => true);
127+
const { lines } = await bootKernel({ ...rowScopeOpen, canReadObject });
128+
129+
expect(about(lines.warn)).toEqual([]);
130+
expect(about(lines.error)).toEqual([]);
131+
expect(about(lines.info).join('\n')).toMatch(
132+
/admitObjectRead bridged to the "security" service; that service is not registered yet at init/,
133+
);
134+
});
135+
136+
it('its first query asks the security service that registered later — both verdicts, as before', async () => {
137+
const canReadObject = vi.fn(() => true);
138+
const admitted = await bootKernel({ ...rowScopeOpen, canReadObject });
139+
140+
expect((await runProbe(admitted.service)).rows).toEqual([{ cnt: 24 }]);
141+
expect(canReadObject).toHaveBeenCalledWith('employer_member', CALLER);
142+
expect(admitted.reads).toHaveLength(1);
143+
// Asking the service that is there is not an absence: nothing to report.
144+
expect(about(admitted.lines.warn)).toEqual([]);
145+
146+
const refused = await bootKernel({ ...rowScopeOpen, canReadObject: () => false });
147+
await expect(runProbe(refused.service)).rejects.toMatchObject({
148+
code: 'PERMISSION_DENIED',
149+
status: 403,
150+
});
151+
expect(refused.reads).toEqual([]);
152+
expect(about(refused.lines.warn).join('\n')).not.toMatch(/No admitObjectRead configured/);
153+
});
154+
});
155+
156+
describe('admission absence report — a real kernel that never registers a security service', () => {
157+
it('logs no init-time WARN, and absence stays loud at query time with the outcome unchanged', async () => {
158+
// The in-repo kernels answer a missing service by THROWING
159+
// ("[Kernel] Service 'security' not found"), which this bridge classifies
160+
// as a resolution that threw: it denies, fail-closed, and says so at
161+
// `error` on every query. That outcome is what this boot produced before
162+
// the init-time WARN moved, and it is not this file's to change; what is
163+
// pinned is that dropping the init-time WARN left this case loud.
164+
const { service, reads, lines } = await bootKernel(undefined);
165+
166+
expect(about(lines.warn)).toEqual([]);
167+
168+
await expect(runProbe(service)).rejects.toMatchObject({
169+
code: 'PERMISSION_DENIED',
170+
status: 403,
171+
});
172+
expect(reads).toEqual([]);
173+
expect(about(lines.error).join('\n')).toMatch(
174+
/object-level read admission could not be resolved for "employer_member" .* denying the query \(fail-closed\)/s,
175+
);
176+
});
177+
});
178+
179+
// ── The ABSENT resolution: a context that answers a missing service with nothing ─
180+
181+
function fakePluginContext() {
182+
const { engine, reads } = fakeEngine();
183+
const registered: Record<string, unknown> = {};
184+
const lines: Lines = { info: [], warn: [], error: [] };
185+
const record = (level: keyof Lines) => (message: unknown) => { lines[level].push(String(message)); };
186+
return {
187+
reads,
188+
lines,
189+
registered,
190+
ctx: {
191+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
192+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
193+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
194+
logger: { info: record('info'), warn: record('warn'), error: record('error'), debug() {} },
195+
},
196+
};
197+
}
198+
199+
describe('admission absence report — no security service ever registered (the ABSENT resolution)', () => {
200+
it('WARNs at the first query, once: a second query does not repeat it, and both are admitted as before', async () => {
201+
const { ctx, registered, reads, lines } = fakePluginContext();
202+
await new AnalyticsServicePlugin({ queryCapabilities: nativeSql }).init(ctx as never);
203+
const service = registered.analytics as AnalyticsService;
204+
205+
// Nothing at init beyond the heads-up: the verdict is the first query's.
206+
expect(about(lines.warn)).toEqual([]);
207+
208+
expect((await runProbe(service)).rows).toEqual([{ cnt: 24 }]);
209+
expect(reads).toHaveLength(1);
210+
const first = about(lines.warn);
211+
expect(first).toHaveLength(1);
212+
expect(first[0]).toMatch(
213+
/No admitObjectRead configured and no "security" service registered when an analytics query needed one \(first: "employer_member"\)/,
214+
);
215+
expect(first[0]).toMatch(/do NOT enforce the OBJECT-LEVEL read grant/);
216+
217+
expect((await runProbe(service)).rows).toEqual([{ cnt: 24 }]);
218+
expect(reads).toHaveLength(2);
219+
expect(about(lines.warn)).toEqual(first);
220+
expect(about(lines.error)).toEqual([]);
221+
});
222+
223+
it('reports per plugin instance, not per process: a second plugin init gets its own report', async () => {
224+
for (let i = 0; i < 2; i++) {
225+
const { ctx, registered, lines } = fakePluginContext();
226+
await new AnalyticsServicePlugin({ queryCapabilities: nativeSql }).init(ctx as never);
227+
await runProbe(registered.analytics as AnalyticsService);
228+
expect(about(lines.warn)).toHaveLength(1);
229+
}
230+
});
231+
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
* - the `security` service is ABSENT — this deployment has no object-level
1212
* gate anywhere, `GET /data/<object>` included, because that gate IS the
1313
* 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 at
15-
* init;
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);
1617
* - resolving the service THREW — a security service exists on this
1718
* deployment and could not be reached;
1819
* - the service resolved but exposes NEITHER `canReadObject` NOR `explain` —

‎packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,9 @@ describe('executeRawSql auto-bridge routes by object (#5033)', () => {
258258
{ stage: 'lost', deal_count: 1 },
259259
]);
260260
// The blanket `expect(warn).not.toHaveBeenCalled()` this replaces could no
261-
// longer hold: the plugin reports at init when no `security` service is
262-
// registered to answer the OBJECT-LEVEL read grant, and this fixture
263-
// deliberately registers none.
261+
// longer hold: the object-level bridge reports, once, at the first query
262+
// that finds no `security` service to answer the OBJECT-LEVEL read grant,
263+
// and this fixture deliberately registers none.
264264
//
265265
// ⛔ But the replacement is not "anything except the routing phrase"
266266
// either — that admits every OTHER new warning into a case whose whole job
@@ -270,7 +270,7 @@ describe('executeRawSql auto-bridge routes by object (#5033)', () => {
270270
const warnings = warn.mock.calls.map((c) => String(c[0]));
271271
expect(warnings).toHaveLength(1);
272272
expect(warnings[0]).toMatch(
273-
/No admitObjectRead configured and no "security" service registered at init/,
273+
/No admitObjectRead configured and no "security" service registered when an analytics query needed one/,
274274
);
275275
// …and it is emphatically not the degradation this case is about.
276276
expect(warnings[0]).not.toMatch(/is unavailable/);

‎packages/services/service-analytics/src/plugin.ts‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,9 @@ export class AnalyticsServicePlugin implements Plugin {
693693
// `/data`, because that gate IS this plugin's absent
694694
// middleware. The two doors still agree, which is the
695695
// equivalence property the card asks for, so this ADMITS and
696-
// is reported loudly at init below.
696+
// is reported loudly, once, by the first query that finds it
697+
// (below) — not at init, where the service is usually just
698+
// not registered YET.
697699
// UNUSABLE — a security service exists but cannot answer: resolving it
698700
// THREW, or the object it returned carries neither
699701
// `canReadObject` nor `explain`. This is a wired-but-broken
@@ -751,11 +753,40 @@ export class AnalyticsServicePlugin implements Plugin {
751753
}
752754
return { kind: 'usable', svc };
753755
};
756+
// [#22154] Absence is judged where it is used, not where init starts
757+
// (AGENTS.md "Startup registry reads"). At this plugin's init the
758+
// "security" service is not registered yet on any composition that
759+
// ships plugin-security: that plugin registers it in its own start(),
760+
// after every init(). This bridge resolves per call, so plugin order is
761+
// not significant. A WARN at init asserted a verdict the same boot
762+
// contradicts; init reports the absence at `info` (below), and the WARN
763+
// waits for a query that needs the gate and finds no service.
764+
//
765+
// Once per bridge, i.e. per plugin init: the state is the deployment's,
766+
// every later query is admitted the same way, and repeating it per query
767+
// buries it. Deliberately NOT a module binding — a second kernel in the
768+
// same process (a test run, a `verify` harness) must get its own report.
769+
// The flag records only that the report was made; it caches no verdict:
770+
// every query still resolves the service afresh.
771+
let admissionAbsenceReported = false;
754772
admitObjectRead = async (object, context) => {
755773
const resolved = trySecurityAdmission();
756774
// No security service resolved at call time → no object-level gate on
757-
// this deployment, which is the state reported at init.
758-
if (resolved.kind === 'absent') return true;
775+
// this deployment: ADMIT, exactly as before, and say so once.
776+
if (resolved.kind === 'absent') {
777+
if (!admissionAbsenceReported) {
778+
admissionAbsenceReported = true;
779+
ctx.logger.warn(
780+
'[Analytics] No admitObjectRead configured and no "security" service registered when an ' +
781+
`analytics query needed one (first: "${object}") — analytics queries do NOT enforce the ` +
782+
'OBJECT-LEVEL read grant. On a SQL driver that means any authenticated caller can post an ' +
783+
'inline dataset and read counts and groupings for an object they hold no grant on. Supply ' +
784+
'admitObjectRead or register a security service. Reported once; later queries are ' +
785+
'admitted the same way.',
786+
);
787+
}
788+
return true;
789+
}
759790
if (resolved.kind === 'unusable') {
760791
ctx.logger.error(
761792
`[Analytics] object-level read admission could not be resolved for "${object}" — ` +
@@ -1464,12 +1495,14 @@ export class AnalyticsServicePlugin implements Plugin {
14641495
'read grant the engine middleware asks, ahead of the strategy chain.',
14651496
);
14661497
} else if (autoBridgedReadAdmission) {
1467-
ctx.logger.warn(
1468-
'[Analytics] No admitObjectRead configured and no "security" service registered at init — ' +
1469-
'the bridge resolves per query, but if no security service ever appears, analytics ' +
1470-
'queries will NOT enforce the OBJECT-LEVEL read grant. On a SQL driver that means any ' +
1471-
'authenticated caller can post an inline dataset and read counts and groupings for an ' +
1472-
'object they hold no grant on. Supply admitObjectRead or register a security service.',
1498+
// [#22154] A heads-up, not a verdict — the same situation the read-scope
1499+
// sibling above logs at `info`. The bridge resolves per query, and
1500+
// plugin-security registers the service in its start(), after this
1501+
// init; a query that needs the gate and finds no service WARNs then,
1502+
// once (see the bridge).
1503+
ctx.logger.info(
1504+
'[Analytics] admitObjectRead bridged to the "security" service; that service is not ' +
1505+
'registered yet at init and will be resolved per query (plugin order is not significant).',
14731506
);
14741507
}
14751508

0 commit comments

Comments
 (0)