diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 324e817138..892c24895d 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -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] diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 63e116391b..372f97ab59 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -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)}}))) diff --git a/backend/src/app/rpc/commands/jobs.clj b/backend/src/app/rpc/commands/jobs.clj index d1aa5b85cb..54c7c352ed 100644 --- a/backend/src/app/rpc/commands/jobs.clj +++ b/backend/src/app/rpc/commands/jobs.clj @@ -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 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 8c31676d3d..ea8787ff67 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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 diff --git a/backend/test/backend_tests/loggers_audit_archive_task_test.clj b/backend/test/backend_tests/loggers_audit_archive_task_test.clj index 27c2c2f872..863885a085 100644 --- a/backend/test/backend_tests/loggers_audit_archive_task_test.clj +++ b/backend/test/backend_tests/loggers_audit_archive_task_test.clj @@ -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)))))))) diff --git a/backend/test/backend_tests/rpc/commands/jobs_test.clj b/backend/test/backend_tests/rpc/commands/jobs_test.clj index a03b5a9ca4..a39f92d71e 100644 --- a/backend/test/backend_tests/rpc/commands/jobs_test.clj +++ b/backend/test/backend_tests/rpc/commands/jobs_test.clj @@ -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 diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 3104e30139..d976ed92de 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -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) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index f066f53db6..7781605860 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -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"})