From 607e0c5c1db4770c7e9ee1610badad85aff383fd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 Nov 2024 10:39:05 +0100 Subject: [PATCH] :sparkles: Move team invitations and access requests to a separate namespace This commit also comes with: - a fix for incorrect conflict handling on team access request creation - a fix for incorrect handling of file-data when it is offloaded - replace some inneficient queries with effcient ones - remove redundant validation on creation of request-access --- backend/src/app/rpc.clj | 1 + backend/src/app/rpc/commands/files.clj | 33 +- backend/src/app/rpc/commands/teams.clj | 593 +----------------- .../app/rpc/commands/teams_invitations.clj | 573 +++++++++++++++++ backend/src/app/rpc/commands/verify_token.clj | 6 +- backend/src/app/util/time.clj | 1 + common/src/app/common/types/team.cljc | 5 +- 7 files changed, 630 insertions(+), 582 deletions(-) create mode 100644 backend/src/app/rpc/commands/teams_invitations.clj diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 5bd604711..d0c3bbcff 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -250,6 +250,7 @@ 'app.rpc.commands.projects 'app.rpc.commands.search 'app.rpc.commands.teams + 'app.rpc.commands.teams-invitations 'app.rpc.commands.verify-token 'app.rpc.commands.viewer 'app.rpc.commands.webhooks) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index b129ccd76..3b746f8ab 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -36,7 +36,8 @@ [app.util.services :as sv] [app.util.time :as dt] [app.worker :as wrk] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [promesa.exec :as px])) ;; --- FEATURES @@ -245,16 +246,16 @@ file))) (defn get-file - [{:keys [::db/conn] :as cfg} id & {:keys [project-id - migrate? - include-deleted? - lock-for-update?] - :or {include-deleted? false - lock-for-update? false - migrate? true}}] - (dm/assert! - "expected cfg with valid connection" - (db/connection-map? cfg)) + [{:keys [::db/conn ::wrk/executor] :as cfg} id + & {:keys [project-id + migrate? + include-deleted? + lock-for-update?] + :or {include-deleted? false + lock-for-update? false + migrate? true}}] + + (assert (db/connection? conn) "expected cfg with valid connection") (let [params (merge {:id id} (when (some? project-id) @@ -263,8 +264,14 @@ {::db/check-deleted (not include-deleted?) ::db/remove-deleted (not include-deleted?) ::sql/for-update lock-for-update?}) - (feat.fdata/resolve-file-data cfg) - (decode-row))] + (feat.fdata/resolve-file-data cfg)) + + ;; NOTE: we perform the file decoding in a separate thread + ;; because it has heavy and synchronous operations for + ;; decoding file body that are not very friendly with virtual + ;; threads. + file (px/invoke! executor #(decode-row file))] + (if (and migrate? (fmg/need-migration? file)) (migrate-file cfg file) file))) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 5bb92c5df..a9850a6e5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -10,7 +10,6 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.features :as cfeat] - [app.common.logging :as l] [app.common.schema :as sm] [app.common.types.team :as tt] [app.common.uuid :as uuid] @@ -25,17 +24,13 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] - [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] [app.storage :as sto] - [app.tokens :as tokens] - [app.util.blob :as blob] [app.util.services :as sv] [app.util.time :as dt] - [app.worker :as wrk] - [cuerdas.core :as str])) + [app.worker :as wrk])) ;; --- Helpers & Specs @@ -84,7 +79,9 @@ (cond-> row (some? features) (assoc :features (db/decode-pgarray features #{})))) -(defn- check-profile-muted +;; FIXME: move + +(defn check-profile-muted "Check if the member's email is part of the global bounce report" [conn member] (let [email (profile/clean-email (:email member))] @@ -94,7 +91,7 @@ :email email :hint "the profile has reported repeatedly as spam or has bounces")))) -(defn- check-email-bounce +(defn check-email-bounce "Check if the email is part of the global complain report" [conn email show?] (when (eml/has-bounce-reports? conn email) @@ -103,7 +100,7 @@ :email (if show? email "private") :hint "this email has been repeatedly reported as bounce"))) -(defn- check-email-spam +(defn check-email-spam "Check if the member email is part of the global complain report" [conn email show?] (when (eml/has-complaint-reports? conn email) @@ -267,6 +264,8 @@ [:fn #(or (contains? % :team-id) (contains? % :file-id))]]) +;; FIXME: split in two separated requests + (sv/defmethod ::get-team-users "Get team users by team-id or by file-id" {::doc/added "1.17" @@ -304,20 +303,29 @@ inner join project as p on (f.project_id = p.id) where p.team_id = ?") -(def sql:team-by-file - "select p.team_id as id - from project as p - join file as f on (p.id = f.project_id) - where f.id = ?") - (defn get-users [conn team-id] (db/exec! conn [sql:team-users team-id team-id team-id])) +(def sql:get-team-by-file + "SELECT t.* + FROM team AS t + JOIN project AS p ON (p.team_id = t.id) + JOIN file AS f ON (f.project_id = p.id) + WHERE f.id = ?") + (defn get-team-for-file [conn file-id] - (->> [sql:team-by-file file-id] - (db/exec-one! conn))) + (let [team (->> (db/exec! conn [sql:get-team-by-file file-id]) + (remove db/is-row-deleted?) + (map decode-row) + (first))] + (when-not team + (ex/raise :type :not-found + :code :object-not-found + :hint "database object not found")) + + team)) ;; --- Query: Team Stats @@ -505,8 +513,6 @@ ;; --- Mutation: Leave Team -(declare role->params) - (defn leave-team [conn {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) @@ -536,7 +542,7 @@ ;; assign owner role to new profile (db/update! conn :team-profile-rel - (role->params :owner) + (get tt/permissions-for-role :owner) {:team-id id :profile-id reassign-to})) ;; and finally, if all other conditions does not match and the @@ -607,16 +613,6 @@ nil))) ;; --- Mutation: Team Update Role -(def schema:role - [::sm/one-of tt/valid-roles]) - -(defn role->params - [role] - (case role - :admin {:is-owner false :is-admin true :can-edit true} - :editor {:is-owner false :is-admin false :can-edit true} - :owner {:is-owner true :is-admin true :can-edit true} - :viewer {:is-owner false :is-admin false :can-edit false})) (defn update-team-member-role [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id team-id member-id role] :as params}] @@ -657,7 +653,7 @@ :team-id team-id :role role}) - (let [params (role->params role)] + (let [params (get tt/permissions-for-role role)] ;; Only allow single owner on team (when (= role :owner) (db/update! conn :team-profile-rel @@ -675,7 +671,7 @@ [:map {:title "update-team-member-role"} [:team-id ::sm/uuid] [:member-id ::sm/uuid] - [:role schema:role]]) + [:role ::tt/role]]) (sv/defmethod ::update-team-member-role {::doc/added "1.17" @@ -755,536 +751,3 @@ {:id team-id}) (assoc team :photo-id (:id photo))))) - -;; --- Mutation: Create Team Invitation - -(def sql:upsert-team-invitation - "insert into team_invitation(id, team_id, email_to, role, valid_until, created_by) - values (?, ?, ?, ?, ?, ?) - on conflict(team_id, email_to) do - update set role = ?, valid_until = ?, updated_at = now() - returning *") - -(defn- create-invitation-token - [cfg {:keys [profile-id valid-until team-id member-id member-email role]}] - (tokens/generate (::setup/props cfg) - {:iss :team-invitation - :exp valid-until - :profile-id profile-id - :role role - :team-id team-id - :member-email member-email - :member-id member-id})) - -(defn- create-profile-identity-token - [cfg profile-id] - - (dm/assert! - "expected valid uuid for profile-id" - (uuid? profile-id)) - - (tokens/generate (::setup/props cfg) - {:iss :profile-identity - :profile-id profile-id - :exp (dt/in-future {:days 30})})) - -(def ^:private schema:create-invitation - [:map {:title "params:create-invitation"} - [::rpc/profile-id ::sm/uuid] - [:team - [:map - [:id ::sm/uuid] - [:name :string]]] - [:profile - [:map - [:id ::sm/uuid] - [:fullname :string]]] - [:role [::sm/one-of tt/valid-roles]] - [:email ::sm/email]]) - -(def ^:private check-create-invitation-params! - (sm/check-fn schema:create-invitation)) - -(defn- create-invitation - [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] - - (dm/assert! - "expected valid connection on cfg parameter" - (db/connection? conn)) - - (dm/assert! - "expected valid params for `create-invitation` fn" - (check-create-invitation-params! params)) - - (let [email (profile/clean-email email) - member (profile/get-profile-by-email conn email)] - - (check-profile-muted conn member) - (check-email-bounce conn email true) - (check-email-spam conn email true) - - ;; When we have email verification disabled and invitation user is - ;; already present in the database, we proceed to add it to the - ;; team as-is, without email roundtrip. - - ;; TODO: if member does not exists and email verification is - ;; disabled, we should proceed to create the profile (?) - (if (and (not (contains? cf/flags :email-verification)) - (some? member)) - (let [params (merge {:team-id (:id team) - :profile-id (:id member)} - (role->params role))] - - ;; Insert the invited member to the team - (db/insert! conn :team-profile-rel params - {::db/on-conflict-do-nothing? true}) - - ;; If profile is not yet verified, mark it as verified because - ;; accepting an invitation link serves as verification. - (when-not (:is-active member) - (db/update! conn :profile - {:is-active true} - {:id (:id member)})) - - nil) - - (let [id (uuid/next) - expire (dt/in-future "168h") ;; 7 days - invitation (db/exec-one! conn [sql:upsert-team-invitation id - (:id team) (str/lower email) - (name role) expire - (:id profile) - (name role) expire]) - updated? (not= id (:id invitation)) - profile-id (:id profile) - tprops {:profile-id profile-id - :invitation-id (:id invitation) - :valid-until expire - :team-id (:id team) - :member-email (:email-to invitation) - :member-id (:id member) - :role role} - itoken (create-invitation-token cfg tprops) - ptoken (create-profile-identity-token cfg profile-id)] - - (when (contains? cf/flags :log-invitation-tokens) - (l/info :hint "invitation token" :token itoken)) - - (let [props (-> (dissoc tprops :profile-id) - (audit/clean-props)) - evname (if updated? - "update-team-invitation" - "create-team-invitation") - event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name evname) - (assoc ::audit/props props))] - (audit/submit! cfg event)) - - (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :token itoken - :extra-data ptoken}) - - itoken)))) - -(defn- add-user-to-team - [conn profile team role email] - - (let [team-id (:id team) - member (db/get* conn :profile - {:email (str/lower email)} - {::sql/columns [:id :email]}) - params (merge - {:team-id team-id - :profile-id (:id member)} - (role->params role))] - - ;; Do not allow blocked users to join teams. - (when (:is-blocked member) - (ex/raise :type :restriction - :code :profile-blocked)) - - (quotes/check! - {::db/conn conn - ::quotes/id ::quotes/profiles-per-team - ::quotes/profile-id (:id member) - ::quotes/team-id team-id}) - - ;; Insert the member to the team - (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) - - ;; Delete any request - (db/delete! conn :team-access-request - {:team-id team-id :requester-id (:id member)}) - - ;; Delete any invitation - (db/delete! conn :team-invitation - {:team-id team-id :email-to (:email member)}) - - (eml/send! {::eml/conn conn - ::eml/factory eml/join-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :team-id (:id team)}))) - -(def sql:valid-requests-email - "SELECT p.email - FROM team_access_request AS tr - JOIN profile AS p ON (tr.requester_id = p.id) - WHERE tr.team_id = ? - AND tr.auto_join_until > now()") - -(defn- get-valid-requests-email - [conn team-id] - (db/exec! conn [sql:valid-requests-email team-id])) - -(def ^:private xf:map-email - (map :email)) - -(defn- create-team-invitations - [{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}] - (let [join-requests (into #{} xf:map-email - (get-valid-requests-email conn (:id team))) - team-members (into #{} xf:map-email - (get-team-members conn (:id team))) - - invitations (into #{} - (comp - ;; We don't re-send inviation to - ;; already existing members - (remove team-members) - ;; We don't send invitations to - ;; join-requested members - (remove join-requests) - (map (fn [email] (assoc params :email email))) - (keep (partial create-invitation cfg))) - emails)] - - ;; For requested invitations, do not send invitation emails, add - ;; the user directly to the team - (->> (filter join-requests emails) - (run! (partial add-user-to-team conn profile team role))) - - invitations)) - -(def ^:private schema:create-team-invitations - [:map {:title "create-team-invitations"} - [:team-id ::sm/uuid] - [:role schema:role] - [:emails [::sm/set ::sm/email]]]) - -(def ^:private max-invitations-by-request-threshold - "The number of invitations can be sent in a single rpc request" - 25) - -(sv/defmethod ::create-team-invitations - "A rpc call that allow to send a single or multiple invitations to - join the team." - {::doc/added "1.17" - ::sm/params schema:create-team-invitations} - [cfg {:keys [::rpc/profile-id team-id emails] :as params}] - (let [perms (get-permissions cfg profile-id team-id) - profile (db/get-by-id cfg :profile profile-id) - emails (into #{} (map profile/clean-email) emails)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (when (> (count emails) max-invitations-by-request-threshold) - (ex/raise :type :validation - :code :max-invitations-by-request - :hint "the maximum of invitation on single request is reached" - :threshold max-invitations-by-request-threshold)) - - (-> cfg - (assoc ::quotes/profile-id profile-id) - (assoc ::quotes/team-id team-id) - (assoc ::quotes/incr (count emails)) - (quotes/check! {::quotes/id ::quotes/invitations-per-team} - {::quotes/id ::quotes/profiles-per-team})) - - ;; Check if the current profile is allowed to send emails - (check-profile-muted cfg profile) - - (let [team (db/get-by-id cfg :team team-id) - ;; NOTE: Is important pass RPC method params down to the - ;; `create-team-invitations` because it uses the implicit - ;; RPC properties from params for fill necessary data on - ;; emiting an entry to the audit-log - invitations (db/tx-run! cfg create-team-invitations - (-> params - (assoc :profile profile) - (assoc :team team) - (assoc :emails emails)))] - - (with-meta {:total (count invitations) - :invitations invitations} - {::audit/props {:invitations (count invitations)}})))) - -;; --- Mutation: Create Team & Invite Members - -(def ^:private schema:create-team-with-invitations - [:map {:title "create-team-with-invitations"} - [:name [:string {:max 250}]] - [:features {:optional true} ::cfeat/features] - [:id {:optional true} ::sm/uuid] - [:emails [::sm/set ::sm/email]] - [:role schema:role]]) - -(sv/defmethod ::create-team-with-invitations - {::doc/added "1.17" - ::sm/params schema:create-team-with-invitations - ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}] - (let [features (-> (cfeat/get-enabled-features cf/flags) - (cfeat/check-client-features! (:features params))) - - params (-> params - (assoc :profile-id profile-id) - (assoc :features features)) - - team (create-team cfg params) - emails (into #{} (map profile/clean-email) emails)] - - (-> cfg - (assoc ::quotes/profile-id profile-id) - (assoc ::quotes/team-id (:id team)) - (assoc ::quotes/incr (count emails)) - (quotes/check! {::quotes/id ::quotes/teams-per-profile} - {::quotes/id ::quotes/invitations-per-team} - {::quotes/id ::quotes/profiles-per-team})) - - (when (> (count emails) max-invitations-by-request-threshold) - (ex/raise :type :validation - :code :max-invitations-by-request - :hint "the maximum of invitation on single request is reached" - :threshold max-invitations-by-request-threshold)) - - (let [props {:name name :features features} - event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "create-team") - (assoc ::audit/props props))] - (audit/submit! cfg event)) - - ;; Create invitations for all provided emails. - (let [profile (db/get-by-id conn :profile profile-id) - params (-> params - (assoc :team team) - (assoc :profile profile) - (assoc :role role)) - invitations (->> emails - (map (fn [email] (assoc params :email email))) - (map (partial create-invitation cfg)))] - - (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) - -;; --- Query: get-team-invitation-token - -(def ^:private schema:get-team-invitation-token - [:map {:title "get-team-invitation-token"} - [:team-id ::sm/uuid] - [:email ::sm/email]]) - -(sv/defmethod ::get-team-invitation-token - {::doc/added "1.17" - ::sm/params schema:get-team-invitation-token} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (check-read-permissions! pool profile-id team-id) - (let [email (profile/clean-email email) - invit (-> (db/get pool :team-invitation - {:team-id team-id - :email-to email}) - (update :role keyword)) - - member (profile/get-profile-by-email pool (:email-to invit)) - token (create-invitation-token cfg {:team-id (:team-id invit) - :profile-id profile-id - :valid-until (:valid-until invit) - :role (:role invit) - :member-id (:id member) - :member-email (or (:email member) - (profile/clean-email (:email-to invit)))})] - {:token token})) - -;; --- Mutation: Update invitation role - -(def ^:private schema:update-team-invitation-role - [:map {:title "update-team-invitation-role"} - [:team-id ::sm/uuid] - [:email ::sm/email] - [:role schema:role]]) - -(sv/defmethod ::update-team-invitation-role - {::doc/added "1.17" - ::sm/params schema:update-team-invitation-role} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id team-id)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (db/update! conn :team-invitation - {:role (name role) :updated-at (dt/now)} - {:team-id team-id :email-to (profile/clean-email email)}) - - nil))) - -;; --- Mutation: Delete invitation - -(def ^:private schema:delete-team-invition - [:map {:title "delete-team-invitation"} - [:team-id ::sm/uuid] - [:email ::sm/email]]) - -(sv/defmethod ::delete-team-invitation - {::doc/added "1.17" - ::sm/params schema:delete-team-invition} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id team-id)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (let [invitation (db/delete! conn :team-invitation - {:team-id team-id - :email-to (profile/clean-email email)} - {::db/return-keys true})] - (rph/wrap nil {::audit/props {:invitation-id (:id invitation)}}))))) - - - - -;; --- Mutation: Request Team Invitation - -(def sql:upsert-team-access-request - "INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until) - VALUES (?, ?, ?, ?, ?) - ON conflict(id) - DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now() - RETURNING *") - - -(def sql:team-access-request - "SELECT id, (valid_until < now()) AS expired - FROM team_access_request - WHERE team_id = ? - AND requester_id = ?") - -(def sql:team-owner - "SELECT profile_id - FROM team_profile_rel - WHERE team_id = ? - AND is_owner = true") - - -(defn- create-team-access-request - [{:keys [::db/conn] :as cfg} {:keys [team requester team-owner file is-viewer] :as params}] - (let [old-request (->> (db/exec-one! conn [sql:team-access-request (:id team) (:id requester)]) - (decode-row))] - (when (false? (:expired old-request)) - (ex/raise :type :validation - :code :request-already-sent - :hint "you have already made a request to join this team less than 24 hours ago")) - - (let [id (or (:id old-request) (uuid/next)) - valid_until (dt/in-future "24h") - auto_join_until (dt/in-future "168h") ;; 7 days - request (db/exec-one! conn [sql:upsert-team-access-request - id (:id team) (:id requester) valid_until auto_join_until - valid_until auto_join_until]) - factory (cond - (and (some? file) (:is-default team) is-viewer) - eml/request-file-access-yourpenpot-view - (and (some? file) (:is-default team)) - eml/request-file-access-yourpenpot - (some? file) - eml/request-file-access - :else - eml/request-team-access) - page-id (when (some? file) - (-> file :data :pages first))] - - ;; TODO needs audit? - - (eml/send! {::eml/conn conn - ::eml/factory factory - :public-uri (cf/get :public-uri) - :to (:email team-owner) - :requested-by (:fullname requester) - :requested-by-email (:email requester) - :team-name (:name team) - :team-id (:id team) - :file-name (:name file) - :file-id (:id file) - :page-id page-id}) - - request))) - - -(def ^:private schema:create-team-access-request - [:and - [:map {:title "create-team-access-request"} - [:file-id {:optional true} ::sm/uuid] - [:team-id {:optional true} ::sm/uuid] - [:is-viewer {:optional true} ::sm/boolean]] - - [:fn (fn [params] - (or (contains? params :file-id) - (contains? params :team-id)))]]) - - -(sv/defmethod ::create-team-access-request - "A rpc call that allow to request for an invitations to join the team." - {::doc/added "2.2.0" - ::sm/params schema:create-team-access-request} - [cfg {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}] - - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - - (let [requester (db/get-by-id conn :profile profile-id) - team-id (if (some? team-id) - team-id - (:id (get-team-for-file conn file-id))) - team (db/get-by-id conn :team team-id) - owner-id (->> (db/exec! conn [sql:team-owner (:id team)]) - (map decode-row) - (first) - :profile-id) - team-owner (db/get-by-id conn :profile owner-id) - file (when (some? file-id) - (db/get* conn :file - {:id file-id} - {::sql/columns [:id :name :data]})) - file (when (some? file) - (assoc file :data (blob/decode (:data file))))] - - ;;TODO needs quotes? - - (when (or (nil? requester) (nil? team) (nil? team-owner) (and (some? file-id) (nil? file))) - (ex/raise :type :validation - :code :invalid-parameters)) - - ;; Check that the requester is not muted - (check-profile-muted conn requester) - - ;; Check that the owner is not marked as bounce nor spam - (check-email-bounce conn (:email team-owner) false) - (check-email-spam conn (:email team-owner) true) - - (let [request (create-team-access-request - cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})] - (when request - (with-meta {:request request} - {::audit/props {:request 1}}))))))) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj new file mode 100644 index 000000000..d8ae02d1c --- /dev/null +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -0,0 +1,573 @@ +;; 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.teams-invitations + (:require + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.features :as cfeat] + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.types.team :as types.team] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.db.sql :as sql] + [app.email :as eml] + [app.loggers.audit :as audit] + [app.main :as-alias main] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams :as teams] + [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] + [app.rpc.quotes :as quotes] + [app.setup :as-alias setup] + [app.tokens :as tokens] + [app.util.services :as sv] + [app.util.time :as dt] + [cuerdas.core :as str])) + +;; --- Mutation: Create Team Invitation + + +(def sql:upsert-team-invitation + "insert into team_invitation(id, team_id, email_to, created_by, role, valid_until) + values (?, ?, ?, ?, ?, ?) + on conflict(team_id, email_to) do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + +(defn- create-invitation-token + [cfg {:keys [profile-id valid-until team-id member-id member-email role]}] + (tokens/generate (::setup/props cfg) + {:iss :team-invitation + :exp valid-until + :profile-id profile-id + :role role + :team-id team-id + :member-email member-email + :member-id member-id})) + +(defn- create-profile-identity-token + [cfg profile-id] + + (dm/assert! + "expected valid uuid for profile-id" + (uuid? profile-id)) + + (tokens/generate (::setup/props cfg) + {:iss :profile-identity + :profile-id profile-id + :exp (dt/in-future {:days 30})})) + +(def ^:private schema:create-invitation + [:map {:title "params:create-invitation"} + [::rpc/profile-id ::sm/uuid] + [:team + [:map + [:id ::sm/uuid] + [:name :string]]] + [:profile + [:map + [:id ::sm/uuid] + [:fullname :string]]] + [:role ::types.team/role] + [:email ::sm/email]]) + +(def ^:private check-create-invitation-params! + (sm/check-fn schema:create-invitation)) + +(defn- create-invitation + [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] + + (dm/assert! + "expected valid connection on cfg parameter" + (db/connection? conn)) + + (dm/assert! + "expected valid params for `create-invitation` fn" + (check-create-invitation-params! params)) + + (let [email (profile/clean-email email) + member (profile/get-profile-by-email conn email)] + + (teams/check-profile-muted conn member) + (teams/check-email-bounce conn email true) + (teams/check-email-spam conn email true) + + ;; When we have email verification disabled and invitation user is + ;; already present in the database, we proceed to add it to the + ;; team as-is, without email roundtrip. + + ;; TODO: if member does not exists and email verification is + ;; disabled, we should proceed to create the profile (?) + (if (and (not (contains? cf/flags :email-verification)) + (some? member)) + (let [params (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role role))] + + ;; Insert the invited member to the team + (db/insert! conn :team-profile-rel params + {::db/on-conflict-do-nothing? true}) + + ;; If profile is not yet verified, mark it as verified because + ;; accepting an invitation link serves as verification. + (when-not (:is-active member) + (db/update! conn :profile + {:is-active true} + {:id (:id member)})) + + nil) + + (let [id (uuid/next) + expire (dt/in-future "168h") ;; 7 days + invitation (db/exec-one! conn [sql:upsert-team-invitation id + (:id team) (str/lower email) + (:id profile) + (name role) expire + (name role) expire]) + updated? (not= id (:id invitation)) + profile-id (:id profile) + tprops {:profile-id profile-id + :invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} + itoken (create-invitation-token cfg tprops) + ptoken (create-profile-identity-token cfg profile-id)] + + (when (contains? cf/flags :log-invitation-tokens) + (l/info :hint "invitation token" :token itoken)) + + (let [props (-> (dissoc tprops :profile-id) + (audit/clean-props)) + evname (if updated? + "update-team-invitation" + "create-team-invitation") + event (-> (audit/event-from-rpc-params params) + (assoc ::audit/name evname) + (assoc ::audit/props props))] + (audit/submit! cfg event)) + + (eml/send! {::eml/conn conn + ::eml/factory eml/invite-to-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :token itoken + :extra-data ptoken}) + + itoken)))) + +(defn- add-user-to-team + [conn profile team role email] + + (let [team-id (:id team) + member (db/get* conn :profile + {:email (str/lower email)} + {::sql/columns [:id :email]}) + params (merge + {:team-id team-id + :profile-id (:id member)} + (get types.team/permissions-for-role role))] + + ;; Do not allow blocked users to join teams. + (when (:is-blocked member) + (ex/raise :type :restriction + :code :profile-blocked)) + + (quotes/check! + {::db/conn conn + ::quotes/id ::quotes/profiles-per-team + ::quotes/profile-id (:id member) + ::quotes/team-id team-id}) + + ;; Insert the member to the team + (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) + + ;; Delete any request + (db/delete! conn :team-access-request + {:team-id team-id :requester-id (:id member)}) + + ;; Delete any invitation + (db/delete! conn :team-invitation + {:team-id team-id :email-to (:email member)}) + + (eml/send! {::eml/conn conn + ::eml/factory eml/join-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :team-id (:id team)}))) + +(def sql:valid-requests-email + "SELECT p.email + FROM team_access_request AS tr + JOIN profile AS p ON (tr.requester_id = p.id) + WHERE tr.team_id = ? + AND tr.auto_join_until > now()") + +(defn- get-valid-requests-email + [conn team-id] + (db/exec! conn [sql:valid-requests-email team-id])) + +(def ^:private xf:map-email + (map :email)) + +(defn- create-team-invitations + [{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}] + (let [join-requests (into #{} xf:map-email + (get-valid-requests-email conn (:id team))) + team-members (into #{} xf:map-email + (teams/get-team-members conn (:id team))) + + invitations (into #{} + (comp + ;; We don't re-send inviation to + ;; already existing members + (remove team-members) + ;; We don't send invitations to + ;; join-requested members + (remove join-requests) + (map (fn [email] (assoc params :email email))) + (keep (partial create-invitation cfg))) + emails)] + + ;; For requested invitations, do not send invitation emails, add + ;; the user directly to the team + (->> (filter join-requests emails) + (run! (partial add-user-to-team conn profile team role))) + + invitations)) + +(def ^:private schema:create-team-invitations + [:map {:title "create-team-invitations"} + [:team-id ::sm/uuid] + [:role ::types.team/role] + [:emails [::sm/set ::sm/email]]]) + +(def ^:private max-invitations-by-request-threshold + "The number of invitations can be sent in a single rpc request" + 25) + +(sv/defmethod ::create-team-invitations + "A rpc call that allow to send a single or multiple invitations to + join the team." + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:create-team-invitations} + [cfg {:keys [::rpc/profile-id team-id emails] :as params}] + (let [perms (teams/get-permissions cfg profile-id team-id) + profile (db/get-by-id cfg :profile profile-id) + emails (into #{} (map profile/clean-email) emails)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id team-id) + (assoc ::quotes/incr (count emails)) + (quotes/check! {::quotes/id ::quotes/invitations-per-team} + {::quotes/id ::quotes/profiles-per-team})) + + ;; Check if the current profile is allowed to send emails + (teams/check-profile-muted cfg profile) + + (let [team (db/get-by-id cfg :team team-id) + ;; NOTE: Is important pass RPC method params down to the + ;; `create-team-invitations` because it uses the implicit + ;; RPC properties from params for fill necessary data on + ;; emiting an entry to the audit-log + invitations (db/tx-run! cfg create-team-invitations + (-> params + (assoc :profile profile) + (assoc :team team) + (assoc :emails emails)))] + + (with-meta {:total (count invitations) + :invitations invitations} + {::audit/props {:invitations (count invitations)}})))) + +;; --- Mutation: Create Team & Invite Members + +(def ^:private schema:create-team-with-invitations + [:map {:title "create-team-with-invitations"} + [:name [:string {:max 250}]] + [:features {:optional true} ::cfeat/features] + [:id {:optional true} ::sm/uuid] + [:emails [::sm/set ::sm/email]] + [:role ::types.team/role]]) + +(sv/defmethod ::create-team-with-invitations + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:create-team-with-invitations + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}] + (let [features (-> (cfeat/get-enabled-features cf/flags) + (cfeat/check-client-features! (:features params))) + + params (-> params + (assoc :profile-id profile-id) + (assoc :features features)) + + team (teams/create-team cfg params) + emails (into #{} (map profile/clean-email) emails)] + + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id (:id team)) + (assoc ::quotes/incr (count emails)) + (quotes/check! {::quotes/id ::quotes/teams-per-profile} + {::quotes/id ::quotes/invitations-per-team} + {::quotes/id ::quotes/profiles-per-team})) + + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + + (let [props {:name name :features features} + event (-> (audit/event-from-rpc-params params) + (assoc ::audit/name "create-team") + (assoc ::audit/props props))] + (audit/submit! cfg event)) + + ;; Create invitations for all provided emails. + (let [profile (db/get-by-id conn :profile profile-id) + params (-> params + (assoc :team team) + (assoc :profile profile) + (assoc :role role)) + invitations (->> emails + (map (fn [email] (assoc params :email email))) + (map (partial create-invitation cfg)))] + + (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) + +;; --- Query: get-team-invitation-token + +(def ^:private schema:get-team-invitation-token + [:map {:title "get-team-invitation-token"} + [:team-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::get-team-invitation-token + {::doc/added "1.17" + ::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) + (let [email (profile/clean-email email) + invit (-> (db/get pool :team-invitation + {:team-id team-id + :email-to email}) + (update :role keyword)) + + member (profile/get-profile-by-email pool (:email-to invit)) + token (create-invitation-token cfg {:team-id (:team-id invit) + :profile-id profile-id + :valid-until (:valid-until invit) + :role (:role invit) + :member-id (:id member) + :member-email (or (:email member) + (profile/clean-email (:email-to invit)))})] + {:token token})) + +;; --- Mutation: Update invitation role + +(def ^:private schema:update-team-invitation-role + [:map {:title "update-team-invitation-role"} + [:team-id ::sm/uuid] + [:email ::sm/email] + [:role ::types.team/role]]) + +(sv/defmethod ::update-team-invitation-role + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:update-team-invitation-role} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}] + (db/with-atomic [conn pool] + (let [perms (teams/get-permissions conn profile-id team-id)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (db/update! conn :team-invitation + {:role (name role) :updated-at (dt/now)} + {:team-id team-id :email-to (profile/clean-email email)}) + + nil))) + +;; --- Mutation: Delete invitation + +(def ^:private schema:delete-team-invition + [:map {:title "delete-team-invitation"} + [:team-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::delete-team-invitation + {::doc/added "1.17" + ::sm/params schema:delete-team-invition} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] + (db/with-atomic [conn pool] + (let [perms (teams/get-permissions conn profile-id team-id)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (let [invitation (db/delete! conn :team-invitation + {:team-id team-id + :email-to (profile/clean-email email)} + {::db/return-keys true})] + (rph/wrap nil {::audit/props {:invitation-id (:id invitation)}}))))) + + +;; --- Mutation: Request Team Invitation + +(def ^:private sql:get-team-owner + "SELECT p.* + FROM profile AS p + JOIN team_profile_rel AS tpr ON (tpr.profile_id = p.id) + WHERE tpr.team_id = ? + AND tpr.is_owner IS TRUE") + +(defn- get-team-owner + "Return a complete profile of the team owner" + [conn team-id] + (->> (db/exec! conn [sql:get-team-owner team-id]) + (remove db/is-row-deleted?) + (map profile/decode-row) + (first))) + +(defn- check-existing-team-access-request + "Checks if an existing team access request is still valid" + [conn team-id profile-id] + (when-let [request (db/get* conn :team-access-request + {:team-id team-id + :requester-id profile-id})] + (when (dt/is-after? (:valid-until request) (dt/now)) + (ex/raise :type :validation + :code :request-already-sent + :hint "you have already made a request to join this team less than 24 hours ago")))) + +(def ^:private sql:upsert-team-access-request + "INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (team_id, requester_id) + DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now() + RETURNING *") + +(defn- upsert-team-access-request + "Create or update team access request for provided team and profile-id" + [conn team-id requester-id] + (check-existing-team-access-request conn team-id requester-id) + (let [valid-until (dt/in-future {:hours 24}) + auto-join-until (dt/in-future {:days 7}) + request-id (uuid/next)] + (db/exec-one! conn [sql:upsert-team-access-request + request-id team-id requester-id + valid-until auto-join-until + valid-until auto-join-until]))) + +(defn- get-file-for-team-access-request + "A specific method for obtain a file with name and page-id used for + team request access procediment" + [cfg file-id] + (let [file (files/get-file cfg file-id :migrate? false)] + (-> file + (dissoc :data) + (dissoc :deleted-at) + (assoc :page-id (-> file :data :pages first))))) + +(def ^:private schema:create-team-access-request + [:and + [:map {:title "create-team-access-request"} + [:file-id {:optional true} ::sm/uuid] + [:team-id {:optional true} ::sm/uuid] + [:is-viewer {:optional true} ::sm/boolean]] + + [:fn (fn [params] + (or (contains? params :file-id) + (contains? params :team-id)))]]) + +(sv/defmethod ::create-team-access-request + "A rpc call that allow to request for an invitations to join the team." + {::doc/added "2.2.0" + ::doc/module :teams + ::sm/params schema:create-team-access-request + ::db/transaction true} + [{:keys [::db/conn] :as cfg} + {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}] + + (let [requester (profile/get-profile conn profile-id) + team (if team-id + (->> (db/get-by-id conn :team team-id) + (teams/decode-row)) + (teams/get-team-for-file conn file-id)) + + team-id (:id team) + + team-owner (get-team-owner conn team-id) + + file (when (some? file-id) + (get-file-for-team-access-request cfg file-id)) + + request (upsert-team-access-request conn team-id profile-id)] + + ;; FIXME missing quotes + + (teams/check-profile-muted conn requester) + (teams/check-email-bounce conn (:email team-owner) false) + (teams/check-email-spam conn (:email team-owner) true) + + (let [factory (cond + (and (some? file) (:is-default team) is-viewer) + eml/request-file-access-yourpenpot-view + + (and (some? file) (:is-default team)) + eml/request-file-access-yourpenpot + + (some? file) + eml/request-file-access + + :else + eml/request-team-access)] + + (eml/send! {::eml/conn conn + ::eml/factory factory + :public-uri (cf/get :public-uri) + :to (:email team-owner) + :requested-by (:fullname requester) + :requested-by-email (:email requester) + :team-name (:name team) + :team-id team-id + :file-name (:name file) + :file-id file-id + :page-id (:page-id file)})) + + (with-meta {:request request} + {::audit/props {:request 1}}))) + + diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 5dcd2a3e5..d725ceda2 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -8,6 +8,7 @@ (:require [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.types.team :as types.team] [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] @@ -16,7 +17,6 @@ [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] - [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] @@ -92,7 +92,7 @@ params (merge {:team-id team-id :profile-id (:id member)} - (teams/role->params role))] + (get types.team/permissions-for-role role))] ;; Do not allow blocked users accept invitations. (when (:is-blocked member) @@ -128,7 +128,7 @@ [:iss :keyword] [:exp ::dt/instant] [:profile-id ::sm/uuid] - [:role teams/schema:role] + [:role ::types.team/role] [:team-id ::sm/uuid] [:member-email ::sm/email] [:member-id {:optional true} ::sm/uuid]]) diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj index c1526bfb4..c451ef742 100644 --- a/backend/src/app/util/time.clj +++ b/backend/src/app/util/time.clj @@ -158,6 +158,7 @@ :iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))))) (defn is-after? + "Analgous to: da > db" [da db] (.isAfter ^Instant da ^Instant db)) diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index aed6f2039..f71c73f50 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -4,7 +4,9 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.common.types.team) +(ns app.common.types.team + (:require + [app.common.schema :as sm])) (def valid-roles #{:owner :admin :editor :viewer}) @@ -15,3 +17,4 @@ :admin {:can-edit true :is-admin true :is-owner false} :owner {:can-edit true :is-admin true :is-owner true}}) +(sm/register! ::role [::sm/one-of valid-roles])