mirror of
https://github.com/penpot/penpot.git
synced 2026-08-10 23:08:41 +00:00
511 lines
19 KiB
Clojure
511 lines
19 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 Sucursal en España SL
|
|
|
|
(ns app.rpc
|
|
(:require
|
|
[app.auth.ldap :as-alias ldap]
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.logging :as l]
|
|
[app.common.schema :as sm]
|
|
[app.common.spec :as us]
|
|
[app.common.time :as ct]
|
|
[app.common.uri :as u]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.http :as-alias http]
|
|
[app.http.access-token :as actoken]
|
|
[app.http.client :as-alias http.client]
|
|
[app.http.middleware :as mw]
|
|
[app.http.security :as sec]
|
|
[app.http.session :as session]
|
|
[app.loggers.audit :as audit]
|
|
[app.main :as-alias main]
|
|
[app.metrics :as mtx]
|
|
[app.msgbus :as-alias mbus]
|
|
[app.nitrate :as nitrate]
|
|
[app.redis :as rds]
|
|
[app.rpc.climit :as climit]
|
|
[app.rpc.commands.teams :as teams]
|
|
[app.rpc.cond :as cond]
|
|
[app.rpc.doc :as doc]
|
|
[app.rpc.helpers :as rph]
|
|
[app.rpc.retry :as retry]
|
|
[app.rpc.rlimit :as rlimit]
|
|
[app.setup :as-alias setup]
|
|
[app.storage :as-alias sto]
|
|
[app.util.cache :as cache]
|
|
[app.util.inet :as inet]
|
|
[app.util.services :as sv]
|
|
[clojure.set :as set]
|
|
[clojure.spec.alpha :as s]
|
|
[cuerdas.core :as str]
|
|
[integrant.core :as ig]
|
|
[yetti.request :as yreq]
|
|
[yetti.response :as yres]))
|
|
|
|
(s/def ::profile-id ::us/uuid)
|
|
|
|
(defn- default-handler
|
|
[_]
|
|
(ex/raise :type :not-found))
|
|
|
|
(defn- handle-response-transformation
|
|
[response request mdata]
|
|
(reduce (fn [response transform-fn]
|
|
(transform-fn request response))
|
|
response
|
|
(::response-transform-fns mdata)))
|
|
|
|
(defn- handle-before-comple-hook
|
|
[response mdata]
|
|
(doseq [hook-fn (::before-complete-fns mdata)]
|
|
(ex/ignoring (hook-fn)))
|
|
response)
|
|
|
|
(defn- handle-response
|
|
[request result]
|
|
(let [mdata (meta result)
|
|
response (if (fn? result)
|
|
(result request)
|
|
(let [result (rph/unwrap result)
|
|
status (or (::http/status mdata)
|
|
(if (nil? result)
|
|
204
|
|
200))
|
|
|
|
headers (::http/headers mdata {})
|
|
headers (cond-> headers
|
|
(and (yres/stream-body? result)
|
|
(not (contains? headers "content-type")))
|
|
(assoc "content-type" "application/octet-stream"))]
|
|
|
|
{::yres/status status
|
|
::yres/headers headers
|
|
::yres/body result}))]
|
|
|
|
(-> response
|
|
(handle-response-transformation request mdata)
|
|
(handle-before-comple-hook mdata))))
|
|
|
|
(defn make-rpc-handler
|
|
"Ring handler that dispatches cmd requests and convert between
|
|
internal async flow into ring async flow."
|
|
[methods]
|
|
(let [methods (update-vals methods peek)]
|
|
(fn [{:keys [path-params method] :as request}]
|
|
(let [handler-name (:method-name path-params)
|
|
etag (yreq/get-header request "if-none-match")
|
|
session-id (yreq/get-header request "x-session-id")
|
|
|
|
key-id (get request ::http/auth-key-id)
|
|
session-pid (::session/profile-id request)
|
|
token-pid (::actoken/profile-id request)
|
|
profile-id (or session-pid
|
|
token-pid
|
|
(if key-id uuid/zero nil))
|
|
|
|
ip-addr (inet/parse-request request)
|
|
|
|
data (-> {}
|
|
(assoc ::handler-name handler-name)
|
|
(assoc ::ip-addr ip-addr)
|
|
(assoc ::request-at (ct/now))
|
|
(assoc ::request-id (uuid/next))
|
|
(assoc ::session-id (some-> session-id uuid/parse*))
|
|
(assoc ::cond/key etag)
|
|
(cond-> (uuid? profile-id)
|
|
(assoc ::profile-id profile-id))
|
|
(cond-> (uuid? session-pid)
|
|
(assoc ::auth-type :session))
|
|
(cond-> (and (not (uuid? session-pid))
|
|
(uuid? token-pid))
|
|
(-> (assoc ::auth-type :token)
|
|
(assoc ::token-perms (set (::actoken/perms request #{})))))
|
|
(cond-> key-id
|
|
(assoc ::auth-key-id key-id)))
|
|
|
|
data (with-meta data
|
|
{::http/request request})
|
|
|
|
handler-fn (get methods (keyword handler-name) default-handler)]
|
|
|
|
(when (and (or (= method :get)
|
|
(= method :head))
|
|
(not (str/starts-with? handler-name "get-")))
|
|
(ex/raise :type :restriction
|
|
:code :method-not-allowed
|
|
:hint "method not allowed for this request"))
|
|
|
|
;; FIXME: why we have this cond enabled here, we need to move it outside this handler
|
|
(binding [cond/*enabled* true]
|
|
(let [response (handler-fn data)]
|
|
(handle-response request response)))))))
|
|
|
|
(defn- wrap-metrics
|
|
"Wrap service method with metrics measurement."
|
|
[{:keys [::mtx/metrics ::metrics-id]} f mdata]
|
|
(let [labels (into-array String [(::sv/name mdata)])]
|
|
(fn [cfg params]
|
|
(let [tp (ct/tpoint)]
|
|
(try
|
|
(f cfg params)
|
|
(finally
|
|
(mtx/run! metrics
|
|
:id metrics-id
|
|
:val (inst-ms (tp))
|
|
:labels labels)))))))
|
|
|
|
(defn- wrap-authentication
|
|
[_ f mdata]
|
|
(let [required-auth? (::auth mdata true)
|
|
required-auth-type (::auth-type mdata)
|
|
required-perms (into #{} (::perms mdata))]
|
|
(fn [cfg params]
|
|
(let [profile-id (::profile-id params)
|
|
auth-type (::auth-type params)
|
|
token-perms (set (::token-perms params #{}))]
|
|
(cond
|
|
(and required-auth? (not (uuid? profile-id)))
|
|
(ex/raise :type :authentication
|
|
:code :authentication-required
|
|
:hint "authentication required for this endpoint")
|
|
|
|
(and (= required-auth-type :token)
|
|
(not= auth-type :token))
|
|
(ex/raise :type :authorization
|
|
:code :token-auth-required
|
|
:hint "access token authentication required for this endpoint")
|
|
|
|
(and (seq required-perms)
|
|
(not= auth-type :token))
|
|
(ex/raise :type :authorization
|
|
:code :token-auth-required
|
|
:hint "access token authentication required for this endpoint")
|
|
|
|
(and (seq required-perms)
|
|
(not (set/subset? required-perms token-perms)))
|
|
(ex/raise :type :authorization
|
|
:code :missing-perms
|
|
:hint "missing required permissions"
|
|
:required required-perms)
|
|
|
|
:else
|
|
(f cfg params))))))
|
|
|
|
(defn- wrap-db-transaction
|
|
[_ f mdata]
|
|
(if (::db/transaction mdata)
|
|
(fn [cfg params]
|
|
(db/tx-run! cfg f params))
|
|
f))
|
|
|
|
(defn- wrap-audit
|
|
[_ f mdata]
|
|
(if (or (contains? cf/flags :webhooks)
|
|
(contains? cf/flags :audit-log)
|
|
(contains? cf/flags :telemetry))
|
|
(if-not (::audit/skip mdata)
|
|
(fn [cfg params]
|
|
(let [result (f cfg params)]
|
|
(->> (audit/prepare-rpc-event cfg mdata params result)
|
|
(audit/submit cfg))
|
|
result))
|
|
f)
|
|
f))
|
|
|
|
(defn- wrap-params-validation
|
|
[_ f mdata]
|
|
(if-let [schema (::sm/params mdata)]
|
|
(let [validate (sm/validator schema)
|
|
explain (sm/explainer schema)
|
|
decode (sm/decoder schema sm/json-transformer)
|
|
encode (sm/encoder schema sm/json-transformer)]
|
|
(fn [cfg params]
|
|
(let [request-params (-> params meta ::http/request :params decode)]
|
|
(if (validate request-params)
|
|
(let [result (f cfg (merge params request-params))]
|
|
(if (instance? clojure.lang.IObj result)
|
|
(vary-meta result assoc :encode/json encode)
|
|
result))
|
|
(ex/raise :type :validation
|
|
:code :params-validation
|
|
::sm/explain (explain request-params))))))
|
|
f))
|
|
|
|
|
|
(defonce ^:private organization-sso-auth-cache
|
|
(cache/create :expire "15m" :max-size 1024))
|
|
|
|
(defn invalidate-organization-sso-cache-by-organization!
|
|
"Invalidates all organization-SSO authorization cache entries for the given organization-id."
|
|
[organization-id]
|
|
(cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id)))
|
|
|
|
(defn- wrap-nitrate-sso
|
|
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
|
|
|
Resolves the organization/team context from request params using priority order:
|
|
1. Explicit :organization-id param
|
|
2. Explicit :team-id param
|
|
3. Explicit :project-id param -> lookup project.team_id
|
|
4. Explicit :file-id param -> lookup file's team via join
|
|
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
|
|
|
Once the context is resolved, checks if the user is authorized within that organization's
|
|
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
|
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
|
|
|
Only activates when:
|
|
- Nitrate flag is enabled
|
|
- Endpoint requires authentication (::auth true by default)
|
|
- Endpoint is not marked with ::nitrate/organization-sso false
|
|
|
|
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
|
[_ f mdata]
|
|
(if (and (contains? cf/flags :admin-console)
|
|
(::auth mdata true) ;; only for endpoints that needs auth
|
|
(::nitrate/sso mdata true))
|
|
(fn [cfg params]
|
|
;; Resolve team/project/file from explicit keys or from :id via metadata
|
|
(let [profile-id (::profile-id params)
|
|
organization-id (uuid/coerce (:organization-id params))
|
|
id-type (::id-type mdata)
|
|
id (uuid/coerce (:id params))
|
|
team-id (or (uuid/coerce (:team-id params))
|
|
(when (= id-type :team) id))
|
|
project-id (or (uuid/coerce (:project-id params))
|
|
(when (= id-type :project) id))
|
|
file-id (or (uuid/coerce (:file-id params))
|
|
(when (= id-type :file) id))]
|
|
(if (and profile-id
|
|
(or organization-id team-id project-id file-id))
|
|
(let [cache-ref (or organization-id team-id project-id file-id)
|
|
|
|
cache-key [profile-id cache-ref]
|
|
cached (cache/get organization-sso-auth-cache cache-key)
|
|
result (if (some? cached)
|
|
cached
|
|
(let [team-id (when-not organization-id
|
|
(or team-id
|
|
(when project-id
|
|
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
|
(:id (teams/get-team-for-file cfg file-id))))
|
|
request (-> (meta params) (get ::http/request))
|
|
{:keys [authorized sso]} (if organization-id
|
|
(nitrate/sso-session-authorized? cfg organization-id nil request)
|
|
(nitrate/sso-session-authorized? cfg nil team-id request))
|
|
entry {:authorized authorized
|
|
:organization-id (:organization-id sso)}]
|
|
(when authorized
|
|
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
|
entry))]
|
|
(if (:authorized result)
|
|
(f cfg params)
|
|
(ex/raise :type :authentication
|
|
:code :nitrate-sso-required
|
|
:hint "organization SSO authentication required")))
|
|
(f cfg params))))
|
|
f))
|
|
|
|
(defn- wrap
|
|
[cfg f mdata]
|
|
(as-> f $
|
|
(wrap-db-transaction cfg $ mdata)
|
|
(cond/wrap cfg $ mdata)
|
|
(retry/wrap-retry cfg $ mdata)
|
|
(climit/wrap cfg $ mdata)
|
|
(wrap-metrics cfg $ mdata)
|
|
(rlimit/wrap cfg $ mdata)
|
|
(wrap-audit cfg $ mdata)
|
|
(wrap-nitrate-sso cfg $ mdata)
|
|
(wrap-params-validation cfg $ mdata)
|
|
(wrap-authentication cfg $ mdata)))
|
|
|
|
(defn- wrap-management
|
|
[cfg f mdata]
|
|
(as-> f $
|
|
(wrap-db-transaction cfg $ mdata)
|
|
(retry/wrap-retry cfg $ mdata)
|
|
(climit/wrap cfg $ mdata)
|
|
(wrap-metrics cfg $ mdata)
|
|
(wrap-audit cfg $ mdata)
|
|
(wrap-nitrate-sso cfg $ mdata)
|
|
(wrap-params-validation cfg $ mdata)
|
|
(wrap-authentication cfg $ mdata)))
|
|
|
|
(defn- process-method
|
|
[cfg wrap-fn [f mdata]]
|
|
(l/trc :hint "add method" :module (::module cfg) :type (::type cfg) :name (::sv/name mdata))
|
|
(let [f (wrap-fn cfg f mdata)
|
|
k (keyword (::sv/name mdata))]
|
|
[k [mdata (partial f cfg)]]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; API METHODS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- resolve-methods
|
|
[cfg]
|
|
(let [cfg (assoc cfg ::module "main" ::type "command" ::metrics-id :rpc-main-timing)]
|
|
(->> (sv/scan-ns
|
|
'app.rpc.commands.access-token
|
|
'app.rpc.commands.audit
|
|
'app.rpc.commands.auth
|
|
'app.rpc.commands.feedback
|
|
'app.rpc.commands.fonts
|
|
'app.rpc.commands.binfile
|
|
'app.rpc.commands.comments
|
|
'app.rpc.commands.demo
|
|
'app.rpc.commands.error-reports
|
|
'app.rpc.commands.files
|
|
'app.rpc.commands.files-create
|
|
'app.rpc.commands.files-share
|
|
'app.rpc.commands.files-update
|
|
'app.rpc.commands.files-snapshot
|
|
'app.rpc.commands.files-thumbnails
|
|
'app.rpc.commands.ldap
|
|
'app.rpc.commands.management
|
|
'app.rpc.commands.media
|
|
'app.rpc.commands.nitrate
|
|
'app.rpc.commands.profile
|
|
'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)
|
|
(map (partial process-method cfg wrap))
|
|
(into {}))))
|
|
|
|
(def ^:private schema:methods-params
|
|
[:map {:title "methods-params"}
|
|
::session/manager
|
|
::http.client/client
|
|
::db/pool
|
|
::rds/pool
|
|
::mbus/msgbus
|
|
::sto/storage
|
|
::mtx/metrics
|
|
[::ldap/provider [:maybe ::ldap/provider]]
|
|
[::climit [:maybe ::climit]]
|
|
[::rlimit [:maybe ::rlimit]]
|
|
::setup/props])
|
|
|
|
(defmethod ig/assert-key ::methods
|
|
[_ params]
|
|
(assert (sm/check schema:methods-params params)))
|
|
|
|
(defmethod ig/init-key ::methods
|
|
[_ cfg]
|
|
(let [cfg (d/without-nils cfg)]
|
|
(resolve-methods cfg)))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; MANAGEMENT METHODS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- resolve-management-methods
|
|
[cfg]
|
|
(let [cfg (assoc cfg ::module "management" ::type "command" ::metrics-id :rpc-management-timing)
|
|
mods (cond->> (list 'app.rpc.management.exporter)
|
|
(contains? cf/flags :admin-console)
|
|
(cons 'app.rpc.management.nitrate))]
|
|
|
|
(->> (apply sv/scan-ns mods)
|
|
(map (partial process-method cfg wrap-management))
|
|
(into {}))))
|
|
|
|
(def ^:private schema:management-methods-params
|
|
[:map {:title "management-methods-params"}
|
|
::session/manager
|
|
::http.client/client
|
|
::db/pool
|
|
::rds/pool
|
|
::mbus/msgbus
|
|
::sto/storage
|
|
::mtx/metrics
|
|
::setup/props])
|
|
|
|
(defmethod ig/assert-key ::management-methods
|
|
[_ params]
|
|
(assert (sm/check schema:management-methods-params params)))
|
|
|
|
(defmethod ig/init-key ::management-methods
|
|
[_ cfg]
|
|
(let [cfg (d/without-nils cfg)]
|
|
(resolve-management-methods cfg)))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; ROUTES
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- redirect
|
|
[href]
|
|
(fn [_]
|
|
{::yres/status 308
|
|
::yres/headers {"location" (str href)}}))
|
|
|
|
(def ^:private schema:methods
|
|
[:map-of :keyword [:tuple :map ::sm/fn]])
|
|
|
|
(sm/register! ::methods schema:methods)
|
|
|
|
(def ^:private valid-methods?
|
|
(sm/validator schema:methods))
|
|
|
|
(defmethod ig/assert-key ::routes
|
|
[_ params]
|
|
(assert (map? (::setup/shared-keys params)))
|
|
(assert (db/pool? (::db/pool params)) "expect valid database pool")
|
|
(assert (session/manager? (::session/manager params)) "expect valid session manager")
|
|
(assert (valid-methods? (::methods params)) "expect valid methods map")
|
|
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
|
|
|
|
(defmethod ig/init-key ::routes
|
|
[_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
|
|
|
|
(let [public-uri (cf/get :public-uri)]
|
|
["/api"
|
|
["/management"
|
|
["/methods/:method-name"
|
|
{:middleware [[mw/shared-key-auth shared-keys]
|
|
[session/authz cfg]]
|
|
:handler (make-rpc-handler management-methods)}]
|
|
|
|
(doc/routes :methods management-methods
|
|
:label "management"
|
|
:base-uri (u/join public-uri "/api/management")
|
|
:description "MANAGEMENT API")]
|
|
|
|
["/main"
|
|
["/methods/:method-name"
|
|
{:middleware [[mw/cors]
|
|
[sec/client-header-check]
|
|
[session/authz cfg]
|
|
[actoken/authz cfg]]
|
|
:handler (make-rpc-handler methods)}]
|
|
|
|
(doc/routes :methods methods
|
|
:label "main"
|
|
:base-uri (u/join public-uri "/api/main")
|
|
:description "MAIN API")]
|
|
|
|
;; BACKWARD COMPATIBILITY
|
|
["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
|
|
["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
|
|
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
|
|
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
|
|
|
|
["/rpc/command/:method-name"
|
|
{:middleware [[mw/cors]
|
|
[sec/client-header-check]
|
|
[session/authz cfg]
|
|
[actoken/authz cfg]]
|
|
:handler (make-rpc-handler methods)}]]))
|