;; 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 SUBSIDIARY SL (ns app.nitrate "Module that make calls to the external nitrate aplication" (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.json :as json] [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] [app.common.types.organization :as cto :refer [schema:nitrate-sso]] [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] [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])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- join-path-segments "Build a single relative path from Nitrate URI segments, normalizing slashes." [segments] (let [path (->> segments (map str) (str/join "/"))] (->> (str/split path #"/") (remove str/blank?) (str/join "/")))) (defn- join-base-uri "Join path segments to a base URI." [base-uri & segments] (u/join (u/ensure-path-slash base-uri) (join-path-segments segments))) (defn- generate-nitrate-uri "Joins relative path segments to the Nitrate backend URI. Segments must not start with `/`" [& segments] (apply join-base-uri (cf/get :admin-console-uri) segments)) (defn- generate-public-uri "Joins relative path segments to the public backend URI. Segments must not start with `/`" [& segments] (apply join-base-uri (cf/get :public-uri) segments)) (defn- request-builder [cfg method uri shared-key profile-id request-params] (fn [] (http/req cfg (cond-> {:method method :headers {"content-type" "application/json" "accept" "application/json" "x-shared-key" shared-key "x-profile-id" (str profile-id)} :uri uri :version :http1.1} (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))) {:skip-ssrf-check? true}))) (defn- with-retries [handler max-retries] (fn [] (loop [attempt 1] (let [result (try (handler) (catch Exception e (if (< attempt max-retries) ::retry (do ;; TODO Error handling (l/error :hint "request fail after multiple retries" :cause e) nil))))] (if (= result ::retry) (recur (inc attempt)) result))))) (defn- with-validate [handler uri schema & {:keys [throw-on-error?]}] (fn [] (let [response (handler) status (:status response)] (cond (nil? status) (do (l/error :hint "couldn't do the nitrate request, it is probably down" :uri uri) (ex/raise :type :nitrate-unavailable :hint (str "nitrate is unreachable at " uri))) (>= status 500) ;; Nitrate is up enough to answer (or the proxy is) but the ;; service itself is failing; treat as unavailable so callers ;; surface the static error page. (do (l/error :hint "nitrate request failed with server error status" :uri uri :status status :body (:body response)) (ex/raise :type :nitrate-unavailable :status status :hint (str "nitrate is unavailable, HTTP " status " at " uri))) (>= status 400) ;; For client error status codes (4xx), fail immediately without validation (do (when (not= status 404) ;; Don't need to log 404 (l/error :hint "nitrate request failed with error status" :uri uri :status status :body (:body response))) (if throw-on-error? (ex/raise :type :nitrate-http-error :status status :hint (str "nitrate HTTP " status " at " uri)) nil)) (= status 204) ;; 204 doesn't return any body nil :else ;; For success status codes, validate the response (let [coercer-http (sm/coercer schema :type :validation :hint (str "invalid data received calling " uri)) data (-> response :body (json/decode :key-fn json/read-kebab-key))] (try (coercer-http data) (catch Exception e ;; TODO Error handling (l/error :hint "error validating json response" :cause e) nil))))))) (defn- request-to-nitrate [cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}] (let [shared-key (-> cfg ::setup/shared-keys :admin-console) full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params) (with-retries 3) (with-validate uri schema :throw-on-error? throw-on-error?))] (full-http-call))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn call [cfg method params] (when (contains? cf/flags :admin-console) (let [uri (cf/get :admin-console-uri)] (if (nil? uri) (ex/raise :type :nitrate-not-configured :hint "admin console is not configured; refer to the documentation to complete the setup") (let [client (get cfg ::client) method (get client method)] (method params)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private schema:organization-summary [:map [:id ::sm/uuid] [:name ::sm/text] [:owner-id ::sm/uuid] [:logo-id {:optional true} [:maybe ::sm/uuid]] [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] [:sso-active {:optional true} [:maybe ::sm/boolean]] [:teams [:vector [:map [:id ::sm/uuid] [:is-your-penpot :boolean]]]]]) ;; TODO Unify with schemas on backend/src/app/http/management.clj (def ^:private schema:timestamp (sm/type-schema {:type ::timestamp :pred ct/inst? :type-properties {:title "inst" :description "The same as :app.common.time/inst but encodes to epoch" :error/message "should be an instant" :gen/gen (->> (sg/small-int) (sg/fmap (fn [v] (ct/inst v)))) :decode/string ct/inst :encode/string inst-ms :decode/json ct/inst :encode/json inst-ms}})) (def ^:private schema:profile-organization [:map [:is-member :boolean] [:organization-id {:optional true} [:maybe ::sm/uuid]] [:default-team-id {:optional true} [:maybe ::sm/uuid]] [:created-at {:optional true} [:maybe schema:timestamp]]]) (def ^:private schema:subscription [:map {:title "Subscription"} [:id ::sm/text] [:customer-id ::sm/text] [:type [:enum "unlimited" "professional" "enterprise" "nitrate"]] [:status [:enum "active" "canceled" "incomplete" "incomplete_expired" "past_due" "paused" "trialing" "unpaid"]] [:billing-period [:enum "month" "day" "week" "year"]] [:manual :boolean] [:quantity :int] [:description [:maybe ::sm/text]] [:created-at schema:timestamp] [:start-date [:maybe schema:timestamp]] [:ended-at [:maybe schema:timestamp]] [:trial-end [:maybe schema:timestamp]] [:trial-start [:maybe schema:timestamp]] [:cancel-at [:maybe schema:timestamp]] [:canceled-at [:maybe schema:timestamp]] [:current-period-end [:maybe schema:timestamp]] [:current-period-start [:maybe schema:timestamp]] [:cancel-at-period-end :boolean] [:cancellation-details [:map {:title "CancellationDetails"} [:comment [:maybe ::sm/text]] [:reason [:maybe ::sm/text]] [:feedback [:maybe [:enum "customer_service" "low_quality" "missing_features" "other" "switched_service" "too_complex" "too_expensive" "unused"]]]]]]) (def ^:private schema:connectivity [:map [:licenses ::sm/boolean]]) (defn- get-team-organization-api [cfg {:keys [team-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" team-id) cto/schema:team-with-organization params)) (defn- get-teams-organizations-api [cfg {:keys [team-ids] :as params}] (let [params (assoc params :request-params {:team-ids team-ids})] (request-to-nitrate cfg :post (generate-nitrate-uri "api/teams/organizations") [:vector cto/schema:team-with-organization] params))) (defn- get-organization-membership-api [cfg {:keys [profile-id organization-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" organization-id "members/" profile-id) schema:profile-organization params)) (defn- get-organization-membership-by-team-api [cfg {:keys [profile-id team-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" team-id "users/" profile-id) schema:profile-organization params)) (defn- get-organization-summary-api [cfg {:keys [organization-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" organization-id "summary") schema:organization-summary params)) (defn- get-owned-organizations-api [cfg {:keys [profile-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/users/" profile-id "owned-organizations") [:vector schema:organization-summary] params)) (def ^:private schema:organization-summary-counts [:map [:id ::sm/uuid] [:name ::sm/text] [:slug ::sm/text] [:team-count ::sm/int] [:member-count ::sm/int] [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] [:logo-id {:optional true} [:maybe ::sm/uuid]]]) (defn- get-owned-organizations-summary-api [cfg {:keys [profile-id] :as params}] (let [organizations (request-to-nitrate cfg :get (generate-nitrate-uri "api/users/" profile-id "owned-organizations-summary") [:vector schema:organization-summary-counts] params)] (mapv (fn [organization] (if-let [logo-id (:logo-id organization)] (assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id)) organization)) organizations))) (defn- cleanup-deleted-penpot-user-api [cfg {:keys [profile-id] :as params}] (request-to-nitrate cfg :post (generate-nitrate-uri "api/users/" profile-id "cleanup-after-deletion") nil params)) (defn- set-team-organization-api [cfg {:keys [organization-id team-id is-default] :as params}] (let [params (assoc params :request-params {:team-id team-id :is-your-penpot (true? is-default)}) team (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" organization-id "add-team") cto/schema:team-with-organization params) custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] (generate-public-uri "assets/by-id/" logo-id))] (cond-> team custom-photo (assoc-in [:organization :custom-photo] custom-photo)))) (defn- add-profile-to-organization-api [cfg {:keys [profile-id organization-id team-id email] :as params}] (let [request-params (cond-> {:user-id profile-id :team-id team-id} (some? email) (assoc :email email)) params (assoc params :request-params request-params)] (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" organization-id "add-user") schema:profile-organization params))) (defn- remove-profile-from-organization-api [cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}] (let [request-params (cond-> {:user-id profile-id} (some? user-who-delete-member) (assoc :user-who-delete-member user-who-delete-member) (some? deleted-by-role) (assoc :deleted-by-role deleted-by-role)) params (assoc params :request-params request-params)] (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" organization-id "remove-user") nil params))) (defn- remove-team-from-organization-api [cfg {:keys [team-id organization-id] :as params}] (let [params (assoc params :request-params {:team-id team-id})] (request-to-nitrate cfg :post (generate-nitrate-uri "api/organizations/" organization-id "remove-team") nil params))) (defn- delete-team-api [cfg {:keys [team-id] :as params}] (request-to-nitrate cfg :delete (generate-nitrate-uri "api/teams/" team-id) nil params)) (defn- get-subscription-api [cfg {:keys [profile-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/subscriptions/" profile-id) schema:subscription params)) (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} schema:timestamp]]]) (defn- get-subscription-warning-api [cfg {:keys [penpot-id profile-id] :as params}] (let [penpot-id (or penpot-id profile-id)] (request-to-nitrate cfg :get (generate-nitrate-uri "api/subscription-warning/" penpot-id) schema:subscription-warning params))) (defn- get-connectivity-api [cfg params] (request-to-nitrate cfg :get (generate-nitrate-uri "api/connectivity") schema:connectivity params)) (def ^:private schema:identity [:map [:nitrate-id ::sm/text] [:public-key ::sm/text]]) (defn- get-identity-api [cfg params] (request-to-nitrate cfg :get (generate-nitrate-uri "api/identity") schema:identity params)) (def ^:private schema:redeem-result [:map [:cancel-at [:maybe schema:timestamp]]]) (defn- get-organization-permissions-api [cfg {:keys [organization-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" organization-id "permissions") [:map [:organization-id ::sm/uuid] [:owner-id ::sm/uuid] [:permissions [:map-of :keyword :string]]] params)) (defn- get-organization-sso-api "Fetches the SSO configuration for an organization from Nitrate." [cfg {:keys [organization-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" organization-id "sso") schema:nitrate-sso params)) (defn- get-organization-sso-by-team-api [cfg {:keys [team-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" team-id "sso") schema:nitrate-sso params)) (defn- get-organization-members-api [cfg {:keys [organization-id] :as params}] (request-to-nitrate cfg :get (generate-nitrate-uri "api/organizations/" organization-id "members-list") [:vector ::sm/uuid] params)) (defn- redeem-activation-code-api [cfg params] (request-to-nitrate cfg :post (generate-nitrate-uri "api/activation-codes/redeem") schema:redeem-result (assoc params :throw-on-error? true))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defmethod ig/init-key ::client [_ cfg] (when (contains? cf/flags :admin-console) (when (nil? (cf/get :admin-console-uri)) (l/warn :hint "admin console is not configured; nitrate calls will fail until the setup is complete")) {:get-team-organization (partial get-team-organization-api cfg) :get-teams-organizations (partial get-teams-organizations-api cfg) :set-team-organization (partial set-team-organization-api cfg) :get-organization-membership (partial get-organization-membership-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) :get-organization-summary (partial get-organization-summary-api cfg) :get-owned-organizations (partial get-owned-organizations-api cfg) :get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg) :get-organization-members (partial get-organization-members-api cfg) :cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg) :add-profile-to-organization (partial add-profile-to-organization-api cfg) :remove-profile-from-organization (partial remove-profile-from-organization-api cfg) :get-organization-permissions (partial get-organization-permissions-api cfg) :get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg) :get-organization-sso (partial get-organization-sso-api cfg) :delete-team (partial delete-team-api cfg) :remove-team-from-organization (partial remove-team-from-organization-api cfg) :get-subscription (partial get-subscription-api cfg) :get-subscription-warning (partial get-subscription-warning-api cfg) :connectivity (partial get-connectivity-api cfg) :get-identity (partial get-identity-api cfg) :redeem-activation-code (partial redeem-activation-code-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defonce ^:private team-organization-owner-cache ;; Short TTL: permission checks run on the read path, so we avoid an ;; HTTP call to nitrate per check. The organization 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-organization-owner) (defn- get-team-organization-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-organization-owner-cache team-id (fn [team-id] (let [team-with-organization (call cfg :get-team-organization {:team-id team-id})] (or (get-in team-with-organization [: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 organization 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 :admin-console) (nitrate-client? cfg) (some? team-id) (some? profile-id)) (= profile-id (get-team-organization-owner-id cfg team-id))))) (defn sso-session-authorized? "Fetches the organization-SSO config for the given organization or team and checks whether the HTTP request has a valid session entry for it. Returns a map with :authorized and :sso keys." [cfg organization-id team-id request] (let [session (session/get-session request) sso (if organization-id (call cfg :get-organization-sso {:organization-id organization-id}) (call cfg :get-organization-sso-by-team {:team-id team-id}))] (if-not (:active sso) {:authorized true :sso sso} (if-not (str/blank? (:issuer sso)) (let [props (:props session) sso-map (get props :sso {}) organization-id (:organization-id sso) exp (get sso-map organization-id) now (ct/now) authorized (and (ct/inst? exp) (ct/is-after? exp now))] {:authorized authorized :sso sso}) {:authorized false :sso sso})))) (defn add-nitrate-licence-to-profile "Enriches a profile map with subscription information from Nitrate. Adds a :subscription field containing the user's license details. Returns the original profile unchanged if the request fails for a reason other than Nitrate being unreachable or unconfigured. When Nitrate is unreachable or unconfigured the exception propagates so the request is rejected." [cfg profile] (try (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] (assoc profile :subscription subscription)) (catch Throwable cause (if (contains? #{:nitrate-unavailable :nitrate-not-configured} (-> cause ex-data :type)) (throw cause) (do (l/error :hint "failed to get nitrate licence" :profile-id (:id profile) :cause cause) profile))))) (defn- apply-organization-info-to-team [team team-with-organization] (let [organization (:organization team-with-organization)] (if (some? organization) (-> (cto/apply-organization team (assoc organization :custom-photo (when-let [logo-id (:logo-id organization)] (generate-public-uri "assets/by-id/" logo-id)))) (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) team))) (defn add-organization-info-to-team "Enriches a team map with organization information from Nitrate. Returns the original team unchanged if the request fails or organization data is nil. Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try (let [params (assoc (or params {}) :team-id (:id team)) team-with-organization (call cfg :get-team-organization params)] (apply-organization-info-to-team team team-with-organization)) (catch Throwable cause (if (= :nitrate-unavailable (-> cause ex-data :type)) (throw cause) (do (l/error :hint "failed to get team organization info" :team-id (:id team) :cause cause) team))))) (defn add-organization-info-to-teams "Enriches teams with organization information using one batched Nitrate request. Teams absent from the Nitrate response are returned unchanged. Rejects the request when Nitrate does not return a valid batch response." [cfg teams params] (let [request-params (assoc (or params {}) :team-ids (mapv :id teams)) teams-with-organization (call cfg :get-teams-organizations request-params)] (when (nil? teams-with-organization) (ex/raise :type :nitrate-unavailable :hint "nitrate did not return a valid teams organization response")) (let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)] (mapv (fn [{:keys [id] :as team}] (if-let [team-with-organization (get organizations-by-team id)] (apply-organization-info-to-team team team-with-organization) team)) teams)))) (defn set-team-organization "Associates a team with an organization in Nitrate. Requires organization-id and is-default in params. Throws an exception if the request fails." [cfg team params] (let [params (assoc (or params {}) :team-id (:id team) :organization-id (:organization-id params) :is-default (:is-default params)) result (call cfg :set-team-organization params)] (when (nil? result) (ex/raise :type :internal :code :failed-to-set-team-organization :context {:team-id (:id team) :organization-id (:organization-id params)})) team)) (defn assert-membership "Verifies that the user is a member of the organization. Raises an exception if the organization doesn't exist or the user is not a member." [cfg profile-id organization-id] (let [membership (call cfg :get-organization-membership {:profile-id profile-id :organization-id organization-id})] (when-not (:organization-id membership) (ex/raise :type :validation :code :organization-does-not-exist)) (when-not (:is-member membership) (ex/raise :type :validation :code :user-doesnt-belong-organization))))