♻️ Build organization invitation audit event in frontend (#11825)

* ♻️ Build organization invitation audit event in frontend

* ♻️ Align invitation token profile-id with created-by

The invitation token carried the minter in :profile-id while the
invitation row tracks the creator in :created-by. Both mean the
inviter, so re-sends or re-requested links made them disagree and
forced a second response key, :user-who-send-invitation.

Mint :profile-id from :created-by in both token creators, backfill
it from the row on accept (covers stale in-flight tokens), and drop
the duplicate response key. The frontend maps :profile-id to the
unchanged :user-who-send-invitation audit prop.

AI-assisted-by: Muse Spark 1.3 Free

* ♻️ Reuse token ids in invitation accept response

Backfill :member-id with the accepting profile and drop the
:user-id duplicate from the verify-token response, mirroring the
:profile-id/:user-who-send-invitation cleanup. The frontend maps
:member-id to the unchanged :user-id audit prop.

AI-assisted-by: Muse Spark 1.3 Free

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Marina López 2026-09-24 14:13:52 +02:00 committed by GitHub
parent 62153dcb0b
commit dfc1848ffc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 186 additions and 80 deletions

View File

@ -75,6 +75,9 @@
(tokens/generate cfg (tokens/generate cfg
{:iss :team-invitation {:iss :team-invitation
:exp valid-until :exp valid-until
;; NOTE: :profile-id is the inviter (the `created-by` of
;; the invitation row). Callers must pass the row value
;; so the token stays consistent with the database.
:profile-id profile-id :profile-id profile-id
:role role :role role
:team-id team-id :team-id team-id
@ -218,7 +221,7 @@
updated? (not= id (:id invitation)) updated? (not= id (:id invitation))
profile-id (:id profile) profile-id (:id profile)
team-organization-id (get-in team [:organization :id]) team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id tprops {:profile-id (or (:created-by invitation) profile-id)
:invitation-id (:id invitation) :invitation-id (:id invitation)
:valid-until expire :valid-until expire
:team-id (:id team) :team-id (:id team)
@ -611,7 +614,11 @@
member (profile/get-profile-by-email pool (:email-to invit)) member (profile/get-profile-by-email pool (:email-to invit))
token (create-invitation-token cfg {:team-id (:team-id invit) token (create-invitation-token cfg {:team-id (:team-id invit)
:profile-id profile-id ;; The inviter is the creator of
;; the invitation row, not the
;; profile requesting the token.
:profile-id (or (:created-by invit)
profile-id)
:valid-until (:valid-until invit) :valid-until (:valid-until invit)
:role (:role invit) :role (:role invit)
:member-id (:id member) :member-id (:id member)

View File

@ -6,6 +6,7 @@
(ns app.rpc.commands.verify-token (ns app.rpc.commands.verify-token
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
@ -174,6 +175,7 @@
[:map {:title "TeamInvitationClaims"} [:map {:title "TeamInvitationClaims"}
[:iss :keyword] [:iss :keyword]
[:exp ::ct/inst] [:exp ::ct/inst]
;; The inviter: always the `created-by` of the invitation row.
[:profile-id ::sm/uuid] [:profile-id ::sm/uuid]
[:role types.team/schema:role] [:role types.team/schema:role]
[:team-id {:optional true} ::sm/uuid] [:team-id {:optional true} ::sm/uuid]
@ -244,18 +246,6 @@
(not (:is-member membership))) (not (:is-member membership)))
(:organization-id membership)) (:organization-id membership))
organization-add-source
(when organization-id-on-add
(if organization-id
"direct-organization-invitation"
"team-invitation"))
organization-event-origin
(when organization-id-on-add
(if organization-id
"organization-invitation-acceptance"
"team-invitation-acceptance"))
organization-member-count-before organization-member-count-before
(when organization-id-on-add (when organization-id-on-add
(count (count
@ -313,25 +303,34 @@
:user-who-send-invitation (:created-by invitation)) :user-who-send-invitation (:created-by invitation))
(audit/clean-props)))))) (audit/clean-props))))))
(cond-> (assoc claims :state :created) (cond-> (assoc claims
:state :created
;; The invitation row is authoritative for the
;; inviter: backfill :profile-id so the response
;; stays consistent even with tokens minted
;; before :profile-id was aligned with
;; :created-by (or re-requested by someone else).
:profile-id (or (:created-by invitation)
(:profile-id claims))
;; Likewise, the accepting profile is
;; authoritative for the invitee: backfill
;; :member-id (nil for invitations sent to an
;; unregistered email, possibly stale
;; otherwise).
:member-id (:id profile))
;; when the invitation is to an organization, instead of a team, add the ;; when the invitation is to an organization, instead of a team, add the
;; accepted-team-id as :organization-team-id ;; accepted-team-id as :organization-team-id
(:organization-id claims) (:organization-id claims)
(assoc :organization-team-id accepted-team-id) (assoc :organization-team-id accepted-team-id)
organization-id-on-add organization-id-on-add
(assoc :organization-invitation-audit (merge (d/without-nils
{:origin organization-event-origin {:invitation-id (:id invitation)
:props :organization-member-count-before
(-> props organization-member-count-before}))
(assoc :organization-id organization-id-on-add
:organization-member-add-source organization-add-source (and organization-id-on-add team-id)
:belongs-to-team-on-add (boolean team-id) (assoc :organization-id organization-id-on-add))))))
:user-id (:id profile)
:user-who-send-invitation (:created-by invitation)
:organization-member-count-before
organization-member-count-before)
(audit/clean-props))}))))))
(do (do
;; If the user is not logged-in and the invitation has been canceled ;; If the user is not logged-in and the invitation has been canceled

View File

@ -498,7 +498,7 @@
(map second) (map second)
(filter #(= "accept-organization-invitation" (:name %))) (filter #(= "accept-organization-invitation" (:name %)))
first)) first))
frontend-event (atom nil)] token-result (atom nil)]
(db/insert! (:app.db/pool th/*system*) (db/insert! (:app.db/pool th/*system*)
:team-invitation :team-invitation
@ -520,8 +520,7 @@
(fn [& _] default-team-id)] (fn [& _] default-team-id)]
(let [out (verify! direct-token)] (let [out (verify! direct-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! frontend-event (reset! token-result (:result out))))
(get-in out [:result :organization-invitation-audit]))))
(let [event (organization-event)] (let [event (organization-event)]
(t/is (= organization-id (get-in event [:props :organization-id]))) (t/is (= organization-id (get-in event [:props :organization-id])))
@ -533,19 +532,17 @@
(t/is (not (contains? (:props event) :organization-member-count-before))) (t/is (not (contains? (:props event) :organization-member-count-before)))
(t/is (= :editor (get-in event [:props :role]))) (t/is (= :editor (get-in event [:props :role])))
(t/is (uuid? (get-in event [:props :invitation-id]))) (t/is (uuid? (get-in event [:props :invitation-id])))
(t/is (= "organization-invitation-acceptance" (t/is (= organization-id (:organization-id @token-result)))
(:origin @frontend-event))) (t/is (= :editor (:role @token-result)))
(t/is (= organization-id (t/is (not (contains? @token-result :organization-invitation-audit)))
(get-in @frontend-event [:props :organization-id]))) (t/is (= (get-in event [:props :invitation-id])
(:invitation-id @token-result)))
(t/is (= (:id invitee) (t/is (= (:id invitee)
(get-in @frontend-event [:props :user-id]))) (:member-id @token-result)))
(t/is (= (:id inviter) (t/is (= (:id inviter)
(get-in @frontend-event [:props :user-who-send-invitation]))) (:profile-id @token-result)))
(t/is (= "direct-organization-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
(t/is (= 3 (t/is (= 3
(get-in @frontend-event [:props :organization-member-count-before]))) (:organization-member-count-before @token-result)))
(t/is (not-any? #(contains? #{"accept-team-invitation" (t/is (not-any? #(contains? #{"accept-team-invitation"
"accept-team-invitation-from"} "accept-team-invitation-from"}
(:name (second %))) (:name (second %)))
@ -572,8 +569,7 @@
teams/add-profile-to-team! (fn [& _] nil)] teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)] (let [out (verify! team-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! frontend-event (reset! token-result (:result out))))
(get-in out [:result :organization-invitation-audit]))))
(let [events (mapv second (:call-args-list @audit-mock)) (let [events (mapv second (:call-args-list @audit-mock))
event (organization-event)] event (organization-event)]
@ -587,20 +583,19 @@
(t/is (not (contains? (:props event) :organization-member-add-source))) (t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before))) (t/is (not (contains? (:props event) :organization-member-count-before)))
(t/is (= "team-invitation-acceptance"
(:origin @frontend-event)))
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
(t/is (= organization-id (t/is (= organization-id
(get-in @frontend-event [:props :organization-id]))) (:organization-id @token-result)))
(t/is (= (:id team) (:team-id @token-result)))
(t/is (= :editor (:role @token-result)))
(t/is (not (contains? @token-result :organization-invitation-audit)))
(t/is (= (get-in event [:props :invitation-id])
(:invitation-id @token-result)))
(t/is (= (:id invitee) (t/is (= (:id invitee)
(get-in @frontend-event [:props :user-id]))) (:member-id @token-result)))
(t/is (= (:id inviter) (t/is (= (:id inviter)
(get-in @frontend-event [:props :user-who-send-invitation]))) (:profile-id @token-result)))
(t/is (= "team-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
(t/is (= 5 (t/is (= 5
(get-in @frontend-event [:props :organization-member-count-before])))) (:organization-member-count-before @token-result))))
(th/reset-mock! audit-mock) (th/reset-mock! audit-mock)
(db/insert! (:app.db/pool th/*system*) (db/insert! (:app.db/pool th/*system*)
@ -621,13 +616,57 @@
teams/add-profile-to-team! (fn [& _] nil)] teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)] (let [out (verify! team-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! frontend-event (reset! token-result (:result out))))
(get-in out [:result :organization-invitation-audit]))))
(let [events (mapv second (:call-args-list @audit-mock))] (let [events (mapv second (:call-args-list @audit-mock))]
(t/is (some #(= "accept-team-invitation" (:name %)) events)) (t/is (some #(= "accept-team-invitation" (:name %)) events))
(t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)) (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events))
(t/is (nil? @frontend-event)))))) (t/is (not (contains? @token-result :organization-invitation-audit)))
(t/is (not (contains? @token-result :organization-member-count-before)))))))
(t/deftest accept-organization-invitation-response-ids-match-database
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [inviter (th/create-profile* 211 {:is-active true})
invitee (th/create-profile* 212 {:is-active true})
organization-id (uuid/random)
default-team-id (uuid/random)
;; Token minted with a stale :profile-id (e.g. a re-sent link
;; requested by someone else) and no :member-id (invitee was
;; unregistered when invited); the invitation row says inviter.
stale-token (tokens/generate
th/*system*
{:iss :team-invitation
:exp (ct/in-future "1h")
:profile-id (uuid/random)
:role :editor
:organization-id organization-id
:member-email (:email invitee)})]
(db/insert! (:app.db/pool th/*system*)
:team-invitation
{:org-id organization-id
:email-to (:email invitee)
:created-by (:id inviter)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call
(fn [_cfg method _params]
(case method
:get-organization-membership {:organization-id organization-id
:is-member false}
:get-organization-members [(:id inviter)]
nil))
teams/initialize-user-in-organization
(fn [& _] default-team-id)]
(let [out (th/command! {::th/type :verify-token
::rpc/profile-id (:id invitee)
:token stale-token})]
(t/is (th/success? out))
(t/is (= (:id inviter) (:profile-id (:result out))))
(t/is (= (:id invitee) (:member-id (:result out))))
(t/is (not (contains? (:result out) :user-who-send-invitation)))
(t/is (not (contains? (:result out) :user-id))))))))
(t/deftest create-team-invitations-with-email-verification-disabled (t/deftest create-team-invitations-with-email-verification-disabled
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]

View File

@ -6,6 +6,7 @@
(ns app.main.ui.auth.verify-token (ns app.main.ui.auth.verify-token
(:require (:require
[app.common.data :as d]
[app.config :as cf] [app.config :as cf]
[app.main.data.auth :as da] [app.main.data.auth :as da]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
@ -45,12 +46,31 @@
(defmethod handle-token :team-invitation (defmethod handle-token :team-invitation
[{:keys [state team-id organization-team-id organization-name invitation-token] :as tdata}] [{:keys [state team-id organization-team-id organization-name invitation-token] :as tdata}]
(when-let [{:keys [origin props]} (:organization-invitation-audit tdata)] (when (and (= state :created)
(st/emit! (contains? tdata :organization-member-count-before))
(ev/event (let [direct-invitation? (some? organization-team-id)]
(assoc props (st/emit!
::ev/name "accept-organization-invitation" (ev/event
::ev/origin origin)))) (-> (select-keys tdata [:team-id :organization-id :role
:invitation-id :member-id :profile-id
:organization-member-count-before])
;; The audit event keeps its own names: :user-id is the
;; invitee (:member-id, backfilled by the backend with the
;; accepting profile) and :user-who-send-invitation is the
;; inviter (:profile-id, backfilled from `created-by`).
(assoc :user-id (:member-id tdata)
:user-who-send-invitation (:profile-id tdata))
(dissoc :member-id :profile-id)
(d/without-nils)
(assoc :organization-member-add-source
(if direct-invitation?
"direct-organization-invitation"
"team-invitation")
:belongs-to-team-on-add (boolean team-id)
::ev/name "accept-organization-invitation"
::ev/origin (if direct-invitation?
"organization-invitation-acceptance"
"team-invitation-acceptance")))))))
(case state (case state
:created :created

View File

@ -121,31 +121,72 @@
(t/is (nil? (:days-since-member-added event))))))) (t/is (nil? (:days-since-member-added event)))))))
(t/deftest accept-organization-invitation-audit-event-test (t/deftest accept-organization-invitation-audit-event-test
(let [emitted (atom []) (let [emitted (atom [])]
props {:team-id "team-1"
:organization-id "organization-1"
:role :editor
:invitation-id "invitation-1"
:organization-member-add-source "team-invitation"
:belongs-to-team-on-add true
:organization-member-count-before 4}]
(with-redefs [st/emit! (fn (with-redefs [st/emit! (fn
([event] ([event]
(swap! emitted conj event)) (swap! emitted conj event))
([event & events] ([event & events]
(swap! emitted into (cons event events))))] (swap! emitted into (cons event events))))]
(verify-token/handle-token (t/testing "accepting a team invitation that adds an organization member"
{:iss :team-invitation (verify-token/handle-token
:state :created {:iss :team-invitation
:team-id "team-1" :state :created
:organization-invitation-audit :team-id "team-1"
{:origin "team-invitation-acceptance" :organization-id "organization-1"
:props props}})) :role :editor
:invitation-id "invitation-1"
:member-id "invitee-1"
:profile-id "inviter-1"
:organization-member-count-before 4})
(let [event @(first @emitted)] (t/is (= {::ev/name "accept-organization-invitation"
(t/is (= "accept-organization-invitation" (::ev/name event))) ::ev/origin "team-invitation-acceptance"
(t/is (= "team-invitation-acceptance" (::ev/origin event))) :team-id "team-1"
(t/is (= props (dissoc event ::ev/name ::ev/origin)))))) :organization-id "organization-1"
:role :editor
:invitation-id "invitation-1"
:user-id "invitee-1"
:user-who-send-invitation "inviter-1"
:organization-member-add-source "team-invitation"
:belongs-to-team-on-add true
:organization-member-count-before 4}
@(first @emitted))))
(reset! emitted [])
(t/testing "accepting an invitation directly to the organization"
(verify-token/handle-token
{:iss :team-invitation
:state :created
:organization-id "organization-2"
:organization-team-id "team-default"
:role :viewer
:invitation-id "invitation-2"
:member-id "invitee-2"
:profile-id "inviter-2"
:organization-member-count-before 0})
(t/is (= {::ev/name "accept-organization-invitation"
::ev/origin "organization-invitation-acceptance"
:organization-id "organization-2"
:role :viewer
:invitation-id "invitation-2"
:user-id "invitee-2"
:user-who-send-invitation "inviter-2"
:organization-member-add-source "direct-organization-invitation"
:belongs-to-team-on-add false
:organization-member-count-before 0}
@(first @emitted))))
(reset! emitted [])
(t/testing "does not audit a team invitation for an existing organization member"
(verify-token/handle-token
{:iss :team-invitation
:state :created
:team-id "team-3"
:organization-id "organization-3"
:role :editor})
(t/is (= 3 (count @emitted)))))))
(t/deftest build-admin-console-url-preserves-public-uri-subpath (t/deftest build-admin-console-url-preserves-public-uri-subpath
(t/testing "builds admin console routes below the configured Penpot subpath" (t/testing "builds admin console routes below the configured Penpot subpath"