🐛 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
This commit is contained in:
andrés gonzález 2026-11-11 16:02:29 +01:00 committed by GitHub
parent bb672a3723
commit ca616a39ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 69 additions and 33 deletions

View File

@ -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]

View File

@ -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"))))

View File

@ -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))]