🐛 Secure organization invitation creation (#11164)

This commit is contained in:
Marina López 2026-08-10 13:45:15 +02:00 committed by GitHub
parent 5d2cb22966
commit 0fd2a9d26f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 152 additions and 38 deletions

View File

@ -167,6 +167,9 @@
[:id ::sm/uuid]
[:name ::sm/text]
[:owner-id ::sm/uuid]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe ::sm/boolean]]
[:teams
[:vector
[:map

View File

@ -488,6 +488,21 @@ RETURNING id, deleted_at;")
;; API: invite-to-organization
(defn- get-invitation-organization
[cfg profile-id organization-id]
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
(when-not (= profile-id owner-id)
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
{:id id
:name name
:initials (if logo-id "" (d/get-initials name))
:logo (when logo-id (files/resolve-public-uri logo-id))
:avatar-bg-url (when-not logo-id avatar-bg-url)
:sso-active (true? sso-active)}))
(sv/defmethod ::invite-to-organization
"Invite to organization"
{::doc/added "2.15"
@ -495,8 +510,11 @@ RETURNING id, deleted_at;")
[:email ::sm/email]
[:organization cto/schema:organization-with-avatar]]
::nitrate/sso false}
[cfg params]
(db/tx-run! cfg ti/create-organization-invitation params)
[cfg {profile-id ::rpc/profile-id
:keys [organization]
:as params}]
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
nil)

View File

@ -50,45 +50,138 @@
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props
(let [owner-id-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method params]
(when (= method :get-organization-summary)
{:id (:organization-id params)
:name "Acme"
:owner-id @owner-id-ref
:teams []}))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
_ (reset! owner-id-ref (:id owner))
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id))))))))
(t/deftest invite-to-organization-rejects-non-owner
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(let [owner (th/create-profile* 103 {:is-active true})
attacker (th/create-profile* 104 {:is-active true})
organization-id (uuid/random)
organization {:id organization-id
:name "Trusted Organization"
:initials "TO"
:logo nil
:avatar-bg-url nil}
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id attacker)
:email "victim@example.com"
:organization organization})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock)))))))
(t/deftest invite-to-organization-rejects-unknown-organization
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(let [profile (th/create-profile* 105 {:is-active true})
organization-id (uuid/random)
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id profile)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Organization"
:initials "FO"
:logo "https://evil.example/logo.png"
:avatar-bg-url nil}})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock))))))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id)))))))
(t/deftest invite-to-organization-uses-authoritative-branding
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 106 {:is-active true})
organization-id (uuid/random)
logo-id (uuid/random)
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:logo-id logo-id
:avatar-bg-url "https://trusted.example/avatar.svg"
:sso-active true
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Bank"
:initials "FB"
:logo "https://evil.example/logo.png"
:avatar-bg-url "https://evil.example/avatar.svg"
:sso-active false}})
email-params (first (:call-args @email-mock))
organization (:organization email-params)]
(t/is (th/success? out))
(t/is (= "Trusted Organization" (:name organization)))
(t/is (= "" (:initials organization)))
(t/is (str/ends-with? (:logo organization)
(str "/assets/by-id/" logo-id)))
(t/is (nil? (:avatar-bg-url organization)))
(t/is (true? (:sso-active organization))))))))
(t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :get-penpot-version})