fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) - #20931
Conversation
…cs door (#20917) Every member an analytics query names, judged against the caller's field-level read permissions before either strategy runs, answering the engine's own refusal: the cube read and the SQL echo over the inferred and an authored cube, and the dataset door, on both strategies, with the real SecurityPlugin, ObjectQL and SqlDriver; plus the service-level gate and the plugin's bridge to the security service. A readable member is the control. Committed ahead of the change that satisfies them. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…oor, before either strategy (#20917) The analytics admission step now resolves every member a query names -- dimensions, measures, time dimensions, where members, order keys, joined members, and a compiled dataset's own and its requested measures' filters -- to the field it reads, and judges each against the caller's readable fields before a strategy is selected. A member the caller may not read answers PERMISSION_DENIED / 403 in the engine's words. The native-SQL strategy held no field permissions and served such members; it now inherits the verdict by construction, as does the SQL echo and any strategy added later. The permission rule stays the security service's: the plugin bridges the new getReadableFields hook to its getReadableFields reader, and the analytics layer contributes only the member-to-field resolution. A host read scope is policy and is not judged, as the engine's own field guard does not judge it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ce's; changeset (#20917) The plugin no longer offers its own option for the field-level reader: it always bridges AnalyticsServiceConfig.getReadableFields to the security service, and a host composing its own reader constructs AnalyticsService with it. The changeset records the narrowing, the new optional service hook, and the ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 10 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 324831b54be9dce95f9535222950dab3c2b8599a && git checkout 324831b54be9dce95f9535222950dab3c2b8599a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aaad682dbcb36bd00635a40530aca826062b9903 992a592dbbeaf200e16fd48051546f1d981b6295 && git checkout -B drift-repro aaad682dbcb36bd00635a40530aca826062b9903 && git merge --no-ff 992a592dbbeaf200e16fd48051546f1d981b6295
node scripts/docs-audit/affected-docs.mjs --json aaad682dbcb36bd00635a40530aca826062b9903
|
Contract reviewServed-tier: Inputs: card #20917 (body, triage ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
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>
Fixes #20917
Clause-②: yes (narrowing)
One admission gate now judges every member an analytics query names against the caller's field-level read permissions, before either strategy runs. A member the caller may not read answers the engine's own refusal:
403 PERMISSION_DENIED, in the engine's words. The gate sits at the analytics door (AnalyticsService.callCtx, right after the object-level admission and before strategy selection), soNativeSQLStrategy,ObjectQLStrategy, the SQL echo and any strategy added later inherit it by construction. There is no second copy of the permission rule: which fields are readable is thesecurityservice's answer, and the analytics layer contributes only the resolution from member to field.Why line 2 is
yes (narrowing), not the claim's expectedno (narrowing)Both directions measured:
400 INVALID_FIELDto the engine's refusal, and the SQL echo printed the statement and now refuses.AnalyticsServiceConfiggains one optional member,getReadableFields, the hookAnalyticsServicePluginfills. It is a new member of a published config type, so the public surface grows by one hook. No query accept set widens. (There is no matching plugin option: nothing in the tree passes the object-level siblingadmitObjectReadeither, so the plugin always bridges to thesecurityservice.)@objectstack/service-analyticsshipsminorwith the BREAKING banner and an ADR-0087not-required (no-migration-prescription)disposition.check-adr-0087-registrationandcheck-changeset-no-majorpass.Per position
Measured on SQLite with the real
SecurityPlugin,ObjectQLandSqlDriver, as a member whose permission set hides fields on the queried object and on a related one. "Refusal" means403 PERMISSION_DENIEDwhose code, status and message equal what the engine answers for the same field as the same caller:engine.aggregatefor a member the query groups or aggregates,engine.findfor one it filters or sorts by. Each face is the cube read (AnalyticsService.query, whatPOST /api/v1/analytics/queryrelays) and, per row, the dataset door (POST /api/v1/analytics/dataset/query) where the position exists there.$or/$notinclude, an authored join, an inferred cube's relationship path400 INVALID_FIELD(cross-object filter) → refusalfilter; a requested measure's ownfilter400) → refusalAnalyticsService.generateSql, whatPOST /api/v1/analytics/sqlrelays), every row above400) → refusalThe dataset door answers every refusal as
403with{ code, message }and no rows beside it.The reader, and how the gate reaches it
engine.findandengine.aggregaterefuse a hidden field inplugin-security's middleware: the aggregate-input guard for a grouped or aggregated field, the predicate guard for a filtered or sorted one. Both build their mask from the caller's permission sets throughpermissionEvaluator.getFieldPermissions, therequiredPermissionsfold and the on-behalf-of delegator intersection.ISecurityService.getReadableFields(object, context)(resolveProjectionFieldMask). It is called, not changed: no export or signature change inobjectql,specorplugin-security.AnalyticsServicePluginbridgesAnalyticsServiceConfig.getReadableFieldsto the registeredsecurityservice at call time, with the three resolutions its object-level and row-scope bridges keep apart. No security service: no field-level gate, as on/data. A service that throws on resolution or carries nogetReadableFields: the query is refused, fail-closed, logged aterror. Otherwise: ask it, once per object the query names a field of, with the caller's context.namedQueryFieldsinanalytics-service.ts, the judgement in the newfield-read-admission.ts). Each member is resolved as the strategies resolve it (declaredMemberEntry). A joined member is resolved through the cube's join at each hop, and its relationship fields are judged on the object before them, as the engine judges a path's first segment. Filter members are read through the strategies' own lowering (normalizeAnalyticsFilterTree+collectFilterLeaves).What is judged, and what is not
sqlresolves to, never by its name in the cube. Both are measured above and pinned.filterand a requested measure's ownfilter: judged. The nearest engine analogue, measured: the ObjectQL strategy hands both to the engine, which refuses a hidden field in either. On the inline dataset door the caller writes both./data; pinned.sqlis an expression names no field the gate can attribute (flagged for a decision in the dev report); an object the reader answers "no answer" for has none of its fields judged, since an object the security service cannot resolve is one the engine serves nothing from; a name the object's declared field list does not carry is not judged, and with no field list available every name is.Pins, committed red ahead of the fix, pushed together with it
77f73705apins, then40c4979d8the gate, then992a592dbthe changeset and the plugin tidy.packages/rest/src/analytics-field-permission-gate.test.ts: both compositions over the realSecurityPlugin,ObjectQLandSqlDriver; the cube read and the SQL echo over the inferred cube and an authored cube, and the dataset door through this package's route; every refusal compared with the engine's live answer for the same field, computed in the same test. Readable members and a system caller are the controls. Against the base tree: 6 of 12 red (the three position tests per strategy), 6 green (the references and the controls). Now 12 of 12.packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts: every position through both strategy paths from one table with nothing executed; the words and their order; the stand-downs; a throwing reader refused fail-closed; no reader wired; the draft-preview branch; and the plugin's bridge. With the three source files restored to the pins commit (by absolute path, the restore proven byte-identical to HEAD andgit diff HEADempty): 39 red, 9 green, the 9 being the stand-down and no-reader controls. Now 48 of 48.envelope-caller-census.test.ts) counts no new call site: the pins call the producer through a receiver namedservice. Its suite passes unchanged.Ablation, predicted before running, at
40c4979d8The door's gate call was replaced by a no-op through
scripts/ablation-replace.mjs(anchor hit once, blob moved),@objectstack/service-analyticsrebuilt, andablation-dist-preflightfound the marker in 2 built files. Predicted: route pin 6 red and 6 green; unit pin 38 red and 10 green (the draft-preview branch keeps its own call, so it stays green). Observed: exactly that. The restore leg proved the blob equal to HEAD,git diff HEADempty and the whole tree clean, rebuilt, and found the marker absent from all 6 built files.Verification, at
992a592db@objectstack/service-analytics:test146 files, 3374 passed;typecheckexit 0, with 144 of 144__tests__files in the tsc program (--listFiles).@objectstack/rest: the 12analytics-*route files pluserror-response-structured-arm-door-parity, 13 files, 215 passed and 3 skipped;typecheckpasses, including the test layer (check:test-typecheckOK).@objectstack/runtimeanalytics-*(3) pluscross-field-refusal-operand-withhold, 28 passed and 4 skipped;@objectstack/clientanalytics-automation-json-erasureplus the census, 27 passed;@objectstack/dogfoodanalytics-*(6 files), 54 passed.dispatch-gates --commandsderived 62. All 62 were run, plus the 4 roster families (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity), all exit 0.dispatch-gates --ran, fed each command with its exit code: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero.check:dual-build-cjs-loadsfirst exited 3 (prerequisite not met) andcheck:type-check-debtwas cut by my own runner's time limit; both were re-run green afterturbo run buildover./packages/*..tsfiles, none ignored by eslint's own config (isPathIgnoredfalse for all 5).eslint --no-inline-configover them gives 5 files, 0 errors, 0 warnings.parserOptions.projectandprojectServiceare unset for all 5, so no type-aware rule runs and no untouched file's verdict can move.Docs
No hand-written
content/docs/**page states how the analytics routes treat field permissions.permissions/authorization.mdxstates the engine's field guard in general terms and stays true;permissions/index.mdxnames the analytics row-scope bridge only.Acceptance notes
@objectstack/restlocalproject and the whole-workspace typecheck. Left to CI.getReadableFields, the published reader. Where that reader and the engine's own field guard differ, the gate follows the reader; the difference is reported separately, as a class, in the dev report. (Reworded by thedomain:servicesseat under the lane's disclosure discipline.)MemoryAnalyticsService(driver-memory's own cube face) is not touched and not measured.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887, the nested-relation form) holdsanalytics-service.tsand both strategies. It mergesmainafter this lands, and the gate runs ahead of its nested-relation decline.Generated by Claude Code