fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) - #20955
Conversation
…e the analytics field gate ask it The security contract gains an optional getQueryableFields: the fields a caller may filter, sort, group or aggregate by. plugin-security derives it from the one query-guard map its predicate guard and aggregate-input guard now share, so a field served masked is readable and not queryable. The analytics field gate asks it beside the read projection; the plugin bridge fails closed for masking-rule fields when the security service cannot answer. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ytics strategies A door-level pin over the real SecurityPlugin, ObjectQL and SqlDriver: a field the caller is served masked, as a group or a filter member, answers the engine's own refusal on both strategies before any strategy runs, and a caller the masking rule is lifted for is the control. plugin-security pins that getQueryableFields equals the middleware's two query guards field for field; the analytics unit pins cover the gate, the bridge's fail-closed fallback and the construction-time warning. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ation and the analytics narrowing Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…sked-not-queryable
…ext parameter Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…sked-not-queryable
…ty, so it refuses masked-rule fields for every caller The fallback for a security service that cannot answer getQueryableFields exempted a system context by reading its system bit, a new elevation read site. It cannot say for whom a masking rule is lifted, so it now refuses every caller alike; the contract text and both changesets say so. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 bd3224fe607a26553bc16e6f45084848a96234e4 && git checkout bd3224fe607a26553bc16e6f45084848a96234e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 013f97df93ed66d0aeec45ec1d507da303991260 fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 && git checkout -B drift-repro 013f97df93ed66d0aeec45ec1d507da303991260 && git merge --no-ff fc844c433ed2e8e175e4568db4e03a4e58b0c5c9
node scripts/docs-audit/affected-docs.mjs --json 013f97df93ed66d0aeec45ec1d507da303991260
|
Contract reviewServed-tier: PR #20955 for card #20935, read as the net diff against ① Derived judgmentsAccept-set and public-surface changes the diff implies
Author-shown and AI-facing text, sentence by sentence (only the ones that are false, unsourced or over-broad are listed; everything else tested true against the tree)
② Semver level
③ Boundary flags
Check-runs on Implemented-by: VERDICT: PASS Adopted and posted by
Generated by Claude Code |
main's #20931 (the field-read admission gate), #20955 (the queryable-field gate), #20954 (plugin-security's comparand guard) and #20962 (relationship path objects in the admitted and scoped set) touched packages/services/service-analytics. The merge is clean at the text level; both sides' additions to analytics-service.ts and native-sql-strategy.ts are kept whole. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Closes #20935
Clause-②: yes (narrowing)
A field whose
maskingRuleapplies to the caller is SERVED, with its value replaced, so the published read projectionISecurityService.getReadableFieldscounts it readable. It is not queryable: the engine refuses it as a group key, an aggregate input, a filter or a sort key, because each of those gives back what the mask hides. The analytics field gate from #20917 was exactly as wide as the read projection. So a masked-for-this-caller field could be grouped or filtered by on the native-SQL strategy, which compiles its own statement. The engine and the ObjectQL strategy refuse the same query.This PR publishes the missing answer on the security contract, implements it from the one decision the engine already makes, and has the analytics gate ask it. The masking rule itself is unchanged, and the analytics layer does not re-derive it.
What changed, per surface
packages/spec/src/contracts/security-service.ts).ISecurityServicegains an optionalgetQueryableFields(object, context). It answers the fields the caller may filter, sort, group or aggregate by: the exact complement of what the engine's field guards refuse. It is a subset ofgetReadableFields, and the two differ by exactly the fields the caller is served masked. It has the same two empty answers as its siblings:undefinedis no answer,[]is none. A system context gets every field.undefined) must treat every field that declares amaskingRuleas not queryable, whoever the caller is. Falling back to the read projection alone would admit exactly the masked fields.packages/plugins/plugin-security/src/security-plugin.ts).computeQueryGuardFieldPerms: the evaluator map, therequiredPermissionsfold, the on-behalf-of delegator intersection, then every masked-for-this-caller field folded in as non-queryable.getQueryableFieldsreads that same map, so a field is in the answer if and only if a query naming it passes both guards. The guards refuse exactly what they refused before.securityservice besidegetReadableFields.packages/services/service-analytics/src/field-read-admission.ts+ minimal wiring inanalytics-service.tsandplugin.ts).AnalyticsServiceConfiggainsgetQueryableFields. The door's one field gate asks it besidegetReadableFieldsfor the same objects. A member is admitted only when both answers carry its field. There is no second gate and no per-strategy copy.403 PERMISSION_DENIEDin the engine's own words for that field. Those are the words the engine answers a masked field with.error, the same way the read reader does.AnalyticsServicePluginbridges the hook to thesecurityservice with the same absent / unusable / usable resolutions as the read half, plus one more state. When a usable service predates the method, or answersundefined, the bridge fails closed: the read projection less every field whose declaration carries amaskingRule, for every caller. That over-refuses a caller for whom the rule is lifted, which is the safe direction. It is also the only one available, because deciding for whom a rule is lifted would be a second copy of the masking rule.AnalyticsServiceitself withgetReadableFieldsand withoutgetQueryableFieldsis warned once at construction.Why the PR line reads
yes (widening), and the analytics changeset declares a narrowingThe line above is the claim's, as corrected under the seat's review adoption
5921385686(scripts/pm/clause2-line.mjs:93: a diff that widens one surface and narrows another is spelledyes (narrowing)). The diff does widen the public surface: one optional contract member, its implementation on the service, and one optional config hook. It also narrows one accept set. Analytics queries that grouped, aggregated, filtered or sorted by a field the caller sees masked were answered on the native-SQL strategy (and printed by the SQL echo on both strategies), and they are now refused. That narrowing is declared where the level and ADR-0087 gates read it: in.changeset/20935-analytics-masked-field-not-queryable.md, which carries the**BREAKING**banner, its ownClause-②: yes (narrowing)line and an ADR-0087not-required (no-migration-prescription)disposition, as #20917's changeset did.@objectstack/specand@objectstack/plugin-securityshipminor(widening). Every bumped package isminor, which is whatyesrequires.check-changeset-no-majorandcheck-adr-0087-registrationboth exit 0.Pins
packages/rest/src/analytics-masked-field-gate.test.tscovers both compositions over the realSecurityPlugin,ObjectQLandSqlDriver(native-SQL first, and ObjectQL only), with synthetic fixtures.packages/plugins/plugin-security/src/get-queryable-fields.test.tsis an equivalence. For every field and four positions (filter, sort key, group key, aggregate input), "the real middleware admitted it" equals "the field is ingetQueryableFields". The cases cover a member, the capability holder, an agent alone and the same agent on behalf of a delegator. It also pins the contract's answers: masked means readable and not queryable, the system and no-sets answers, the unresolvable object, the dangling delegator, and registration on the service.packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.tsruns the masked positions its cases name (six) through both strategy paths with nothing executed. It also covers the throwing reader, each reader judged on its own, the construction warning, and the bridge: service answer, rule lifted, the fail-closed fallback for a service that predates the method and for anundefinedanswer, and no security service.packages/spec/src/contracts/security-service.test.tspins the member's optionality (an unguarded call does not compile), the masked-readable-not-queryable relation and the two empty answers.Ablations, predicted before running, at
20c56c9810Each ablation followed the same legs:
scripts/ablation-replace.mjs: the anchor hits once and the blob moves.dist/, rebuild the package and confirm withablation-dist-preflightthat the marker is in 2 built files.git diff HEAD.--absentpreflight that the marker is gone from all 6 built files and the tree is clean.field-read-admission.ts, service-analytics rebuilt).getQueryableFieldson the service (plugin-security rebuilt).get-queryable-fieldsreds.field-masking-rule.test.ts(a filter and an aggregate over a masked field). This shows both engine guards now read the one derivation.Commits after
20c56c9810touched only the bridge's fallback. It no longer exempts a system caller, which kept a new elevation read site out of the system-context census. None of the three ablations reaches that branch.Verification, at
fc844c433e(main merged at95fed33a20)@objectstack/service-analytics:test147 files, 3398 passed, andtypecheck0 (the new test file is in the tsc program: it reported a tuple error before its fix). This run was taken ate84882a09f. The later edits were re-run: the two gate unit files, 72 passed, plustypecheck0.@objectstack/plugin-security:test150 files, 3237 passed and 23 skipped, andtypecheck0 including the test layer.@objectstack/spec:typecheck0,src/contracts45 files and 434 passed, andcheck:generatedshows all 15 artifacts up to date.@objectstack/rest: everyanalytics-*route file (13 files, 175 passed and 3 skipped), andtypecheck0 including the test layer. Both field-gate route files re-ran at03153e00f1after the second merge of main: 22 passed.dispatch-gates --commandsderived 87 commands atfc844c433e, with no stale tree.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: 44 packages have nodist/in this worktree). That is NOT MEASURED, not a pass.--ranreconciliation are recorded in the os-dev-report on security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935, because this body is written once.Acceptance notes
MemoryAnalyticsService(driver-memory's cube face) is neither touched nor measured.getReadableFieldsare not changed here. Only the analytics door uses it to admit query positions.analytics-service.ts. The wiring here is kept to the config member, the stored provider, the construction warning and one extra argument at the one gate call site.Generated by Claude Code