diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 9c9c8339fc..c99451d6df 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -21,6 +21,7 @@ [app.http.session :as session] [app.rpc :as-alias rpc] [app.setup :as-alias setup] + [app.util.cache :as cache] [clojure.core :as c] [clojure.string :as str] [integrant.core :as ig])) @@ -507,6 +508,47 @@ ;; UTILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defonce ^:private team-org-owner-cache + ;; Short TTL: permission checks run on the read path, so we avoid an + ;; HTTP call to nitrate per check. The org owner of a team rarely + ;; changes, and stale entries only grant read access for a few seconds. + (cache/create :expire "30s" :max-size 2048)) + +(defn- nitrate-client? + "True when `cfg` is a config map carrying the nitrate client (i.e. not + a raw db connection/pool passed by an internal caller)." + [cfg] + (and (map? cfg) (some? (get cfg ::client)))) + +(def ^:private cache-miss ::no-org-owner) + +(defn- get-team-org-owner-id + "Returns the organization owner-id for `team-id`, or nil. Cached + briefly, including negative results (teams with no organization) so + repeated unauthorized probes don't each hit nitrate." + [cfg team-id] + (let [owner-id (cache/get team-org-owner-cache team-id + (fn [team-id] + (let [team-with-org (call cfg :get-team-org {:team-id team-id})] + (or (get-in team-with-org [:organization :owner-id]) + cache-miss))))] + (when-not (= owner-id cache-miss) + owner-id))) + +(defn organization-owner-of-team? + "True if `profile-id` is the owner of the organization that owns + `team-id`. Used to grant non-member org owners read-only access to the + teams of their organizations. `cfg` must be a config map with the + nitrate client; raw db connections/pools yield false so internal + callers are unaffected. Returns false when the :nitrate flag is off." + [cfg profile-id team-id] + (boolean + (when (and (contains? cf/flags :nitrate) + (nitrate-client? cfg) + (some? team-id) + (some? profile-id)) + (= profile-id (get-team-org-owner-id cfg team-id))))) + (defn sso-session-authorized? "Fetches the org-SSO config for the given organization or team and checks whether the HTTP request has a valid session entry for it. Returns a map diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ec27d455b8..79b0bf7cf9 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -74,8 +74,8 @@ ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] ::webhooks/event? true ::sm/params schema:export-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] - (files/check-read-permissions! pool profile-id file-id) + [cfg {:keys [::rpc/profile-id file-id] :as params}] + (files/check-read-permissions! cfg profile-id file-id) (sse/response (partial export-binfile cfg params))) ;; --- Command: import-binfile diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 3383d3d343..6a926d1e98 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -230,8 +230,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-threads} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comment-threads conn profile-id file-id)))) (defn- get-comment-threads-sql @@ -328,8 +328,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-thread} [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) (decode-row))))) @@ -347,9 +347,9 @@ {::doc/added "1.15" ::sm/params schema:get-comments} [cfg {:keys [::rpc/profile-id thread-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] (let [{:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comments conn thread-id))))) (def sql:get-comments @@ -406,8 +406,8 @@ ::doc/changes ["1.15" "Imported from queries and renamed."] ::sm/params schema:get-profiles-for-file-comments} [cfg {:keys [::rpc/profile-id file-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-file-comments-users conn file-id profile-id)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -534,9 +534,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-status ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (upsert-comment-thread-status! conn profile-id id))) ;; --- COMMAND: Update Comment Thread @@ -552,9 +552,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:is-resolved is-resolved} {:id id}) @@ -582,7 +582,7 @@ {:keys [team-id project-id] :as file} (get-file cfg file-id page-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (quotes/check! cfg {::quotes/id ::quotes/comments-per-file ::quotes/profile-id profile-id @@ -653,7 +653,7 @@ {:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) ;; Don't allow edit comments to not owners (when-not (= owner-id profile-id) @@ -690,9 +690,9 @@ {::doc/added "1.15" ::sm/params schema:delete-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -713,14 +713,14 @@ {::doc/added "1.15" ::sm/params schema:delete-comment ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true) {:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -743,9 +743,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-position ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :position (db/pgpoint position) @@ -767,9 +767,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-frame ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :frame-id frame-id} diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 3851ea577b..98a6ec0ec7 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -84,10 +84,10 @@ (perms/make-edition-predicate-fn bfc/get-file-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn bfc/get-file-permissions)) + (perms/make-read-predicate-fn perms/get-file-read-permissions)) (def has-comment-permissions? - (perms/make-comment-predicate-fn bfc/get-file-permissions)) + (perms/make-comment-predicate-fn perms/get-file-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -99,8 +99,8 @@ ;; explicit comment permissions through the share-id (defn check-comment-permissions! - [conn profile-id file-id share-id] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + [cfg profile-id file-id share-id] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) can-read (has-read-permissions? perms) can-comment (has-comment-permissions? perms)] (when-not (or can-read can-comment) @@ -152,7 +152,7 @@ (defn- get-minimal-file-with-perms [cfg {:keys [:id ::rpc/profile-id]}] (let [mfile (get-minimal-file cfg id) - perms (bfc/get-file-permissions cfg profile-id id)] + perms (perms/get-file-read-permissions cfg profile-id id)] (assoc mfile :permissions perms))) (defn get-file-etag @@ -171,7 +171,7 @@ ::sm/params schema:get-file ::sm/result schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}] + [cfg {:keys [::rpc/profile-id id project-id] :as params}] ;; The COND middleware makes initial request for a file and ;; permissions when the incoming request comes with an ;; ETAG. When ETAG does not matches, the request is resolved @@ -179,10 +179,10 @@ ;; will be already prefetched and we just reuse them instead ;; of making an additional database queries. (let [perms (or (:permissions (::cond/object params)) - (bfc/get-file-permissions conn profile-id id))] + (perms/get-file-read-permissions cfg profile-id id))] (check-read-permissions! perms) - (let [team (teams/get-team conn + (let [team (teams/get-team cfg :profile-id profile-id :project-id project-id :file-id id) @@ -242,7 +242,7 @@ ::sm/result schema:file-fragment} [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] - (let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -286,7 +286,7 @@ ::sm/params schema:get-project-files ::sm/result schema:files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}] - (projects/check-read-permissions! pool profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (get-project-files pool project-id)) ;; --- COMMAND QUERY: has-file-libraries @@ -304,7 +304,7 @@ ::sm/result ::sm/boolean} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-has-file-libraries conn file-id))) (def ^:private sql:has-file-libraries @@ -337,7 +337,7 @@ ::sm/result ::sm/int} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-usage conn file-id))) (def ^:private sql:get-library-usage @@ -387,7 +387,7 @@ :code :params-validation :hint "page-id is required when object-id is provided")) - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) proj (db/get conn :project {:id (:project-id file)}) @@ -438,8 +438,8 @@ ::sm/params schema:get-page} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (check-read-permissions! conn profile-id file-id share-id) + (fn [cfg] + (check-read-permissions! cfg profile-id file-id share-id) (get-page cfg (assoc params :profile-id profile-id))))) ;; --- COMMAND QUERY: get-team-shared-files @@ -562,7 +562,7 @@ (defn- get-team-shared-files [{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [process-row (fn [{:keys [id library-file-ids]}] @@ -675,8 +675,8 @@ ::sm/params schema:get-file-stats ::sm/result schema:get-file-stats-result ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] - (check-read-permissions! conn profile-id id) + [cfg {:keys [::rpc/profile-id id]}] + (check-read-permissions! cfg profile-id id) (get-file-stats cfg id)) @@ -719,7 +719,7 @@ ::sm/params schema:get-library-file-references} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-file-references conn file-id))) ;; --- COMMAND QUERY: get-team-recent-files @@ -763,7 +763,7 @@ ::sm/params schema:get-team-recent-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (get-team-recent-files conn team-id))) @@ -808,8 +808,8 @@ {::doc/added "2.12" ::sm/params schema:get-team-deleted-files} [cfg {:keys [::rpc/profile-id team-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (teams/check-read-permissions! conn profile-id team-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (teams/check-read-permissions! cfg profile-id team-id) (get-team-deleted-files conn team-id)))) ;; --- COMMAND QUERY: get-file-info diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 38caa0aa05..7baac52428 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -22,6 +22,7 @@ [app.rpc.commands.files :as files] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.util.services :as sv])) @@ -33,8 +34,8 @@ {::doc/added "1.20" ::sm/params schema:get-file-snapshots} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-read-permissions! conn profile-id file-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-read-permissions! cfg profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) ;; --- COMMAND QUERY: get-file-snapshot @@ -52,8 +53,8 @@ ::sm/params schema:get-file-snapshot ::sm/result files/schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] - (let [perms (bfc/get-file-permissions conn profile-id file-id)] + [cfg {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id)] (files/check-read-permissions! perms) (let [snapshot (fsnap/get-snapshot cfg file-id id)] (when-not snapshot diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 130d9a86be..2681b51c09 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -85,7 +85,7 @@ ::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}] (dm/with-open [conn (db/open pool)] - (files/check-read-permissions! conn profile-id file-id) + (files/check-read-permissions! cfg profile-id file-id) (if tag (get-object-thumbnails-by-tag conn file-id tag) (get-object-thumbnails conn file-id)))) @@ -197,9 +197,9 @@ ::sm/params schema:get-file-data-for-thumbnail ::sm/result schema:partial-file} [cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}] - (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-read-permissions! conn profile-id file-id) - (let [team (teams/get-team conn + (db/run! cfg (fn [cfg] + (files/check-read-permissions! cfg profile-id file-id) + (let [team (teams/get-team cfg :profile-id profile-id :file-id file-id) file (bfc/get-file cfg file-id diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 5f031a4583..4d9eb77636 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -6,7 +6,6 @@ (ns app.rpc.commands.fonts (:require - [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] @@ -30,6 +29,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.storage :as sto] [app.storage.tmp :as tmp] @@ -71,14 +71,14 @@ (cond (uuid? team-id) (do - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (db/query conn :team-font-variant {:team-id team-id :deleted-at nil})) (uuid? project-id) (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})] - (projects/check-read-permissions! conn profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (db/query conn :team-font-variant {:team-id (:team-id project) :deleted-at nil})) @@ -86,7 +86,7 @@ (uuid? file-id) (let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]}) project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]}) - perms (bfc/get-file-permissions conn profile-id file-id share-id)] + perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (files/check-read-permissions! perms) (db/query conn :team-font-variant {:team-id (:team-id project) @@ -400,7 +400,7 @@ ::sm/params schema:download-font} [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (let [variant (db/get pool :team-font-variant {:id id})] - (teams/check-read-permissions! pool profile-id (:team-id variant)) + (teams/check-read-permissions! cfg profile-id (:team-id variant)) ;; Try to get the best available font format (prefer TTF for broader compatibility). (let [media-id (or (:ttf-file-id variant) @@ -432,7 +432,7 @@ (ex/raise :type :not-found :code :object-not-found)) - (teams/check-read-permissions! pool profile-id (:team-id (first variants))) + (teams/check-read-permissions! cfg profile-id (:team-id (first variants))) (let [tempfile (tmp/tempfile :suffix ".zip") ffamily (-> variants first :font-family)] diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index c56f07ef83..41931f53ec 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -176,7 +176,7 @@ ;; profile-id is present; it can be ommited if this function is ;; called from SREPL helpers where no profile is available (when (uuid? profile-id) - (teams/check-read-permissions! conn profile-id team-id)) + (teams/check-read-permissions! cfg profile-id team-id)) (binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})] (let [projs (bfc/get-team-projects cfg team-id) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 0fdb9fb88f..0cb1b46e57 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -56,11 +56,16 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn- get-read-permissions + [cfg profile-id project-id] + (or (get-permissions cfg profile-id project-id) + (perms/get-organization-owner-permissions cfg profile-id :project-id project-id))) + (def has-edit-permissions? (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -159,10 +164,10 @@ {::doc/added "1.18" ::rpc/id-type :project ::sm/params schema:get-project} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id]}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (dm/with-open [conn (db/open pool)] (let [project (db/get-by-id conn :project id)] - (check-read-permissions! conn profile-id id) + (check-read-permissions! cfg profile-id id) project))) @@ -230,8 +235,8 @@ ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) ::webhooks/event? true ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] - (check-read-permissions! conn profile-id id) + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] + (check-read-permissions! cfg profile-id id) (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) nil) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 50458c27f8..5efc564daa 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -60,6 +60,11 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn get-read-permissions + [cfg profile-id team-id] + (or (get-permissions cfg profile-id team-id) + (perms/get-organization-owner-permissions cfg profile-id :team-id team-id))) + (def has-admin-permissions? (perms/make-admin-predicate-fn get-permissions)) @@ -67,7 +72,7 @@ (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-admin-permissions! (perms/make-check-fn has-admin-permissions?)) @@ -180,7 +185,6 @@ sql (if (contains? cf/flags :subscriptions) sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions)] - (->> (db/exec! conn [sql (:default-team-id profile) profile-id]) (into [] xform:process-teams)))) @@ -238,19 +242,34 @@ {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:get-team} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}] - (get-team pool :profile-id profile-id :team-id id :file-id file-id)) + [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) + (nitrate/add-org-info-to-team cfg team params) + team))) + +(defn- get-org-owner-viewer-team + "When `profile-id` is a non-member owner of the organization that owns + the requested team, returns the team shaped with viewer permissions; + otherwise nil. `cfg` must carry the nitrate client." + [cfg profile-id default-team-id params] + (when-let [team-id (perms/resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (when-let [team (db/get* cfg :team {:id team-id})] + (when-not (db/is-row-deleted? team) + (-> team + (decode-row) + (merge perms/viewer-role-flags) + (assoc :is-default (= team-id default-team-id)) + (process-permissions))))))) (defn get-team - [conn & {:keys [profile-id team-id project-id file-id] :as params}] + [cfg & {:keys [profile-id team-id project-id file-id] :as params}] (assert (uuid? profile-id) "profile-id is mandatory") - (assert (or (db/connection? conn) - (db/pool? conn)) - "connection or pool is mandatory") (let [{:keys [default-team-id] :as profile} - (profile/get-profile conn profile-id) + (profile/get-profile cfg profile-id) sql (if (contains? cf/flags :subscriptions) @@ -262,14 +281,14 @@ (some? team-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT * FROM teams WHERE id=?")] - (db/exec-one! conn [sql default-team-id profile-id team-id])) + (db/exec-one! cfg [sql default-team-id profile-id team-id])) (some? project-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT t.* FROM teams AS t " " JOIN project AS p ON (p.team_id = t.id) " " WHERE p.id=?")] - (db/exec-one! conn [sql default-team-id profile-id project-id])) + (db/exec-one! cfg [sql default-team-id profile-id project-id])) (some? file-id) (let [sql (str "WITH teams AS (" sql ") " @@ -277,17 +296,18 @@ " JOIN project AS p ON (p.team_id = t.id) " " JOIN file AS f ON (f.project_id = p.id) " " WHERE f.id=?")] - (db/exec-one! conn [sql default-team-id profile-id file-id])) + (db/exec-one! cfg [sql default-team-id profile-id file-id])) :else (throw (IllegalArgumentException. "invalid arguments")))] - (when-not result - (ex/raise :type :not-found - :code :team-does-not-exist)) - (-> result - (decode-row) - (process-permissions)))) + (if result + (-> result + (decode-row) + (process-permissions)) + (or (get-org-owner-viewer-team cfg profile-id default-team-id params) + (ex/raise :type :not-found + :code :team-does-not-exist))))) ;; --- Query: Team Members @@ -316,7 +336,7 @@ ::sm/params schema:get-team-memebrs} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-members conn team-id))) ;; --- Query: Team Users @@ -342,10 +362,10 @@ (dm/with-open [conn (db/open pool)] (if team-id (do - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id)) (let [{team-id :id} (get-team-for-file conn file-id)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id))))) ;; This is a similar query to team members but can contain more data @@ -432,7 +452,7 @@ ::sm/params schema:get-team-stats} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-stats conn team-id))) (def sql:team-stats @@ -468,7 +488,7 @@ ::sm/params schema:get-team-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-invitations conn team-id))) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 34795ba77b..c17a4e5a45 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -541,7 +541,7 @@ ::doc/module :teams ::sm/params schema:get-team-invitation-token} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (teams/check-read-permissions! pool profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [email (profile/clean-email email) invit (-> (db/get pool :team-invitation {:team-id team-id diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 570751e48b..9333800af6 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -16,6 +16,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.cond :as-alias cond] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.util.services :as sv] [cuerdas.core :as str])) @@ -125,8 +126,8 @@ ::sm/params schema:get-view-only-bundle} [system {:keys [::rpc/profile-id file-id share-id] :as params}] (db/run! system - (fn [{:keys [::db/conn] :as system}] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (fn [system] + (let [perms (perms/get-file-read-permissions system profile-id file-id share-id) params (-> params (assoc ::perms perms) (assoc :profile-id profile-id))] @@ -139,5 +140,3 @@ :hint "object not found")) (get-view-only-bundle system params))))) - - diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 702a9bdd14..33341bb34e 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -172,6 +172,6 @@ ::sm/params schema:get-webhooks} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (->> (db/exec! conn [sql:get-webhooks team-id]) (mapv decode-row)))) diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index f4107ec235..36ff9b2c23 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -7,8 +7,11 @@ (ns app.rpc.permissions "A permission checking helper factories." (:require + [app.binfile.common :as bfc] [app.common.exceptions :as ex] - [app.common.schema :as sm])) + [app.common.schema :as sm] + [app.db :as db] + [app.nitrate :as nitrate])) (def schema:permissions [:map {:title "Permissions"} @@ -89,3 +92,64 @@ (ex/raise :type :not-found :code :object-not-found :hint "not found")))) + +;; --- Organization owner (Nitrate) viewer access +;; +;; Read-permission helpers that augment normal Penpot membership with +;; Nitrate organization-owner viewer access. Edit/admin permission +;; providers intentionally stay membership-only. + +(def viewer-role-flags + "Role flags granted to a non-member organization owner: read-only. + Shared so callers that build full team/file rows shape permissions the + same way the permission lookups do." + {:is-owner false + :is-admin false + :can-edit false}) + +(def ^:private sql:get-team-id-for-project + "SELECT team_id FROM project WHERE id = ?") + +(def ^:private sql:get-team-id-for-file + "SELECT p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ?") + +(defn get-team-id-for-project + [cfg project-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id]) + (:team-id))) + +(defn get-team-id-for-file + [cfg file-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id]) + (:team-id))) + +(defn resolve-team-id + [cfg {:keys [team-id project-id file-id]}] + (cond + (some? team-id) team-id + (some? project-id) (get-team-id-for-project cfg project-id) + (some? file-id) (get-team-id-for-file cfg file-id))) + +(defn get-organization-owner-permissions + "When `profile-id` is a non-member owner of the organization that owns + the team/project/file referenced by `params`, returns read-only viewer + permissions; otherwise nil." + [cfg profile-id & {:as params}] + (when-let [team-id (resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (assoc viewer-role-flags + :can-read true + :type :membership + :is-logged (some? profile-id))))) + +(defn get-file-read-permissions + ([cfg profile-id file-id] + (or (bfc/get-file-permissions cfg profile-id file-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id))) + + ([cfg profile-id file-id share-id] + (or (bfc/get-file-permissions cfg profile-id file-id share-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id)))) diff --git a/backend/test/backend_tests/rpc_org_owner_permissions_test.clj b/backend/test/backend_tests/rpc_org_owner_permissions_test.clj new file mode 100644 index 0000000000..7ac84ef042 --- /dev/null +++ b/backend/test/backend_tests/rpc_org_owner_permissions_test.clj @@ -0,0 +1,150 @@ +;; 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 Sucursal en EspaƱa SL + +(ns backend-tests.rpc-org-owner-permissions-test + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- org-data + [org-id owner-id] + {:id org-id + :name "Acme" + :slug "acme" + :owner-id owner-id + :avatar-bg-url "http://example.com/avatar.png" + :permissions {}}) + +(defn- with-org-owner-access + [{:keys [org-owner-id org-id team-id]} f] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/organization-owner-of-team? + (fn [_cfg profile-id candidate-team-id] + (and (= org-owner-id profile-id) + (= team-id candidate-team-id))) + + nitrate/call + (fn [_cfg method params] + (case method + :get-owned-orgs + [{:id org-id + :name "Acme" + :owner-id org-owner-id + :teams [{:id team-id :is-your-penpot false}]}] + + :get-team-org + (if (= team-id (:team-id params)) + {:id team-id + :is-your-penpot false + :organization (org-data org-id org-owner-id)} + {:id (:team-id params) + :is-your-penpot false + :organization nil})))] + (f))) + +(t/deftest org-owner-access-disabled-without-nitrate-flag + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)})] + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + +(t/deftest non-member-org-owner-gets-viewer-access-to-org-team + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + project (th/create-project* 1 {:profile-id (:id team-owner) + :team-id (:id target-team)}) + file (th/create-file* 1 {:profile-id (:id team-owner) + :project-id (:id project)}) + org-id (uuid/next)] + + (with-org-owner-access {:org-owner-id (:id org-owner) + :org-id org-id + :team-id (:id target-team)} + (fn [] + ;; The team is not listed for a non-member, even though the org + ;; owner can access it directly. + (let [out (th/command! {::th/type :get-teams + ::rpc/profile-id (:id org-owner)})] + (t/is (nil? (:error out))) + (t/is (not-any? #(= (:id target-team) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id org-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (= (:id target-team) (:id team))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (false? (get-in team [:permissions :can-edit]))) + (t/is (= org-id (get-in team [:organization :id]))) + (t/is (= "Acme" (get-in team [:organization :name])))) + + (let [out (th/command! {::th/type :get-team-members + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)}) + members (:result out)] + (t/is (nil? (:error out))) + (t/is (some #(= (:id team-owner) (:id %)) members)) + (t/is (not-any? #(= (:id org-owner) (:id %)) members))) + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id org-owner) + :team-id (:id target-team)})] + (t/is (nil? (:error out))) + (t/is (some #(= (:id project) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id org-owner) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= (:id file) (get-in out [:result :id]))) + (t/is (false? (get-in out [:result :permissions :can-edit])))) + + (let [out (th/command! {::th/type :rename-project + ::rpc/profile-id (:id org-owner) + :id (:id project) + :name "Nope"}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))))) + +(t/deftest org-owner-member-keeps-team-role + (let [team-owner (th/create-profile* 1) + org-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + org-id (uuid/next)] + + (th/create-team-role* {:team-id (:id target-team) + :profile-id (:id org-owner) + :role :editor}) + + (with-org-owner-access {:org-owner-id (:id org-owner) + :org-id org-id + :team-id (:id target-team)} + (fn [] + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id org-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (true? (get-in team [:permissions :can-edit])))))))) diff --git a/frontend/src/app/main/data/helpers.cljs b/frontend/src/app/main/data/helpers.cljs index fd22d092a0..1a3cd54509 100644 --- a/frontend/src/app/main/data/helpers.cljs +++ b/frontend/src/app/main/data/helpers.cljs @@ -209,6 +209,20 @@ (filter #(= team-id (:team-id (val %)))) (into {})))) +(defn lookup-team + "The team identified by `team-id`, looked up first in the membership + `:teams` map and falling back to the directly-opened `:current-team`. + The fallback covers org-owner access to teams the profile is not a + member of, which are kept out of `:teams` so they don't leak into the + teams listing." + ([state] + (lookup-team state (:current-team-id state))) + ([state team-id] + (or (dm/get-in state [:teams team-id]) + (let [current (:current-team state)] + (when (= team-id (:id current)) + current))))) + (defn get-selrect [selrect-transform shape] (if (some? selrect-transform) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index f99765b5c3..a83add6a99 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.common.logging :as log] [app.common.schema :as sm] [app.common.types.nitrate-permissions :as nitrate-perms] @@ -16,6 +15,7 @@ [app.common.uri :as u] [app.config :as cf] [app.main.data.event :as ev] + [app.main.data.helpers :as dsh] [app.main.data.media :as di] [app.main.data.modal :as modal] [app.main.data.profile :as dp] @@ -67,6 +67,18 @@ (->> (rp/cmd! :get-teams) (rx/map teams-fetched))))) +(defn- update-team-data + [state team-id f & args] + (cond + (contains? (:teams state) team-id) + (apply update-in state [:teams team-id] f args) + + (= team-id (dm/get-in state [:current-team :id])) + (apply update state :current-team f args) + + :else + state)) + (defn with-refreshed-team "Fetches fresh team data from the server to ensure up-to-date org permissions, updates the app state, and calls f with the fresh team data. @@ -197,7 +209,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update-in [:teams team-id] assoc :members members) + (update-team-data team-id assoc :members members) (update :profiles merge (d/index-by :id members)))))) (defn fetch-members @@ -223,7 +235,7 @@ (ptk/reify ::invitations-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :invitations invitations)))) + (update-team-data state team-id assoc :invitations invitations)))) (defn fetch-invitations [] @@ -239,15 +251,24 @@ (ptk/reify ::team-initialized ptk/WatchEvent (watch [_ state _] - (let [teams (get state :teams) - team (get teams team-id)] - (if (not team) - (rx/throw (ex/error :type :authentication)) + (let [team (dsh/lookup-team state team-id)] + (if team (let [permissions (get team :permissions) features (get team :features)] - (rx/of #(assoc % :permissions permissions) + (rx/of #(-> % + (assoc :current-team team) + (assoc :permissions permissions)) (features/initialize features) - (fetch-members team-id)))))) + (fetch-members team-id))) + (->> (rp/cmd! :get-team {:id team-id}) + (rx/mapcat (fn [team] + (let [permissions (get team :permissions) + features (get team :features)] + (rx/of #(-> % + (assoc :current-team team) + (assoc :permissions permissions)) + (features/initialize features) + (fetch-members team-id))))))))) ptk/EffectEvent (effect [_ _ _] @@ -258,7 +279,9 @@ (ptk/reify ::initialize-team ptk/UpdateEvent (update [_ state] - (assoc state :current-team-id team-id)) + (-> state + (assoc :current-team-id team-id) + (dissoc :current-team))) ptk/WatchEvent (watch [_ _ stream] @@ -279,6 +302,7 @@ (if (= team-id' team-id) (-> state (dissoc :current-team-id) + (dissoc :current-team) (dissoc :shared-files) (dissoc :fonts)) state))))) @@ -330,7 +354,7 @@ (ptk/reify ::stats-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :stats stats)))) + (update-team-data state team-id assoc :stats stats)))) (defn fetch-stats [] @@ -346,7 +370,7 @@ (ptk/reify ::webhooks-fetched ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id] assoc :webhooks webhooks)))) + (update-team-data state team-id assoc :webhooks webhooks)))) (defn fetch-webhooks [] @@ -776,4 +800,3 @@ (defn team->organization [team] (when-let [org (:organization team)] (assoc org :default-team-id (:id team)))) - diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 58b33ce313..7e7890c435 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -12,6 +12,7 @@ [app.common.features :as cfeat] [app.common.logging :as log] [app.config :as cf] + [app.main.data.helpers :as dsh] [app.main.router :as rt] [app.main.store :as st] [app.render-wasm :as wasm] @@ -66,7 +67,7 @@ (defn get-enabled-features "An explicit lookup of enabled features for the current team" [state team-id] - (let [team (dm/get-in state [:teams team-id])] + (let [team (dsh/lookup-team state team-id)] (-> global-enabled-features (set/union (get state :features-runtime #{})) (set/intersection cfeat/no-migration-features) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 791520efd9..fc14e846f1 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -36,11 +36,7 @@ (l/derived (l/key :current-page-id) st/state)) (def team - (l/derived (fn [state] - (let [team-id (:current-team-id state) - teams (:teams state)] - (get teams team-id))) - st/state)) + (l/derived dsh/lookup-team st/state)) (def project (l/derived (fn [state] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index bcd2c92d7c..9ba8dfd53b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -676,19 +676,25 @@ [{:keys [team profile]}] (let [teams (mf/deref refs/teams) - ;; Find the "your-penpot" teams, and transform them in orgs - orgs (mf/with-memo [teams] - (->> teams - vals - (filter :is-default) - (map dtm/team->organization) - (d/index-by :id))) + current-org (dtm/team->organization team) + + ;; Find the "your-penpot" teams, and transform them in orgs. When + ;; the selected team is directly accessible but not listed in + ;; membership teams, include only its org so the org selector can + ;; show the current selection without leaking the team into the + ;; teams dropdown. + orgs (mf/with-memo [teams current-org] + (cond-> (->> teams + vals + (filter :is-default) + (map dtm/team->organization) + (d/index-by :id)) + (:id current-org) + (assoc (:id current-org) current-org))) show-dropdown? (or (dnt/is-valid-license? profile) (> (count orgs) 1)) - current-org (dtm/team->organization team) - org-teams (mf/with-memo [teams current-org] (->> teams vals @@ -1446,4 +1452,3 @@ [:> profile-section* {:profile profile :team team}]]) -