Add Nitrate advanced permissions delete (#9416)

*  Add Nitrate advanced permissions delete

* 📎 Code review
This commit is contained in:
María Valderrama 2026-05-07 21:14:30 +02:00 committed by GitHub
parent d84685c0cb
commit 7c5fa038c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 241 additions and 75 deletions

View File

@ -360,7 +360,7 @@
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:create-teams [:enum "any" "onlyMe"]]]
[:permissions [:map-of :keyword :string]]]
params)))
(defn- redeem-activation-code-api

View File

@ -12,6 +12,7 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.config :as cf]
[app.db :as db]
[app.nitrate :as nitrate]
@ -313,12 +314,12 @@
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(let [create-perm (:create-teams org-perms)
is-owner? (= profile-id (:owner-id org-perms))]
(when (and (= create-perm "onlyMe") (not is-owner?))
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to add teams in this organization"))))))
(when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to add teams in this organization")))))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed

View File

@ -12,6 +12,7 @@
[app.common.features :as cfeat]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -520,12 +521,12 @@
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(let [create-perm (:create-teams org-perms)
is-owner? (= profile-id (:owner-id org-perms))]
(when (and (= create-perm "onlyMe") (not is-owner?))
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization"))))))
(when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization")))))
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
@ -773,20 +774,35 @@
(defn delete-team
"Mark a team for deletion"
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}]
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [team (get-team conn :profile-id profile-id :team-id team-id)
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team params)
team)
perms (get team :permissions)]
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :only-owner-can-delete-team))
(when (:is-default team)
(ex/raise :type :validation
:code :non-deletable-team
:hint "impossible to delete default team"))
;; Check delete permissions based on organization settings.
;; For non-org teams or when nitrate is disabled, only owners can delete.
(if (and (:organization-id team) (contains? cf/flags :nitrate))
(let [org-perms {:owner-id (:organization-owner-id team)
:permissions (:organization-permissions team)}]
(when-not (nitrate-perms/allowed? :delete-team
{:org-perms org-perms
:profile-id profile-id
:team-perms perms})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to delete teams in this organization")))
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :only-owner-can-delete-team)))
(let [delay (ldel/get-deletion-delay team)
team (db/update! conn :team
{:deleted-at (ct/in-future delay)}

View File

@ -0,0 +1,41 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.types.nitrate-permissions)
(def ^:private defaults
{:create-teams "any"
:delete-teams "ownersAndAdmins"})
(def ^:private action-rules
{:create-team {:permission-key :create-teams
:allowed-values #{"any"}
:requires-admin? false}
:delete-team {:permission-key :delete-teams
:allowed-values #{"ownersAndAdmins"}
:requires-admin? true}})
(defn- normalize-org-permissions
[org-perms]
(merge defaults (or (:permissions org-perms) {})))
(defn- owner?
[org-perms profile-id]
(= profile-id (:owner-id org-perms)))
(defn allowed?
"Returns true only for explicitly allowed actions (fail-closed)."
[action {:keys [org-perms profile-id team-perms]}]
(let [{:keys [permission-key allowed-values requires-admin?] :as rule}
(get action-rules action)
permissions (normalize-org-permissions org-perms)
is-owner? (owner? org-perms profile-id)
is-admin? (boolean (:is-admin team-perms))]
(cond
(nil? rule) false
is-owner? true
(and requires-admin? (not is-admin?)) false
:else (contains? allowed-values (get permissions permission-key)))))

View File

@ -16,7 +16,10 @@
[:owner-id ::sm/uuid]
[:avatar-bg-url ::sm/uri]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]])
[:permissions {:optional true}
[:maybe [:map
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
[:delete-teams {:optional true} [:maybe [:enum "ownersAndAdmins" "onlyOwners"]]]]]]])
(def schema:team-with-organization
@ -33,7 +36,7 @@
[:slug :organization-slug]
[:avatar-bg-url :organization-avatar-bg-url]
[:owner-id :organization-owner-id]
[:create-teams :organization-create-teams]])
[:permissions :organization-permissions]])
(defn apply-organization
"Updates a team map with organization fields sourced from org.

View File

@ -67,6 +67,7 @@
[common-tests.types.container-test]
[common-tests.types.fill-test]
[common-tests.types.modifiers-test]
[common-tests.types.nitrate-permissions-test]
[common-tests.types.objects-map-test]
[common-tests.types.path-data-test]
[common-tests.types.shape-decode-encode-test]
@ -147,6 +148,7 @@
'common-tests.types.container-test
'common-tests.types.fill-test
'common-tests.types.modifiers-test
'common-tests.types.nitrate-permissions-test
'common-tests.types.objects-map-test
'common-tests.types.path-data-test
'common-tests.types.shape-decode-encode-test

View File

@ -0,0 +1,57 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.types.nitrate-permissions-test
(:require
[app.common.types.nitrate-permissions :as nitrate-perms]
[clojure.test :as t]))
(def org-perms
{:owner-id :owner
:permissions {:create-teams "any"
:delete-teams "ownersAndAdmins"}})
(t/deftest unknown-action-is-denied
(t/is (false? (nitrate-perms/allowed? :unknown
{:org-perms org-perms
:profile-id :member
:team-perms {:is-admin true}}))))
(t/deftest owner-is-always-allowed
(t/is (true? (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id :owner
:team-perms {:is-admin false}})))
(t/is (true? (nitrate-perms/allowed? :delete-team
{:org-perms org-perms
:profile-id :owner
:team-perms {:is-admin false}}))))
(t/deftest create-team-permission-rules
(t/is (true? (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id :member
:team-perms {:is-admin false}})))
(t/is (false? (nitrate-perms/allowed? :create-team
{:org-perms (assoc org-perms :permissions {:create-teams "none"
:delete-teams "ownersAndAdmins"})
:profile-id :member
:team-perms {:is-admin false}}))))
(t/deftest delete-team-requires-admin-and-policy
(t/is (false? (nitrate-perms/allowed? :delete-team
{:org-perms org-perms
:profile-id :member
:team-perms {:is-admin false}})))
(t/is (true? (nitrate-perms/allowed? :delete-team
{:org-perms org-perms
:profile-id :member
:team-perms {:is-admin true}})))
(t/is (false? (nitrate-perms/allowed? :delete-team
{:org-perms (assoc org-perms :permissions {:create-teams "any"
:delete-teams "onlyOwners"})
:profile-id :member
:team-perms {:is-admin true}}))))

