✨ Send more enriched events to nitrate log (#12210)

* ✨ Send more enriched events to nitrate log

* ✨ Fix mr
This commit is contained in:
Pablo Alba 2026-10-09 10:58:05 +02:00 committed by GitHub
parent 7745c1f052
commit 76e9c132ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 343 additions and 28 deletions

View File

@ -11,6 +11,7 @@
[app.common.schema :as sm]
[app.common.transit :as t]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
@ -80,6 +81,7 @@
"add-team-to-organization"
"cancel-organization-invitation"
"change-organization-advanced-permission"
"create-export-binfile-job"
"create-file"
"create-organization"
"create-organization-attribute"
@ -90,8 +92,10 @@
"create-team-invitation"
"create-team-invitations"
"create-webhook"
"delete-file"
"delete-font"
"delete-organization"
"delete-organization-member"
"delete-project"
"delete-team"
"delete-team-invitation"
@ -108,6 +112,8 @@
"rename-organization"
"rename-project"
"restore-deleted-team-files"
"success-activate-organization-sso"
"success-apply-organization-sso-changes"
"update-organization-invitation"
"update-organization-permissions"
"update-team-invitation"
@ -116,12 +122,96 @@
"update-team-photo"
"verify-token"})
;; Props id key -> email prop filled from profile.email (nitrate path only).
;; Some producers store ids as UUID strings (e.g. :user-who-send-invitation).
(def ^:private id->email-prop
{:profile-id :profile-email
:user-id :user-email
:member-id :member-email
:user-who-send-invitation :user-who-send-invitation-email})
(defn- collect-team-ids-needing-name
[events]
(into #{}
(comp (map :props)
(keep (fn [props]
(when-let [team-id (and (not (contains? props :team-name))
(uuid/coerce (:team-id props)))]
team-id))))
events))
(defn- collect-profile-ids-needing-email
[events]
(into #{}
(mapcat (fn [{:keys [props]}]
(keep (fn [[id-key email-key]]
(when-not (contains? props email-key)
(uuid/coerce (get props id-key))))
id->email-prop)))
events))
(defn- load-team-names
[conn ids]
(if (seq ids)
(let [arr (db/create-array conn "uuid" ids)
rows (db/exec! conn ["SELECT id, name FROM team WHERE id = ANY(?)" arr])]
(into {} (map (juxt :id :name) rows)))
{}))
(defn- load-profile-emails
[conn ids]
(if (seq ids)
(let [arr (db/create-array conn "uuid" ids)
rows (db/exec! conn ["SELECT id, email FROM profile WHERE id = ANY(?)" arr])]
(into {} (map (juxt :id :email) rows)))
{}))
(defn- maybe-assoc-team-name
[event team-names]
(let [props (:props event)]
(if-let [team-name (and (not (contains? props :team-name))
(when-let [team-id (uuid/coerce (:team-id props))]
(get team-names team-id)))]
(assoc-in event [:props :team-name] team-name)
event)))
(defn- maybe-assoc-emails
[event emails]
(update event :props
(fn [props]
(reduce-kv (fn [props id-key email-key]
(if-let [email (and (not (contains? props email-key))
(when-let [id (uuid/coerce (get props id-key))]
(get emails id)))]
(assoc props email-key email)
props))
props
id->email-prop))))
(defn- enrich-events-for-nitrate
"Enrich allowlisted events before nitrate ingest:
1. Fill missing :team-name from team id (including soft-deleted).
2. Fill missing emails from matching id keys (including soft-deleted
profiles): :profile-email, :user-email, :member-email,
:user-who-send-invitation-email."
[{:keys [::db/conn]} events]
(let [team-ids (collect-team-ids-needing-name events)
profile-ids (collect-profile-ids-needing-email events)
team-names (load-team-names conn team-ids)
emails (load-profile-emails conn profile-ids)]
(mapv (fn [event]
(-> event
(maybe-assoc-team-name team-names)
(maybe-assoc-emails emails)))
events)))
(defn- send-to-nitrate!
[cfg rows]
(when-let [events (->> rows
(filterv #(contains? event-names-for-nitrate (:name %)))
(not-empty))]
(nitrate/call cfg :ingest-audit-log {:events events})))
(nitrate/call cfg :ingest-audit-log
{:events (enrich-events-for-nitrate cfg events)})))
(defn- mark-archived!
[{:keys [::db/conn]} rows]

View File

@ -1095,7 +1095,8 @@
:profile-id profile-id})
(rph/with-meta (rph/wrap)
{::audit/props {:project-id (:project-id file)
{::audit/props {:team-id (:id team)
:project-id (:project-id file)
:name (:name file)
:created-at (:created-at file)
:modified-at (:modified-at file)}})))

