Skip to content

[Bug] Org member can self-promote to admin and bypass role boundaries #3388

@AriOliv

Description

@AriOliv

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

  1. Log in as org owner, invite a second account with role member.
  2. Sign in as the new member. Confirm the role on member table is member.
  3. 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"]}}}'
  1. Observed: the DB row flips to admin. The member is now an admin.
  2. 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)

  1. 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.
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions