From ca616a39ed824683e1500418d6aa3fa33d783091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 11 Nov 2026 16:02:29 +0100 Subject: [PATCH] :bug: Send the plugin origin with penpotMgmt dashboard actions (#12971) Dashboard actions made by a plugin or the MCP through penpotMgmt reached the backend audit log without event origin, so they looked like actions made by hand in the dashboard. The backend reads the origin from the x-event-origin header, which rp/cmd! only sends when the params carry ::ev/origin in their meta. Tag the params of every penpotMgmt mutation with the same meta that se/add-event sets (mcp or plugin). The rename, share and team update events now keep that meta on the RPC params, so setters send it too, and set-file-variants-shared takes the caller origin instead of a fixed dashboard one. Closes #12901 AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/data/dashboard.cljs | 13 +++-- frontend/src/app/plugins/management.cljs | 56 +++++++++++-------- .../plugins/management_test.cljs | 33 +++++++++-- 3 files changed, 69 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index ab2ace94da..c46b56806c 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -373,7 +373,7 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:id id :name name}] + (let [params (with-meta {:id id :name name} (meta params))] (->> (rp/cmd! :rename-project params) (rx/ignore)))))) @@ -435,7 +435,7 @@ ptk/WatchEvent (watch [_ _ _] - (let [params (select-keys params [:id :name])] + (let [params (with-meta (select-keys params [:id :name]) (meta params))] (->> (rp/cmd! :rename-file params) (rx/ignore)))))) @@ -460,15 +460,18 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:id id :is-shared is-shared}] + (let [mdata (meta params) + origin (::ev/origin mdata "dashboard")] (rx/concat - (->> (rp/cmd! :set-file-shared params) + (->> (rp/cmd! :set-file-shared (with-meta {:id id :is-shared is-shared} mdata)) (rx/ignore)) (when is-shared (->> (rp/cmd! :get-file-summary {:id id}) (rx/map (fn [summary] (when (-> summary :variants :count pos?) - (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) + (ev/event (merge {::ev/name "set-file-variants-shared" + ::ev/origin origin} + (select-keys mdata [::ev/context]))))))))))))) (defn set-file-thumbnail [file-id thumbnail-id] diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 91671991f8..fe1662a11f 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -46,6 +46,14 @@ (update-fn state) state)))) +;; Dashboard events are logged by the backend, which reads the origin from +;; the `x-event-origin` header that `rp/cmd!` builds from the params meta. +(defn- with-origin + "Tags RPC `params` with the plugin or MCP origin, as `se/add-event` does + for frontend events." + [plugin-id params] + (se/add-event params plugin-id)) + (defn- refresh-restored-project [team-id project-id] (if (dashboard-of-team? @st/state team-id) @@ -65,8 +73,9 @@ (defn- restore-deleted-files "Uses the dashboard's Trash operation and waits for its final result." - [team-id ids] - (->> (rp/cmd! ::sse/restore-deleted-team-files {:team-id team-id :ids ids}) + [plugin-id team-id ids] + (->> (rp/cmd! ::sse/restore-deleted-team-files + (with-origin plugin-id {:team-id team-id :ids ids})) (rx/filter sse/end-of-stream?) (rx/take 1) (rx/map sse/get-payload) @@ -252,7 +261,7 @@ [plugin-id team-id options] (if-let [name (valid-name (obj/get options "name"))] (request plugin-id "manage:projects" - #(->> (rp/cmd! :create-project {:team-id team-id :name name}) + #(->> (rp/cmd! :create-project (with-origin plugin-id {:team-id team-id :name name})) (rx/map (fn [project] (assoc project :count 0))) (rx/tap (fn [project] (update-dashboard team-id @@ -309,7 +318,7 @@ :else (set-property plugin-id data :name name - (dtm/update-team {:id (:id @data) :name name})))))} + (dtm/update-team (with-origin plugin-id {:id (:id @data) :name name}))))))} :listProjects (fn [] (list-projects plugin-id (:id @data))) @@ -355,7 +364,7 @@ :else (set-property plugin-id data :name name - (dd/rename-project {:id (:id @data) :name name})))))} + (dd/rename-project (with-origin plugin-id {:id (:id @data) :name name}))))))} :pinned {:get #(boolean (:is-pinned @data)) @@ -370,9 +379,10 @@ :else (set-property plugin-id data :is-pinned value - (set-project-pin {:id (:id @data) - :team-id (:team-id @data) - :is-pinned value}))))} + (set-project-pin (with-origin plugin-id + {:id (:id @data) + :team-id (:team-id @data) + :is-pinned value})))))} :listFiles (fn [] @@ -395,7 +405,7 @@ (let [ids (into #{} (comp (filter (fn [file] (= id (:project-id file)))) (map :id)) files)] (if (seq ids) - (restore-deleted-files team-id ids) + (restore-deleted-files plugin-id team-id ids) (rx/throw (js/Error. "Cannot restore a project without recoverable files")))))) (rx/tap (fn [_] (swap! data dissoc :deleted-at))) (rx/mapcat (fn [_] (refresh-restored-project team-id id))))))) @@ -406,7 +416,7 @@ (request plugin-id "content:write" #(let [{:keys [id team-id]} @data features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] - (->> (rp/cmd! :create-file {:project-id id :name name :features features}) + (->> (rp/cmd! :create-file (with-origin plugin-id {:project-id id :name name :features features})) (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) (rx/tap (fn [file] (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) @@ -420,8 +430,9 @@ (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) (request plugin-id "manage:projects" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :duplicate-project (cond-> {:project-id id} - (some? name) (assoc :name (valid-name name)))) + (->> (rp/cmd! :duplicate-project (with-origin plugin-id + (cond-> {:project-id id} + (some? name) (assoc :name (valid-name name))))) (rx/mapcat dd/project-with-file-count) (rx/tap (fn [project] (update-dashboard team-id (fn [state] (ptk/update (dd/project-duplicated project) state))))) @@ -432,7 +443,7 @@ (if-let [team-id (uuid/parse* (obj/get team "id"))] (request plugin-id "manage:teams" #(let [{:keys [id] :as project} @data] - (->> (rp/cmd! :move-project {:project-id id :team-id team-id}) + (->> (rp/cmd! :move-project (with-origin plugin-id {:project-id id :team-id team-id})) (rx/tap (fn [_] (swap! data assoc :team-id team-id) (update-dashboard (:team-id project) (fn [state] (update state :projects dissoc id))))) @@ -443,7 +454,7 @@ (fn [] (request plugin-id "manage:delete" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :delete-project {:id id}) + (->> (rp/cmd! :delete-project (with-origin plugin-id {:id id})) (rx/tap (fn [_] (update-dashboard team-id (fn [state] (update state :projects dissoc id))))) (rx/map (constantly nil))))))))) @@ -480,7 +491,7 @@ :else (set-property plugin-id data :name name - (dd/rename-file {:id (:id @data) :name name})))))} + (dd/rename-file (with-origin plugin-id {:id (:id @data) :name name}))))))} :shared {:get #(boolean (:is-shared @data)) @@ -495,7 +506,7 @@ :else (set-property plugin-id data :is-shared value - (dd/set-file-shared {:id (:id @data) :is-shared value}))))} + (dd/set-file-shared (with-origin plugin-id {:id (:id @data) :is-shared value})))))} :open (fn [] @@ -506,7 +517,7 @@ :restore (fn [] (request plugin-id "content:write" - #(->> (restore-deleted-files (:team-id @data) #{(:id @data)}) + #(->> (restore-deleted-files plugin-id (:team-id @data) #{(:id @data)}) (rx/tap (fn [_] (swap! data dissoc :deleted-at :will-be-deleted-at))) (rx/mapcat (fn [_] (refresh-restored-project (:team-id @data) (:project-id @data))))))) @@ -519,7 +530,7 @@ (->> (rp/cmd! :get-project {:id project-id}) (rx/mapcat (fn [target] (if (= team-id (:team-id target)) - (rp/cmd! :move-files {:ids #{id} :project-id project-id}) + (rp/cmd! :move-files (with-origin plugin-id {:ids #{id} :project-id project-id})) (rx/throw (js/Error. "Cannot move a file to a project in another team"))))) (rx/tap (fn [_] (update-dashboard team-id (fn [state] (ptk/update (dd/move-files {:ids #{id} :project-id project-id}) state))) @@ -534,8 +545,9 @@ (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) (request plugin-id "content:write" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :duplicate-file (cond-> {:file-id id} - (some? name) (assoc :name (valid-name name)))) + (->> (rp/cmd! :duplicate-file (with-origin plugin-id + (cond-> {:file-id id} + (some? name) (assoc :name (valid-name name))))) (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) (rx/tap (fn [file] (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) @@ -545,7 +557,7 @@ (fn [] (request plugin-id "manage:delete" #(let [{:keys [id project-id team-id]} @data] - (->> (rp/cmd! :delete-file {:id id}) + (->> (rp/cmd! :delete-file (with-origin plugin-id {:id id})) (rx/tap (fn [_] (update-dashboard team-id (fn [state] @@ -576,7 +588,7 @@ (fn [options] (if-let [name (valid-name (obj/get options "name"))] (request plugin-id "manage:teams" - #(->> (rp/cmd! :create-team {:name name :features features/global-enabled-features}) + #(->> (rp/cmd! :create-team (with-origin plugin-id {:name name :features features/global-enabled-features})) (rx/tap (fn [_] (st/emit! (dtm/fetch-teams)))) (rx/map (partial team-proxy plugin-id)))) (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 15d04f8f7f..e9637d4d88 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.data.dashboard :as dd] + [app.main.data.event :as ev] [app.main.data.team :as dtm] [app.main.repo :as rp] [app.main.store :as st] @@ -80,6 +81,15 @@ ([command] (swap! requests conj [command]) (respond command nil)) ([command params] (swap! requests conj [command params]) (respond command params))))) +(defn- origins + "Maps each recorded command that is not a read to the origin of its params." + [requests] + (into {} + (keep (fn [[command params]] + (when-not (re-find #"^get-" (name command)) + [command (::ev/origin (meta params))]))) + requests)) + (defn- emit-stub "Mock `st/emit!` applying update functions to `state` and recording events." [state events] @@ -231,18 +241,20 @@ (props team* ["id" "name" "isDefault"]))) (t/is (= [[:create-team "Brand"]] (mapv (fn [[command params]] [command (:name params)]) @requests))) + (t/is (= {:create-team "plugin"} (origins @requests))) (t/is (= 1 (count (filter #(= ::dtm/fetch-teams (ptk/type %)) @events)))) (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createTeam (management/create-context plugin-id) #js {:name ""})))))))))) (t/deftest ^:async setters-update-values-after-the-backend-succeeds - (let [state (atom (throwing dashboard)) - events (atom [])] + (let [state (atom (throwing dashboard)) + events (atom []) + requests (atom [])] (await (mock/with-mocks* {st/state state st/emit! (emit-stub state events) - rp/cmd! (mock/stub (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} (let [team* (management/team-proxy plugin-id team) project* (management/project-proxy plugin-id project) file* (management/file-proxy plugin-id file)] @@ -260,7 +272,10 @@ (t/is (true? (obj/get project* "pinned"))) (t/is (= "Login" (obj/get file* "name"))) (t/is (true? (obj/get file* "shared"))) - (t/is (= 5 (count @events)))))))) + (t/is (= 5 (count @events))) + (t/is (= {:update-team "plugin" :rename-project "plugin" :update-project-pin "plugin" + :rename-file "plugin" :set-file-shared "plugin"} + (origins @requests)))))))) (t/deftest ^:async stale-object-setter-errors-stay-out-of-the-global-handler (doseq [[constructor property value original] @@ -340,6 +355,7 @@ (t/is (= {"name" "Login" "teamId" (str team-id)} (props file* ["name" "teamId"]))) (t/is (= 2 (obj/get parent "fileCount"))) (t/is (= [:create-project {:team-id team-id :name "Icons"}] (first @requests))) + (t/is (= {:create-project "plugin" :create-file "plugin"} (origins @requests))) (if (= initial dashboard) (do (t/is (contains? (:projects @state) (:id created-project))) (t/is (contains? (:files @state) (:id created-file)))) @@ -383,7 +399,10 @@ [:move-project {:project-id other-id :team-id other-id}] [:delete-file {:id file-id}] [:delete-project {:id other-id}]] - @requests))))))) + @requests)) + (t/is (= {:duplicate-file "plugin" :duplicate-project "plugin" :move-files "plugin" + :move-project "plugin" :delete-file "plugin" :delete-project "plugin"} + (origins @requests)))))))) (t/deftest ^:async methods-reject-invalid-input-and-missing-permissions (let [requests (atom [])] @@ -475,7 +494,8 @@ (doseq [proxy [(management/project-proxy r/mcp-plugin-id project) (management/file-proxy r/mcp-plugin-id file)]] (t/is (nil? (await (.remove proxy))))) - (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)))))) + (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)) + (t/is (= {:delete-project "mcp" :delete-file "mcp"} (origins @requests))))))) (t/deftest ^:async deleted-objects-can-be-listed-and-restored (let [state (atom ready) @@ -532,6 +552,7 @@ rp/cmd! (mock/stub (fn [command params] (t/is (= ::sse/restore-deleted-team-files command)) (t/is (= {:team-id team-id :ids #{file-id}} params)) + (t/is (= "plugin" (::ev/origin (meta params)))) (rx/observe-on :async response)))} (let [promise (.then (.restore (management/file-proxy plugin-id file)) #(reset! completed? true))]