Check nitrate permission only org members for move teams

This commit is contained in:
Pablo Alba 2026-05-25 18:36:32 +02:00 committed by Pablo Alba
parent 5c93ad0ab3
commit a637dda554
11 changed files with 221 additions and 41 deletions

View File

@ -516,3 +516,36 @@
team-member-ids (into #{} (map :profile-id team-members))] team-member-ids (into #{} (map :profile-id team-members))]
(every? #(contains? team-member-ids %) org-member-ids))) (every? #(contains? team-member-ids %) org-member-ids)))
false)) false))
(def ^:private schema:all-team-members-in-orgs-params
[:map {:title "CheckTeamMembersInOrgsParams"}
[:team-id ::sm/uuid]
[:organization-ids [:vector ::sm/uuid]]])
(sv/defmethod ::all-team-members-in-orgs
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-team-members-in-orgs-params
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
;; Validate requester membership in all orgs before fetching members.
(run! #(assert-membership cfg profile-id %) organization-ids)
(into {}
(map (fn [organization-id]
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)]
[organization-id
(every? #(contains? org-member-ids %) team-member-ids)])))
organization-ids)))
{}))

View File

@ -7,6 +7,7 @@
(ns backend-tests.rpc-nitrate-test (ns backend-tests.rpc-nitrate-test
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db] [app.db :as-alias db]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -715,6 +716,71 @@
(t/is (= :validation (th/ex-type (:error out)))) (t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out)))))))) (t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map
(let [profile-user (th/create-profile* 201 {:is-active true})
profile-other (th/create-profile* 202 {:is-active true})
team (th/create-team* 201 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id profile-other)
:role :editor})
team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)})
(map :profile-id)
(into #{}))
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership {:is-member true
:organization-id (:organization-id params)}
:get-org-members (get {org-id-1 (vec team-member-ids)
org-id-2 [(:id profile-user)]}
(:organization-id params)
[])
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)
membership-calls (count (filter #(= :get-org-membership %) methods))
get-members-calls (count (filter #(= :get-org-members %) methods))]
(t/is (th/success? out))
(t/is (= {org-id-1 true
org-id-2 false}
(:result out)))
(t/is (= 2 membership-calls))
(t/is (= 2 get-members-calls))))))
(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members
(let [profile-user (th/create-profile* 203 {:is-active true})
team (th/create-team* 203 {:profile-id (:id profile-user)})
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership (if (= (:organization-id params) org-id-2)
{:is-member false
:organization-id (:organization-id params)}
{:is-member true
:organization-id (:organization-id params)})
:get-org-members []
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :user-doesnt-belong-organization (th/ex-code (:error out))))
(t/is (= 0 (count (filter #(= :get-org-members %) methods))))))))
(t/deftest leave-org-error-reassign-on-non-owned-team (t/deftest leave-org-error-reassign-on-non-owned-team
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true})

View File

@ -247,25 +247,69 @@
is-own? (= profile-id (:owner-id org))] is-own? (= profile-id (:owner-id org))]
(or (= perm "any") is-own?))) all-orgs) (or (= perm "any") is-own?))) all-orgs)
team (first (filter #(= (:id %) team-id) teams)) team (first (filter #(= (:id %) team-id) teams))
add-anybody-to-team-orgs
(filterv #(nitrate-perms/allowed? :add-anybody-to-team
{:org-perms %})
orgs)
orgs-to-check
(filterv #(not (nitrate-perms/allowed? :add-anybody-to-team
{:org-perms %}))
orgs)
org-ids-to-check (mapv :id orgs-to-check)
on-confirm (fn [organization-id] on-confirm (fn [organization-id]
(st/emit! (add-team-to-org {:team-id team-id (st/emit! (add-team-to-org {:team-id team-id
:organization-id organization-id})))] :organization-id organization-id})))
(rx/of (dt/teams-fetched teams) show-select-modal
(if (empty? orgs) (fn [orgs-allowed]
(modal/show :no-permission-modal {:type :no-orgs-create}) (let [has-filtered? (< (count orgs) (count all-orgs))
(let [has-filtered? (< (count orgs) (count all-orgs)) extra-props (when has-filtered?
extra-props (when has-filtered? {:info-message-key "dashboard.select-org-modal.permission-info"})]
{:info-message-key "dashboard.select-org-modal.permission-info"})] (modal/show :select-organization-modal
(modal/show :select-organization-modal (merge {:organizations orgs
(merge {:organizations orgs :orgs-allowed orgs-allowed
:current-organization-id (dm/get-in team [:organization :id]) :current-organization-id (dm/get-in team [:organization :id])
:on-confirm on-confirm :on-confirm on-confirm
:title-key "dashboard.select-org-modal.title" :title-key "dashboard.select-org-modal.title"
:choose-key "dashboard.select-org-modal.choose" :choose-key "dashboard.select-org-modal.choose"
:placeholder-key "dashboard.select-org-modal.select" :placeholder-key "dashboard.select-org-modal.select"
:accept-key "dashboard.select-org-modal.accept" :accept-key "dashboard.select-org-modal.accept"
:cancel-key "labels.cancel"} :cancel-key "labels.cancel"}
extra-props))))))))))))) extra-props))))]
(cond
(empty? orgs)
(rx/of (dt/teams-fetched teams)
(modal/show :no-permission-modal {:type :no-orgs-create}))
(empty? org-ids-to-check)
(let [orgs-allowed (into {}
(map (fn [org] [(:id org) true]))
orgs)]
(rx/of (dt/teams-fetched teams)
(show-select-modal orgs-allowed)))
:else
(->> (rp/cmd! :all-team-members-in-orgs
{:team-id team-id
:organization-ids org-ids-to-check})
(rx/mapcat
(fn [checked-orgs]
(let [orgs-allowed
(merge (into {}
(map (fn [org] [(:id org) true]))
add-anybody-to-team-orgs)
checked-orgs)
valid-orgs
(filterv #(true? (get orgs-allowed (:id %))) orgs)]
(rx/of
(dt/teams-fetched teams)
(if (empty? valid-orgs)
(modal/show
{:type :alert
:message (tr "dashboard.team-organization.add.no-valid-orgs")
:accept-label (tr "labels.accept")
:accept-style :primary
:title (tr "dashboard.select-org-modal.title")})
(show-select-modal orgs-allowed))))))))))))))))
(defn show-change-team-org-modal (defn show-change-team-org-modal
"Fetches fresh team/org data, then shows the change-org modal "Fetches fresh team/org data, then shows the change-org modal

View File

@ -883,16 +883,24 @@
(mf/defc select-organization-modal (mf/defc select-organization-modal
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :select-organization-modal} ::mf/register-as :select-organization-modal}
[{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}] [{:keys [organizations orgs-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}]
(let [valid-organizations (mf/with-memo [organizations] (let [valid-organizations (mf/with-memo [organizations]
(remove #(= (:id %) current-organization-id) organizations)) (remove #(= (:id %) current-organization-id) organizations))
options (mf/with-memo [valid-organizations] options (mf/with-memo [valid-organizations orgs-allowed]
(mapv (fn [organization] (mapv (fn [organization]
{:id (str (:id organization)) (let [org-id (:id organization)
:label (:name organization) ;; orgs-allowed is a map of org-id and a boolean indicating if it is allowed
:avatar {:render-fn render-org-combobox-avatar* enabled? (or (nil? orgs-allowed)
:organization organization (true? (get orgs-allowed org-id)))]
:size "xl"}}) (cond-> {:id (str org-id)
:label (:name organization)
:disabled (not enabled?)
:dimmed (not enabled?)
:avatar {:render-fn render-org-combobox-avatar*
:organization organization
:size "xl"}}
(not enabled?)
(assoc :title (tr "dashboard.team-organization.disabled-org-tooltip")))))
valid-organizations)) valid-organizations))
form (fm/use-form :schema schema:organization-form :initial {}) form (fm/use-form :schema schema:organization-form :initial {})

View File

@ -93,16 +93,18 @@
on-option-click on-option-click
(mf/use-fn (mf/use-fn
(mf/deps on-change) (mf/deps on-change options)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(let [node (dom/get-current-target event) (let [node (dom/get-current-target event)
id (dom/get-data node "id")] id (dom/get-data node "id")
(reset! selected-id* id) option (d/seek #(= id (get % :id)) options)]
(reset! is-open* false) (when-not (true? (:disabled option))
(reset! focused-id* nil) (reset! selected-id* id)
(when (fn? on-change) (reset! is-open* false)
(on-change id))))) (reset! focused-id* nil)
(when (fn? on-change)
(on-change id))))))
on-click on-click
(mf/use-fn (mf/use-fn
@ -192,14 +194,15 @@
(handle-focus-change options focused-id* new-index nodes)) (handle-focus-change options focused-id* new-index nodes))
(kbd/enter? event) (kbd/enter? event)
(do (let [focused-option (d/seek #(= focused-id (get % :id)) options)]
(reset! selected-id* focused-id) (when-not (true? (:disabled focused-option))
(reset! is-open* false) (reset! selected-id* focused-id)
(reset! focused-id* nil) (reset! is-open* false)
(dom/blur! (mf/ref-val input-ref)) (reset! focused-id* nil)
(when (and (fn? on-change) (dom/blur! (mf/ref-val input-ref))
(some? focused-id)) (when (and (fn? on-change)
(on-change focused-id))) (some? focused-id))
(on-change focused-id))))
(kbd/esc? event) (kbd/esc? event)
(do (reset! is-open* false) (do (reset! is-open* false)

View File

@ -20,8 +20,10 @@
[:icon {:optional true} [:maybe :string]] [:icon {:optional true} [:maybe :string]]
[:selected {:optional true} :boolean] [:selected {:optional true} :boolean]
[:focused {:optional true} :boolean] [:focused {:optional true} :boolean]
[:disabled {:optional true} :boolean]
[:dimmed {:optional true} :boolean] [:dimmed {:optional true} :boolean]
[:label {:optional true} :string] [:label {:optional true} :string]
[:title {:optional true} [:maybe :string]]
[:avatar {:optional true} [:avatar {:optional true}
[:maybe [:maybe
[:map [:map
@ -39,23 +41,26 @@
(mf/defc option* (mf/defc option*
{::mf/schema schema:option} {::mf/schema schema:option}
[{:keys [id ref label icon avatar aria-label on-click selected focused dimmed] :rest props}] [{:keys [id ref label icon avatar aria-label on-click selected focused disabled dimmed title] :rest props}]
(let [render-avatar-fn (when avatar (let [render-avatar-fn (when avatar
(get avatar :render-fn)) (get avatar :render-fn))
class (stl/css-case :option true class (stl/css-case :option true
:option-with-icon (some? icon) :option-with-icon (some? icon)
:option-with-avatar (fn? render-avatar-fn) :option-with-avatar (fn? render-avatar-fn)
:option-disabled disabled
:option-selected selected :option-selected selected
:option-current focused)] :option-current focused)]
[:li {:value id [:li {:value id
:class class :class class
:aria-selected selected :aria-selected selected
:aria-disabled disabled
:ref ref :ref ref
:role "option" :role "option"
:id id :id id
:on-click on-click :title title
:on-click (when-not disabled on-click)
:data-id id :data-id id
:data-testid "dropdown-option"} :data-testid "dropdown-option"}

View File

@ -71,6 +71,10 @@
--options-icon-fg-color: var(--color-accent-primary); --options-icon-fg-color: var(--color-accent-primary);
} }
.option-disabled {
opacity: 0.6;
}
.option-check { .option-check {
color: var(--token-options-icon-fg-color); color: var(--token-options-icon-fg-color);
min-width: var(--sp-l); min-width: var(--sp-l);

View File

@ -37,11 +37,14 @@
[:value {:optional true} :keyword] [:value {:optional true} :keyword]
[:icon {:optional true} schema:icon-list] [:icon {:optional true} schema:icon-list]
[:label {:optional true} :string] [:label {:optional true} :string]
[:title {:optional true} :string]
[:avatar {:optional true} [:avatar {:optional true}
[:map [:map
[:size {:optional true} :string] [:size {:optional true} :string]
[:organization {:optional true} :any] [:organization {:optional true} :any]
[:render-fn {:optional true} fn?]]] [:render-fn {:optional true} fn?]]]
[:disabled {:optional true} :boolean]
[:dimmed {:optional true} :boolean]
[:aria-label {:optional true} :string]]) [:aria-label {:optional true} :string]])
(def ^:private schema:options-dropdown (def ^:private schema:options-dropdown

View File

@ -59,9 +59,11 @@
:key (weak-key option) :key (weak-key option)
:id id :id id
:label (get option :label) :label (get option :label)
:title (get option :title)
:aria-label (get option :aria-label) :aria-label (get option :aria-label)
:icon (get option :icon) :icon (get option :icon)
:avatar (get option :avatar) :avatar (get option :avatar)
:disabled (true? (:disabled option))
:ref ref :ref ref
:role "option" :role "option"
:focused (= id focused) :focused (= id focused)

View File

@ -1150,6 +1150,9 @@ msgstr "This team is not part of any organization"
msgid "dashboard.team-organization.add" msgid "dashboard.team-organization.add"
msgstr "Add to an organization" msgstr "Add to an organization"
msgid "dashboard.team-organization.add.no-valid-orgs"
msgstr "You are not allowed to add this team to any of your organizations."
msgid "dashboard.select-org-modal.title" msgid "dashboard.select-org-modal.title"
msgstr "Add team to an organization" msgstr "Add team to an organization"
@ -1183,6 +1186,9 @@ msgstr "Change team organization"
msgid "dashboard.team-organization.remove" msgid "dashboard.team-organization.remove"
msgstr "Remove team from organization" msgstr "Remove team from organization"
msgid "dashboard.team-organization.disabled-org-tooltip"
msgstr "Only people within your organization can be invited."
#: src/app/main/ui/dashboard/team.cljs:1344 #: src/app/main/ui/dashboard/team.cljs:1344
msgid "dashboard.team-projects" msgid "dashboard.team-projects"
msgstr "Team projects" msgstr "Team projects"

View File

@ -1154,6 +1154,9 @@ msgstr "Este equipo no pertenece a ninguna organización"
msgid "dashboard.team-organization.add" msgid "dashboard.team-organization.add"
msgstr "Añadir a una organización" msgstr "Añadir a una organización"
msgid "dashboard.team-organization.add.no-valid-orgs"
msgstr "No tienes permiso para añadir este equipo a ninguna de tus organizaciones."
msgid "dashboard.select-org-modal.title" msgid "dashboard.select-org-modal.title"
msgstr "Añadir el equipo a una organización" msgstr "Añadir el equipo a una organización"
@ -1187,6 +1190,9 @@ msgstr "Cambiar el equipo de organización"
msgid "dashboard.team-organization.remove" msgid "dashboard.team-organization.remove"
msgstr "Eliminar equipo de la organización" msgstr "Eliminar equipo de la organización"
msgid "dashboard.team-organization.disabled-org-tooltip"
msgstr "Solo se puede invitar a personas que estén dentro de tu organización."
#: src/app/main/ui/dashboard/team.cljs:1344 #: src/app/main/ui/dashboard/team.cljs:1344
msgid "dashboard.team-projects" msgid "dashboard.team-projects"
msgstr "Proyectos del equipo" msgstr "Proyectos del equipo"