fix(service-analytics): row-scope bridge tells absent from broken security service - #17125
Conversation
…urity service The plugin bridges to the `security` service twice. The object-level bridge resolves an explicit three-way — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into `undefined`, which is the value `ISecurityService.getReadFilter` reserves for "this caller has no row restriction", so a wired-but-broken security service made analytics queries run with no row-level policy at all, indistinguishable from a deployment that ships no security plugin. Resolve the same three-way here. ABSENT keeps today's behaviour byte for byte (a legitimate single-tenant configuration, reported at init). THROWING and METHOD-LESS report at `error` and throw, which the fail-closed `AnalyticsService.resolveReadScopes` seam already turns into a refusal of the whole query rather than SQL with the object unscoped — the same outcome the object-level bridge produces, and neutral between the two candidate tenant walls. No new error code, no exported symbol, no payload key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dfc5f62dc8d5fc1360004be55d754edab11944cd && git checkout dfc5f62dc8d5fc1360004be55d754edab11944cd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 50b6f17d4140bcb3d161137a58aeaf7f49cc22ba cb3c68513fabcadf96d9e4afd65710c0ba83bd22 && git checkout -B drift-repro 50b6f17d4140bcb3d161137a58aeaf7f49cc22ba && git merge --no-ff cb3c68513fabcadf96d9e4afd65710c0ba83bd22
node scripts/docs-audit/affected-docs.mjs --json 50b6f17d4140bcb3d161137a58aeaf7f49cc22ba
|
… half Two doubles in `admission-bridge-resolution.test.ts` model a WORKING security service with `canReadObject` (or the `explain` fallback) and nothing else. With the row-scope bridge now refusing a registered service that exposes no `getReadFilter`, those doubles describe a state the platform refuses, and the two object-level ADMIT assertions could no longer be reached. `getReadFilter` is a required member of `ISecurityService`, and `undefined` is its documented "no row restriction on this object", so the doubles gain exactly that and stay minimal and conforming. No assertion, no verdict and no object-level behaviour changes; the deny-path doubles are untouched because the object-level gate refuses before the row half is asked. Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16918
What changed
packages/services/service-analytics/src/plugin.tsbridges to thesecurityservice twice. The object-level bridge (admitObjectRead→canReadObject, landed and contract-reviewed via #16860) resolves an explicit three-way: ABSENT admits, THROWING and METHOD-LESS deny aterror. The row-scope bridge (getReadScope→getReadFilter) collapsed all three into one value:undefinedis the valueISecurityService.getReadFilterreserves for one meaning only — "this caller has no row restriction on this object". Spending it on a provider that never answered is how an analytics query ends up running with no row-level policy at all. After #16860 one door of this file failed closed on a throwing resolver and its neighbour failed open, and the neighbour is the one carrying row-level policy.This mirrors the landed shape, one function up:
plugin-securityhas no row-level policy anywhere,/dataincluded; the init log already says so.ctx.logger.error(...)naming the object and which state it was, then a throw.AnalyticsService.resolveReadScopes— fail-closed since ADR-0021 D-C — turns that into "deny the whole query rather than emit SQL with that object unscoped".That is refusal option (1) from the dispatch brief: refuse the query, the outcome the object-level bridge already produces. It is neutral between the two candidate tenant walls — it answers "should we serve at all", never "what shape is the wall" — so it does not touch triage condition 5 (#16645). ⛔ Option (3) (a louder log over an
undefined) is not what landed: a log is not a refusal.Published-surface delta: none
Clause-② stays no, checked rather than assumed against all three triggers:
packages/spec/src/api/error-code-ledger.zod.ts)resolveReadScopes, which throws its own bare error. Nothing new is declared.SecurityReadFilterResolutionis a localtypeinsideinit(), exactly like the object-level bridge'sSecurityAdmissionResolution.AnalyticsServicePluginOptionsis untouched.git diff --statis three files: the bridge, one new test file, one changeset.Prove it can fail — three arms, predicted then measured
Predictions were written before running:
50b6f17d4)getService('security'){cnt: 24}off an unscoped statementgetReadFilter{cnt: 24}off an unscoped statement{cnt: 24}, unscoped, no error logResults are appended below as they arrive.
Escalation clause — arm 2's reachability
Answered in the report; see the "escalation" section appended below.
Fences
driver-sql's auto-scope andplugin-security's Layer 0 are untouched.验收备注
securityPresentAtInitkeeps its exact truth table (kind === 'usable'⟺ the old!!trySecurity()), so both init log branches — including the object-level bridge's, which reads the same flag — are unchanged. That the admission branch reads a flag computed by the row-scope bridge (and staysfalsewhenever a host supplies its owngetReadScope) is pre-existing and deliberately left alone here.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Measurements
Ablation — the three arms, predicted above, then measured
The mutation is
git checkout 50b6f17d4 -- packages/services/service-analytics/src/plugin.ts(this branch's only edit to that file is the bridge), restored undertrap … EXIT INT TERMwithgit checkout HEAD -- <ABSPATH>.The mutation reached disk (counted, not inferred from an editor's exit code):
BEFORE —
src/__tests__/read-scope-bridge-resolution.test.tsagainst the pre-fix bridge:Both broken-provider arms served rows. ⭐ The absent-service control passed untouched, as did the positive arm (a usable service, whose predicate appears in the statement that ran) and the both-bridges-live arm.
AFTER:
Test Files 1 passed (1) · Tests 5 passed (5).Restoration, proven by state and not by an exit code:
The two object-level doubles this change invalidated — reported, not quietly rewritten
The first full-package run came back
Tests 2 failed | 2193 passed, both inadmission-bridge-resolution.test.ts:Their doubles model a working security service as
{ canReadObject }(and{ explain }) with nothing else — which is now, correctly, a service that cannot answer the row half.getReadFilteris a REQUIRED member ofISecurityServiceandundefinedis its documented "no row restriction on this object", so each double gained exactly that (rowScopeOpen). No assertion, no verdict and no object-level behaviour changed; the deny-path doubles are untouched because the object-level gate refuses before the row half is asked. ⛔ Nothing was skipped, disabled or quarantined.Second run:
Test Files 99 passed (99) · Tests 2195 passed (2195), andtsc --noEmitexit 0 — with--listFilesconfirming the new test file is inside the typecheck program (the package'stsconfig.jsonincludessrcand excludes no test).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover the real change set (4 paths, merge base50b6f17d4) derived 59 commands. All 59 were invoked; the--ranreconciliation reports59 derived famil(ies) accounted for — 59 run, 0 UNRUN.56 measured green. 3 are NOT MEASURED, and are named rather than counted as passes — each exits 3
PREREQUISITE NOT METbecause it reads a whole-workspacedist/, which is a 66-package build and therefore CI's run, not this seat's:pnpm check:dual-build-cjs-loadspnpm check:lean-entry-closurepnpm check:type-check-debtRepo-wide lint is the union, not a narrowing:
pnpm lint(eslint . --no-inline-config) exits 0 with zero findings atcb3c68513.Also NOT MEASURED, same cause:
packages/qa/dogfood'sanalytics-rls/analytics-label-scopesuites fail to resolve@objectstack/verify(nodist). Unaffected by construction — both fixtures boot a realSecurityPlugin, which registersgetReadFilter— but declared here rather than claimed.Escalation clause — arm 2 is NOT reachable in any configuration this repo ships or permits
The card escalates to
priority:p1if a registered security service withoutgetReadFilteris reachable through a version pair the workspace allows or an out-of-repo provider the publishedISecurityServiceadmits. Measured on three independent legs:getReadFilterwas introduced on the registered literal by69da71b7d, the same commit that first registered the bare'security'name at all — at its parent the plugin registers onlysecurity.permissions,security.rls,security.fieldMasker, … sogetService('security')answered nothing there. Every plugin-security that has ever exposed this service has exposedgetReadFilter; every earlier one is the ABSENT arm. Across all 146 commits touchingsecurity-plugin.tssince that commit, the count of commits whose registered literal lacksgetReadFilter:is 0 (checked commit by commit,--followagreeing with the path-as-is walk, so no rename hides a gap).const securityService: ISecurityService, andgetReadFilteris a required member — kept compiler-exhaustive in both directions byREQUIRED_MEMBERSinpackages/spec/src/contracts/security-service.test.ts. Dropping it fails plugin-security's own build.plugin-securityis the only in-repo registrant of the bare name'security'(registerService('security'overpackages/,apps/,examples/, non-test); the dogfood and verify fixtures pass realSecurityPlugininstances.⇒ The card stays
priority:p2. Two boundaries of that answer, stated rather than hedged:explain.ISecurityService's own preamble tells implementations to "omit" a method they cannot honour and consumers to feature-detect, which reads as permission to do exactly that, while the type says the member is required. That tension is not this card's to settle and nothing here depends on it — the state is now refused either way.admitObjectRead(a documented option) or a fault window that opens between the two resolutions — the resolver is consulted twice per query, at two different instants, and the second call is the row scope. A boot-order fault clearing between them is precisely the shape the card describes.Generated by Claude Code