View File

@ -110,6 +110,25 @@
;; EXPORT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- single-team-id
"The team id when every `file-ids` entry resolves and they share one
team. Otherwise nil."
[cfg file-ids]
(when (seq file-ids)
(db/run! cfg
(fn [{:keys [::db/conn]}]
(let [arr (db/create-array conn "uuid" file-ids)
rows (db/exec! conn
["SELECT f.id AS file_id, t.id AS team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
WHERE f.id = ANY(?)" arr])
teams (into #{} (map :team-id) rows)]
(when (and (= (count file-ids) (count rows))
(= 1 (count teams)))
(first teams)))))))
(sv/defmethod ::create-export-binfile-job
"Create a durable job that exports a set of files as a `.penpot`
package.
@ -146,11 +165,13 @@
::quotes/profile-id profile-id})
(let [summary (get-job-summary cfg (submit-job cfg job-def :export-binfile
params profile-id))]
params profile-id))
team-id (single-team-id cfg file-ids)]
(with-meta summary
{::audit/props {:job-id (:id summary)
:files (count file-ids)
:export-type (d/name (:export-type params))}}))))
{::audit/props (cond-> {:job-id (:id summary)
:files (count file-ids)
:export-type (d/name (:export-type params))}
team-id (assoc :team-id team-id))}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPORT

View File

@ -29,6 +29,7 @@
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes]
[app.setup :as-alias setup]
@ -867,11 +868,13 @@
:code :non-deletable-team
:hint "impossible to delete default team"))
(let [delay (ldel/get-deletion-delay team)
team (db/update! conn :team
{:deleted-at (ct/in-future delay)}
{:id team-id}
{::db/return-keys true})]
(let [delay (ldel/get-deletion-delay team)
organization-id (dm/get-in team [:organization :id])
team-name (:name team)
team (db/update! conn :team
{:deleted-at (ct/in-future delay)}
{:id team-id}
{::db/return-keys true})]
;; Api call to nitrate
(when (contains? cf/flags :admin-console)
@ -882,7 +885,10 @@
::jobs/params {:object :team
:deleted-at (:deleted-at team)
:id team-id}})
team)))
(rph/with-meta (rph/wrap)
{::audit/props {:organization-id organization-id
:team-id team-id
:team-name team-name}}))))
(def ^:private schema:delete-team
[:map {:title "delete-team"}
@ -894,8 +900,7 @@
::sm/params schema:delete-team
::db/transaction true}
[cfg {:keys [::rpc/profile-id id] :as params}]
(delete-team cfg {:team-id id :profile-id profile-id})
nil)
(delete-team cfg {:team-id id :profile-id profile-id}))
;; --- Mutation: Team Update Role

View File

