Skip to content

fix(plugin-security): an OAuth MCP client's list_objects resolves its delegator once per request - #33202

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-zezcrr
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-zezcrr

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33179

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:用 OAuth 登录的 MCP 客户端代表某个人操作时,list_objects 现在每个请求只查一次「被代表的人」的权限,不再每个对象查两次。150 个对象的列表从 4620 条 SQL 降到 11 条,describe_object 从 22 条降到 11 条。
  • 为什么改:每个对象都要重新读一遍这个人的用户行、成员、授权和岗位,对象一多、数据库在远端时,环境 owner 列一次对象要几十秒,接近客户端 60 秒超时。
  • 风险与回滚:每个调用者看到的对象、字段数和顺序与 main 逐字节一致(有 golden 钉);复用只在同一个请求上下文内,且任何写入都会立即作废,撤销授权或移除成员在同一请求内也立刻生效。回滚 = revert 本 PR,不涉及数据。
  • 先看: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

  • The cost that grew. An OAuth MCP client acts on behalf of a person (ADR-0090 D10: onBehalfOf, permissions mcp_agent_data_read). For such a caller, list_objects asks the security service two questions per object, canReadObject and getMetadataReadableFields (through resolveObjectSchemaMaskPosture). Each one called resolveDelegatorContext(ql, context), which re-reads the person's sys_user row, 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 no onBehalfOf and were already constant; this matches the card's split (OAuth slow, API key fast).
  • The fix. SecurityPlugin.resolveDelegatorForContext(context) (private) memoizes resolveDelegatorContext per live context object, on the same discipline as permissionSetMemo: a WeakMap keyed on the context, reused only while the write epoch (this.epoch.current) and a key of onBehalfOf.userId, tenantId and org_user_ids stand. 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.
  • Wired at the two object-schema read sites only: canReadObject (security-plugin-object-admission.ts) and computeReadableFields (security-plugin-field-readers.ts). Both only read del.context, and both still run delegatedOrganizationRefusal themselves 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.
  • Frozen. The memoized resolution, its context and every array on it are frozen, so neither site (nor a future one) can mutate a shared answer.
  • No cache outlives the request. The memo lives on the context object, which every door that produces an onBehalfOf context builds fresh per request (checked below). mcp.ts is unchanged: the security API is per object, so a bridge-side memo could not remove these reads.

Tests

  • New 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 by assembleExecutionContext with an oauth grant, the way /mcp builds them. Statements are counted with knex's query event inside an AsyncLocalStorage.
    • ⭐ round-trip pin, for owner, member, ownerAgent and memberAgent: statements per list_objects at 150 objects equal those at 10, within 2.
    • ⭐ the delegated callers pay their delegator once: a listing costs at most one describe_object on main (22).
    • answer-identity golden: the sha256 of each caller's listing at 10 and 150 objects and of describe_object, taken from the main-side run, must match byte for byte.
    • CONTROL: describe_object answers unchanged for all four callers.
    • ⭐ invalidation pin: inside one bridge (one context), the memberAgent lists, the member's sys_member rows are deleted, and the next listing is strictly narrower and a subset (rows restored in finally).
  • Why a new file. mcp-object-schema-mask.test.ts's rig has no identity tables, so no delegator can be resolved there.
  • Census pin updated in delegated-organization-membership.test.ts. It counts the files that resolve a delegator and must also refuse a foreign organization. It now counts this.resolveDelegatorForContext(context as a resolution, and checks that the memo resolves in exactly one place. The seven-file list is unchanged.

Acceptance notes

Verification

All readings are at head 04753f4b (base 68972cd2; origin/main merged once at f8b2d950, 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)
caller objects listed (10 / 150 app objects) main statements 10 → 150 main wall 10 → 150 after statements 10 → 150 after wall 10 → 150
owner (signed in) 68 / 208 0 → 0 — 0 → 0 —
member (signed in) 29 / 29 0 → 0 — 0 → 0 —
ownerAgent (OAuth, on behalf of owner) 49 / 189 1540 → 4620 1057 ms → 2059 ms 11 → 11 31 ms → 40 ms
memberAgent (OAuth, on behalf of member) 23 / 23 1260 → 3780 642 ms → 1734 ms 9 → 9 28 ms → 44 ms

describe_object: ownerAgent 22 → 11, memberAgent 18 → 9 statements. The listings and descriptions hash identically before and after:

caller 10 objects 150 objects
owner 304fb285… 9edb5243…
member 1c5b772f… 1c5b772f…
ownerAgent f4e77a4d… d0342442…
memberAgent 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)
  • A, memo hook-up removed (resolveDelegatorForContext returns resolveDelegatorContext(this.ql, context) directly): 3 failed | 16 passed.
    • ownerAgent: expected 3080 to be less than or equal to 2
    • memberAgent: expected 2520 to be less than or equal to 2
    • delegated-once pin: ownerAgent: expected 4620 to be less than or equal to 22
    • The golden hashes still matched, so main's answers are the ones this PR keeps.
  • B, epoch check removed (a hit no longer compares this.epoch.current): 1 failed | 18 passed.
    • invalidation pin: expected 23 to be less than 23, so the delegator's removed membership was not seen inside the request.
  • Restored: 19 passed (19). ablation-dist-preflight --absent exited 0 and the tree was clean.
Suites, gates, lint
  • mcp-list-objects-statements.pin.test.ts at 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.
  • runtime MCP suites (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/null and every exit code was recorded. --ran reconciliation: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED".
    • check-plugin-teardown-shape --self-test first answered 3 on a shallow clone. It answered 0 after fetching the commit it reads.
    • check:i18n and check:dual-build-cjs-loads first answered 3 (not built). They answered 0 after a full build.
    • check:test-source-alias first answered 1 on a lazy @objectstack/metadata load 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

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 18 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via SecurityPlugin (symbol, a top-level class), canReadObject (symbol, a method of class SecurityPlugin; a top-level function), resolveProjectionFieldMask (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via canReadObject (symbol, a method of class SecurityPlugin; a top-level function))
  • content/docs/releases/v17/17-6.mdx (via canReadObject (symbol, a method of class SecurityPlugin; a top-level function))
  • content/docs/releases/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7257f57a68e250653d06fa448895c1a8abd7e179 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e6815fa962e37d3d0ae4a8825e888686e040ea6 — the merge of head 1bf877186c3a56a634b4e56104d9924ddeb74210 into base 7257f57a68e250653d06fa448895c1a8abd7e179, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7257f57a68e250653d06fa448895c1a8abd7e179 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@github-actions github-actions Bot added size/l and removed size/m labels Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33202 @ 1bf87718 · 2026-11-14T03:21Z

  • 改了什么:OAuth 代理(代某人执行的 MCP 客户端)调 list_objects 时,安全服务对每个对象都要重新解析一次被代理人(11 条语句,每对象两次)。现在同一请求上下文内只解析一次:按上下文挂一个 WeakMap 备忘,键为被代理人、租户与组织成员 id,权限纪元一变就作废,解析失败不记。
  • 为什么改:MCP list_objects takes 44 s for an environment owner on hosted (describe_object 1.7 s): close to the common 60 s client timeout #33179,owner 的代理列 150 个对象要 4620 条语句,约 44 秒;改后恒为 11 条,与对象数无关。
  • 权限与边界:不变。备忘只在一个请求上下文里复用,任何写入都会推进纪元而立即作废,所以撤销授权或移除成员在同一请求内也立刻生效。登录会话和 API key 不带 onBehalfOf,不受影响。
  • 风险与回滚:只改 plugin-security 的两处读取入口和测试,没有数据或存储变化。回滚就是 revert。
  • 先看:security-plugin.ts 的 resolveDelegatorForContext,以及 security-plugin-object-admission.ts 与 security-plugin-field-readers.ts 两处接入点。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。Clause-② no,席位自审 PASS;CI 无失败,没有碰受管面,改动 515 行。

席位核对

  • 形态: draft → main。正文写 Fixes #33179,合并后应当关卡;正文其他地方没有挨着别的 open 卡号的闭合关键词。
    • 申报 Clause-②: no、Metadata-protocol: none;@objectstack/plugin-security patch。
    • 文件面 = 认领 6868398958 加修订 6868565123(plugin-security 的委托解析备忘与它的身份、失效钉),6 个文件都在其中。
  • 前提实测: dev 报告 6869800389。main 上 OAuth owner 代理在 10 → 150 个对象时为 1540 → 4620 条语句,describe_object 为 22 条;本 PR 为 11 → 11 条与 11 条。API key 与登录会话在两边都是 0 条。
  • 门禁 check-runs @ 1bf87718: 35 success、16 skipped、0 失败、0 进行中。
  • 第二轮(04753f4b → 1bf87718): 合入 origin/main 66919b6(无冲突,main 未改 plugin-security),再加一次提交,只改了 packages/runtime/src/mcp-list-objects-statements.pin.test.ts,其余 5 个文件逐字节不变。
    • 起因:Test Core 3/8 上 owner 的 golden sha256 不符,而计数相同。在 04753f4b 的干净树上,旧钉 19/19 通过。合并后,两件已合入 main 的变化改了哈希:feat(plugin-auth): an SSO provider can sign the person out of the company sign-in too, per provider and off by default #33170 给 sys_sso_provider 加了一个字段(仅 owner 可见,15 → 16),feat(platform-objects,mcp): a member's agent finds a colleague through the read-only sys_user #33180 把 sys_user 加进了四个调用者的 sys_* 读取白名单。备忘本身没有改变任何答案。
    • 新钉:同一棵树里,每个调用者在 10 和 150 个对象时各读两遍,一遍走备忘,一遍绕过备忘(每次询问前删掉该上下文的备忘项,即 main 的逐对象解析),两次答案必须深相等。CONTROL:绕过时的语句数仍随对象数增长;describe_object 两边答案相同,备忘一边不更贵。冻结的 golden 与 sha256 已删。20/20 通过。
    • 消融:A(关掉备忘接入)3 红;B(关掉纪元检查)1 红,即失效钉;C(备忘命中时返回错误的解析结果)4 红,包括 memberAgent 的新身份钉;复原后 20/20,preflight --absent 为 0,树干净。
  • 契约复审: Clause-② no,没有碰契约四面,所以由席位自审,不另派复审者:
    • 备忘键包含被代理人、租户和组织成员 id,换人、换租户、换组织都会落到新的项;
    • 纪元不符即重新解析,解析被拒(抛错)不进备忘,下一次询问会重试;
    • 存入的解析结果已冻结,调用方无法改写共享项;
    • 只有 canReadObject 与 resolveProjectionFieldMask 两处接入,没有改动这两处的判断逻辑;
    • WeakMap 以上下文对象为键,请求结束、上下文被回收后备忘随之释放;每个入口每个请求都新建上下文(认领修订的条件),所以备忘不会跨请求。
  • 接受的偏差: PR 正文有几处仍写的是第一轮,正文不改,以本条为准:
    • 「golden 钉」「answer-identity golden … taken from the main-side run」「The golden hashes still matched」三处:现在是同一棵树里备忘与绕过两条路径深相等,见上文。
    • 「两分钟验证」应为 20/20 通过,不是 19/19。
    • 「Verification」应以 1bf87718 为准;sha256 表已删,以上面的读数为准。
  • 范围外去向: 无。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z);本卡按「mcp 相关 p2 任务也可以派发处理」 (2026-11-13T15:50Z) 入本席。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP list_objects takes 44 s for an environment owner on hosted (describe_object 1.7 s): close to the common 60 s client timeout

2 participants