From ba8af3a1db680e83a47eed3633a45dfcb94f2914 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 20:00:24 +0000 Subject: [PATCH] :bug: Prevent admins from granting owner role in team invitations Add role-ceiling check to create-team-invitations and update-team-invitation-role methods. These RPC methods allowed team admins to grant or elevate invitations to :owner role, bypassing the protection that exists in update-team-member-role. The fix replicates the existing check from update-team-member-role: reject promotion to :owner when the caller is not an owner. Closes #11098 AI-assisted-by: qwen3.7-plus --- .../app/rpc/commands/teams_invitations.clj | 12 +++ backend/test/backend_tests/rpc_team_test.clj | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 7cd8933354..74f520bce1 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -482,6 +482,13 @@ (ex/raise :type :validation :code :insufficient-permissions)) + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) + (or (= role :owner) + (some #(= :owner (:role %)) (:invitations params)))) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (when (> invitation-count max-invitations-by-request-threshold) (ex/raise :type :validation :code :max-invitations-by-request @@ -625,6 +632,11 @@ (ex/raise :type :validation :code :insufficient-permissions)) + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) (= role :owner)) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (db/update! conn :team-invitation {:role (name role) :updated-at (ct/now)} {:team-id team-id :email-to (profile/clean-email email)}) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 5c0477ed17..c5e2a1c19c 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1096,3 +1096,102 @@ :name "My Valid Team"} out (th/command! data)] (t/is (th/success? out))))) + +;; --- T7-F-01: Role ceiling in team invitations --- + +(t/deftest admin-cannot-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (emails+role format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (invitations format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :invitations [{:email "invitee@example.com" :role :owner}]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-update-invitation-role-to-owner + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Owner creates an invitation with :editor role + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out))) + + (th/reset-mock! mock) + + ;; Admin tries to update invitation role to :owner + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :update-team-invitation-role + ::rpc/profile-id (:id admin) + :team-id (:id team) + :email "invitee@example.com" + :role :owner} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))))) + +(t/deftest owner-can-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Owner creates invitation with :owner role + ;; This should SUCCEED (owner has full privileges) + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))))))