View File

@ -189,7 +189,7 @@
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
(let [perm (get-in org [:permissions :create-teams])
is-own? (= profile-id (:owner-id org))]
(or (= perm "any") is-own?))) all-orgs)
team (first (filter #(= (:id %) team-id) teams))
@ -198,7 +198,7 @@
:organization-id organization-id})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(modal/show :no-permission-modal {:type :no-orgs-create})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]
@ -228,7 +228,7 @@
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
(let [perm (get-in org [:permissions :create-teams])
is-own? (= profile-id (:owner-id org))]
(or (= perm "any") is-own?))) all-orgs)
team (first (filter #(= (:id %) team-id) teams))
@ -237,7 +237,7 @@
:organization-id organization-id})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(modal/show :no-permission-modal {:type :no-orgs-change})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]

View File

@ -11,6 +11,7 @@
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.common.schema :as sm]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as ctt]
[app.common.uri :as u]
[app.config :as cf]
@ -22,6 +23,7 @@
[app.main.repo :as rp]
[app.main.router :as rt]
[app.util.clipboard :as clipboard]
[app.util.i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[clojure.string :as str]
@ -52,7 +54,7 @@
:organization-slug
:organization-owner-id
:organization-avatar-bg-url
:organization-create-teams))]
:organization-permissions))]
(update state :teams assoc id team-updated)))
state
teams)))))
@ -78,16 +80,55 @@
(fn [teams]
(let [team (d/seek #(= (:id %) team-id) teams)
in-org? (and (contains? cf/flags :nitrate) (:organization-id team))
create-perm (:organization-create-teams team)
is-owner? (= profile-id (:organization-owner-id team))
can-create? (or (not in-org?) (= create-perm "any") is-owner?)]
can-create? (if in-org?
(nitrate-perms/allowed? :create-team
{:org-perms {:owner-id (:organization-owner-id team)
:permissions (:organization-permissions team)}
:profile-id profile-id
:team-perms (:permissions team)})
true)]
(rx/of (teams-fetched teams)
(if can-create?
(modal/show :team-form (if in-org?
{:organization-id (:organization-id team)
:organization-name (:organization-name team)}
{}))
(modal/show :no-permission-create-team {:organization-name (:organization-name team)})))))))))))
(modal/show :no-permission-modal {:type :create-team
:organization-name (:organization-name team)})))))))))))
(defn check-and-delete-team
"Fetches fresh team data from the server to ensure up-to-date org
permissions, then shows the confirmation modal or a no-permission modal."
[{:keys [team-id delete-fn]}]
(ptk/reify ::check-and-delete-team
ptk/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [team (d/seek #(= (:id %) team-id) teams)
in-org? (and (contains? cf/flags :nitrate) (:organization-id team))
can-delete? (if in-org?
(nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (:organization-owner-id team)
:permissions (:organization-permissions team)}
:profile-id profile-id
:team-perms (:permissions team)})
(boolean (dm/get-in team [:permissions :is-owner])))
message (if in-org?
(tr "modals.delete-org-team-confirm.message" (:organization-name team))
(tr "modals.delete-team-confirm.message"))]
(rx/of (teams-fetched teams)
(if can-delete?
(modal/show
{:type :confirm
:title (tr "modals.delete-team-confirm.title")
:message message
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})
(modal/show :no-permission-modal {:type :delete-team
:organization-name (:organization-name team)})))))))))))
;; --- EVENT: fetch-members
@ -625,5 +666,5 @@
:custom-photo (:organization-custom-photo team)
:name (:organization-name team)
:default-team-id (:id team)
:create-teams (:organization-create-teams team)})
:permissions (:organization-permissions team)})

