Skip to content

feat(teams): several owners per team, and roles only from someone who holds them - #114

Merged
kipavy merged 1 commit into
mainfrom
feat/multi-owner-652
Oct 18, 2026
Merged

kipavy merged 1 commit into
mainfrom
feat/multi-owner-652

Conversation

@kipavy

@kipavy kipavy commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Supports VoltiusApp/voltius#652 and fixes a role escalation found while working on it.

Security fix

assign_member_role checked only PERM_MANAGE_MEMBERS. Managers hold that, so a Manager could call the API and give themselves the Owner role (every permission, Administrator included). The same class of gap let a Manager:

  • create a custom role with every permission and assign it to themselves,
  • invite an account of their own straight in as owner (invite_member did not even validate the role name),
  • remove the Owner role from any Owner other than the creator.

New module team_authority: Authority { permissions, owner } and covers(). Every path that confers or revokes a role now requires the actor to hold every bit the role carries, and an Owner for the Owner role:

  • assign / remove member role
  • create / update / delete custom role (update checks old and new bits)
  • add member, invite member, create join link (require_may_confer_builtin, 400 on an unknown role)
  • removing a member who holds Owner needs an Owner

Raw role bits are compared on purpose: a bit that is inert alone (VIEW_SECRETS without CONNECT) comes alive next to another role the target holds.

Several owners

teams.owner_id stays and now means the primary owner: the account billing sits on, and the only one who can delete the team.

  • It always keeps the Owner role and cannot be removed or leave, so a team always has an Owner.
  • Any other Owner can leave.
  • PUT /v1/teams/:id/billing-owner {user_id} hands it to another Owner (409 if they are not one).
    • Self-hosted: any Owner may move it.
    • Cloud: only the current primary owner. The target needs their own Teams or Business plan, at least as high as the current owner's, and seats for everyone in their own teams plus this one, else 402. The old subscription is left alone.
  • Ownership changes (remove_member, removing the Owner role, the transfer) lock the team row, so a transfer cannot race a demotion of its target.
  • /v1/meta advertises multi_owner: true. The client shows the new controls only when it is set.

Behaviour change

A custom role that holds Invite members but few other bits can no longer invite someone as member, because member carries bits it lacks. 30 existing tests used such a role. Their fixtures now use test_support::inviter_with, which adds the member role's bits.

Not in this PR

  • The purge job still only releases the email when a soft-deleted user is a primary owner. It does not hand the team to another Owner yet.
  • A Manager can still set a custom role's position above their own.

Tests

  • team_authority: property tests. covers is reflexive and transitive and never lets through a bit the actor lacks. Builtin roles cover exactly the roles at or below them.
  • ownership_tests in teams.rs: manager escalation via role, custom role and invite. Owners promote and demote each other. The billing owner is protected. A co-owner can leave.
  • team_ownership: self-hosted vs hosted transfer rights, the target must be an Owner, plan rank, seat cap.
  • Full suite 752/752 against Postgres. Clippy clean.
  • Exercised by hand: the client branch against a local build of this server (self-hosted) — promote, transfer, leave.

… holds them

Fixes a privilege escalation: any member with Manage members could give
themselves or anyone the Owner role through the API, since
assign_member_role checked only that one permission. The same gap let a
Manager create a custom role with every permission and assign it to
themselves, or invite an account of their own straight in as owner.

One rule now gates every path that confers or revokes a role: the actor
must hold every permission the role carries, and only an Owner may grant
or remove the Owner role. It covers assigning and removing roles,
creating, editing and deleting custom roles, add/invite member, and join
links. Invites also reject unknown role names now.

Several members can hold Owner (VoltiusApp/voltius#652). teams.owner_id
becomes the primary owner: the account billing sits on and the only one
who can delete the team. It always keeps the Owner role and cannot be
removed or leave, so a team always has an Owner. Other Owners can leave.
PUT /v1/teams/:id/billing-owner hands it to another Owner. Self-hosted,
any Owner may move it. On the cloud only the current primary owner may,
and the target needs their own Teams or Business plan at least as high
as the current one, with seats for everyone in their teams plus this
one (402 otherwise). Ownership changes lock the team row so a transfer
cannot race a demotion. /v1/meta advertises multi_owner.
@kipavy
kipavy merged commit eb3627b into main Oct 18, 2026
1 check passed
@kipavy
kipavy deleted the feat/multi-owner-652 branch October 18, 2026 21:16
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.

1 participant