Summary
Once the OAuth-for-MCP door is open (with #16530 worked around locally), a user connecting "as themselves" sees far less than they see in the Console or through a per-user API key. Measured with the same account, same questions, same server:
| identity path |
crm_account |
crm_opportunity |
crm_task |
API key bound to sales.manager (principalKind: human) |
9 |
23 (11 in Q3) |
45 |
OAuth as sales.manager (principalKind: agent, onBehalfOf = sales.manager) |
5 |
0 |
0 |
The Setup → Connect an Agent page says, verbatim: "Identity is self-serve: … you connect as yourself, and every call runs under your own permissions and row-level security." and, in the page header, "every call runs under the caller's own permissions and row-level security." The OAuth path does not do that: it runs under the intersection of the caller's permission sets and a platform agent ceiling that has no viewAllRecords, so any user whose access comes from viewAllRecords / modifyAllRecords (every manager-type profile) collapses to own + explicit shares.
Environment
- hotcrm
789a7324, @objectstack/* 17.3.0, dev server with OS_AUTH_URL=https://localhost:4443 behind a local TLS proxy
- Claude Code desktop (Claude Code 2.1.260), MCP entry
https://localhost:4443/api/v1/mcp, OAuth (DCR + PKCE), consent granted for data:read data:write actions:execute offline_access as sales.manager@objectos.ai
sys_oauth_consent row: scopes ["data:read","data:write","actions:execute","offline_access"], user_id = sales.manager
Reproduction
- Connect via OAuth as sales.manager (profile
sales_manager: crm_opportunity / crm_account / crm_lead … all viewAllRecords: true, modifyAllRecords: true).
- Ask the agent: 「本季度金额前十的商机里,哪些超过两周没有跟进?」 — it runs
query_records / aggregate_records on crm_opportunity, crm_account, crm_task and reports crm_account 5, crm_opportunity 0, crm_task 0 (count(*) = 0 with and without filters). No error is returned — empty sets.
- Same account through
x-api-key (per-user key from POST /api/v1/keys), same questions: 9 accounts, 23 opportunities, 45 tasks, and the Console shows the same.
Where it happens (@objectstack/plugin-security 17.3.0)
resolveDelegatorContext / the CRUD explain path: when context.onBehalfOf.userId is set, sets = the agent principal's sets and delegatorSets = the user's; crudAllowed = agentCrud && delegatorCrud, field masks are intersected, and read scope goes through narrowerScope(a, b) over ["own","own_and_reports","unit","unit_and_below","org"].
- The agent principal's set for a
data:write token is the platform's mcp_agent_data_write: {"*": {allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: false, viewAllRecords: false, modifyAllRecords: false}, sys_*: read-only} (mcp_agent_data_read is the same with read only). Its description: "Read+write ceiling for an AI agent acting on behalf of a user (OAuth data:write)".
narrowerScope(agent = own, delegator = org) → own. Rows then come only from ownership (sales.manager owns nothing in the demo seed — everything is owned by the dev admin) plus sharing rules aimed at her position: hotcrm's account_team_sharing → the 5 accounts; the opportunity rules target sales_director / executive → 0.
hasWriteBypass returns false and resolveWriteScope returns "own" unconditionally for any on-behalf-of context (if (!context?.userId || context?.onBehalfOf?.userId) return "own").
So the behaviour is exactly what D10 + the shipped ceiling specify. The problem is that the ceiling is below the user for the most common case (any profile whose visibility is viewAllRecords), while the UI and the docs promise parity with the user.
Expected — one of
- Parity with the user (what the Setup page promises): the agent ceiling for
data:read / data:write should carry viewAllRecords / modifyAllRecords (or the intersection should treat the ceiling's scope as "no opinion" and take the delegator's), so that agent ∩ user = user for CRUD/RLS, with the ceiling only removing things the scopes exclude (e.g. no writes on a data:read token, no allowTransfer).
- Keep the ceiling, fix the promise: change the Setup page / SKILL.md / discovery
resource_name copy to say that OAuth agents run under own-records + shares regardless of the user's profile, and document that managers must use a per-user API key to see what they see in the Console.
Either way the current combination — the page says "as yourself", the engine says "own only" — will be read by users as data loss ("the AI says I have no opportunities").
Side observations
Summary
Once the OAuth-for-MCP door is open (with #16530 worked around locally), a user connecting "as themselves" sees far less than they see in the Console or through a per-user API key. Measured with the same account, same questions, same server:
principalKind: human)principalKind: agent,onBehalfOf= sales.manager)The Setup → Connect an Agent page says, verbatim: "Identity is self-serve: … you connect as yourself, and every call runs under your own permissions and row-level security." and, in the page header, "every call runs under the caller's own permissions and row-level security." The OAuth path does not do that: it runs under the intersection of the caller's permission sets and a platform agent ceiling that has no
viewAllRecords, so any user whose access comes fromviewAllRecords/modifyAllRecords(every manager-type profile) collapses toown + explicit shares.Environment
789a7324,@objectstack/*17.3.0, dev server withOS_AUTH_URL=https://localhost:4443behind a local TLS proxyhttps://localhost:4443/api/v1/mcp, OAuth (DCR + PKCE), consent granted fordata:read data:write actions:execute offline_accessassales.manager@objectos.aisys_oauth_consentrow: scopes["data:read","data:write","actions:execute","offline_access"],user_id= sales.managerReproduction
sales_manager:crm_opportunity/crm_account/crm_lead… allviewAllRecords: true, modifyAllRecords: true).query_records/aggregate_recordsoncrm_opportunity,crm_account,crm_taskand reportscrm_account 5,crm_opportunity 0,crm_task 0(count(*)= 0 with and without filters). No error is returned — empty sets.x-api-key(per-user key fromPOST /api/v1/keys), same questions: 9 accounts, 23 opportunities, 45 tasks, and the Console shows the same.Where it happens (
@objectstack/plugin-security17.3.0)resolveDelegatorContext/ the CRUD explain path: whencontext.onBehalfOf.userIdis set,sets= the agent principal's sets anddelegatorSets= the user's;crudAllowed = agentCrud && delegatorCrud, field masks are intersected, and read scope goes throughnarrowerScope(a, b)over["own","own_and_reports","unit","unit_and_below","org"].data:writetoken is the platform'smcp_agent_data_write:{"*": {allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: false, viewAllRecords: false, modifyAllRecords: false}, sys_*: read-only}(mcp_agent_data_readis the same with read only). Its description: "Read+write ceiling for an AI agent acting on behalf of a user (OAuthdata:write)".narrowerScope(agent = own, delegator = org)→own. Rows then come only from ownership (sales.manager owns nothing in the demo seed — everything is owned by the dev admin) plus sharing rules aimed at her position: hotcrm'saccount_team_sharing→ the 5 accounts; the opportunity rules targetsales_director/executive→ 0.hasWriteBypassreturnsfalseandresolveWriteScopereturns"own"unconditionally for any on-behalf-of context (if (!context?.userId || context?.onBehalfOf?.userId) return "own").So the behaviour is exactly what D10 + the shipped ceiling specify. The problem is that the ceiling is below the user for the most common case (any profile whose visibility is
viewAllRecords), while the UI and the docs promise parity with the user.Expected — one of
data:read/data:writeshould carryviewAllRecords/modifyAllRecords(or the intersection should treat the ceiling's scope as "no opinion" and take the delegator's), so thatagent ∩ user = userfor CRUD/RLS, with the ceiling only removing things the scopes exclude (e.g. no writes on adata:readtoken, noallowTransfer).resource_namecopy to say that OAuth agents run under own-records + shares regardless of the user's profile, and document that managers must use a per-user API key to see what they see in the Console.Either way the current combination — the page says "as yourself", the engine says "own only" — will be read by users as data loss ("the AI says I have no opportunities").
Side observations
total: 0/n: 0, never "narrowed by agent ceiling". The engine's explain path (layer: "object_crud", "D10 intersection") has the sentence; the MCPquery_recordsresult does not carry it.<client_id>wants to access your account —<client_id>is requesting permission (sales.manager@objectos.ai). This app will be able to: ✓ data:read ✓ data:write ✓ actions:execute ✓ Stay signed in (refresh access) — Deny / Authorize — You can revoke access at any time from your account settings." The client is shown as its DCR id, not as "Claude Code (hotcrm)" (the registeredname).validAudiences, which @better-auth/oauth-provider 1.7.2 no longer reads — everyresource=request fails withinvalid_target … is not configured#16530 being worked around; related demo cards MCPrun_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705, MCPrun_actionon a flow action answersok: trueand starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #16370.