decocms / decocms/studio

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

Open
#3,388 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
407
Forks
57
Avg merge
5h 6m
Merged PRs (30d)
880

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with apps/mesh/src/auth/index.ts and apps/mesh/src/core/access-control.ts, tracing ctx.access.check and boundAuth.hasPermission for a member calling ORGANIZATION_MEMBER_UPDATE_ROLE. Define the built-in member permissions and verify the static and Dynamic Access Control role behavior. Add the integration coverage described for ORGANIZATION_/COLLECTION_ mutations and self-role changes, then confirm member callers receive 403 responses.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, typescript
Domain
authorization, backend-api-design, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.