mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
718 lines
32 KiB
Clojure
718 lines
32 KiB
Clojure
;; 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.rpc.commands.nitrate
|
|
"Nitrate API for Penpot. Provides nitrate-related endpoints to be called
|
|
from Penpot frontend."
|
|
(:require
|
|
[app.auth.oidc :as oidc]
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.json :as json]
|
|
[app.common.schema :as sm]
|
|
[app.common.time :as ct]
|
|
[app.common.types.organization :as cto]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.nitrate :as nitrate]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.teams :as teams]
|
|
[app.rpc.doc :as-alias doc]
|
|
[app.rpc.helpers :as rph]
|
|
[app.rpc.nitrate.emails-helper :as neh]
|
|
[app.rpc.nitrate.organization-helper :as noh]
|
|
[app.rpc.notifications :as notifications]
|
|
[app.util.services :as sv]
|
|
[buddy.core.codecs :as bc]))
|
|
|
|
|
|
(defn assert-is-owner [cfg profile-id team-id]
|
|
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
|
(when-not (:is-owner perms)
|
|
(ex/raise :type :validation
|
|
:code :insufficient-permissions))))
|
|
|
|
(defn assert-not-default-team [cfg team-id]
|
|
(let [team (teams/get-team-info cfg {:id team-id})]
|
|
(when (:is-default team)
|
|
(ex/raise :type :validation
|
|
:code :cant-move-default-team))))
|
|
|
|
|
|
(def schema:connectivity
|
|
[:map {:title "nitrate-connectivity"}
|
|
[:licenses ::sm/boolean]])
|
|
|
|
(sv/defmethod ::get-nitrate-connectivity
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params [:map]
|
|
::sm/result schema:connectivity}
|
|
[cfg _params]
|
|
(nitrate/call cfg :connectivity {}))
|
|
|
|
(def ^:private schema:subscription-warning
|
|
[:maybe
|
|
[:map {:title "SubscriptionWarning"}
|
|
[:type {:optional true} ::sm/text]
|
|
[:days-from-expiry {:optional true} ::sm/int]
|
|
[:days-until-expiry {:optional true} ::sm/int]
|
|
[:expiration-date {:optional true} ct/schema:inst]]])
|
|
|
|
(sv/defmethod ::get-subscription-warning
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params [:map]
|
|
::sm/result schema:subscription-warning}
|
|
[cfg {:keys [::rpc/profile-id]}]
|
|
(nitrate/call cfg :get-subscription-warning {:profile-id profile-id}))
|
|
|
|
(def ^:private schema:redeem-activation-code-params
|
|
[:map {:title "RedeemActivationCodeParams"}
|
|
[:activation-code ::sm/text]])
|
|
|
|
(def ^:private schema:redeem-activation-code-result
|
|
[:map {:title "RedeemActivationCodeResult"}
|
|
[:cancel-at [:maybe ct/schema:inst]]])
|
|
|
|
(sv/defmethod ::redeem-nitrate-activation-code
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:redeem-activation-code-params
|
|
::sm/result schema:redeem-activation-code-result}
|
|
[cfg {:keys [::rpc/profile-id activation-code]}]
|
|
(let [profile (db/get cfg :profile {:id profile-id})]
|
|
(try
|
|
(let [result (nitrate/call cfg :redeem-activation-code
|
|
{:request-params {:code activation-code
|
|
:penpot-id profile-id
|
|
:email (:email profile)}})]
|
|
(when-not result
|
|
(ex/raise :type :validation
|
|
:code :invalid-activation-code
|
|
:hint "The activation code is invalid, expired or fully redeemed"))
|
|
result)
|
|
(catch Exception cause
|
|
(let [{:keys [type status]} (ex-data cause)]
|
|
(if (= type :nitrate-http-error)
|
|
(ex/raise :type :validation
|
|
:code (case status
|
|
410 :expired-activation-code
|
|
409 :used-activation-code
|
|
:invalid-activation-code)
|
|
:cause cause)
|
|
(throw cause)))))))
|
|
|
|
(def ^:private activation-code-request-filename
|
|
"penpot-activation-code-request.txt")
|
|
|
|
(sv/defmethod ::get-nitrate-activation-code-request
|
|
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
|
Payload includes nitrateId, publicKey, email and iat."
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params [:map]
|
|
::sm/result ::sm/text}
|
|
[cfg {:keys [::rpc/profile-id]}]
|
|
(let [profile (db/get cfg :profile {:id profile-id})
|
|
nitrate-identity (nitrate/call cfg :get-identity {})]
|
|
(when-not nitrate-identity
|
|
(ex/raise :type :validation
|
|
:code :nitrate-identity-unavailable
|
|
:hint "Unable to retrieve nitrate identity"))
|
|
(-> (json/encode {:nitrate-id (:nitrate-id nitrate-identity)
|
|
:public-key (:public-key nitrate-identity)
|
|
:email (:email profile)
|
|
:iat (ct/seconds (ct/now))}
|
|
:key-fn json/write-camel-key)
|
|
(bc/str->bytes)
|
|
(bc/bytes->b64-str)
|
|
(rph/wrap)
|
|
(rph/with-header "content-type" "text/plain")
|
|
(rph/with-header "content-disposition"
|
|
(str "attachment; filename=\"" activation-code-request-filename "\"")))))
|
|
|
|
(def ^:private sql:prefix-team-name-and-unset-default
|
|
"UPDATE team
|
|
SET name = ? || name,
|
|
is_default = FALSE
|
|
WHERE id = ?;")
|
|
|
|
(def ^:private sql:get-member-teams-info
|
|
"SELECT t.id,
|
|
t.is_default,
|
|
tpr.is_owner,
|
|
(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members,
|
|
(SELECT array_agg(profile_id) FROM team_profile_rel WHERE team_id = t.id) AS member_ids
|
|
FROM team AS t
|
|
JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id)
|
|
WHERE tpr.profile_id = ?
|
|
AND t.id = ANY(?)
|
|
AND t.deleted_at IS NULL")
|
|
|
|
(def ^:private sql:get-teams-files-counts
|
|
"SELECT p.team_id, count(*) AS total
|
|
FROM file AS f
|
|
JOIN project AS p ON (p.id = f.project_id)
|
|
WHERE p.team_id = ANY(?)
|
|
AND f.deleted_at IS NULL
|
|
GROUP BY p.team_id")
|
|
|
|
(defn- get-team-files-counts
|
|
[conn team-ids]
|
|
(if (seq team-ids)
|
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
|
(->> (db/exec! conn [sql:get-teams-files-counts ids-array])
|
|
(reduce (fn [acc {:keys [team-id total]}]
|
|
(assoc acc team-id (long total)))
|
|
{})))
|
|
{}))
|
|
|
|
(defn- build-leave-organization-plan
|
|
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
|
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
|
files-counts (get-team-files-counts conn all-teams)
|
|
has-files? (fn [id] (pos? (long (get files-counts id 0))))
|
|
deletable (remove has-files? teams-to-delete)
|
|
keep-default? (or keep-default-team-requested?
|
|
(and default-team-id (has-files? default-team-id)))
|
|
to-detach (cond-> (into [] (remove (set deletable) teams-to-delete))
|
|
(and default-team-id keep-default?) (conj default-team-id))]
|
|
{:deletable-team-ids deletable
|
|
:keep-default-team? keep-default?
|
|
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
|
:detach-from-organization-team-ids to-detach}))
|
|
|
|
(defn get-leave-organization-summary
|
|
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
|
(let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
|
|
(build-leave-organization-plan cfg default-team-id teams-to-delete nil)]
|
|
{:teams-to-delete (count deletable-team-ids)
|
|
:teams-to-transfer teams-to-transfer-count
|
|
:teams-to-exit teams-to-exit-count
|
|
:teams-to-detach (count detach-from-organization-team-ids)}))
|
|
|
|
(def ^:private schema:leave-organization
|
|
[:map
|
|
[:id ::sm/uuid]
|
|
[:name ::sm/text]
|
|
[:default-team-id ::sm/uuid]
|
|
[:teams-to-delete
|
|
[:vector ::sm/uuid]]
|
|
[:teams-to-leave
|
|
[:vector
|
|
[:map
|
|
[:id ::sm/uuid]
|
|
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
|
|
|
(def ^:private schema:get-leave-organization-summary-result
|
|
[:map
|
|
[:teams-to-delete ::sm/int]
|
|
[:teams-to-transfer ::sm/int]
|
|
[:teams-to-exit ::sm/int]
|
|
[:teams-to-detach ::sm/int]
|
|
[:member-added-at [:maybe ct/schema:inst]]
|
|
[:organization-member-count-before ::sm/int]])
|
|
|
|
(def ^:private schema:get-leave-organization-summary
|
|
[:map
|
|
[:id ::sm/uuid]
|
|
[:default-team-id ::sm/uuid]])
|
|
|
|
|
|
(defn- get-organization-teams-for-user
|
|
[{:keys [::db/conn] :as cfg} organization-summary profile-id]
|
|
(let [organization-team-ids (->> (:teams organization-summary)
|
|
(map :id))
|
|
ids-array (db/create-array conn "uuid" organization-team-ids)]
|
|
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
|
|
|
(defn- calculate-valid-teams
|
|
([organization-teams default-team-id]
|
|
(let [;; valid default team is the one which id is default-team-id
|
|
valid-default-team (d/seek #(= default-team-id (:id %)) organization-teams)
|
|
|
|
;; Remove your-penpot for the rest of validations
|
|
organization-teams (remove #(= default-team-id (:id %)) organization-teams)
|
|
|
|
;; valid teams to delete are those that the user is owner, and only have one member
|
|
valid-teams-to-delete-ids (->> organization-teams
|
|
(filter #(and (:is-owner %)
|
|
(= (:num-members %) 1)))
|
|
(map :id)
|
|
(into #{}))
|
|
;; valid teams to transfer are those that the user is owner, and have more than one member
|
|
valid-teams-to-transfer (->> organization-teams
|
|
(filter #(and (:is-owner %)
|
|
(> (:num-members %) 1))))
|
|
|
|
;; valid teams to exit are those that the user isn't owner, and have more than one member
|
|
valid-teams-to-exit (->> organization-teams
|
|
(filter #(and (not (:is-owner %))
|
|
(> (:num-members %) 1))))]
|
|
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
|
:valid-teams-to-transfer valid-teams-to-transfer
|
|
:valid-teams-to-exit valid-teams-to-exit
|
|
:valid-default-team valid-default-team})))
|
|
|
|
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
|
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)]
|
|
(calculate-valid-teams organization-teams default-team-id)))
|
|
|
|
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
|
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)
|
|
{:keys [valid-teams-to-delete-ids
|
|
valid-teams-to-transfer
|
|
valid-teams-to-exit
|
|
valid-default-team]} (calculate-valid-teams organization-teams default-team-id)
|
|
|
|
|
|
|
|
valid-teams-to-exit-ids (->> valid-teams-to-exit (map :id) (into #{}))
|
|
valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{}))
|
|
valid-teams-to-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids)
|
|
|
|
valid-default-team-id? (some? valid-default-team)
|
|
|
|
|
|
|
|
valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete))
|
|
|
|
;; for every team in teams-to-leave, check that:
|
|
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
|
|
;; the reassign-to is a member of the team and not the current user;
|
|
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
|
|
teams-by-id (d/index-by :id organization-teams)
|
|
valid-teams-to-leave? (and
|
|
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
|
(every? (fn [{:keys [id reassign-to]}]
|
|
(if reassign-to
|
|
(let [members (db/pgarray->set (:member-ids (get teams-by-id id)))]
|
|
(and (contains? valid-teams-to-transfer-ids id)
|
|
(not= reassign-to profile-id)
|
|
(contains? members reassign-to)))
|
|
(contains? valid-teams-to-exit-ids id)))
|
|
teams-to-leave))]
|
|
;; the organization owner cannot leave
|
|
(when (= (:owner-id organization-summary) profile-id)
|
|
(ex/raise :type :validation
|
|
:code :organization-owner-cannot-leave))
|
|
|
|
(when (or
|
|
(not valid-teams-to-delete?)
|
|
(not valid-teams-to-leave?)
|
|
(not valid-default-team-id?))
|
|
(ex/raise :type :validation
|
|
:code :not-valid-teams))))
|
|
|
|
|
|
|
|
(defn leave-organization
|
|
[{:keys [::db/conn] :as cfg}
|
|
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?
|
|
user-who-delete-member deleted-by-role]}]
|
|
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
|
|
{:keys [deletable-team-ids
|
|
keep-default-team?
|
|
detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
|
|
|
;; assert that the received teams are valid, checking the different constraints
|
|
(when-not skip-validation
|
|
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
|
|
|
(nitrate/assert-membership cfg profile-id id)
|
|
|
|
;; delete only eligible teams (non-protected and without files)
|
|
(doseq [id deletable-team-ids]
|
|
(teams/delete-team cfg {:profile-id profile-id
|
|
:team-id id}))
|
|
|
|
;; leave the teams-to-leave
|
|
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
|
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
|
|
|
;; Process organization "Personal Projects" team: keep with prefix if needed, otherwise delete.
|
|
(when default-team-id
|
|
(if keep-default-team?
|
|
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
|
|
(teams/delete-team cfg {:profile-id profile-id
|
|
:team-id default-team-id})))
|
|
|
|
;; Detach retained owned teams from the organization in Nitrate.
|
|
;; Nitrate will rehome them to its fallback/default organization.
|
|
(doseq [team-id detach-from-organization-team-ids]
|
|
(nitrate/call cfg :remove-team-from-organization {:team-id team-id
|
|
:organization-id id}))
|
|
|
|
;; Api call to nitrate
|
|
(nitrate/call cfg :remove-profile-from-organization
|
|
{:profile-id profile-id
|
|
:organization-id id
|
|
:user-who-delete-member user-who-delete-member
|
|
:deleted-by-role deleted-by-role})
|
|
|
|
nil))
|
|
|
|
|
|
(sv/defmethod ::leave-organization
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:leave-organization
|
|
::db/transaction true}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(leave-organization cfg (assoc params
|
|
:profile-id profile-id
|
|
:user-who-delete-member profile-id
|
|
:deleted-by-role "organization-member")))
|
|
|
|
|
|
(sv/defmethod ::get-leave-organization-summary
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:get-leave-organization-summary
|
|
::sm/result schema:get-leave-organization-summary-result
|
|
::db/transaction true}
|
|
[cfg {:keys [::rpc/profile-id id default-team-id]}]
|
|
(let [{:keys [valid-teams-to-delete-ids
|
|
valid-teams-to-transfer
|
|
valid-teams-to-exit
|
|
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
|
membership (nitrate/call cfg :get-organization-membership
|
|
{:profile-id profile-id
|
|
:organization-id id})
|
|
organization-members (nitrate/call cfg :get-organization-members
|
|
{:organization-id id})
|
|
teams-to-transfer-count (count valid-teams-to-transfer)
|
|
teams-to-exit-count (count valid-teams-to-exit)]
|
|
(when-not valid-default-team
|
|
(ex/raise :type :validation
|
|
:code :not-valid-teams))
|
|
(assoc
|
|
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
|
:member-added-at (:created-at membership)
|
|
:organization-member-count-before (count organization-members))))
|
|
|
|
|
|
(def ^:private schema:remove-team-from-organization
|
|
[:map
|
|
[:team-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]
|
|
[:organization-name ::sm/text]])
|
|
|
|
(sv/defmethod ::remove-team-from-organization
|
|
{::doc/added "2.18"
|
|
::sm/params schema:remove-team-from-organization}
|
|
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
|
|
|
(assert-is-owner cfg profile-id team-id)
|
|
(assert-not-default-team cfg team-id)
|
|
(nitrate/assert-membership cfg profile-id organization-id)
|
|
;; Check moveTeams permission on the source organization
|
|
(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 (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.")))))
|
|
|
|
;; Api call to nitrate
|
|
(nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id})
|
|
|
|
;; Notify connected users
|
|
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization")
|
|
nil)
|
|
|
|
(def ^:private sql:delete-team-external-invitations
|
|
"DELETE FROM team_invitation
|
|
WHERE team_id = ?
|
|
AND email_to = ANY(?)
|
|
AND valid_until > now()")
|
|
|
|
(def ^:private sql:get-profiles-by-emails
|
|
"SELECT id, email
|
|
FROM profile
|
|
WHERE email = ANY(?)
|
|
AND deleted_at IS NULL")
|
|
|
|
(defn- get-external-invitation-info
|
|
"Returns info about external (non-organization-member) invitations pending for a team.
|
|
External invitations are those sent to users who are not members of the given organization.
|
|
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 (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))]
|
|
(if (empty? emails)
|
|
{:allows-anybody false :external-emails []}
|
|
(let [emails-array (db/create-array conn "text" (vec emails))
|
|
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
|
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
|
member-emails (->> profiles
|
|
(filter #(contains? organization-member-ids (:id %)))
|
|
(map :email)
|
|
(into #{}))
|
|
external-emails (into [] (remove member-emails emails))]
|
|
{:allows-anybody false :external-emails external-emails}))))))
|
|
|
|
(def ^:private schema:add-team-to-organization
|
|
[:map
|
|
[:team-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::add-team-to-organization
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:add-team-to-organization
|
|
::db/transaction true}
|
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
|
|
|
(assert-is-owner cfg profile-id team-id)
|
|
(assert-not-default-team cfg team-id)
|
|
(nitrate/assert-membership cfg profile-id organization-id)
|
|
|
|
(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])
|
|
source-organization-perms (when source-organization-id
|
|
(nitrate/call cfg :get-organization-permissions
|
|
{:organization-id source-organization-id}))
|
|
target-organization-perms (nitrate/call cfg :get-organization-permissions
|
|
{:organization-id organization-id})
|
|
target-organization-same-owner? (and (some? source-organization-perms)
|
|
(some? target-organization-perms)
|
|
(= (:owner-id source-organization-perms)
|
|
(:owner-id target-organization-perms)))]
|
|
(when (nil? target-organization-perms)
|
|
(ex/raise :type :validation
|
|
:code :not-allowed
|
|
:hint "Unable to verify organization permissions"))
|
|
|
|
;; Team already belongs to an organization: check move-teams on the source organization.
|
|
(when (some? source-organization-id)
|
|
(when (nil? source-organization-perms)
|
|
(ex/raise :type :validation
|
|
:code :not-allowed
|
|
:hint "Unable to verify organization permissions"))
|
|
(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 (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"))
|
|
|
|
;; Add teammates to the organization if needed
|
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
|
new-member-ids (->> team-members
|
|
(map :profile-id)
|
|
(remove #{profile-id})
|
|
(remove organization-member-ids-before))]
|
|
(doseq [member-id new-member-ids]
|
|
(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
|
|
:organization-id organization-id
|
|
:is-default false})]
|
|
;; Notify connected users
|
|
(notifications/notify-team-change cfg team "dashboard.team-belong-organization"))
|
|
|
|
;; Delete pending invitations for users who are not members of the target organization
|
|
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
|
(when (and (not allows-anybody) (seq external-emails))
|
|
(let [conn (::db/conn cfg)
|
|
emails-array (db/create-array conn "text" external-emails)]
|
|
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
|
|
|
|
;; Send warnings via email if the organization has sso
|
|
(neh/send-organization-setup-sso-emails-for-team!
|
|
cfg organization-id team-id organization-member-ids-before)))
|
|
|
|
nil)
|
|
|
|
(def ^:private schema:check-organization-members-params
|
|
[:map {:title "CheckOrganizationMembersParams"}
|
|
[:organization-id ::sm/uuid]
|
|
[:emails [:vector ::sm/email]]])
|
|
|
|
(sv/defmethod ::check-organization-members
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:check-organization-members-params
|
|
::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 :admin-console)
|
|
(nitrate/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])
|
|
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
|
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))]
|
|
(into {}
|
|
(map (fn [email]
|
|
(let [pid (get email->id email)]
|
|
[email (boolean (and pid (contains? organization-member-ids pid)))])))
|
|
emails)))
|
|
{}))
|
|
|
|
(def ^:private schema:all-organization-members-in-team-params
|
|
[:map {:title "CheckOrganizationMembersInTeamParams"}
|
|
[:team-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::all-organization-members-in-team
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::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 :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
|
|
:code :insufficient-permissions))
|
|
(nitrate/assert-membership cfg profile-id organization-id)
|
|
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
|
organization-member-ids (into #{} organization-members)
|
|
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
|
team-member-ids (into #{} (map :profile-id team-members))]
|
|
(every? #(contains? team-member-ids %) organization-member-ids)))
|
|
false))
|
|
|
|
(def ^:private schema:all-team-members-in-organizations-params
|
|
[:map {:title "CheckTeamMembersInOrganizationsParams"}
|
|
[:team-id ::sm/uuid]
|
|
[:organization-ids [:vector ::sm/uuid]]])
|
|
|
|
(sv/defmethod ::all-team-members-in-organizations
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::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 :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
|
|
:code :insufficient-permissions))
|
|
|
|
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
|
team-member-ids (into #{} (map :profile-id team-members))]
|
|
;; Validate requester membership in all organizations before fetching members.
|
|
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
|
|
|
|
(into {}
|
|
(map (fn [organization-id]
|
|
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
|
organization-member-ids (into #{} organization-members)]
|
|
[organization-id
|
|
(every? #(contains? organization-member-ids %) team-member-ids)])))
|
|
organization-ids)))
|
|
{}))
|
|
|
|
(def ^:private schema:check-team-external-invitations-params
|
|
[:map {:title "CheckTeamExternalInvitationsParams"}
|
|
[:team-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(def ^:private schema:check-team-external-invitations-result
|
|
[:map {:title "CheckTeamExternalInvitationsResult"}
|
|
[:has-external-invitations ::sm/boolean]
|
|
[:allows-anybody ::sm/boolean]])
|
|
|
|
(sv/defmethod ::check-team-external-invitations
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::sm/params schema:check-team-external-invitations-params
|
|
::sm/result schema:check-team-external-invitations-result
|
|
::db/transaction true}
|
|
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
|
(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
|
|
:code :insufficient-permissions))
|
|
(nitrate/assert-membership cfg profile-id organization-id)
|
|
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
|
{:has-external-invitations (boolean (seq external-emails))
|
|
:allows-anybody allows-anybody}))
|
|
{:has-external-invitations false
|
|
:allows-anybody false}))
|
|
|
|
|
|
(def ^:private schema:check-nitrate-sso
|
|
[:and
|
|
[:map {:title "CheckNitrateSsoParams"}
|
|
[:team-id {:optional true} ::sm/uuid]
|
|
[:organization-id {:optional true} ::sm/uuid]
|
|
[:url ::sm/uri]]
|
|
[::sm/contains-any #{:team-id :organization-id}]])
|
|
|
|
(sv/defmethod ::check-nitrate-sso
|
|
"Check if a user needs to login into the organization SSO.
|
|
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
|
Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the
|
|
session already holds a valid entry for the organization, and
|
|
{:authorized true :reason :no-team-access} when the gate was skipped because the
|
|
user cannot access the team; the reason lets the client tell a usable session
|
|
apart from a plain permission failure.
|
|
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
|
the client must redirect there. The OIDC provider itself handles
|
|
re-authentication transparently if the user already has an active SSO session.
|
|
A nil :redirect-uri means SSO is required but the provider is not usable."
|
|
{::rpc/auth true
|
|
::doc/added "2.18"
|
|
::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 :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
|
|
;; access is established sends unrelated users through the organization's IdP.
|
|
{:authorized true :reason :no-team-access}
|
|
(let [request (rph/get-request params)
|
|
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
|
(if authorized
|
|
{:authorized true :reason :sso-satisfied}
|
|
(if (oidc/organization-sso-discovery-uri sso)
|
|
(try
|
|
(let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri
|
|
cfg sso
|
|
:dest-url url
|
|
:organization-id organization-id)
|
|
organization-id (or organization-id (:organization-id sso))]
|
|
(oidc/submit-organization-sso-auth-started-event
|
|
cfg request profile-id organization-id)
|
|
{:authorized false :redirect-uri redirect-uri})
|
|
(catch Throwable cause
|
|
(oidc/submit-organization-sso-auth-failed-event
|
|
cfg request profile-id (or organization-id (:organization-id sso)) cause)
|
|
(throw cause)))
|
|
{:authorized false
|
|
:redirect-uri nil}))))
|
|
{:authorized true :reason :sso-satisfied}))
|