Repository navigation
fix(service-analytics)!: the analytics door judges the generic-exit declarations — an object's enable block and a field's internal flag (#22634) - #22645
Conversation
…e analytics door Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…clarations every other door judges An object's enable block is asked through the spec's one exposure decision for the aggregate operation, and a member reading a field declared internal: true is refused in every position, for every caller, ahead of strategy selection. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…osure pins Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…ions Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…alytics-exposure-gate
📓 Docs Drift CheckThis PR changes 2 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 139 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 bcee50ca7fc3e8b3f3fe8163c5c8d23aa824f28e && git checkout bcee50ca7fc3e8b3f3fe8163c5c8d23aa824f28e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b 6709a204273e6a5af101d9d94ea1a7e4aa97c126 && git checkout -B drift-repro cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b && git merge --no-ff 6709a204273e6a5af101d9d94ea1a7e4aa97c126
node scripts/docs-audit/affected-docs.mjs --json cc305a3cfc730dcf538c791fa5ff43d3ad3a7c4b
|
…alytics-exposure-gate
…sure codes in the error-code ledger Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…alytics-exposure-gate
Contract reviewServed-tier: Inputs: card #22634 (body and its four comments: claim ① Derived judgmentsGate verdicts. All 35 named check-runs on the head are complete, read 2026-10-10T09:36Z: 30 1. Object facet — the accept set narrows. Every object a query reads (base, declared joins and 2. Field facet — the accept set narrows. Any member that resolves to a field declared 3. Admission order. RIGHT. In 4. Coverage. Every entry is covered, at the one seam ahead of One read inside a covered entry is not judged: 5. Every caller, fail direction. RIGHT. Neither gate takes a user or a context; there is no system carve-out (stricter than the MCP stdio bridge, which stands down for a system context). A provider throw refuses 6. Public surface. Additive only: 7. The 405 whitelist facet ("bounded in-place widening"). RIGHT to include. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #22634
Clause-②: no (narrowing)
The analytics door now judges the two generic-exit declarations every other door judges:
enableblock is judged through the spec's one exposure decision,apiExposureDenialReason, for theaggregateoperation.internal: trueis refused.Both bind every caller, administrators included, on both strategies, and both are asked before any strategy is selected.
Step 1 — the measurement, by class
The measurement used a real stack:
@objectstack/verifybootStackwith the realSecurityPlugin, onsqlite-wasm. The native-SQL strategy answered on that driver; the ObjectQL strategy was forced by withholding the native-SQL capability. Callers were a member holding read on every subject through a fallback permission set, and the seeded platform administrator. Subjects were two platform credential stores (sys_jwks,sys_oauth_access_token),sys_session, and fixture objects for the classes no platform object isolates. The probe was a scratch file and is not committed. No values are quoted below.Before, on
origin/mainf368b7e980. "Served" means200with the rows; for a column, the stored value came back as a group key, or the filter answered differently for a stored value and for any other value.apiEnabled: false404 OBJECT_API_DISABLEDapiMethodsomitslist405 OBJECT_API_METHOD_NOT_ALLOWEDinternalfield as a dimensionsys_jwksandsys_oauth_access_tokentoo500500internalfield as an aggregate input500internalfield as a filter operandinternalfield in the SQL faceAfter, on
013c717f91(this branch merged withorigin/main). Same stack, same subjects, both strategies, both callers, one answer per row:apiEnabled: false404 OBJECT_API_DISABLED, in the data door's wordslist405 OBJECT_API_METHOD_NOT_ALLOWEDinternalfield, in any position (dimension, measure input, filter, SQL face, configured cube, dataset)400 INVALID_FIELD, naming the object and the fieldinternalfield on anapiEnabled: falseobject404 OBJECT_API_DISABLED(the object answers first)200, unchanged: an ordinary column, a bare count over an object that has an internal field,sys_sessioncountedsys_approval_tokenisapiEnabled: falsesince #22633 landed. WithApprovalsServicePluginmounted, it answered404 OBJECT_API_DISABLEDon every door, both strategies and both callers. The codes reach the wire on all three route families:/analytics/queryand/analytics/sqlthrough the runtime dispatcher, and/analytics/dataset/querythrough the REST server.Census
Read from the built object definitions on
f368b7e980, plus #22633:apiEnabled: false:sys_flow_credential,sys_jwks,sys_oauth_access_token,sys_oauth_client_assertion,sys_oauth_client_resource,sys_oauth_consent,sys_oauth_refresh_token,sys_oauth_resource, and since fix(plugin-approvals)!: sys_approval_token, the action-link tokens, is no longer exposed through the automatic API (#22616) #22633sys_approval_token. Four of them also declare aninternalfield:sys_jwks,sys_oauth_access_token,sys_oauth_refresh_token,sys_approval_token.list:sys_device_code,sys_two_factor,sys_verification.internalfields on exposed objects:sys_account(5),sys_api_key,sys_email,sys_http_delivery,sys_oauth_application,sys_scim_connection_credential,sys_session,sys_share_link(2),sys_sso_provider(2),sys_two_factor(2),sys_verification(2).examples/declares noapiEnabled: false, noapiMethodsand nointernal. The only authored analytics oversys_*objects are the System Overview datasets. They countsys_user,sys_organizationandsys_session, and groupsys_audit_logbyactionanduser_id. Each of those objects grantslist, and none of those fields isinternal.Which precedent decided refused vs withheld
Measured on the data door,
origin/main, both callers:internalfield is withheld. It is omitted from rows, including whenselectnames it.POST /data/:object/querygrouping by it): refused, by the engine'srejectCredentialAggregation(rejectCredentialAggregationkeys off thesecret/passwordTYPES, so aninternal-flagged column is not covered — the same type-vs-flag gap #7728 just fixed on the read path #7922), because a group key cannot be withheld without changing what the groups count. The refusal arrives as an undeclared500.Every analytics member is evaluated, never projected: a group key, an aggregate input, a predicate. So nothing can be omitted, and the governing precedent is the aggregate face's: refuse. The card's Ask names the filter position too. The envelope is the one this door already uses for a member it will not evaluate whoever asks, the stored-metadata-body refusal (#21120):
400 INVALID_FIELD, naming the object and the field.The change
packages/services/service-analytics/src/api-exposure-door.ts, new:assertObjectsExposedasksapiExposureDenialReason(enable, 'aggregate'). It honours both answers: off switch →404, whitelist →405with the effective set. It holds no rule of its own.assertNoInternalFieldNamedasks@objectstack/core'scollectInternalWriteResponseFields, the collector every write mouth and the knowledge index already reuse, over whatnamedQueryFieldsresolves (dimensions, measures, filters, sort keys, dataset filters, relationship hops).403 PERMISSION_DENIED, logged aterror.analytics-service.tsasks the gates at these seams:ensureCubeand before dataset compile, over the named cube's objects. A refused ad-hoc request infers nothing (analytics: an ad-hoc/analytics/queryor/analytics/sqlrequest writes inferred and augmented cubes into the shared registry before admission, so a refused request still changes every member'smeta#20381 stays closed).callCtx, over the fullqueryObjectsset, relationship hops included, ahead of the read admission (exposure first, then permission, the data door's order). The field half sits beside the stored-metadata-body refusal.callCtx.plugin.tswiresgetObjectDeclarationfrom the data engine'sgetObject(), and throws (refuses) when no engine can answer. There is no system carve-out and no persona input. A host that buildsAnalyticsServiceby hand with no hook gets no gate and a one-time warning, the same as the other providers.Bounded in-place widening, stated. The card names the off switch. Honouring only that half of the decision would be a second rule: the whitelist read as unrestricted at this door alone. So the
405facet is included. It is the same defect class, in the same file, with the same envelope family. No in-repo analytics reads an object whose whitelist omitslist(census above).Pins and their ablation
src/__tests__/api-exposure-door.test.ts: 30 cases.Harness: a real ObjectQL engine and a real
SqlDriver, underAnalyticsServicePlugin's own composition, on both strategies. Two callers: a member and an administrator, each granted read on every object and field by the security double.Negative pins, per facet: off switch, whitelist, no minting on refusal, internal field in eight positions, internal field through a hop. Each asserts the envelope (
code+status+object[+field]) and that nothing read the object.Controls: an ordinary object and field; an object that has an internal field, served for its other fields and through a hop.
Fail-closed: a throwing lookup; a plugin with no data engine.
Red first: at
6c5eb0278f(the pins only, on the unmodified door), 22 failed / 8 passed. The 8 are the controls. The reds were rows served, or the engine's undeclared refusal.Green: at
160b2200db, 30/30.Ablation: run through
scripts/ablation-replace.mjsata7c40fbb2d. Every anchor hit as declared and every blob changed. Each restore is proven by the blob equalling HEAD withgit diff HEADempty. The subject is imported from source, so no build sits between mutation and run.callCtxhalf still refuses, so the entry gate is what keeps a refused request from inferring a cube).Tests and gates
All at
013c717f91, this branch merged withorigin/main, unless a line says otherwise.@objectstack/service-analyticstest:vitest run --maxWorkers=2, 181 files, 4473 passed / 262 skipped.@objectstack/service-analyticstypecheck:tsc --noEmitexit 0. The package tsconfig compiles the tests; it caught one unused parameter in the pins, fixed ina7c40fbb2d.@objectstack/spectest:repo: 54 files / 915 tests passed. The new stamp sites are in that project's scan reach.dispatch-gates --commands --repo objectstack-ai/objectstack(no paths) derived 65 commands from the real change set (5 paths vs merge base86da19491). All 65 exited 0, each exit captured before any pipe.--ranwith exit codes recorded: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, packages withoutdist/); after the full build (72 tasks, 71 cached) it exited 0 (107 entries load).check:adr-0087-registrationexit 0: the changeset readsnot-required (no-migration-prescription).check-changeset-no-majorexit 0.check:error-code-provenance(spec) exit 1: the named gap above, two stamp sites and nothing else (337 sites: 317 listed, 18 waived).eslint --no-inline-configon the 4 changed.tsfiles.--print-configresolves 6, 6, 6 and 5 active rules.--format jsongives 4 files, 0 errors, 0 warnings.parserOptions.project, noprojectService), so the verdicts of untouched files cannot move.pnpm lintis CI's.bootStack, before and after; the tables above. Not committed.Acceptance notes
internalfield. Onorigin/main, its list and query routes answer differently for the stored value and for any other value. That is a confirmation oracle over a withheld value, for any caller holding read. It is outside this card's surface and reported to the seat as a security finding.500. It is a refusal of the caller's request, not a server fault. Reported to the seat.check:error-code-provenanceis red until the ledger row lands (the named gap above).GET /analytics/metastill lists a configured cube over anapiEnabled: falseobject. That is metadata, not rows. Querying the cube is refused. Not changed here.registerDatasetdoes not refuse a dataset over such an object; the query is refused instead. An authoring-time refusal would be a new gate, and none is added.$expandjudges the target's exposure. No in-repo reach: the only lookup into anapiEnabled: falseobject starts from another one.@objectstack/mcp's stdio bridge stampsOBJECT_API_DISABLEDwithout a ledger row of its own. Its constant's name is outside the provenance gate's declared patterns. Noted, not filed.Seat's append: patch round 1 (head
6709a20427)Appended by
domain:servicesseat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T09:23Z, from the dev's round-1 report (6096072918on #22634).The seat answered open question 1 with A: claim amendment
6095740822, with the spec-lane declaration on [PM seat] domain:spec — 🟢 os-project-manager · session_01S3aAf11JjbW1mSGL1EhfFj #6017.The data door's two findings are filed as security(data): the data door's filter and group-by positions do not honour a field's
internal: truethe way its row read does — detail withheld pending maintainer #22646: a filter on aninternalfield, and the group-by500.The ledger row. In
packages/spec/src/api/error-code-ledger.zod.ts, the existing'@objectstack/service-analytics'row now listsOBJECT_API_DISABLEDandOBJECT_API_METHOD_NOT_ALLOWED, in alphabetical order. A comment names the measured wire paths:/analytics/queryand/analytics/sqlthrough the runtime dispatcher, and/analytics/dataset/querythrough rest. The change is value-only: no other spec file, no export, no schema change.Nothing generated moved.
REGISTERED_ERROR_CODESis byte-identical before (e2b9e83626) and after: 279 codes, the same array, the same sha256 prefixb4910b3ab70945c7.check:generatedreports all 15 generated artifacts up to date and nothing regenerated.check:error-code-provenance: exit 0. 337 stamp sites: 319 listed (was 317), 18 waived.Changeset. The ledger row ships in
@objectstack/spec's published files, so it gets its ownpatchchangeset,.changeset/22634-spec-ledger-analytics-exposure-codes.md. That keeps the analytics BREAKING narrative out of spec's CHANGELOG.origin/mainmerged twice, asd85615ddd9andcc305a3cfc. Both merges were clean, with no regeneration debt.Gates and tests at
6709a20427:dispatch-gates --commandsderived 90 commands from 7 paths. All 90 exited 0, and--ranreports 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN.check:adr-0087-registrationandcheck:error-code-provenanceare among them.@objectstack/service-analytics: 181 files, 4473 passed and 262 skipped. Typecheck exits 0.@objectstack/spec: test (local) 642 files, 19171 passed.test:repo54 files, 915 passed. Typecheck exits 0..tsfiles: 0 errors, 0 warnings.Generated by Claude Code