Skip to content

fix(organization): stop admins granting or removing roles above their own - #122

Merged
juicycleff merged 3 commits into
mainfrom
fix/org-role-rank-checks
Oct 8, 2026
Merged

juicycleff merged 3 commits into
mainfrom
fix/org-role-rank-checks

Conversation

@jaymesC

@jaymesC jaymesC commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Problem

Three organization-plugin writes require only org admin, then take the role from the request body without checking it. Any admin can therefore:

Route Escalation
POST /orgs/:orgId/invitations invite an alias as owner, then accept it
POST /orgs/:orgId/members add an existing account as owner
DELETE /orgs/:orgId/members/:memberId remove the owner

PATCH /orgs/:orgId/members/:memberId (role change) already requires owner, so the same outcome was reachable through the other three.

Found while auditing Kineta's role-escalation paths. Kineta now blocks these routes on its side, but every other consumer of the plugin is exposed.

Fix

Bound both writes by the caller's own rank, using the existing orgRoleRank (owner 3, admin 2, member 1):

  • Granting (AddMember, CreateInvitation): the requested role can't outrank the caller's. Only an owner can make an owner; admins can still grant admin and below.
  • Removing (RemoveMember): you can't remove a member who outranks you. Admins can still remove admins and members, and owners can remove owners.

The built-in role names are normalised (case-insensitive, trimmed). Without that, "Owner" ranks as an unknown role (0) and slips past the check, while any consumer comparing roles loosely still treats it as owner. Free-form roles that apps use (e.g. viewer) pass through unchanged and outrank no one.

Tests

plugins/organization/role_rank_test.go, through the real HTTP routes. Each escalation test failed on main before the fix:

  • admin invites as owner (owner, Owner, OWNER): was 201, now 403
  • admin adds an account as owner: was 201, now 403
  • admin removes the owner: was 200 (owner removed), now 403, and the owner is still a member

They also pin what must keep working: an admin invites admin, member, viewer or the default; an admin adds an admin; an admin removes a member or an admin; an owner invites an owner; an owner removes a co-owner.

go test ./... green; gofmt and go vet clean. Lint is left to CI, since it runs golangci-lint v2.

🤖 Generated with Claude Code

… own

The membership and invitation writes required org admin and then took the
role from the request body unchecked. An admin could therefore:

- invite an alias as owner (POST /orgs/:orgId/invitations),
- add an existing account as owner (POST /orgs/:orgId/members),
- remove the owner (DELETE /orgs/:orgId/members/:memberId).

UpdateMember already required owner; these did not. Granting a role and
removing a member are now bounded by the caller's own rank, using the
existing orgRoleRank: only an owner can make or remove an owner, while
admins can still grant admin and below and remove admins and members.

Built-in role names are matched case-insensitively and trimmed, so "Owner"
cannot rank as an unknown role while meaning owner to a consumer that
compares loosely. Free-form roles (e.g. "viewer") still pass through.
golangci-lint's govet shadow check flagged the rank-check error in
handleCreateInvitation shadowing the handler's err; the same pattern was in
handleAddMember and handleRemoveMember.
@juicycleff
juicycleff merged commit f783b5b into main Oct 8, 2026
18 checks passed
@juicycleff
juicycleff deleted the fix/org-role-rank-checks branch October 8, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants