Repository navigation
fix(plugin-security): an OAuth MCP client's list_objects resolves its delegator once per request - #33202
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 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 1e6815fa962e37d3d0ae4a8825e888686e040ea6 && git checkout 1e6815fa962e37d3d0ae4a8825e888686e040ea6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7257f57a68e250653d06fa448895c1a8abd7e179 1bf877186c3a56a634b4e56104d9924ddeb74210 && git checkout -B drift-repro 7257f57a68e250653d06fa448895c1a8abd7e179 && git merge --no-ff 1bf877186c3a56a634b4e56104d9924ddeb74210
node scripts/docs-audit/affected-docs.mjs --json 7257f57a68e250653d06fa448895c1a8abd7e179
|
…in the same tree The answer-identity pin compared each caller's listing against sha256 values frozen from one main commit, so any later label, field or read opt-in change on main broke it. It now reads every caller twice in the tree under test, as shipped and with the delegator memo bypassed, and requires deep-equal answers. A CONTROL shows the bypassed readings still grow with the object count. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx 维护者速读 · PR #33202 @
|
Requested by Tesla · project thread
Fixes #33179
Clause-②: no
Metadata-protocol: none
维护者速读
list_objects现在每个请求只查一次「被代表的人」的权限,不再每个对象查两次。150 个对象的列表从 4620 条 SQL 降到 11 条,describe_object从 22 条降到 11 条。packages/plugins/plugin-security/src/security-plugin.ts新增的resolveDelegatorForContext(约 L3776–L3845),以及 object-admission / field-readers 两处调用点的改动。pnpm --filter @objectstack/runtime exec vitest run src/mcp-list-objects-statements.pin.test.ts,预期 19/19 通过。What changes
onBehalfOf, permissionsmcp_agent_data_read). For such a caller,list_objectsasks the security service two questions per object,canReadObjectandgetMetadataReadableFields(throughresolveObjectSchemaMaskPosture). Each one calledresolveDelegatorContext(ql, context), which re-reads the person'ssys_userrow, memberships, permission-set grants, positions and activation rows (11 statements), and returns a fresh delegator context, so the per-context permission-set memo missed every time. That is 22 statements per listed object. Signed-in sessions and API keys carry noonBehalfOfand were already constant; this matches the card's split (OAuth slow, API key fast).SecurityPlugin.resolveDelegatorForContext(context)(private) memoizesresolveDelegatorContextper live context object, on the same discipline aspermissionSetMemo: aWeakMapkeyed on the context, reused only while the write epoch (this.epoch.current) and a key ofonBehalfOf.userId,tenantIdandorg_user_idsstand. All three resolution kinds (none,missing,resolved) are memoized. A rejected resolution is dropped, not kept. Because the same resolved delegator context comes back, the delegator's own permission-set memo hits too.canReadObject(security-plugin-object-admission.ts) andcomputeReadableFields(security-plugin-field-readers.ts). Both only readdel.context, and both still rundelegatedOrganizationRefusalthemselves on every call. The other call sites (read filter, write admission, export, explain, operation principals, scope) are untouched, because some of them carry the delegator context onward.contextand every array on it are frozen, so neither site (nor a future one) can mutate a shared answer.onBehalfOfcontext builds fresh per request (checked below).mcp.tsis unchanged: the security API is per object, so a bridge-side memo could not remove these reads.Tests
packages/runtime/src/mcp-list-objects-statements.pin.test.ts, a full kernel (ObjectQLPlugin, sqlite-wasm datasource,PlatformObjectsPlugin,AuthPlugin,SecurityPlugin,SharingServicePlugin, REST, dispatcher) with the dev admin as owner and a member signed up by invitation. The OAuth callers are built byassembleExecutionContextwith anoauthgrant, the way/mcpbuilds them. Statements are counted with knex'squeryevent inside anAsyncLocalStorage.list_objectsat 150 objects equal those at 10, within 2.describe_objecton main (22).describe_object, taken from the main-side run, must match byte for byte.describe_objectanswers unchanged for all four callers.sys_memberrows are deleted, and the next listing is strictly narrower and a subset (rows restored infinally).mcp-object-schema-mask.test.ts's rig has no identity tables, so no delegator can be resolved there.delegated-organization-membership.test.ts. It counts the files that resolve a delegator and must also refuse a foreign organization. It now countsthis.resolveDelegatorForContext(contextas a resolution, and checks that the memo resolves in exactly one place. The seven-file list is unchanged.Acceptance notes
buildContextForUser: "⭐ Ruled bypass of the Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633 leg-B grants cache", maintainer acceptance 2026-08-25). That ruling keeps the explainer off the cross-request grants cache. It still holds. The new memo is per context, epoch-invalidated, and not a grants cache.buildContextForUserkeepsbypassGrantsCache: true, and the explain call sites are untouched.onBehalfOf(seat condition 1)./mcpdoor builds it per request (resolveExecutionContext→assembleExecutionContextwithoauth).req(execCtxMemo).assembleExecutionContextcallers passoauth: undefined, so they produce no delegated context.toolCallPrincipalOfis a frozen projection, not a context the security service is asked with. The explain engine'sonBehalfOf(explain-engine.ts) is output only.runtime/src/domains/mcp.ts, which this PR does not edit. No open PR touches the three plugin-security files.ruling|Ruling-ref|maintainer|⛔) on the touched surface: security-plugin.ts 80 hits (only "ruling 6278784335 letter B" touches delegation, outside the edited region); object-admission 3; field-readers 1. None governs this memo besides authz caching leg B: grants cache — coarse invalidation, default TTL=0 (off), expiry-boundary expiry, bypass list #11971 above.Verification
All readings are at head
04753f4b(base68972cd2;origin/mainmerged once atf8b2d950, with no overlap with the touched files), unless named otherwise.Measured at origin/main first, then after (statements per call, real kernel, in-memory sqlite)
describe_object: ownerAgent 22 → 11, memberAgent 18 → 9 statements. The listings and descriptions hash identically before and after:304fb285…9edb5243…1c5b772f…1c5b772f…f4e77a4d…d0342442…6e73539a…6e73539a…Growth on main is 22 statements per extra listed object for ownerAgent ((4620 − 1540) / 140), which is 2 security calls × 11 statements.
Ablations (each leg rebuilt through dist, then restored)
resolveDelegatorForContextreturnsresolveDelegatorContext(this.ql, context)directly): 3 failed | 16 passed.expected 3080 to be less than or equal to 2expected 2520 to be less than or equal to 2ownerAgent: expected 4620 to be less than or equal to 22this.epoch.current): 1 failed | 18 passed.expected 23 to be less than 23, so the delegator's removed membership was not seen inside the request.ablation-dist-preflight --absentexited 0 and the tree was clean.Suites, gates, lint
mcp-list-objects-statements.pin.test.tsat head: Tests 19 passed (19).pnpm --filter @objectstack/plugin-security test: Test Files 267 passed (267), Tests 6605 passed | 45 skipped (6650), exit 0.pnpm --filter @objectstack/runtime test: Test Files 598 passed (598), Tests 10620 passed | 19 skipped (10639), exit 0.mcp-object-schema-mask, the bridge tests): 5 files, 251 tests passed.pnpm --filter @objectstack/plugin-security --filter @objectstack/runtime typecheck: exit 0.node scripts/pm/dispatch-gates.mjs --commands: 70 families derived. All 70 were run with</dev/nulland every exit code was recorded.--ranreconciliation: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED".check-plugin-teardown-shape --self-testfirst answered 3 on a shallow clone. It answered 0 after fetching the commit it reads.check:i18nandcheck:dual-build-cjs-loadsfirst answered 3 (not built). They answered 0 after a full build.check:test-source-aliasfirst answered 1 on a lazy@objectstack/metadataload in the new pin. The load moved to the module top, and the gate now answers 0.pnpm lint: exit 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01L9qibzmMRNkjCwvFhzunNc