View File

@ -468,6 +468,10 @@
:owner-cant-leave-team
(rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave")))
:not-allowed
(rx/of (modal/show :no-permission-modal {:type :delete-team
:organization-name (:organization-name team)}))
(rx/throw error))))
leave-fn
@ -526,17 +530,9 @@
(mf/use-fn
(mf/deps team delete-fn)
(fn []
(let [is-org-team? (some? (:organization-id team))
message (if is-org-team?
(tr "modals.delete-org-team-confirm.message" (:organization-name team))
(tr "modals.delete-team-confirm.message"))]
(st/emit!
(modal/show
{:type :confirm
:title (tr "modals.delete-team-confirm.title")
:message message
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})))))]
;; Fetch fresh team data to check current permission, then show appropriate modal
(st/emit! (dtm/check-and-delete-team {:team-id (:id team)
:delete-fn delete-fn}))))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click go-members
@ -583,11 +579,19 @@
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")])
(when (get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-delete-clicked
:class (stl/css :team-options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(let [is-owner? (get-in team [:permissions :is-owner])
is-admin? (get-in team [:permissions :is-admin])
is-org-team? (some? (:organization-id team))
in-org? (and (contains? cf/flags :nitrate) is-org-team?)
show-delete? (if in-org?
(or is-owner? is-admin?)
is-owner?)]
(when show-delete?
[:> dropdown-menu-item* {:on-click on-delete-clicked
:class (stl/css :team-options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")]))]))
(mf/defc org-options-dropdown*
{::mf/private true}

View File

@ -1381,7 +1381,7 @@
organizations (mf/with-memo [all-organizations profile-id]
(->> all-organizations
(filter (fn [org]
(let [perm (:create-teams org)
(let [perm (get-in org [:permissions :create-teams])
is-owner? (= profile-id (:owner-id org))]
(or (= perm "any") is-owner?))))))

View File

@ -46,7 +46,8 @@
(let [id (get-in @form [:clean-data :id])
code (-> response ex-data :code)]
(if (= code :not-allowed)
(rx/of (modal/show :no-permission-create-team {:organization-name organization-name}))
(rx/of (modal/show :no-permission-modal {:type :create-team
:organization-name organization-name}))
(if id
(rx/of (ntf/error "Error on updating team."))
(rx/of (ntf/error "Error on creating team."))))))
@ -139,31 +140,25 @@
:class (stl/css :accept-btn)}]]]]]]))
(mf/defc no-permission-create-team-modal*
(mf/defc no-permission-modal*
"Generic modal for displaying permission-related messages based on error type"
{::mf/register modal/components
::mf/register-as :no-permission-create-team}
[{:keys [organization-name]}]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "labels.create-team")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div (tr "dashboard.no-permission-create-team.message" organization-name)]]]])
(mf/defc no-org-allows-create-team-modal*
{::mf/register modal/components
::mf/register-as :no-org-allows-create-team}
[_props]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "dashboard.select-org-modal.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div (tr "dashboard.no-org-allows-create-team.message")]]]])
::mf/register-as :no-permission-modal}
[{:keys [type organization-name]}]
(let [[title message] (case type
:create-team [(tr "labels.create-team")
(tr "dashboard.no-permission-create-team.message" organization-name)]
:delete-team [(tr "dashboard.delete-team")
(tr "dashboard.no-permission-delete-team.message" organization-name)]
:no-orgs-create [(tr "dashboard.select-org-modal.title")
(tr "dashboard.no-org-allows-create-team.message")]
:no-orgs-change [(tr "dashboard.change-org-modal.title")
(tr "dashboard.no-org-allows-create-team.message")])]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} title]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div message]]]]))

View File

@ -9423,3 +9423,6 @@ msgstr "You don't have permission to add teams to any of your organizations."
msgid "dashboard.select-org-modal.permission-info"
msgstr "Here you find all your organizations where you are allowed to create or add teams."
msgid "dashboard.no-permission-delete-team.message"
msgstr "You are not allowed to delete teams that are part of %s organization. If you need more information, contact the owner."

View File

@ -9128,3 +9128,6 @@ msgstr "No tienes permiso para añadir equipos a ninguna de tus organizaciones."
msgid "dashboard.select-org-modal.permission-info"
msgstr "Aquí encontrarás todas las organizaciones en las que tienes permiso para crear o añadir equipos."
msgid "dashboard.no-permission-delete-team.message"
msgstr "No tienes permiso para eliminar equipos que pertenecen a la organización %s. Si necesitas más información, contacta con el propietario."