Summary
A user with member role in an org can call ORGANIZATION_MEMBER_UPDATE_ROLE against their own member row and promote themselves to admin. This breaks the segregation of duties expected for non-tech members (e.g. CEO, compliance, support) operating an MCP gateway. Related closed issues #327 (Segregation of access) and #853 ([Permissions] authorization for any integration tool) suggest this is supposed to work, but the surface area shipped today is permissive.
Reproduce
- Log in as org
owner, invite a second account with role member.
- Sign in as the new member. Confirm the role on
member table is member.
- From the member's session, call the management MCP self endpoint:
curl -X POST 'http://<studio>/api/<org>/mcp/self' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Origin: http://<studio>' -b <member-session-cookie> \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"ORGANIZATION_MEMBER_UPDATE_ROLE",
"arguments":{"memberId":"<their-own-member-id>","role":["admin"]}}}'
- Observed: the DB row flips to
admin. The member is now an admin.
- Expected: 403 Access denied —
ORGANIZATION_MEMBER_UPDATE_ROLE requires admin/owner; even with admin, mutating one's own row should be guarded.
What I think is going on
apps/mesh/src/auth/index.ts registers user/admin/owner roles as { self: ["*"], ...adminAc.statements } — wildcard on every MCP tool. Better Auth's organization plugin default for new members is the role string "member", which isn't in the roles: { user, admin, owner } map, so member rows fall through to undefined or some permissive default.
- Even when patched locally so
roles includes a narrowly-scoped member (read-only on connections + memberAc.statements from the org plugin), the call still succeeded. Smells like ctx.access.check has an alternate code path, or Better Auth caches role defs across bun --hot reloads.
AccessControl.checkResource at apps/mesh/src/core/access-control.ts early-returns true for roles literally named admin or owner, then delegates to boundAuth.hasPermission. The permissionToCheck is keyed by connectionId (default "self") and the action is the tool name. Worth instrumenting to log what boundAuth.hasPermission actually returns for a member calling ORGANIZATION_MEMBER_UPDATE_ROLE.
Desired outcome (proposed scope)
member role gets read-only access to org metadata and connections, plus write access ONLY to their own per-user OAuth tokens. No mutation of org settings, members, invitations, connection definitions, virtual MCPs.
- Built-in roles
owner/admin/member are first-class and consistent between static config (apps/mesh/src/auth/index.ts) and the Dynamic Access Control surface (creating a "custom user" from a built-in role currently 400s with THAT_ROLE_NAME_IS_ALREADY_TAKEN).
- Integration test that asserts a
member-role caller cannot call any tool in the ORGANIZATION_* / COLLECTION_* mutation families, nor MEMBER_UPDATE_ROLE against themselves.
Context
Surfaced while building per-user OAuth on downstream MCPs (CEO uses Notion as themselves, compliance uses internal tools as themselves) over an organization-wide unified MCP gateway. The feature works end-to-end, but role isolation is the missing safety floor for non-tech members.
Summary
A user with
memberrole in an org can callORGANIZATION_MEMBER_UPDATE_ROLEagainst their own member row and promote themselves toadmin. This breaks the segregation of duties expected for non-tech members (e.g. CEO, compliance, support) operating an MCP gateway. Related closed issues #327 (Segregation of access) and #853 ([Permissions] authorization for any integration tool) suggest this is supposed to work, but the surface area shipped today is permissive.Reproduce
owner, invite a second account with rolemember.membertable ismember.admin. The member is now an admin.ORGANIZATION_MEMBER_UPDATE_ROLErequires admin/owner; even with admin, mutating one's own row should be guarded.What I think is going on
apps/mesh/src/auth/index.tsregistersuser/admin/ownerroles as{ self: ["*"], ...adminAc.statements }— wildcard on every MCP tool. Better Auth's organization plugin default for new members is the role string"member", which isn't in theroles: { user, admin, owner }map, so member rows fall through to undefined or some permissive default.rolesincludes a narrowly-scopedmember(read-only on connections +memberAc.statementsfrom the org plugin), the call still succeeded. Smells likectx.access.checkhas an alternate code path, or Better Auth caches role defs acrossbun --hotreloads.AccessControl.checkResourceatapps/mesh/src/core/access-control.tsearly-returnstruefor roles literally namedadminorowner, then delegates toboundAuth.hasPermission. ThepermissionToCheckis keyed byconnectionId(default"self") and the action is the tool name. Worth instrumenting to log whatboundAuth.hasPermissionactually returns for a member callingORGANIZATION_MEMBER_UPDATE_ROLE.Desired outcome (proposed scope)
memberrole gets read-only access to org metadata and connections, plus write access ONLY to their own per-user OAuth tokens. No mutation of org settings, members, invitations, connection definitions, virtual MCPs.owner/admin/memberare first-class and consistent between static config (apps/mesh/src/auth/index.ts) and the Dynamic Access Control surface (creating a "custom user" from a built-in role currently 400s withTHAT_ROLE_NAME_IS_ALREADY_TAKEN).member-role caller cannot call any tool in theORGANIZATION_*/COLLECTION_*mutation families, norMEMBER_UPDATE_ROLEagainst themselves.Context
Surfaced while building per-user OAuth on downstream MCPs (CEO uses Notion as themselves, compliance uses internal tools as themselves) over an organization-wide unified MCP gateway. The feature works end-to-end, but role isolation is the missing safety floor for non-tech members.