From 49119e0339ca63b516fa38d96da26ca6f560b273 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 3 Aug 2026 17:23:55 +0200 Subject: [PATCH 1/3] :recycle: Rename nitrate config to admin-console (#10929) * :recycle: Rename nitrate config to admin-console Rename user-facing configuration from 'nitrate' to 'admin-console': - Feature flags: :nitrate -> :admin-console, :nitrate-bulk-create-profiles -> :admin-console-bulk-create-profiles - Config keys: :nitrate-shared-key -> :admin-console-shared-key, :nitrate-backend-uri -> :admin-console-uri - Shared-keys map entry: :nitrate -> :admin-console (setup.clj + main.clj) - Env vars: PENPOT_NITRATE_SHARED_KEY -> PENPOT_ADMIN_CONSOLE_SHARED_KEY, PENPOT_NITRATE_BACKEND_URI removed (consolidated into PENPOT_ADMIN_CONSOLE_URI) - Docker/nginx: PENPOT_NITRATE_URI -> PENPOT_ADMIN_CONSOLE_URI Code namespaces, file paths, CSS classes, and i18n keys stay as-is. AI-assisted-by: mimo-v2.5-pro * :recycle: Rename initialize-user-in-nitrate-organization to initialize-user-in-organization Part of the nitrate -> admin-console rename series. The function and all 9 references across 6 files have been renamed. * :recycle: Rename :nitrate-bulk-create-profiles-not-allowed to :bulk-create-profiles-not-allowed * :recycle: Inline nitrate-permissions into app.common.types.organization - Delete app.common.types.nitrate-permissions and its test - Move permission rules (allowed?, can-send-invitations?, etc.) into organization.cljc - Harmonize all consumers to use alias cto for app.common.types.organization - Update test runner and create organization_test.cljc --- .serena/memories/common/core.md | 2 +- backend/scripts/_env | 4 +- backend/src/app/config.clj | 4 +- backend/src/app/main.clj | 2 +- backend/src/app/nitrate.clj | 10 +- backend/src/app/rpc.clj | 4 +- backend/src/app/rpc/commands/nitrate.clj | 40 ++-- backend/src/app/rpc/commands/profile.clj | 6 +- backend/src/app/rpc/commands/teams.clj | 46 ++--- .../app/rpc/commands/teams_invitations.clj | 14 +- backend/src/app/rpc/commands/verify_token.clj | 10 +- backend/src/app/rpc/management/nitrate.clj | 16 +- backend/src/app/rpc/nitrate/emails_helper.clj | 2 +- backend/src/app/setup.clj | 2 +- .../rpc_management_nitrate_test.clj | 2 +- .../test/backend_tests/rpc_nitrate_test.clj | 22 +-- ...pc_organization_owner_permissions_test.clj | 2 +- .../test/backend_tests/rpc_profile_test.clj | 2 +- backend/test/backend_tests/rpc_team_test.clj | 10 +- common/src/app/common/flags.cljc | 8 +- .../app/common/types/nitrate_permissions.cljc | 103 ---------- common/src/app/common/types/organization.cljc | 102 ++++++++++ common/test/common_tests/runner.cljc | 4 +- .../types/nitrate_permissions_test.cljc | 178 ------------------ .../common_tests/types/organization_test.cljc | 178 ++++++++++++++++++ docker/images/files/nginx-entrypoint.sh | 4 +- docker/images/files/nginx.conf.template | 2 +- frontend/src/app/main/data/common.cljs | 10 +- frontend/src/app/main/data/dashboard.cljs | 4 +- frontend/src/app/main/data/nitrate.cljs | 15 +- frontend/src/app/main/data/team.cljs | 45 +++-- .../src/app/main/ui/dashboard/sidebar.cljs | 18 +- frontend/src/app/main/ui/dashboard/team.cljs | 2 +- frontend/src/app/main/ui/routes.cljs | 4 +- .../app/main/ui/settings/delete_account.cljs | 2 +- .../app/main/ui/settings/subscription.cljs | 10 +- .../frontend_tests/data/dashboard_test.cljs | 4 +- 37 files changed, 445 insertions(+), 448 deletions(-) delete mode 100644 common/src/app/common/types/nitrate_permissions.cljc delete mode 100644 common/test/common_tests/types/nitrate_permissions_test.cljc create mode 100644 common/test/common_tests/types/organization_test.cljc diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index 6ae8f084e8..816d131e14 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -5,7 +5,7 @@ ## Stable namespace map - `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities. -- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules. +- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.organization` contains organization schemas, `apply-organization`, and fail-closed organization/team permission rules (`allowed?`, `can-send-invitations?`). - `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic. - `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc. - `app.common.geom.*`: geometry helpers and transformations. diff --git a/backend/scripts/_env b/backend/scripts/_env index d416e7bd59..120bb648bc 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -1,6 +1,6 @@ #!/usr/bin/env bash -export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key +export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key export PENPOT_SECRET_KEY=super-secret-devenv-key @@ -71,7 +71,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800 export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com" -export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console +export PENPOT_ADMIN_CONSOLE_URI=http://localhost:3000/admin-console export JAVA_OPTS="\ -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \ diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index dd6904a7a2..27ca224a88 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -119,7 +119,7 @@ [:allowed-origins {:optional true} [::sm/set :string]] [:exporter-shared-key {:optional true} :string] - [:nitrate-shared-key {:optional true} :string] + [:admin-console-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string] [:management-api-key {:optional true} :string] @@ -264,7 +264,7 @@ [:netty-io-threads {:optional true} ::sm/int] - [:nitrate-backend-uri {:optional true} ::sm/uri] + [:admin-console-uri {:optional true} ::sm/uri] ;; DEPRECATED [:assets-storage-backend {:optional true} :keyword] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d473190051..9f90d0b72f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -469,7 +469,7 @@ ::setup/shared-keys {::setup/props (ig/ref ::setup/props) :nexus (cf/get :nexus-shared-key) - :nitrate (cf/get :nitrate-shared-key) + :admin-console (cf/get :admin-console-shared-key) :exporter (cf/get :exporter-shared-key)} ::setup/clock diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 70a072b7c0..7f4b470ab3 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -49,7 +49,7 @@ "Joins relative path segments to the Nitrate backend URI. Segments must not start with `/`" [& segments] - (apply join-base-uri (cf/get :nitrate-backend-uri) segments)) + (apply join-base-uri (cf/get :admin-console-uri) segments)) (defn- generate-public-uri "Joins relative path segments to the public backend URI. @@ -143,7 +143,7 @@ (defn- request-to-nitrate [cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}] - (let [shared-key (-> cfg ::setup/shared-keys :nitrate) + (let [shared-key (-> cfg ::setup/shared-keys :admin-console) full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params) (with-retries 3) (with-validate uri schema :throw-on-error? throw-on-error?))] @@ -155,7 +155,7 @@ (defn call [cfg method params] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [client (get cfg ::client) method (get client method)] (method params)))) @@ -487,7 +487,7 @@ (defmethod ig/init-key ::client [_ cfg] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) {:get-team-organization (partial get-team-organization-api cfg) :set-team-organization (partial set-team-organization-api cfg) :get-organization-membership (partial get-organization-membership-api cfg) @@ -549,7 +549,7 @@ callers are unaffected. Returns false when the :nitrate flag is off." [cfg profile-id team-id] (boolean - (when (and (contains? cf/flags :nitrate) + (when (and (contains? cf/flags :admin-console) (nitrate-client? cfg) (some? team-id) (some? profile-id)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 3196b5c855..c7e33312ca 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -279,7 +279,7 @@ Raises :nitrate-sso-required error if user is not authorized in the organization." [_ f mdata] - (if (and (contains? cf/flags :nitrate) + (if (and (contains? cf/flags :admin-console) (::auth mdata true) ;; only for endpoints that needs auth (::nitrate/sso mdata true)) (fn [cfg params] @@ -430,7 +430,7 @@ [cfg] (let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing) mods (cond->> (list 'app.rpc.management.exporter) - (contains? cf/flags :nitrate) + (contains? cf/flags :admin-console) (cons 'app.rpc.management.nitrate))] (->> (apply sv/scan-ns mods) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 55b7a8e6d2..b7143ebe85 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -14,7 +14,7 @@ [app.common.json :as json] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.nitrate-permissions :as nitrate-perms] + [app.common.types.organization :as cto] [app.config :as cf] [app.db :as db] [app.nitrate :as nitrate] @@ -423,16 +423,16 @@ (assert-not-default-team cfg team-id) (assert-membership cfg profile-id organization-id) ;; Check moveTeams permission on the source organization - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})] (if (nil? organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") - (when-not (nitrate-perms/allowed? :move-team - {:organization-perms organization-perms - :profile-id profile-id}) + (when-not (cto/allowed? :move-team + {:organization-perms organization-perms + :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))))) @@ -462,7 +462,7 @@ Returns {:allows-anybody bool :external-emails [...]}" [{:keys [::db/conn] :as cfg} team-id organization-id] (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id}) - allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization-perms})] + allows-anybody (cto/allowed? :add-anybody-to-team {:organization-perms organization-perms})] (if allows-anybody {:allows-anybody true :external-emails []} (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] @@ -493,7 +493,7 @@ (assert-not-default-team cfg team-id) (assert-membership cfg profile-id organization-id) - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id}) source-organization-id (get-in team-with-organization [:organization :id]) @@ -517,18 +517,18 @@ (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions")) - (when-not (nitrate-perms/allowed? :move-team - {:organization-perms source-organization-perms - :profile-id profile-id - :target-organization-same-owner? target-organization-same-owner?}) + (when-not (cto/allowed? :move-team + {:organization-perms source-organization-perms + :profile-id profile-id + :target-organization-same-owner? target-organization-same-owner?}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))) ;; Always check target create-teams permission (new/add and move flows). - (when-not (nitrate-perms/allowed? :create-team - {:organization-perms target-organization-perms - :profile-id profile-id}) + (when-not (cto/allowed? :create-team + {:organization-perms target-organization-perms + :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to add teams in this organization")) @@ -540,7 +540,7 @@ (remove #{profile-id}) (remove organization-member-ids-before))] (doseq [member-id new-member-ids] - (teams/initialize-user-in-nitrate-organization cfg member-id organization-id))) + (teams/initialize-user-in-organization cfg member-id organization-id))) ;; Api call to nitrate (let [team (nitrate/call cfg :set-team-organization {:team-id team-id @@ -574,7 +574,7 @@ ::sm/result [:map-of :string :boolean] ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] - (or (when (contains? cf/flags :nitrate) + (or (when (contains? cf/flags :admin-console) (assert-membership cfg profile-id organization-id) (let [emails-array (db/create-array conn "text" emails) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) @@ -598,7 +598,7 @@ ::sm/params schema:all-organization-members-in-team-params ::sm/result ::sm/boolean} [cfg {:keys [::rpc/profile-id team-id organization-id]}] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (let [perms (teams/get-permissions cfg profile-id team-id)] (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation @@ -622,7 +622,7 @@ ::sm/params schema:all-team-members-in-organizations-params ::sm/result [:map-of ::sm/uuid ::sm/boolean]} [cfg {:keys [::rpc/profile-id team-id organization-ids]}] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (let [perms (teams/get-permissions cfg profile-id team-id)] (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation @@ -659,7 +659,7 @@ ::sm/result schema:check-team-external-invitations-result ::db/transaction true} [cfg {:keys [::rpc/profile-id team-id organization-id]}] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (let [perms (teams/get-permissions cfg profile-id team-id)] (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation @@ -692,7 +692,7 @@ ::sm/params schema:check-nitrate-sso ::nitrate/sso false} [cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (if (and team-id (not (teams/has-read-permissions? cfg profile-id team-id))) ;; Let the destination RPC enforce its own permissions. Starting SSO before diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index e56752b1b8..f6743b975a 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -96,7 +96,7 @@ (defn- with-nitrate-licence [profile cfg] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (nitrate/add-nitrate-licence-to-profile cfg profile) profile)) @@ -503,7 +503,7 @@ ;; imported "Your Penpot" teams according to whether they still have files. ;; Let Nitrate clean up the data associated with the deleted Penpot user: ;; owned organizations, remaining memberships, and subscription cancellation. - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (nitrate/call cfg :cleanup-deleted-penpot-user {:profile-id profile-id})) @@ -562,7 +562,7 @@ {::doc/added "2.18" ::sm/result schema:get-owned-organizations-summary-result} [cfg {:keys [::rpc/profile-id]}] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) []) [])) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 817f8f7848..76d9b162c5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -12,7 +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.organization :as cto] [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] @@ -197,9 +197,9 @@ [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) - (contains? cf/flags :nitrate) + (contains? cf/flags :admin-console) (map #(nitrate/add-organization-info-to-team cfg % params)) - (contains? cf/flags :nitrate) + (contains? cf/flags :admin-console) (remove #(get-in % [:organization :expired-license]))))) (def ^:private sql:get-owned-teams @@ -244,7 +244,7 @@ ::sm/params schema:get-team} [cfg {:keys [::rpc/profile-id id file-id] :as params}] (let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (nitrate/add-organization-info-to-team cfg team params) team))) @@ -537,16 +537,16 @@ ;; When creating inside an organization, verify the user has permission to do so. ;; Fail closed: if organization permissions cannot be fetched, deny the operation. - (when (and organization-id (contains? cf/flags :nitrate)) + (when (and organization-id (contains? cf/flags :admin-console)) (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})] (if (nil? organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") - (when-not (nitrate-perms/allowed? :create-team - {:organization-perms organization-perms - :profile-id profile-id}) + (when-not (cto/allowed? :create-team + {:organization-perms organization-perms + :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to create teams in this organization"))))) @@ -579,15 +579,15 @@ team (create-team cfg params)] (select-keys team [:id]))) -(defn initialize-user-in-nitrate-organization +(defn initialize-user-in-organization "If needed, create a default team for the user on the organization, - and notify Nitrate that a user has been added to an organization." + and initialize the user in the organization." ([cfg profile-id organization-id] - (initialize-user-in-nitrate-organization cfg profile-id organization-id nil)) + (initialize-user-in-organization cfg profile-id organization-id nil)) ([cfg profile-id organization-id email] (assert (db/connection-map? cfg) "expected cfg with valid connection") - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (db/tx-run! cfg (fn [{:keys [::db/conn] :as tx-cfg}] @@ -621,13 +621,13 @@ ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options] (assert (db/connection-map? cfg) "expected cfg with valid connection") - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})] ;; Only when the team belong to an organization and the user is not a member (when (and (some? (:organization-id membership)) ;; the team do belong to an organization (not (:is-member membership))) ;; the user is not a member of the organization yet - (initialize-user-in-nitrate-organization cfg profile-id (:organization-id membership))))) + (initialize-user-in-organization cfg profile-id (:organization-id membership))))) (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options))) (defn create-team @@ -643,7 +643,7 @@ project (create-team-default-project conn params)] (create-team-role cfg params) ;; Set team organization in Nitrate if organization-id is provided - (when (and (contains? cf/flags :nitrate) (:organization-id params)) + (when (and (contains? cf/flags :admin-console) (:organization-id params)) (nitrate/set-team-organization cfg team params)) (assoc team :default-project-id (:id project)))) @@ -803,19 +803,19 @@ [{: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) + team (if (contains? cf/flags :admin-console) (nitrate/add-organization-info-to-team cfg team params) team) perms (get team :permissions) organization (:organization team) - in-organization? (and (contains? cf/flags :nitrate) organization) + in-organization? (and (contains? cf/flags :admin-console) organization) can-delete? (if in-organization? - (nitrate-perms/allowed? :delete-team - {:organization-perms {:owner-id (dm/get-in team [:organization :owner-id]) - :permissions (dm/get-in team [:organization :permissions])} - :profile-id profile-id - :team-perms perms}) + (cto/allowed? :delete-team + {:organization-perms {:owner-id (dm/get-in team [:organization :owner-id]) + :permissions (dm/get-in team [:organization :permissions])} + :profile-id profile-id + :team-perms perms}) (boolean (:is-owner perms)))] (when-not can-delete? @@ -836,7 +836,7 @@ {::db/return-keys true})] ;; Api call to nitrate - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id})) (wrk/submit! {::db/conn conn diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 679ea1dd10..8b1a8c357c 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -14,7 +14,7 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.nitrate-permissions :as nitrate-perms] + [app.common.types.organization :as cto] [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] @@ -147,7 +147,7 @@ ;; When nitrate is active and the team belongs to an organization, check that ;; the email is already an organization member unless the organization explicitly allows adding anybody. - (when (and (contains? cf/flags :nitrate) + (when (and (contains? cf/flags :admin-console) (:organization team)) (assert-email-can-be-invited member organization-member-ids)) @@ -166,8 +166,8 @@ (if organization ;; Insert the invited member to the organization - (when (contains? cf/flags :nitrate) - (teams/initialize-user-in-nitrate-organization cfg (:id member) (:id organization) email)) + (when (contains? cf/flags :admin-console) + (teams/initialize-user-in-organization cfg (:id member) (:id organization) email)) ;; Insert the invited member to the team (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) @@ -253,7 +253,7 @@ (when (allow-invitation-emails? member) (if organization - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (eml/send! {::eml/conn conn ::eml/factory eml/invite-to-organization :public-uri (cf/get :public-uri) @@ -346,12 +346,12 @@ - invitations (vector of {:email :role} maps)" [{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] (let [;; Enrich team with organization info once for all invitations when nitrate is active - team (if (contains? cf/flags :nitrate) + team (if (contains? cf/flags :admin-console) (nitrate/add-organization-info-to-team cfg team {}) team) organization (:organization team) organization-id (:id organization) - restricted? (and organization-id (not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization}))) + restricted? (and organization-id (not (cto/allowed? :add-anybody-to-team {:organization-perms organization}))) all-organization-member-ids (when organization-id (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 99fabd2e8b..8db7afcd8b 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -87,7 +87,7 @@ (defn- with-nitrate-licence [profile cfg] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (nitrate/add-nitrate-licence-to-profile cfg profile) profile)) @@ -136,8 +136,8 @@ accepted-team-id (if organization-id ;; Insert the invited member to the organization - (when (contains? cf/flags :nitrate) - (teams/initialize-user-in-nitrate-organization cfg id-member organization-id member-email)) + (when (contains? cf/flags :admin-console) + (teams/initialize-user-in-organization cfg id-member organization-id member-email)) ;; Insert the invited member to the team (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) team-id))] @@ -206,7 +206,7 @@ {:columns [:id :email :default-team-id]}) registration-disabled? (not (contains? cf/flags :registration)) - organization-invitation? (and (contains? cf/flags :nitrate) organization-id)] + organization-invitation? (and (contains? cf/flags :admin-console) organization-id)] (if profile (do @@ -229,7 +229,7 @@ ;; would call nitrate needlessly and could mask the clean ;; :canceled-invitation/:invalid-token response with a generic error. (let [membership - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (cond organization-id (nitrate/call cfg :get-organization-membership {:profile-id profile-id diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index adde1bde40..e36b04fbb0 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -14,7 +14,7 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]] + [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] [app.common.uuid :as uuid] @@ -152,7 +152,7 @@ (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" {::doc/added "2.14" - ::sm/params schema:team-with-organization + ::sm/params cto/schema:team-with-organization ::rpc/auth false} [cfg team] (notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil) @@ -493,7 +493,7 @@ RETURNING id, deleted_at;") {::doc/added "2.15" ::sm/params [:map [:email ::sm/email] - [:organization schema:organization-with-avatar]] + [:organization cto/schema:organization-with-avatar]] ::nitrate/sso false} [cfg params] (db/tx-run! cfg ti/create-organization-invitation params) @@ -688,7 +688,7 @@ RETURNING id, deleted_at;") [:user-name [:maybe ::sm/text]] [:renewal-date :string] [:estimated-amount :double] - [:organizations [:vector schema:organization-with-avatar]]]) + [:organizations [:vector cto/schema:organization-with-avatar]]]) (sv/defmethod ::send-renewal-email "Send an Enterprise subscription renewal notice email to a user." @@ -941,7 +941,7 @@ RETURNING id, deleted_at;") Nitrate calls this while configuring SSO to verify client credentials and OIDC discovery before saving the settings." {::doc/added "2.20" - ::sm/params schema:nitrate-sso + ::sm/params cto/schema:nitrate-sso ::sm/result schema:check-organization-sso-result ::rpc/auth false} [cfg params] @@ -997,7 +997,7 @@ RETURNING id, deleted_at;") created users skip email verification and onboarding. Emails that already belong to an existing profile are skipped. Intended for the Nitrate admin bulk-creation screen; access is gated by the shared key and, in Nitrate, an - email allow-list. Requires the `nitrate-bulk-create-profiles` flag, disabled + email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled by default so it is only available on test environments." {::doc/added "2.19" ::sm/params schema:bulk-create-profiles-params @@ -1005,9 +1005,9 @@ RETURNING id, deleted_at;") ::rpc/auth false} [cfg {:keys [password emails]}] - (when-not (contains? cf/flags :nitrate-bulk-create-profiles) + (when-not (contains? cf/flags :admin-console-bulk-create-profiles) (ex/raise :type :restriction - :code :nitrate-bulk-create-profiles-not-allowed + :code :bulk-create-profiles-not-allowed :hint "Bulk profile creation is disabled by config.")) (let [derived (aauth/derive-password password)] diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj index 3a2286f3f5..73d72c178c 100644 --- a/backend/src/app/rpc/nitrate/emails_helper.clj +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -31,7 +31,7 @@ (defn- organization-sso-active? "Return whether SSO is enabled for the organization." [cfg organization-id] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id}))))) (def ^:private xf:map-email (map :email)) diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index 58964c92c4..ed3a3364f0 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -115,7 +115,7 @@ (assoc keys id key))))) {} [:exporter - :nitrate + :admin-console :nexus]))) (sm/register! ::props [:map-of :keyword ::sm/any]) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 49a6605324..769b5ec535 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -25,7 +25,7 @@ [mockery.core :refer [with-mocks]])) (t/use-fixtures :once (t/compose-fixtures - (partial th/init-config [:enable-nitrate]) + (partial th/init-config [:enable-admin-console]) th/init-system)) (t/use-fixtures :each th/database-reset) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 4bfed06abb..5d973d94bd 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -101,7 +101,7 @@ :team-id (:id team) :url "https://penpot.example.com/#/workspace"} {::http/request {}})] - (binding [cf/flags (conj cf/flags :nitrate)] + (binding [cf/flags (conj cf/flags :admin-console)] (with-redefs [nitrate/call (active-sso-call-mock (:id team) @@ -124,7 +124,7 @@ :team-id (:id team) :url "https://penpot.example.com/#/workspace"} {::http/request {}})] - (binding [cf/flags (conj cf/flags :nitrate)] + (binding [cf/flags (conj cf/flags :admin-console)] (with-redefs [nitrate/call (active-sso-call-mock (:id team) @@ -150,7 +150,7 @@ :team-id (:id team) :url "https://penpot.example.com/#/workspace"} {::http/request {}})] - (binding [cf/flags (conj cf/flags :nitrate)] + (binding [cf/flags (conj cf/flags :admin-console)] (with-redefs [nitrate/organization-owner-of-team? (fn [_cfg profile-id team-id] (and (= (:id organization-owner) profile-id) @@ -851,7 +851,7 @@ organization-id-1 (uuid/random) organization-id-2 (uuid/random) calls (atom [])] - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg method params] (swap! calls conj [method params]) (case method @@ -882,7 +882,7 @@ organization-id-1 (uuid/random) organization-id-2 (uuid/random) calls (atom [])] - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg method params] (swap! calls conj [method params]) (case method @@ -990,7 +990,7 @@ :role "editor" :valid-until (ct/in-future "48h")}) - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (add-team-to-organization-nitrate-mock {:organization-id organization-id :organization-summary organization-summary @@ -999,7 +999,7 @@ :team-id (:id team) :sso-active? true :set-team-params set-team-params}) - teams/initialize-user-in-nitrate-organization (fn [& _] nil) + teams/initialize-user-in-organization (fn [& _] nil) eml/send! (fn [params] (swap! sent conj params))] (let [out (th/command! {::th/type :add-team-to-organization ::rpc/profile-id (:id owner) @@ -1066,7 +1066,7 @@ :role "editor" :valid-until (ct/in-future "48h")}) - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (add-team-to-organization-nitrate-mock {:organization-id organization-id :organization-summary organization-summary @@ -1074,7 +1074,7 @@ :owner-id (:id owner) :team-id (:id team) :sso-active? false}) - teams/initialize-user-in-nitrate-organization (fn [& _] nil) + teams/initialize-user-in-organization (fn [& _] nil) eml/send! (fn [params] (swap! sent conj params))] (let [out (th/command! {::th/type :add-team-to-organization ::rpc/profile-id (:id owner) @@ -1088,7 +1088,7 @@ nitrate-id "nitrate-instance-1" public-key "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----" now (ct/now)] - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) ct/now (constantly now) nitrate/call (fn [_cfg method _params] (t/is (= :get-identity method)) @@ -1117,7 +1117,7 @@ (t/deftest get-nitrate-activation-code-request-identity-unavailable (let [profile (th/create-profile* 1 {:is-active true})] - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg _method _params] nil)] (let [out (th/command! {::th/type :get-nitrate-activation-code-request ::rpc/profile-id (:id profile)})] diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj index 8ac1a8fd95..8168242f16 100644 --- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -28,7 +28,7 @@ (defn- with-organization-owner-access [{:keys [organization-owner-id organization-id team-id]} f] - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/organization-owner-of-team? (fn [_cfg profile-id candidate-team-id] (and (= organization-owner-id profile-id) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 7adc12d68e..b4fb080583 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -91,7 +91,7 @@ (t/is (not (contains? result :password)))))) (t/testing "update profile" - (with-redefs [app.config/flags #{:nitrate}] + (with-redefs [app.config/flags #{:admin-console}] (with-redefs [nitrate/add-nitrate-licence-to-profile (fn [_ profile] (assoc profile :subscription {:plan :pro}))] diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index a590f93a32..7c2b5d0552 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -137,7 +137,7 @@ :team-id (:id team) :role :editor :emails [email]}))] - (with-redefs [cf/flags (conj cf/flags :nitrate :email-verification) + (with-redefs [cf/flags (conj cf/flags :admin-console :email-verification) nitrate/call nitrate-call] (t/is (th/success? (invite! organization-team (:email invitee)))) (t/is (th/success? (invite! organization-team (:email invitee)))) @@ -482,7 +482,7 @@ :role "editor" :valid-until (ct/in-future "48h")}) - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg method _params] (case method @@ -490,7 +490,7 @@ :is-member false} :get-organization-members [(:id inviter) (uuid/random) (uuid/random)] nil)) - teams/initialize-user-in-nitrate-organization + teams/initialize-user-in-organization (fn [& _] default-team-id)] (let [out (verify! direct-token)] (t/is (th/success? out)) @@ -527,7 +527,7 @@ :role "editor" :valid-until (ct/in-future "48h")}) - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg method _params] (case method @@ -570,7 +570,7 @@ :role "editor" :valid-until (ct/in-future "48h")}) - (with-redefs [cf/flags (conj cf/flags :nitrate) + (with-redefs [cf/flags (conj cf/flags :admin-console) nitrate/call (fn [_cfg method _params] (case method diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 27519d487c..ddfa667165 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -164,13 +164,13 @@ ;; redis for caching data :redis-cache - ;; Activates the nitrate module - :nitrate + ;; Activates the admin-console module + :admin-console - ;; disabled by default. When enabled, allows the nitrate + ;; disabled by default. When enabled, allows the admin-console ;; `bulk-create-profiles` method to create batches of already ;; active profiles. Only intended for test environments. - :nitrate-bulk-create-profiles + :admin-console-bulk-create-profiles :mcp :background-blur diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc deleted file mode 100644 index fc47f2ed7c..0000000000 --- a/common/src/app/common/types/nitrate_permissions.cljc +++ /dev/null @@ -1,103 +0,0 @@ -;; 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 "onlyOwners" - :move-teams "always" - :send-invitations "ownersAndAdmins" - :new-team-members "anyone"}) - -(defn- can-create-team? - [{:keys [is-organization-owner? permission-value]}] - (or is-organization-owner? - (= permission-value "any"))) - -(defn- can-delete-team? - [{:keys [is-organization-owner? permission-value team-perms]}] - (cond - ;; Organization owners can always delete teams inside their organizations. - is-organization-owner? - true - (= permission-value "onlyOwners") - (boolean (:is-owner team-perms)) - :else false)) - -(defn- can-move-team? - [{:keys [permission-value target-organization-same-owner?]}] - (cond - (= permission-value "never") - false - (= permission-value "always") - true - (= permission-value "myOrganizations") - (true? target-organization-same-owner?) - :else false)) - -(defn- can-invite-to-team? - [{:keys [permission-value team-perms]}] - (cond - (= permission-value "ownersAndAdmins") - (or (boolean (:is-owner team-perms)) - (boolean (:is-admin team-perms))) - - (= permission-value "owners") - (boolean (:is-owner team-perms)) - - :else false)) - -(defn- can-add-anybody-to-team? - [{:keys [permission-value]}] - (= permission-value "anyone")) - -(def ^:private action-rules - {:create-team {:permission-key :create-teams - :check-fn can-create-team?} - :delete-team {:permission-key :delete-teams - :check-fn can-delete-team?} - :move-team {:permission-key :move-teams - :check-fn can-move-team?} - :send-invitations {:permission-key :send-invitations - :check-fn can-invite-to-team?} - :add-anybody-to-team {:permission-key :new-team-members - :check-fn can-add-anybody-to-team?}}) - -(defn- normalize-organization-permissions - [organization-perms] - (merge defaults (or (:permissions organization-perms) {}))) - -(defn- owner? - [organization-perms profile-id] - (= profile-id (:owner-id organization-perms))) - -(defn allowed? - "Returns true only for explicitly allowed actions (fail-closed)." - [action {:keys [organization-perms profile-id team-perms target-organization-same-owner?]}] - (let [{:keys [permission-key check-fn] :as rule} - (get action-rules action) - permissions (normalize-organization-permissions organization-perms) - is-organization-owner? (owner? organization-perms profile-id) - permission-value (get permissions permission-key)] - (cond - (nil? rule) false - :else (boolean (check-fn {:is-organization-owner? is-organization-owner? - :permission-value permission-value - :team-perms team-perms - :target-organization-same-owner? target-organization-same-owner?}))))) - -(defn can-send-invitations? - [{:keys [nitrate-enabled? organization profile-id team-permissions]}] - (let [in-organization? (and nitrate-enabled? organization)] - (if in-organization? - (allowed? :send-invitations - {:organization-perms {:owner-id (:owner-id organization) - :permissions (:permissions organization)} - :profile-id profile-id - :team-perms team-permissions}) - (or (boolean (:is-owner team-permissions)) - (boolean (:is-admin team-permissions)))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 451c61e6d5..8a79598d74 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -6,6 +6,7 @@ (ns app.common.types.organization (:require + [app.common.flags :as flags] [app.common.schema :as sm])) (def schema:organization @@ -72,3 +73,104 @@ [:client-id {:optional true} [:maybe :string]] [:client-secret {:optional true} [:maybe :string]] [:issuer {:optional true} [:maybe :string]]]) + + +;; --- Organization permission rules --- + +(def ^:private defaults + {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "always" + :send-invitations "ownersAndAdmins" + :new-team-members "anyone"}) + +(defn- can-create-team? + [{:keys [is-organization-owner? permission-value]}] + (or is-organization-owner? + (= permission-value "any"))) + +(defn- can-delete-team? + [{:keys [is-organization-owner? permission-value team-perms]}] + (cond + ;; Organization owners can always delete teams inside their organizations. + is-organization-owner? + true + (= permission-value "onlyOwners") + (boolean (:is-owner team-perms)) + :else false)) + +(defn- can-move-team? + [{:keys [permission-value target-organization-same-owner?]}] + (cond + (= permission-value "never") + false + (= permission-value "always") + true + (= permission-value "myOrganizations") + (true? target-organization-same-owner?) + :else false)) + +(defn- can-invite-to-team? + [{:keys [permission-value team-perms]}] + (cond + (= permission-value "ownersAndAdmins") + (or (boolean (:is-owner team-perms)) + (boolean (:is-admin team-perms))) + + (= permission-value "owners") + (boolean (:is-owner team-perms)) + + :else false)) + +(defn- can-add-anybody-to-team? + [{:keys [permission-value]}] + (= permission-value "anyone")) + +(def ^:private action-rules + {:create-team {:permission-key :create-teams + :check-fn can-create-team?} + :delete-team {:permission-key :delete-teams + :check-fn can-delete-team?} + :move-team {:permission-key :move-teams + :check-fn can-move-team?} + :send-invitations {:permission-key :send-invitations + :check-fn can-invite-to-team?} + :add-anybody-to-team {:permission-key :new-team-members + :check-fn can-add-anybody-to-team?}}) + +(defn- normalize-organization-permissions + [organization-perms] + (merge defaults (or (:permissions organization-perms) {}))) + +(defn- owner? + [organization-perms profile-id] + (= profile-id (:owner-id organization-perms))) + +(defn allowed? + "Returns true only for explicitly allowed actions (fail-closed)." + [action {:keys [organization-perms profile-id team-perms target-organization-same-owner?]}] + (let [{:keys [permission-key check-fn] :as rule} + (get action-rules action) + permissions (normalize-organization-permissions organization-perms) + is-organization-owner? (owner? organization-perms profile-id) + permission-value (get permissions permission-key)] + (cond + (nil? rule) false + :else (boolean (check-fn {:is-organization-owner? is-organization-owner? + :permission-value permission-value + :team-perms team-perms + :target-organization-same-owner? target-organization-same-owner?}))))) + +(defn can-send-invitations? + "Returns true when the user can send invitations to a team. + Falls back to team-level permissions (owner/admin) when the + admin-console flag is off or the team has no organization." + [{:keys [organization profile-id team-permissions]}] + (if (and (contains? flags/*current* :admin-console) organization) + (allowed? :send-invitations + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms team-permissions}) + (or (boolean (:is-owner team-permissions)) + (boolean (:is-admin team-permissions))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 84930589d2..b24b045e1a 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -77,8 +77,8 @@ [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.organization-test] [common-tests.types.path-data-test] [common-tests.types.shape-decode-encode-test] [common-tests.types.shape-interactions-test] @@ -154,8 +154,8 @@ '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.organization-test 'common-tests.types.path-data-test 'common-tests.types.shape-decode-encode-test 'common-tests.types.shape-interactions-test diff --git a/common/test/common_tests/types/nitrate_permissions_test.cljc b/common/test/common_tests/types/nitrate_permissions_test.cljc deleted file mode 100644 index eeab8099ac..0000000000 --- a/common/test/common_tests/types/nitrate_permissions_test.cljc +++ /dev/null @@ -1,178 +0,0 @@ -;; 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 organization-perms - {:owner-id :owner - :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :send-invitations "ownersAndAdmins"}}) - -(t/deftest unknown-action-is-denied - (t/is (false? (nitrate-perms/allowed? :unknown - {:organization-perms organization-perms - :profile-id :member - :team-perms {:is-admin true}})))) - -(t/deftest organization-owner-is-allowed-for-create-and-delete - (t/is (true? (nitrate-perms/allowed? :create-team - {:organization-perms organization-perms - :profile-id :owner - :team-perms {:is-admin false}}))) - (t/is (true? (nitrate-perms/allowed? :delete-team - {:organization-perms organization-perms - :profile-id :owner - :team-perms {:is-admin false}})))) - -(t/deftest create-team-permission-rules - (t/is (true? (nitrate-perms/allowed? :create-team - {:organization-perms organization-perms - :profile-id :member - :team-perms {:is-admin false}}))) - (t/is (false? (nitrate-perms/allowed? :create-team - {:organization-perms (assoc organization-perms :permissions {:create-teams "none" - :delete-teams "onlyOwners"}) - :profile-id :member - :team-perms {:is-admin false}})))) - -(t/deftest delete-team-onlyowners-allows-only-team-owners - (t/is (true? (nitrate-perms/allowed? :delete-team - {:organization-perms organization-perms - :profile-id :member - :team-perms {:is-owner true :is-admin true}}))) - (t/is (false? (nitrate-perms/allowed? :delete-team - {:organization-perms organization-perms - :profile-id :member - :team-perms {:is-admin true}}))) - (t/is (false? (nitrate-perms/allowed? :delete-team - {:organization-perms (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "invalid-value"}) - :profile-id :member - :team-perms {:is-admin true}})))) - -(t/deftest delete-team-onlyme-still-allows-organization-owner - (let [only-me-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyMe"})] - (t/is (true? (nitrate-perms/allowed? :delete-team - {:organization-perms only-me-organization - :profile-id :owner - :team-perms {:is-owner false :is-admin false}}))) - (t/is (false? (nitrate-perms/allowed? :delete-team - {:organization-perms only-me-organization - :profile-id :member - :team-perms {:is-owner true :is-admin true}}))))) - -(t/deftest move-team-always-allows-any-organization-owner-or-all-users - (let [always-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "always"})] - ;; Organization owner should always be allowed - (t/is (true? (nitrate-perms/allowed? :move-team - {:organization-perms always-organization - :profile-id :owner - :team-perms {}}))) - ;; Regular member should be allowed when move-teams is "always" - (t/is (true? (nitrate-perms/allowed? :move-team - {:organization-perms always-organization - :profile-id :member - :team-perms {}}))))) - -(t/deftest move-team-myorganizations-allows-only-within-same-owner - (let [my-organizations (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "myOrganizations"})] - ;; Organization owner must also stay within same-owner organizations - (t/is (false? (nitrate-perms/allowed? :move-team - {:organization-perms my-organizations - :profile-id :owner - :team-perms {} - :target-organization-same-owner? false}))) - (t/is (true? (nitrate-perms/allowed? :move-team - {:organization-perms my-organizations - :profile-id :owner - :team-perms {} - :target-organization-same-owner? true}))) - ;; Regular member should be allowed only if target has same owner - (t/is (true? (nitrate-perms/allowed? :move-team - {:organization-perms my-organizations - :profile-id :member - :team-perms {} - :target-organization-same-owner? true}))) - (t/is (false? (nitrate-perms/allowed? :move-team - {:organization-perms my-organizations - :profile-id :member - :team-perms {} - :target-organization-same-owner? false}))))) - -(t/deftest move-team-never-denies-all - (let [never-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "never"})] - ;; Even organization owner should be denied - (t/is (false? (nitrate-perms/allowed? :move-team - {:organization-perms never-organization - :profile-id :owner - :team-perms {}}))) - ;; Regular member should be denied - (t/is (false? (nitrate-perms/allowed? :move-team - {:organization-perms never-organization - :profile-id :member - :team-perms {}}))))) - -(t/deftest move-team-defaults-to-always - (let [default-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners"})] - ;; Should default to "always" when not specified - (t/is (true? (nitrate-perms/allowed? :move-team - {:organization-perms default-organization - :profile-id :member - :team-perms {}}))))) - -(t/deftest send-invitations-defaults-to-owners-and-admins - (let [default-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners"})] - (t/is (true? (nitrate-perms/allowed? :send-invitations - {:organization-perms default-organization - :profile-id :owner - :team-perms {:is-owner true :is-admin false}}))) - (t/is (true? (nitrate-perms/allowed? :send-invitations - {:organization-perms default-organization - :profile-id :member - :team-perms {:is-owner false :is-admin true}}))) - (t/is (false? (nitrate-perms/allowed? :send-invitations - {:organization-perms default-organization - :profile-id :member - :team-perms {:is-owner false :is-admin false}}))))) - -(t/deftest send-invitations-owners-allows-only-team-owners - (let [only-owners-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :send-invitations "owners"})] - (t/is (true? (nitrate-perms/allowed? :send-invitations - {:organization-perms only-owners-organization - :profile-id :member - :team-perms {:is-owner true :is-admin true}}))) - (t/is (false? (nitrate-perms/allowed? :send-invitations - {:organization-perms only-owners-organization - :profile-id :owner - :team-perms {:is-owner false :is-admin false}}))) - (t/is (false? (nitrate-perms/allowed? :send-invitations - {:organization-perms only-owners-organization - :profile-id :member - :team-perms {:is-owner false :is-admin true}}))))) - -(t/deftest send-invitations-invalid-value-is-denied - (let [invalid-organization (assoc organization-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :send-invitations "invalid-value"})] - (t/is (false? (nitrate-perms/allowed? :send-invitations - {:organization-perms invalid-organization - :profile-id :member - :team-perms {:is-owner true :is-admin true}}))))) diff --git a/common/test/common_tests/types/organization_test.cljc b/common/test/common_tests/types/organization_test.cljc new file mode 100644 index 0000000000..e6a24aeb00 --- /dev/null +++ b/common/test/common_tests/types/organization_test.cljc @@ -0,0 +1,178 @@ +;; 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.organization-test + (:require + [app.common.types.organization :as cto] + [clojure.test :as t])) + +(def organization-perms + {:owner-id :owner + :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "ownersAndAdmins"}}) + +(t/deftest unknown-action-is-denied + (t/is (false? (cto/allowed? :unknown + {:organization-perms organization-perms + :profile-id :member + :team-perms {:is-admin true}})))) + +(t/deftest organization-owner-is-allowed-for-create-and-delete + (t/is (true? (cto/allowed? :create-team + {:organization-perms organization-perms + :profile-id :owner + :team-perms {:is-admin false}}))) + (t/is (true? (cto/allowed? :delete-team + {:organization-perms organization-perms + :profile-id :owner + :team-perms {:is-admin false}})))) + +(t/deftest create-team-permission-rules + (t/is (true? (cto/allowed? :create-team + {:organization-perms organization-perms + :profile-id :member + :team-perms {:is-admin false}}))) + (t/is (false? (cto/allowed? :create-team + {:organization-perms (assoc organization-perms :permissions {:create-teams "none" + :delete-teams "onlyOwners"}) + :profile-id :member + :team-perms {:is-admin false}})))) + +(t/deftest delete-team-onlyowners-allows-only-team-owners + (t/is (true? (cto/allowed? :delete-team + {:organization-perms organization-perms + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))) + (t/is (false? (cto/allowed? :delete-team + {:organization-perms organization-perms + :profile-id :member + :team-perms {:is-admin true}}))) + (t/is (false? (cto/allowed? :delete-team + {:organization-perms (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "invalid-value"}) + :profile-id :member + :team-perms {:is-admin true}})))) + +(t/deftest delete-team-onlyme-still-allows-organization-owner + (let [only-me-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyMe"})] + (t/is (true? (cto/allowed? :delete-team + {:organization-perms only-me-organization + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (false? (cto/allowed? :delete-team + {:organization-perms only-me-organization + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))))) + +(t/deftest move-team-always-allows-any-organization-owner-or-all-users + (let [always-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "always"})] + ;; Organization owner should always be allowed + (t/is (true? (cto/allowed? :move-team + {:organization-perms always-organization + :profile-id :owner + :team-perms {}}))) + ;; Regular member should be allowed when move-teams is "always" + (t/is (true? (cto/allowed? :move-team + {:organization-perms always-organization + :profile-id :member + :team-perms {}}))))) + +(t/deftest move-team-myorganizations-allows-only-within-same-owner + (let [my-organizations (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "myOrganizations"})] + ;; Organization owner must also stay within same-owner organizations + (t/is (false? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :owner + :team-perms {} + :target-organization-same-owner? false}))) + (t/is (true? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :owner + :team-perms {} + :target-organization-same-owner? true}))) + ;; Regular member should be allowed only if target has same owner + (t/is (true? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :member + :team-perms {} + :target-organization-same-owner? true}))) + (t/is (false? (cto/allowed? :move-team + {:organization-perms my-organizations + :profile-id :member + :team-perms {} + :target-organization-same-owner? false}))))) + +(t/deftest move-team-never-denies-all + (let [never-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "never"})] + ;; Even organization owner should be denied + (t/is (false? (cto/allowed? :move-team + {:organization-perms never-organization + :profile-id :owner + :team-perms {}}))) + ;; Regular member should be denied + (t/is (false? (cto/allowed? :move-team + {:organization-perms never-organization + :profile-id :member + :team-perms {}}))))) + +(t/deftest move-team-defaults-to-always + (let [default-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] + ;; Should default to "always" when not specified + (t/is (true? (cto/allowed? :move-team + {:organization-perms default-organization + :profile-id :member + :team-perms {}}))))) + +(t/deftest send-invitations-defaults-to-owners-and-admins + (let [default-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] + (t/is (true? (cto/allowed? :send-invitations + {:organization-perms default-organization + :profile-id :owner + :team-perms {:is-owner true :is-admin false}}))) + (t/is (true? (cto/allowed? :send-invitations + {:organization-perms default-organization + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))) + (t/is (false? (cto/allowed? :send-invitations + {:organization-perms default-organization + :profile-id :member + :team-perms {:is-owner false :is-admin false}}))))) + +(t/deftest send-invitations-owners-allows-only-team-owners + (let [only-owners-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "owners"})] + (t/is (true? (cto/allowed? :send-invitations + {:organization-perms only-owners-organization + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))) + (t/is (false? (cto/allowed? :send-invitations + {:organization-perms only-owners-organization + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (false? (cto/allowed? :send-invitations + {:organization-perms only-owners-organization + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))))) + +(t/deftest send-invitations-invalid-value-is-denied + (let [invalid-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "invalid-value"})] + (t/is (false? (cto/allowed? :send-invitations + {:organization-perms invalid-organization + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))))) diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 999e14ad3f..fe2fbc9abf 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,13 +53,13 @@ update_oidc_name /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} -export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} +export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"} if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 95ddf348a1..8524f4fb3d 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -156,7 +156,7 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_NITRATE_URI$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; } include /etc/nginx/overrides/server.d/*.conf; diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 4f5af3d0fe..f74874d297 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -11,7 +11,7 @@ [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.organization :as co] + [app.common.types.organization :as cto] [app.common.types.team :as ctt] [app.config :as cf] [app.main.data.helpers :as dsh] @@ -255,7 +255,7 @@ (let [current-team-id (:current-team-id state) organization (:organization team) current-team? (= (:id team) current-team-id)] - (when (and (contains? cf/flags :nitrate) + (when (and (contains? cf/flags :admin-console) current-team?) (rx/concat (when notification @@ -267,13 +267,13 @@ (check-team-sso current-team-id)))))) ptk/UpdateEvent (update [_ state] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (let [team-id (:id team) team-name (:name team) organization (:organization team)] (d/update-in-when state [:teams team-id] (fn [team] - (cond-> (co/apply-organization team organization) + (cond-> (cto/apply-organization team organization) team-name (assoc :name team-name))))) state)))) @@ -284,7 +284,7 @@ (ptk/reify ::handle-organization-change-sso ptk/WatchEvent (watch [_ state _] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [team-id (:current-team-id state) team (dm/get-in state [:teams team-id]) team-organization-id (dm/get-in team [:organization :id])] diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 470ade6bed..95b9f06039 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -690,7 +690,7 @@ (ptk/reify ::handle-user-organization-change ptk/WatchEvent (watch [_ state _] - (when (and notification (contains? cf/flags :nitrate)) + (when (and notification (contains? cf/flags :admin-console)) (let [team-id (:current-team-id state) team (dm/get-in state [:teams team-id])] (rx/of (ntf/show {:content (tr notification organization-name) @@ -708,7 +708,7 @@ (ptk/reify ::handle-organization-deleted ptk/WatchEvent (watch [_ state _] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) (let [team-id (:current-team-id state) current-team (dm/get-in state [:teams team-id]) current-organization-id (dm/get-in current-team [:organization :id]) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 08114451e3..ff0f48c3e2 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -1,7 +1,7 @@ (ns app.main.data.nitrate (:require [app.common.data.macros :as dm] - [app.common.types.nitrate-permissions :as nitrate-perms] + [app.common.types.organization :as cto] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -84,9 +84,8 @@ (defn can-send-invitations? [{:keys [organization profile-id team-permissions]}] - (nitrate-perms/can-send-invitations? - {:nitrate-enabled? (contains? cf/flags :nitrate) - :organization organization + (cto/can-send-invitations? + {:organization organization :profile-id profile-id :team-permissions team-permissions})) @@ -149,7 +148,7 @@ (defn is-valid-license? [profile] - (and (contains? cf/flags :nitrate) + (and (contains? cf/flags :admin-console) ;; Possible values: "active" "canceled" "incomplete" "incomplete_expired" "past_due" "paused" "trialing" "unpaid" (contains? #{"active" "past_due" "trialing"} (dm/get-in profile [:subscription :status])))) @@ -279,7 +278,7 @@ (dt/with-refreshed-team team-id (fn [team] (let [source-organization (:organization team) - can-move? (nitrate-perms/allowed? + can-move? (cto/allowed? :move-team {:organization-perms {:owner-id (:owner-id source-organization) :permissions (:permissions source-organization)} @@ -357,8 +356,8 @@ Organizations where :add-anybody-to-team is permitted are pre-approved; the rest are verified via :all-team-members-in-organizations." [team-id organizations] - (let [add-anybody-organizations (filterv #(nitrate-perms/allowed? :add-anybody-to-team {:organization-perms %}) organizations) - organizations-to-check (filterv #(not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms %})) organizations) + (let [add-anybody-organizations (filterv #(cto/allowed? :add-anybody-to-team {:organization-perms %}) organizations) + organizations-to-check (filterv #(not (cto/allowed? :add-anybody-to-team {:organization-perms %})) organizations) organization-ids-to-check (mapv :id organizations-to-check)] (if (empty? organization-ids-to-check) (rx/of (into {} (map (fn [organization] [(:id organization) true])) organizations)) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 1a85fc86cb..74b9a97bc9 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -10,7 +10,7 @@ [app.common.data.macros :as dm] [app.common.logging :as log] [app.common.schema :as sm] - [app.common.types.nitrate-permissions :as nitrate-perms] + [app.common.types.organization :as cto] [app.common.types.team :as ctt] [app.common.uri :as u] [app.config :as cf] @@ -42,7 +42,7 @@ default team when the candidate requires SSO and the user has no valid SSO session for it." [{:keys [team-id default-team-id]}] - (if (or (not (contains? cf/flags :nitrate)) + (if (or (not (contains? cf/flags :admin-console)) (= team-id default-team-id)) (rx/of team-id) (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url (rt/get-current-href)}) @@ -116,13 +116,13 @@ (with-refreshed-team team-id (fn [team] (let [organization (:organization team) - in-organization? (and (contains? cf/flags :nitrate) organization) + in-organization? (and (contains? cf/flags :admin-console) organization) can-create? (if in-organization? - (nitrate-perms/allowed? :create-team - {:organization-perms {:owner-id (:owner-id organization) - :permissions (:permissions organization)} - :profile-id profile-id - :team-perms (:permissions team)}) + (cto/allowed? :create-team + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms (:permissions team)}) true)] (rx/of (if can-create? (modal/show :team-form (if in-organization? @@ -142,13 +142,13 @@ (with-refreshed-team team-id (fn [team] (let [organization (:organization team) - in-organization? (and (contains? cf/flags :nitrate) organization) + in-organization? (and (contains? cf/flags :admin-console) organization) can-delete? (if in-organization? - (nitrate-perms/allowed? :delete-team - {:organization-perms {:owner-id (:owner-id organization) - :permissions (:permissions organization)} - :profile-id profile-id - :team-perms (:permissions team)}) + (cto/allowed? :delete-team + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms (:permissions team)}) (boolean (dm/get-in team [:permissions :is-owner]))) message (if in-organization? (tr "modals.delete-organization-team-confirm.message" (:name organization)) @@ -173,9 +173,9 @@ :team team :origin (or origin :team) :invite-email invite-email}))] - (if (and (contains? cf/flags :nitrate) - (not (nitrate-perms/allowed? :add-anybody-to-team - {:organization-perms (:organization team)}))) + (if (and (contains? cf/flags :admin-console) + (not (cto/allowed? :add-anybody-to-team + {:organization-perms (:organization team)}))) (->> (rp/cmd! :all-organization-members-in-team {:team-id (:id team) :organization-id (get-in team [:organization :id])}) @@ -203,9 +203,8 @@ (with-refreshed-team team-id (fn [team] (let [organization (:organization team) - can-invite? (nitrate-perms/can-send-invitations? - {:nitrate-enabled? (contains? cf/flags :nitrate) - :organization organization + can-invite? (cto/can-send-invitations? + {:organization organization :profile-id profile-id :team-permissions (:permissions team)})] (rx/of (if can-invite? @@ -615,11 +614,11 @@ (ptk/reify ::check-and-submit-invite-members ptk/WatchEvent (watch [_ _ _] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (with-refreshed-team team-id (fn [team] - (if (not (nitrate-perms/allowed? :add-anybody-to-team - {:organization-perms (:organization team)})) + (if (not (cto/allowed? :add-anybody-to-team + {:organization-perms (:organization team)})) (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) :emails (vec (:emails params))}) (rx/mapcat diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index cd17b7b3bc..b2dfb2f119 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -418,7 +418,7 @@ (mf/use-fn (mf/deps team) (fn [] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (st/emit! (dtm/check-and-create-team (:id team))) (st/emit! (modal/show :team-form {}))))) @@ -434,15 +434,15 @@ [:> dropdown-menu-item* {:on-click on-team-click :data-value default-team-id :class (stl/css-case :team-dropdown-item true - :team-dropdown-item-no-logo (contains? cf/flags :nitrate))} - (when-not (contains? cf/flags :nitrate) + :team-dropdown-item-no-logo (contains? cf/flags :admin-console))} + (when-not (contains? cf/flags :admin-console) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if (contains? cf/flags :nitrate) (tr "dashboard.my-files") (tr "dashboard.your-penpot"))] + [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.my-files") (tr "dashboard.your-penpot"))] (when (= default-team-id (:id team)) tick-icon)] - (when (and (contains? cf/flags :nitrate) + (when (and (contains? cf/flags :admin-console) (seq (remove :is-default (vals teams)))) [:* [:hr {:role "separator" :class (stl/css :team-separator)}] @@ -890,7 +890,7 @@ (mf/defc sidebar-team-switch* [{:keys [team profile]}] - (let [nitrate? (contains? cf/flags :nitrate) + (let [nitrate? (contains? cf/flags :admin-console) organization (:organization team) organization-id (when nitrate? (:id organization)) teams (cond->> (mf/deref refs/teams) @@ -1039,7 +1039,7 @@ overflow* (mf/use-state false) overflow? (deref overflow*) - nitrate? (contains? cf/flags :nitrate) + nitrate? (contains? cf/flags :admin-console) focus-timer-ref (use-focus-timer-ref) @@ -1401,7 +1401,7 @@ (dom/open-new-window "https://penpot.app/pricing")))] (mf/with-effect [teams] - (when (and (contains? cf/flags :nitrate) + (when (and (contains? cf/flags :admin-console) (empty? teams)) (st/emit! (dtm/fetch-teams)))) @@ -1410,7 +1410,7 @@ (reset! sub-menu* nil))) [:* - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) [:* [:> nitrate-sidebar* {:profile profile :teams teams}] [:> nitrate-current-plan* {:profile profile}]] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 513aa29d91..2baaf90d12 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -1504,7 +1504,7 @@ (mf/defc team-settings-page* [{:keys [team]}] - (let [nitrate? (contains? cfg/flags :nitrate) + (let [nitrate? (contains? cfg/flags :admin-console) finput (mf/use-ref) members (get team :members) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 6d14ef811c..52d7554e1e 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -31,7 +31,7 @@ ["/recovery" :auth-recovery] ["/verify-token" :auth-verify-token]] - (when (contains? cf/flags :nitrate) + (when (contains? cf/flags :admin-console) ["/subscribe-nitrate" :nitrate-entry]) ["/settings" @@ -105,7 +105,7 @@ or redirects to the SSO provider URL." [match send-event-info? url] (let [route-name (name (get-in match [:data :name])) - relevant? (and (contains? cf/flags :nitrate) + relevant? (and (contains? cf/flags :admin-console) (or (str/starts-with? route-name "dashboard") (str/starts-with? route-name "workspace"))) team-id-str (when relevant? diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index 32efbca332..b6d0a0e4c8 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -48,7 +48,7 @@ (with-meta {} {:on-error on-error}))))] (mf/with-effect [] - (if (contains? cf/flags :nitrate) + (if (contains? cf/flags :admin-console) (let [sub (->> (rp/cmd! :get-owned-organizations-summary {}) (rx/subs! (fn [result] (reset! organizations* (or result []))) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index a972fd0f93..98626cda7d 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -457,7 +457,7 @@ (-> profile :props :subscription) subscription-type - (if (and (contains? cf/flags :nitrate) nitrate?) (:type nitrate-license) (get-subscription-type subscription)) + (if (and (contains? cf/flags :admin-console) nitrate?) (:type nitrate-license) (get-subscription-type subscription)) subscription-name (cond @@ -724,7 +724,7 @@ (tr "subscription.settings.professional.teams-editors-benefit") (tr "subscription.settings.professional.selfhost.community-support"))] :cta-text (tr "subscription.settings.subscribe") - :cta-link (if (and (contains? cf/flags :nitrate) nitrate?) + :cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Professional" (and (:licenses connectivity) (not (:manual nitrate-license)))) go-to-payments) @@ -742,14 +742,14 @@ (tr "subscription.settings.unlimited.autosave-benefit"), (tr "subscription.settings.unlimited.bill")] :cta-text (if (:type subscription) (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) - :cta-link (if (and (contains? cf/flags :nitrate) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) + :cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :recommended (= subscription-type "professional") :show-button-cta (= subscription-type "professional") :current-plan false}]) - (when (and (not= subscription-type "enterprise") cf/saas? (not (contains? cf/flags :nitrate))) + (when (and (not= subscription-type "enterprise") cf/saas? (not (contains? cf/flags :admin-console))) [:> plan-card* {:card-title (tr "subscription.settings.enterprise") :card-title-icon i/character-e :price-value "$950" @@ -765,7 +765,7 @@ :show-button-cta (= subscription-type "professional") :current-plan false}]) - (when (and (contains? cf/flags :nitrate) (not nitrate?)) + (when (and (contains? cf/flags :admin-console) (not nitrate?)) [:> plan-card* {:card-title (tr "subscription.settings.enterprise") :price-value "$25" :price-period (tr "subscription.settings.organization-member-month") diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index bfc218bce8..e73ebb889a 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -27,7 +27,7 @@ {:team {:id team-id :organization organization} :notification nil})] (mock/with-mocks - {cf/flags (conj cf/flags :nitrate) + {cf/flags (conj cf/flags :admin-console) rp/cmd! (mock/stub (fn [cmd params] (if (= [:check-nitrate-sso @@ -61,7 +61,7 @@ event (dcm/handle-organization-change-sso {:organization-id organization-id})] (mock/with-mocks - {cf/flags (conj cf/flags :nitrate) + {cf/flags (conj cf/flags :admin-console) rp/cmd! (mock/stub (fn [cmd params] (if (= [:check-nitrate-sso From 1136e5eda589f3e0f6500a231694478768f0a160 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 3 Aug 2026 17:27:09 +0200 Subject: [PATCH 2/3] :bug: Restrict update-profile-props to documented keys only (#10992) Close the profile props schema to reject undocumented keys and add a denylist for system-managed props like :subscription that should not be user-writable via RPC. Changes: - Add system-managed-props denylist (#{:subscription}) - Close schema:props with :closed true - Add tests for subscription rejection and valid key acceptance AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/profile.clj | 8 +++- .../test/backend_tests/rpc_profile_test.clj | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index f6743b975a..0dc0629eac 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -45,8 +45,12 @@ [:email-comments [::sm/one-of #{:all :partial :none}]] [:email-invites [::sm/one-of #{:all :none}]]]) +(def system-managed-props + "Props keys managed by the system (not user-writable via RPC)." + #{:subscription}) + (def schema:props - [:map {:title "ProfileProps"} + [:map {:title "ProfileProps" :closed true} [:plugins {:optional true} schema:plugin-registry] [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] [:mcp-enabled {:optional true} ::sm/boolean] @@ -454,7 +458,7 @@ (assoc props k v)) props)) (:props profile) - props)] + (apply dissoc props system-managed-props))] (db/update! conn :profile {:props (db/tjson props)} diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index b4fb080583..5c5ef63c0d 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -1160,3 +1160,45 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation)) (t/is (th/ex-of-code? error :email-as-password)))) + + +(t/deftest update-profile-props-rejects-subscription + ;; N1-16: Mass Assignment — :subscription must not be writable via RPC + ;; The closed schema rejects :subscription at validation time + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:subscription {:type "unlimited" :status "active"}}} + out (th/command! data)] + + ;; The call must fail with validation error + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + ;; And :subscription must NOT be persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (nil? (get-in props [:props :subscription])) + ":subscription must not be writable via update-profile-props")))) + + +(t/deftest update-profile-props-accepts-valid-keys + ;; Verify that valid props keys still work after closing the schema + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-viewed true + :newsletter-updates false + :renderer :wasm}} + out (th/command! data)] + + ;; The call should succeed + (t/is (nil? (:error out))) + + ;; And all valid keys should be persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (true? (get-in props [:props :onboarding-viewed]))) + (t/is (false? (get-in props [:props :newsletter-updates]))) + (t/is (= :wasm (get-in props [:props :renderer])))))) From 319a2185c97299da3345e016d2d8478e61faba29 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 3 Aug 2026 17:31:55 +0200 Subject: [PATCH 3/3] :bug: Fix crash on drag-and-drop of selected text in Draft.js editor (#10959) After `Modifier.removeRange` modified the block map, the `targetRange` from the drop event still referenced stale block keys from the pre-removal DOM state, causing `TypeError: Cannot read properties of undefined`. - Added a guarded `moveText` export in `frontend/packages/draft-js/index.js` that validates block keys exist before and after removal. Falls back gracefully when target range references stale keys. - Added a `handle-drop` callback in `frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` that returns "handled" for internal drag operations, preventing Draft.js from calling its default (crash-prone) handler. AI-assisted-by: mimo-v2.5 --- frontend/packages/draft-js/package.json | 2 +- frontend/pnpm-lock.yaml | 10 +++++----- .../src/app/main/ui/workspace/shapes/text/editor.cljs | 8 +++++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index ec153b2eac..d71c2bf0cf 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,7 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35", + "draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d", "immutable": "^5.1.9" }, "peerDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6459be6e82..16975f3248 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -268,8 +268,8 @@ importers: packages/draft-js: dependencies: draft-js: - specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 - version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d + version: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) immutable: specifier: ^5.1.9 version: 5.1.9 @@ -2895,8 +2895,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35: - resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35} + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d: + resolution: {gitHosted: true, integrity: sha512-Ji/UoFEm11scM7Zbp1O+7f498Ly5zkGRCJNvxR8BRL0xhqEs1ubgckZdmopjNxi+xjepHopa5hIv9pJTRaQOgg==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d} version: 0.11.7 peerDependencies: react: '>=0.14.0' @@ -8249,7 +8249,7 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 237ae71ac0..536febb61b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -221,7 +221,12 @@ state (-> (ted/insert-text state text style) (handle-change))] (st/emit! (dwt/update-editor-state shape state)))) - "handled")] + "handled") + + handle-drop + (fn [_ _ drag-type] + (when (= drag-type "internal") + "handled"))] (mf/use-layout-effect on-mount) @@ -246,6 +251,7 @@ :handle-return handle-return :strip-pasted-styles true :handle-pasted-text handle-pasted-text + :handle-drop handle-drop :custom-style-fn (partial styles-fn shape) :block-renderer-fn #(render-block % shape) :ref on-editor