🐛 Add permission checks to shape proxy interactions, detach, export, and variants

The shape proxy (shape.cljs) had multiple operations missing permission
checks, plus a cond ordering bug that bypassed the existing content:write
check for text shapes in commit-fills!.

Fix commit-fills! cond ordering: move permission check before the
text-shape branch so text shapes are also protected.

Add content:write permission checks to:
- interaction-proxy: :trigger, :delay, :action setters, :remove method
- shape-proxy: :addInteraction, :removeInteraction, :detach
- shape-proxy: :applyToken, :switchVariant, :combineAsVariants

Add content:read permission check to:
- shape-proxy: :export (read/extraction operation)

Follows the established pattern from :resize, :rotate, :blocked setters.

Relates to #11137

AI-assisted-by: qwen3.7-plus
This commit is contained in:
Andrey Antukh 2026-08-07 16:58:38 +00:00
parent 37743e6d46
commit 16ac021dce
2 changed files with 247 additions and 5 deletions

View File

@ -103,6 +103,9 @@
(not (contains? ctsi/event-types value))
(u/not-valid plugin-id :trigger value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@ -119,6 +122,9 @@
(or (not (sm/valid-safe-int? value)) (neg? value))
(u/not-valid plugin-id :delay value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@ -139,6 +145,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :action interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/update-interaction
(u/locate-shape file-id page-id shape-id)
@ -148,7 +157,12 @@
:remove
(fn []
(st/emit! (dwi/remove-interaction {:id shape-id} index)))))
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwi/remove-interaction {:id shape-id} index))))))
(def lib-typography-proxy? nil)
(def lib-component-proxy nil)
@ -200,15 +214,15 @@
(not (sm/validate [:vector types.fills/schema:fill] value))
(u/not-valid plugin-id :fills value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
(not (u/page-active? (obj/get self "$page")))
(u/not-valid plugin-id :fills "Cannot modify a page that is not currently active")
(cfh/text-shape? shape)
(st/emit! (dwt/update-attrs id {:fills value}))
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))
@ -1482,6 +1496,9 @@
(not (u/page-active? page-id))
(u/not-valid plugin-id :detach "Cannot modify a page that is not currently active")
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwl/detach-component id))))
@ -1531,6 +1548,9 @@
(not (sm/validate ctse/schema:export value))
(u/not-valid plugin-id :export value)
(not (r/check-permission plugin-id "content:read"))
(u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
:else
(if (and (contains? cf/flags :wasm-export)
(contains? #{:jpeg :webp :png} (:type value :png)))
@ -1602,6 +1622,9 @@
(not (sm/validate ctsi/schema:interaction interaction))
(u/not-valid plugin-id :addInteraction interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission")
:else
(let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)]
(st/emit!
@ -1615,6 +1638,9 @@
(not (interaction-proxy? interaction))
(u/not-valid plugin-id :removeInteraction interaction)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(dwi/remove-interaction {:id id} (obj/get interaction "$index"))
@ -1695,8 +1721,14 @@
:fn (fn [token attrs]
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
(if (some #(not (token-attr? %)) kw-attrs)
(cond
(some #(not (token-attr? %)) kw-attrs)
(u/not-valid plugin-id :applyToken attrs)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission")
:else
(st/emit!
(-> (dwta/toggle-token {:token token
:attrs kw-attrs
@ -1724,6 +1756,9 @@
(not (string? value))
(u/not-valid plugin-id :value value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission")
:else
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
@ -1737,6 +1772,9 @@
(or (not (seq ids)) (not (every? uuid/parse* ids)))
(u/not-valid plugin-id :ids ids)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission")
:else
(let [;; Keep the input order (head shape first): it determines
;; the order of the resulting variant components (see

View File

@ -11,10 +11,15 @@
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.variants :as dwv]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.public-utils :as public-utils]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.utils :as u]
[cljs.test :as t :include-macros true]
@ -204,3 +209,202 @@
(t/deftest group-empty-input-returns-nil
(let [context (api/create-context plugin-id)]
(t/is (nil? (.group context #js [])))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-04)
;; ---------------------------------------------------------------------------
(t/deftest commit-fills-text-shape-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/proxy->shape (constantly {:id shape-id :type :text})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-trigger-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-trigger inter) "click")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-delay-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-delay inter) 100)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-action-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(set! (.-action inter) #js {:type "open-url" :url "https://example.com"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest interaction-remove-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(.remove inter)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest add-interaction-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (constantly {:id shape-id})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest remove-interaction-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)]
(.removeInteraction proxy inter)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest detach-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/page-active? (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.detach proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest export-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.export proxy #js {:type "png" :scale 1})
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"]
(first @errors)))))))
(t/deftest apply-token-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
set-id (uuid/next)
token-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color})
shape/token-proxy? (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)
token #js {"$set-id" (str set-id) "$id" (str token-id)}]
(.applyToken proxy token #js [])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest switch-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id})
u/locate-library-component (constantly {:id (uuid/next)})
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.switchVariant proxy 0 "value")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))
(t/deftest combine-as-variants-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
other-id (uuid/next)
errors (atom [])]
(with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id})
u/locate-library-component (constantly {:id (uuid/next)})
ctk/is-variant? (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
r/check-permission (constantly false)
st/emit! mock/noop]
(let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)]
(.combineAsVariants proxy #js [(str other-id)])
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"]
(first @errors)))))))