Enable org owners to view organization teams (#10388)

This commit is contained in:
Juanfran 2026-06-25 13:07:20 +02:00 committed by GitHub
parent 14fb211733
commit d328cb4a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 445 additions and 125 deletions

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)]

View File

@ -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)

View File

@ -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)

View File

@ -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)))

View File

@ -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

View File

@ -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)))))

View File

@ -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))))

View File

@ -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))))

View File

@ -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]))))))))

View File

@ -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)

View File

@ -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))))

View File

@ -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)

View File

@ -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]

View File

@ -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}]])