Skip to content
back to blog

Twenty's Invite Mutation Could Hand Out the Admin Role

4 min read

#security #disclosure #authorization #graphql


mutation {
  sendInvitations(
    emails: ["second-account@example.com"]
    roleId: "<admin-role-uuid>"
  ) { success }
}

In Twenty, the open-source CRM, that mutation worked for any user whose role could manage workspace members, even if the same role was not allowed to touch roles. The invitee accepted, signed up, and joined the workspace as Admin. If the caller controlled the invited address, that was a two-step path from "member manager" to full workspace control.

This is GHSA-8w82-h3w4-62r5, rated Moderate, fixed in Twenty 2.47.0. I am one of four credited reporters. I reported it in July, and the maintainers published the advisory on 19 October.

Two doors to the same room

Twenty splits workspace administration into separate permissions. WORKSPACE_MEMBERS covers adding and removing users. ROLES covers creating roles and deciding who holds which one. The product ships UI for exactly this split, so a deployment can have someone who runs onboarding without being able to make anyone an admin.

The direct way to give someone a role, updateWorkspaceMemberRole, sits behind the ROLES check. The invite path did not. sendInvitations accepted an optional, client-supplied roleId, checked only that the role existed and could be assigned to users, stored it in the invitation, and applied it when the invite was accepted. The default Admin role is assignable to users, and any member could read its ID from their own workspace data.

Assigning a role at invite time is assigning a role. One door had a lock and the other did not.

The fix

PR #27820, merged on 12 October 2026 and shipped in 2.47.0 on 15 October, adds the check inline in the resolver: when sendInvitations receives a roleId, the caller also needs ROLES, through the same userHasWorkspaceSettingPermission call the settings guard uses. Inviting without a roleId still needs only WORKSPACE_MEMBERS.

The PR explains why the check is inline rather than a decorator: a guard cannot say "only when this argument is set". It also lists the other paths that assign a role and already carried the check (updateWorkspaceMemberRole, createApiKey and assignRoleToApiKey, defaultRoleId on updateWorkspace), and why resendWorkspaceInvitation and the public invite link stay unchanged. It comes with an integration test for the three cases that matter: a members-only user inviting as Admin is refused and no invitation is created, the same user inviting without a role succeeds, and a user with ROLES can still invite as Admin.

The pattern to look for

The obvious endpoint had its check. A second endpoint reached the same state change by a side road, and that shape is worth hunting for on purpose: an invite that sets a role, an import that sets an owner, an API key that carries a role. The permission model is right; one path into it skips it.

The check is mechanical. Pick a privileged state change, such as "user X now holds role Y". List every mutation that can cause it, including the ones where it happens as a side effect of an optional argument. Each one needs the same permission as the direct path. PR #27820 is a good model because it did exactly this enumeration in its description.

Who was exposed

Self-hosted Twenty from 1.19.0 up to 2.46.x, in workspaces that actually use the split, where someone has WORKSPACE_MEMBERS without ROLES. If every member manager in a workspace is already an admin, the precondition is not met. The attacker also needs an email address they control to accept the invite.

Upgrade to 2.47.0 or later. If you cannot upgrade yet, review pending invitations for unexpected roles and recent joins that landed on Admin.

Timeline

  • 2026-07-11: Reported via GitHub private vulnerability reporting
  • 2026-09-28: Advisory GHSA-8w82-h3w4-62r5 opened by another reporter
  • 2026-10-12: Fix merged (PR #27820)
  • 2026-10-15: Twenty 2.47.0 released
  • 2026-10-19: Advisory published, crediting tal2k9dev-star, skeletonsec, Oskii and me

Thanks to the Twenty team for the fix and for crediting every reporter.

References

Earlier disclosures: CVE-2026-104850 in the MCP TypeScript SDK · CVE-2026-44429 on the MCP Registry.