@ -26,18 +26,25 @@
(def ^:private archive-uri "http://nexus.example/archive")
(defn- insert-audit-row!
[profile-id name]
(th/db-insert! :audit-log
{:id (uuid/next)
:name name
:type "action"
:source "backend"
:profile-id profile-id
:ip-addr (db/inet "127.0.0.1")
:props (db/tjson {:team-id (uuid/next)})
:context (db/tjson {})
:tracked-at (ct/now)
:created-at (ct/now)}))
([profile-id name]
(insert-audit-row! profile-id name {:team-id (uuid/next)}))
([profile-id name props]
(th/db-insert! :audit-log
{:id (uuid/next)
:name name
:type "action"
:source "backend"
:profile-id profile-id
:ip-addr (db/inet "127.0.0.1")
:props (db/tjson props)
:context (db/tjson {})
:tracked-at (ct/now)
:created-at (ct/now)})))
(defn- nitrate-event-props
[nitrate-mock]
(let [[_ _ params] (:call-args @nitrate-mock)]
(:props (first (:events params)))))
(t/deftest archive-events-sends-to-nitrate-then-nexus
;; Create profile before enabling :admin-console — system nitrate
@ -231,3 +238,113 @@
(catch Throwable cause
cause)))]
(t/is (= :task-not-configured (:code data))))))
(t/deftest archive-events-enriches-team-name-and-emails
(let [actor (th/create-profile* 1 {:is-active true})
member (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:profile-id (:id actor)
:user-id (:id member)
:member-id (:id member)
;; Producers store this as a string UUID.
:user-who-send-invitation (str (:id actor))})
(th/run-task! :audit-log-archive {})
(t/is (:called? @nitrate-mock))
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= (:name team) (:team-name props)))
(t/is (= (:email actor) (:profile-email props)))
(t/is (= (:email member) (:user-email props)))
(t/is (= (:email member) (:member-email props)))
(t/is (= (:email actor) (:user-who-send-invitation-email props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-enriches-soft-deleted-team-and-profile
(let [actor (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(th/db-update! :team {:deleted-at (ct/now)} {:id (:id team)})
(th/db-update! :profile {:deleted-at (ct/now)} {:id (:id other)})
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:profile-id (:id other)})
(th/run-task! :audit-log-archive {})
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= (:name team) (:team-name props)))
(t/is (= (:email other) (:profile-email props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-does-not-overwrite-existing-enrich-props
(let [actor (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:team-name "already-set-name"
:profile-id (:id actor)
:profile-email "already-set@example.com"
:user-id (:id actor)
:user-email "already-user@example.com"
:member-id (:id actor)
:member-email "already-member@example.com"
:user-who-send-invitation (str (:id actor))
:user-who-send-invitation-email "already-inviter@example.com"})
(th/run-task! :audit-log-archive {})
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= "already-set-name" (:team-name props)))
(t/is (= "already-set@example.com" (:profile-email props)))
(t/is (= "already-user@example.com" (:user-email props)))
(t/is (= "already-member@example.com" (:member-email props)))
(t/is (= "already-inviter@example.com"
(:user-who-send-invitation-email props))))))))
(t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids
(let [actor (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (uuid/next)
:profile-id (uuid/next)
:user-id "not-a-uuid"
:member-id 42})
(th/run-task! :audit-log-archive {})
(t/is (:called? @nitrate-mock))
(let [props (nitrate-event-props nitrate-mock)]
(t/is (nil? (:team-name props)))
(t/is (nil? (:profile-email props)))
(t/is (nil? (:user-email props)))
(t/is (nil? (:member-email props)))
(t/is (= "not-a-uuid" (:user-id props)))
(t/is (= 42 (:member-id props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-forwards-export-job-not-legacy-export
(let [actor (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-export-binfile-job"
{:team-id (:id team)
:job-id (uuid/next)
:files 1
:export-type "detach-libraries"})
(insert-audit-row! (:id actor) "export-binfile"
{:file-id (uuid/next)})
(th/run-task! :audit-log-archive {})
(let [[_ _ params] (:call-args @nitrate-mock)
events (:events params)
props (:props (first events))]
(t/is (= ["create-export-binfile-job"] (mapv :name events)))
(t/is (= (:id team) (:team-id props)))
(t/is (= (:name team) (:team-name props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 2 (count rows))))))))

View File

@ -173,11 +173,44 @@
out (create-export-binfile-job (:id profile) #{file-id})
props (::audit/props (meta (:result out)))]
(t/testing "the audit of the call points at the job, not at its files"
(t/testing "the audit of the call points at the job and its team"
(t/is (= (:id (:result out)) (:job-id props)))
(t/is (= 1 (:files props)))
(t/is (= "detach-libraries" (:export-type props)))
(t/is (= #{:job-id :files :export-type} (set (keys props)))))))
(t/is (= (:default-team-id profile) (:team-id props)))
(t/is (= #{:job-id :files :export-type :team-id} (set (keys props))))
(t/is (not (contains? (:result out) :team-id))))))
(t/deftest create-export-binfile-job-records-the-team-when-files-share-one
(let [profile (th/create-profile* 1)
file-a (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
file-b (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
out (create-export-binfile-job (:id profile)
#{(:id file-a) (:id file-b)})
props (::audit/props (meta (:result out)))]
(t/is (th/success? out))
(t/is (= (:default-team-id profile) (:team-id props)))
(t/is (= 2 (:files props)))
(t/is (not (contains? (:result out) :team-id)))))
(t/deftest create-export-binfile-job-omits-the-team-when-files-span-teams
(let [profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file-a (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
file-b (th/create-file* 2 {:profile-id (:id profile)
:project-id (:id project)})
out (create-export-binfile-job (:id profile)
#{(:id file-a) (:id file-b)})
props (::audit/props (meta (:result out)))]
(t/is (th/success? out))
(t/is (nil? (:team-id props)))
(t/is (= #{:job-id :files :export-type} (set (keys props))))
(t/is (not (contains? (:result out) :team-id)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPORT

View File

@ -17,6 +17,7 @@
[app.db.sql :as sql]
[app.features.fdata :as fdata]
[app.http :as http]
[app.loggers.audit :as-alias audit]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.storage :as sto]
@ -811,6 +812,20 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/deftest delete-file-audit-includes-team-id
;; Assert result metadata directly: wrap-audit is inactive in the
;; default test flags, so mocking audit/submit would never see this.
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
result (db/tx-run! th/*system*
(fn [cfg]
(#'files/delete-file cfg {:id (:id file)
:profile-id (:id profile)})))
props (::audit/props (meta result))]
(t/is (= (:default-team-id profile) (:team-id props)))
(t/is (= (:project-id file) (:project-id props)))))
(t/deftest permissions-checks-set-file-shared
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)

View File

@ -13,6 +13,7 @@
[app.db :as db]
[app.email.blacklist :as email.blacklist]
[app.http :as http]
[app.loggers.audit :as-alias audit]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
@ -928,6 +929,38 @@
(let [result (th/run-task! :objects-gc {})]
(t/is (= 7 (:processed result)))))))
(t/deftest delete-team-audit-includes-organization-id
;; Assert result metadata directly: wrap-audit is inactive in the
;; default test flags, so mocking audit/submit would never see this.
(let [owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
organization-id (uuid/next)
organization {:id organization-id
:name "Acme"
:slug "acme"
:owner-id (:id owner)
:avatar-bg-url "https://example.com/avatar.svg"
:permissions {:delete-teams "onlyOwners"}}]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call
(fn [_cfg method params]
(case method
:get-team-organization
(when (= (:id team) (:team-id params))
{:id (:id team)
:is-your-penpot false
:organization organization})
:delete-team nil
nil))]
(let [result (db/tx-run! th/*system*
(fn [cfg]
(teams/delete-team cfg {:team-id (:id team)
:profile-id (:id owner)})))
props (::audit/props (meta result))]
(t/is (= organization-id (:organization-id props)))
(t/is (= (:id team) (:team-id props)))
(t/is (= (:name team) (:team-name props)))))))
(t/deftest create-team-access-request
(with-mocks [mock {:target 'app.email/send :return nil}]
(let [owner (th/create-profile* 1 {:is-active true :email "owner@bar.com"})