mirror of
https://github.com/penpot/penpot.git
synced 2026-06-20 22:32:03 +00:00
✨ Add nitrate sso wards to organization navigation
This commit is contained in:
parent
08721127a3
commit
b984e7bbe8
@ -24,6 +24,7 @@
|
||||
[app.http.errors :as errors]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
@ -42,9 +43,9 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [base-uri] :as provider}]
|
||||
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
|
||||
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
||||
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
|
||||
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
|
||||
(if (= 200 (:status rsp))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
@ -105,8 +106,8 @@
|
||||
keys))
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[cfg jwks-uri]
|
||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
|
||||
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
|
||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
(if (= 200 status)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(ex/raise :type ::internal
|
||||
@ -118,7 +119,8 @@
|
||||
"Fetch and Add (if possible) JWK's to the OIDC provider"
|
||||
[cfg provider]
|
||||
(try
|
||||
(if-let [jwks (some->> (:jwks-uri provider) (fetch-oidc-jwks cfg))]
|
||||
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
|
||||
(fetch-oidc-jwks cfg jwks-uri {:skip-ssrf-check? (:skip-ssrf-check? provider)}))]
|
||||
(assoc provider :jwks jwks)
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
@ -409,9 +411,9 @@
|
||||
(defn- build-redirect-uri
|
||||
[]
|
||||
(let [public (u/uri (cf/get :public-uri))]
|
||||
(str (assoc public :path (str "/api/auth/oidc/callback")))))
|
||||
(str (assoc public :path "/api/auth/oidc/callback"))))
|
||||
|
||||
(defn- build-auth-redirect-uri
|
||||
(defn build-auth-redirect-uri
|
||||
[provider token]
|
||||
(let [params {:client_id (:client-id provider)
|
||||
:redirect_uri (build-redirect-uri)
|
||||
@ -454,7 +456,7 @@
|
||||
:grant-type (:grant_type params)
|
||||
:redirect-uri (:redirect_uri params))
|
||||
|
||||
(let [{:keys [status body]} (http/req cfg req)]
|
||||
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)
|
||||
data {:token/access (get data :access_token)
|
||||
@ -509,7 +511,7 @@
|
||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
response (http/req cfg params)]
|
||||
response (http/req cfg params {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
||||
|
||||
(l/trc :hint "user info response"
|
||||
:status (:status response)
|
||||
@ -755,6 +757,25 @@
|
||||
(assoc profile :props props'))
|
||||
profile)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; ORG SSO HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn prepare-org-sso-provider
|
||||
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
|
||||
Uses OIDC discovery via :base-url (or :issuer as fallback) when
|
||||
token/auth/user URIs are absent."
|
||||
[cfg {:keys [client-id client-secret base-url issuer scopes]}]
|
||||
(prepare-oidc-provider cfg
|
||||
{:type "oidc"
|
||||
:client-id client-id
|
||||
:client-secret client-secret
|
||||
:base-uri (some-> (or base-url issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes (into default-oidc-scopes (or scopes #{}))
|
||||
:skip-ssrf-check? true}))
|
||||
|
||||
(defn- auth-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
(let [provider (resolve-provider cfg params)
|
||||
@ -779,64 +800,81 @@
|
||||
(try
|
||||
(let [code (get params :code)
|
||||
state (get params :state)
|
||||
state (tokens/verify cfg {:token state :iss "oidc"})
|
||||
state (tokens/verify cfg {:token state :iss "oidc"})]
|
||||
|
||||
provider (resolve-provider cfg state)
|
||||
info (get-info cfg provider state code)
|
||||
profile (get-profile cfg (:email info))]
|
||||
;; Org SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(let [team-id (:team-id state)
|
||||
organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id})
|
||||
provider (prepare-org-sso-provider cfg sso)
|
||||
;; verify token or throw error
|
||||
_info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:hours 48})]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||
(redirect-response dest-url))
|
||||
|
||||
(cond
|
||||
(not profile)
|
||||
(cond
|
||||
(and (email.blacklist/enabled? cfg)
|
||||
(email.blacklist/contains? cfg (:email info)))
|
||||
(redirect-with-error "email-domain-not-allowed")
|
||||
(let [provider (resolve-provider cfg state)
|
||||
info (get-info cfg provider state code)
|
||||
profile (get-profile cfg (:email info))]
|
||||
|
||||
(and (email.whitelist/enabled? cfg)
|
||||
(not (email.whitelist/contains? cfg (:email info))))
|
||||
(redirect-with-error "email-domain-not-allowed")
|
||||
(cond
|
||||
(not profile)
|
||||
(cond
|
||||
(and (email.blacklist/enabled? cfg)
|
||||
(email.blacklist/contains? cfg (:email info)))
|
||||
(redirect-with-error "email-domain-not-allowed")
|
||||
|
||||
:else
|
||||
(if (or (contains? cf/flags :registration)
|
||||
(contains? cf/flags :oidc-registration))
|
||||
(redirect-to-register cfg info provider)
|
||||
(redirect-with-error "registration-disabled")))
|
||||
(and (email.whitelist/enabled? cfg)
|
||||
(not (email.whitelist/contains? cfg (:email info))))
|
||||
(redirect-with-error "email-domain-not-allowed")
|
||||
|
||||
(:is-blocked profile)
|
||||
(redirect-with-error "profile-blocked")
|
||||
:else
|
||||
(if (or (contains? cf/flags :registration)
|
||||
(contains? cf/flags :oidc-registration))
|
||||
(redirect-to-register cfg info provider)
|
||||
(redirect-with-error "registration-disabled")))
|
||||
|
||||
(not (or (= (:auth-backend profile) (:type provider))
|
||||
(profile-has-provider-props? provider profile)
|
||||
(provider-has-email-verified? provider info)))
|
||||
(redirect-with-error "auth-provider-not-allowed")
|
||||
(:is-blocked profile)
|
||||
(redirect-with-error "profile-blocked")
|
||||
|
||||
(not (:is-active profile))
|
||||
(let [info (assoc info :profile-id (:id profile))]
|
||||
(redirect-to-register cfg info provider))
|
||||
(not (or (= (:auth-backend profile) (:type provider))
|
||||
(profile-has-provider-props? provider profile)
|
||||
(provider-has-email-verified? provider info)))
|
||||
(redirect-with-error "auth-provider-not-allowed")
|
||||
|
||||
:else
|
||||
(let [sxf (session/create-fn cfg profile info)
|
||||
token (or (:invitation-token info)
|
||||
(tokens/generate cfg
|
||||
{:iss :auth
|
||||
:exp (ct/in-future "15m")
|
||||
:profile-id (:id profile)}))
|
||||
(not (:is-active profile))
|
||||
(let [info (assoc info :profile-id (:id profile))]
|
||||
(redirect-to-register cfg info provider))
|
||||
|
||||
;; If proceed, update profile on the database
|
||||
profile (update-profile-with-info cfg profile info)
|
||||
:else
|
||||
(let [sxf (session/create-fn cfg profile info)
|
||||
token (or (:invitation-token info)
|
||||
(tokens/generate cfg
|
||||
{:iss :auth
|
||||
:exp (ct/in-future "15m")
|
||||
:profile-id (:id profile)}))
|
||||
|
||||
props (audit/profile->props profile)
|
||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||
;; If proceed, update profile on the database
|
||||
profile (update-profile-with-info cfg profile info)
|
||||
|
||||
(audit/submit cfg {:type "action"
|
||||
:name "login-with-oidc"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (inet/parse-request request)
|
||||
:props props
|
||||
:context context})
|
||||
props (audit/profile->props profile)
|
||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||
|
||||
(->> (redirect-to-verify-token token)
|
||||
(sxf request)))))
|
||||
(audit/submit cfg {:type "action"
|
||||
:name "login-with-oidc"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (inet/parse-request request)
|
||||
:props props
|
||||
:context context})
|
||||
|
||||
(->> (redirect-to-verify-token token)
|
||||
(sxf request)))))))
|
||||
|
||||
(catch Throwable cause
|
||||
(binding [l/*context* (errors/request->context request)]
|
||||
@ -849,6 +887,7 @@
|
||||
::http/client
|
||||
::setup/props
|
||||
::db/pool
|
||||
[:app.nitrate/client [:maybe :map]]
|
||||
[::providers schema:providers]])
|
||||
|
||||
(defmethod ig/assert-key ::routes
|
||||
|
||||
@ -68,17 +68,24 @@
|
||||
(def ^:private valid-params?
|
||||
(sm/validator schema:params))
|
||||
|
||||
(defn- decode-session
|
||||
[session]
|
||||
(cond-> session
|
||||
(db/pgobject? (:props session))
|
||||
(update :props db/decode-transit-pgobject)))
|
||||
|
||||
(defn- database-manager
|
||||
[pool]
|
||||
(reify ISessionManager
|
||||
(read-session [_ id]
|
||||
(if (string? id)
|
||||
;; Backward compatibility
|
||||
;; Backward compatibility: http_session (v1) has no props column
|
||||
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
|
||||
(-> session
|
||||
(assoc :modified-at (:updated-at session))
|
||||
(dissoc :updated-at)))
|
||||
(db/exec-one! pool (sql/select :http-session-v2 {:id id}))))
|
||||
(some-> (db/exec-one! pool (sql/select :http-session-v2 {:id id}))
|
||||
(decode-session))))
|
||||
|
||||
(create-session [_ params]
|
||||
(assert (valid-params? params) "expect valid session params")
|
||||
@ -100,7 +107,9 @@
|
||||
(assoc :created-at modified-at)
|
||||
(assoc :modified-at modified-at)))
|
||||
(db/update! pool :http-session-v2
|
||||
{:modified-at modified-at}
|
||||
(cond-> {:modified-at modified-at}
|
||||
(some? (:props session))
|
||||
(assoc :props (db/tjson (:props session))))
|
||||
{:id (:id session)}
|
||||
{::db/return-keys true}))))
|
||||
|
||||
@ -129,9 +138,10 @@
|
||||
session))
|
||||
|
||||
(update-session [_ session]
|
||||
(let [modified-at (ct/now)]
|
||||
(swap! cache update (:id session) assoc :modified-at modified-at)
|
||||
(assoc session :modified-at modified-at)))
|
||||
(let [modified-at (ct/now)
|
||||
session (assoc session :modified-at modified-at)]
|
||||
(swap! cache assoc (:id session) session)
|
||||
session))
|
||||
|
||||
(delete-session [_ id]
|
||||
(swap! cache dissoc id)
|
||||
@ -216,6 +226,20 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:clear-org-sso-sessions
|
||||
(str "UPDATE http_session_v2 "
|
||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||
"WHERE props IS NOT NULL "
|
||||
"AND jsonb_exists(props -> '~:sso', ?)"))
|
||||
|
||||
(defn clear-org-sso-sessions!
|
||||
"Remove the SSO entry for organization-id from the props of every
|
||||
session that currently holds it. The key is transit-encoded as the
|
||||
string '~u<uuid>' under the '~:sso' path."
|
||||
[pool organization-id]
|
||||
(let [org-key (str "~u" organization-id)]
|
||||
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
|
||||
|
||||
(defn- renew-session?
|
||||
[{:keys [id modified-at] :as session}]
|
||||
(or (string? id)
|
||||
|
||||
@ -261,7 +261,8 @@
|
||||
::oidc/providers (ig/ref ::oidc/providers)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::email/blacklist (ig/ref ::email/blacklist)
|
||||
::email/whitelist (ig/ref ::email/whitelist)}
|
||||
::email/whitelist (ig/ref ::email/whitelist)
|
||||
:app.nitrate/client (ig/ref :app.nitrate/client)}
|
||||
|
||||
::mgmt/routes
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
|
||||
@ -484,7 +484,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}
|
||||
|
||||
{:name "0149-mod-file-library-rel-synced-at"
|
||||
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}
|
||||
|
||||
{:name "0150-mod-http-session-v2"
|
||||
:fn (mg/resource "app/migrations/sql/0150-mod-http-session-v2.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE http_session_v2
|
||||
ADD COLUMN props jsonb NULL;
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.types.organization :as cto]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.http.session :as session]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.setup :as-alias setup]
|
||||
[clojure.core :as c]
|
||||
@ -425,6 +426,28 @@
|
||||
[:permissions [:map-of :keyword :string]]]
|
||||
params)))
|
||||
|
||||
(def ^:private schema:nitrate-sso
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:active [:maybe :boolean]]
|
||||
[:provider [:maybe :string]]
|
||||
[:client-id [:maybe :string]]
|
||||
[:base-url [:maybe :string]]
|
||||
[:client-secret [:maybe :string]]
|
||||
[:issuer [:maybe :string]]
|
||||
[:scopes [:maybe [::sm/set ::sm/text]]]])
|
||||
|
||||
(defn- get-org-sso-by-team-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id
|
||||
"/sso")
|
||||
schema:nitrate-sso
|
||||
params)))
|
||||
|
||||
(defn- get-org-members-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
@ -463,6 +486,7 @@
|
||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||
:get-org-permissions (partial get-org-permissions-api cfg)
|
||||
:get-org-sso-by-team (partial get-org-sso-by-team-api cfg)
|
||||
:delete-team (partial delete-team-api cfg)
|
||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||
:get-subscription (partial get-subscription-api cfg)
|
||||
@ -474,6 +498,24 @@
|
||||
;; UTILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn sso-session-authorized?
|
||||
"Fetches the org-SSO config for the given team and checks whether
|
||||
the HTTP request has a valid session entry for it. Returns a map
|
||||
with :authorized and :sso keys."
|
||||
[cfg team-id request]
|
||||
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})]
|
||||
(if-not (:active sso)
|
||||
{:authorized true :sso sso}
|
||||
(if (or (:issuer sso) (:base-url 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.
|
||||
|
||||
@ -27,8 +27,10 @@
|
||||
[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]
|
||||
@ -36,6 +38,7 @@
|
||||
[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.spec.alpha :as s]
|
||||
@ -208,6 +211,74 @@
|
||||
::sm/explain (explain params)))))))
|
||||
f))
|
||||
|
||||
|
||||
(defonce ^:private org-sso-auth-cache
|
||||
(cache/create :expire "15m" :max-size 1024))
|
||||
|
||||
(defn invalidate-org-sso-cache-by-org!
|
||||
"Invalidates all org-SSO authorization cache entries for the given organization-id."
|
||||
[organization-id]
|
||||
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||
|
||||
(defn- wrap-nitrate-sso
|
||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||
|
||||
Resolves the team context from request params using priority order:
|
||||
1. Explicit :team-id param
|
||||
2. Explicit :project-id param → lookup project.team_id
|
||||
3. Explicit :file-id param → lookup file's team via join
|
||||
4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
||||
|
||||
Once team-id is resolved, checks if the user is authorized within that org's SSO
|
||||
session using nitrate/sso-session-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/org-sso false
|
||||
|
||||
Raises :nitrate-sso-required error if user is not authorized in the org."
|
||||
[_ f mdata]
|
||||
(if (and (contains? cf/flags :nitrate)
|
||||
(::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 [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 (or team-id project-id file-id)
|
||||
(let [cache-ref (or team-id project-id file-id)
|
||||
profile-id (::profile-id params)
|
||||
cache-key [profile-id cache-ref]
|
||||
cached (cache/get org-sso-auth-cache cache-key)
|
||||
result (if (some? cached)
|
||||
cached
|
||||
(let [team-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]} (nitrate/sso-session-authorized? cfg team-id request)
|
||||
entry {:authorized authorized
|
||||
:organization-id (:organization-id sso)}]
|
||||
(when authorized
|
||||
(cache/get org-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 $
|
||||
@ -220,7 +291,8 @@
|
||||
(wrap-audit cfg $ mdata)
|
||||
(wrap-spec-conform cfg $ mdata)
|
||||
(wrap-params-validation cfg $ mdata)
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
(wrap-authentication cfg $ mdata)
|
||||
(wrap-nitrate-sso cfg $ mdata)))
|
||||
|
||||
(defn- wrap-management
|
||||
[cfg f mdata]
|
||||
@ -232,7 +304,10 @@
|
||||
(wrap-audit cfg $ mdata)
|
||||
(wrap-spec-conform cfg $ mdata)
|
||||
(wrap-params-validation cfg $ mdata)
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
(wrap-authentication cfg $ mdata)
|
||||
(wrap-nitrate-sso cfg $ mdata)))
|
||||
|
||||
|
||||
|
||||
(defn- process-method
|
||||
[cfg wrap-fn [f mdata]]
|
||||
|
||||
@ -165,6 +165,7 @@
|
||||
(sv/defmethod ::get-file
|
||||
"Retrieve a file by its ID. Only authenticated users."
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :file
|
||||
::cond/get-object #(get-minimal-file-with-perms %1 %2)
|
||||
::cond/key-fn get-file-etag
|
||||
::sm/params schema:get-file
|
||||
@ -601,6 +602,7 @@
|
||||
(sv/defmethod ::get-file-summary
|
||||
"Retrieve a file summary by its ID. Only authenticated users."
|
||||
{::doc/added "1.20"
|
||||
::rpc/id-type :file
|
||||
::sm/params schema:get-file-summary}
|
||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
@ -669,6 +671,7 @@
|
||||
outbound library reference counts. Cheap alternative to `get-file`
|
||||
when only metrics are needed."
|
||||
{::doc/added "2.17"
|
||||
::rpc/id-type :file
|
||||
::sm/params schema:get-file-stats
|
||||
::sm/result schema:get-file-stats-result
|
||||
::db/transaction true}
|
||||
@ -842,6 +845,7 @@
|
||||
|
||||
(sv/defmethod ::rename-file
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :file
|
||||
::webhooks/event? true
|
||||
|
||||
::sm/webhook
|
||||
@ -1001,6 +1005,7 @@
|
||||
|
||||
(sv/defmethod ::set-file-shared
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :file
|
||||
::webhooks/event? true
|
||||
::sm/params schema:set-file-shared}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
@ -1056,6 +1061,7 @@
|
||||
|
||||
(sv/defmethod ::delete-file
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :file
|
||||
::webhooks/event? true
|
||||
::sm/params schema:delete-file}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
|
||||
@ -130,7 +130,8 @@
|
||||
;; database.
|
||||
|
||||
(sv/defmethod ::update-file
|
||||
{::climit/id [[:update-file/by-profile ::rpc/profile-id]
|
||||
{::rpc/id-type :file
|
||||
::climit/id [[:update-file/by-profile ::rpc/profile-id]
|
||||
[:update-file/global]]
|
||||
|
||||
::webhooks/event? true
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"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.schema :as sm]
|
||||
@ -19,7 +20,9 @@
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
|
||||
@ -617,3 +620,39 @@
|
||||
:allows-anybody false}))
|
||||
|
||||
|
||||
(def ^:private schema:check-nitrate-sso
|
||||
[:map {:title "AuthSsoParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:url ::sm/uri]])
|
||||
|
||||
(sv/defmethod ::check-nitrate-sso
|
||||
"Check if a user needs to login into the organization SSO.
|
||||
Returns {:authorized true} when SSO is not active for the team.
|
||||
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."
|
||||
{::rpc/auth true
|
||||
::doc/added "2.19"
|
||||
::sm/params schema:check-nitrate-sso
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [team-id url] :as params}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(let [request (rph/get-request params)
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)]
|
||||
(if authorized
|
||||
{:authorized true}
|
||||
(if-let [issuer (or (:issuer sso) (:base-url sso))]
|
||||
(let [oidc-provider (oidc/prepare-org-sso-provider cfg sso)
|
||||
organization-id (:organization-id sso)
|
||||
state-token (tokens/generate cfg {:iss "oidc"
|
||||
:dest-url url
|
||||
:team-id team-id
|
||||
:organization-id organization-id
|
||||
:issuer issuer
|
||||
:exp (ct/in-future "4h")})
|
||||
redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)]
|
||||
{:authorized false
|
||||
:redirect-uri redirect-uri})
|
||||
{:authorized false
|
||||
:redirect-uri nil})))
|
||||
{:authorized true}))
|
||||
|
||||
@ -157,6 +157,7 @@
|
||||
|
||||
(sv/defmethod ::get-project
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:get-project}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
@ -223,6 +224,7 @@
|
||||
|
||||
(sv/defmethod ::update-project-pin
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:update-project-pin
|
||||
::webhooks/batch-timeout (ct/duration "5s")
|
||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||
@ -244,6 +246,7 @@
|
||||
|
||||
(sv/defmethod ::rename-project
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:rename-project
|
||||
::webhooks/event? true
|
||||
::db/transaction true}
|
||||
@ -286,6 +289,7 @@
|
||||
|
||||
(sv/defmethod ::delete-project
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:delete-project
|
||||
::webhooks/event? true
|
||||
::db/transaction true}
|
||||
|
||||
@ -236,6 +236,7 @@
|
||||
|
||||
(sv/defmethod ::get-team
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:get-team}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
|
||||
(get-team pool :profile-id profile-id :team-id id :file-id file-id))
|
||||
@ -691,6 +692,7 @@
|
||||
|
||||
(sv/defmethod ::update-team
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:update-team
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||
@ -766,6 +768,7 @@
|
||||
|
||||
(sv/defmethod ::leave-team
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:leave-team
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
@ -828,6 +831,7 @@
|
||||
|
||||
(sv/defmethod ::delete-team
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:delete-team
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||
|
||||
@ -18,10 +18,11 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc :as rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.nitrate :as cnit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
@ -823,3 +824,19 @@ LEFT JOIN profile AS p
|
||||
(run! (partial submit-nitrate-audit-event cfg) events))
|
||||
nil))
|
||||
|
||||
|
||||
;; ---- API: notify-org-sso-change
|
||||
|
||||
(sv/defmethod ::notify-org-sso-change
|
||||
"Nitrate notifies that an organization sso values have changed"
|
||||
{::doc/added "2.19"
|
||||
::sm/params [:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:updated-props ::sm/boolean]]
|
||||
::rpc/auth false}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}]
|
||||
(when updated-props
|
||||
(rpc/invalidate-org-sso-cache-by-org! organization-id)
|
||||
(session/clear-org-sso-sessions! pool organization-id))
|
||||
(notifications/notify-organization-change-sso cfg organization-id)
|
||||
nil)
|
||||
|
||||
@ -43,3 +43,11 @@
|
||||
:organization-name organization-name
|
||||
:teams teams
|
||||
:deleted-teams deleted-teams})))
|
||||
|
||||
(defn notify-organization-change-sso
|
||||
[cfg organization-id]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic uuid/zero
|
||||
:message {:type :organization-change-sso
|
||||
:organization-id organization-id})))
|
||||
|
||||
@ -24,7 +24,8 @@
|
||||
|
||||
(defprotocol ICache
|
||||
(get [_ k] [_ k load-fn] "get cache entry")
|
||||
(invalidate! [_] [_ k] "invalidate cache"))
|
||||
(invalidate [_] [_ k] "invalidate cache")
|
||||
(invalidate-if [_ pred] "invalidate all entries whose value satisfies pred"))
|
||||
|
||||
(defprotocol ICacheStats
|
||||
(stats [_] "get stats"))
|
||||
@ -68,10 +69,14 @@
|
||||
^Function (reify Function
|
||||
(apply [_ k]
|
||||
(load-fn k)))))
|
||||
(invalidate! [_]
|
||||
(invalidate [_]
|
||||
(.invalidateAll ^Cache cache))
|
||||
(invalidate! [_ k]
|
||||
(invalidate [_ k]
|
||||
(.invalidate ^Cache cache ^Object k))
|
||||
(invalidate-if [_ pred]
|
||||
(doseq [[k v] (.asMap ^Cache cache)]
|
||||
(when (pred v)
|
||||
(.invalidate ^Cache cache ^Object k))))
|
||||
|
||||
ICacheStats
|
||||
(stats [_]
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[:avatar-bg-url ::sm/uri]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:expired-license {:optional true} [:maybe :boolean]]
|
||||
[:sso-active {:optional true} [:maybe :boolean]]
|
||||
[:permissions {:optional true}
|
||||
[:maybe [:map
|
||||
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
|
||||
@ -33,7 +34,7 @@
|
||||
|
||||
(def organization->team-keys
|
||||
"Organization field keys to include in the nested :organization map."
|
||||
[:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions])
|
||||
[:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions :sso-active])
|
||||
|
||||
(defn apply-organization
|
||||
"Updates a team map with organization fields in a nested :organization map.
|
||||
|
||||
@ -183,3 +183,8 @@
|
||||
;; (js/console.log "RES: " res))
|
||||
;;
|
||||
;; )))
|
||||
|
||||
|
||||
(defn coerce [v]
|
||||
(cond (uuid? v) v
|
||||
(string? v) (parse* v)))
|
||||
@ -27,6 +27,7 @@
|
||||
[app.main.data.team :as dtm]
|
||||
[app.main.data.websocket :as dws]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.sse :as sse]
|
||||
@ -756,15 +757,35 @@
|
||||
(when fetch? ;; If the user belonged to the org
|
||||
(rx/of (dtm/fetch-teams)))))))))
|
||||
|
||||
(defn- handle-nitrate-change-sso
|
||||
[{:keys [organization-id]}]
|
||||
(ptk/reify ::handle-nitrate-change-sso
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [team-id (:current-team-id state)
|
||||
team (dm/get-in state [:teams team-id])
|
||||
org-id (dm/get-in team [:organization :id])]
|
||||
(when (= organization-id org-id)
|
||||
(let [url (rt/get-current-href)]
|
||||
(->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url})
|
||||
(rx/mapcat (fn [{:keys [authorized redirect-uri]}]
|
||||
(if authorized
|
||||
(rx/empty)
|
||||
(if redirect-uri
|
||||
(rx/of (rt/nav-raw :uri (str redirect-uri)))
|
||||
(rx/empty)))))))))))))
|
||||
|
||||
(defn- process-message
|
||||
[{:keys [type] :as msg}]
|
||||
(case type
|
||||
:notification (dcm/handle-notification msg)
|
||||
:team-role-change (handle-change-team-role msg)
|
||||
:team-membership-change (dcm/team-membership-change msg)
|
||||
:team-org-change (handle-change-team-org msg)
|
||||
:user-org-change (handle-user-org-change msg)
|
||||
:organization-deleted (handle-organization-deleted msg)
|
||||
:notification (dcm/handle-notification msg)
|
||||
:team-role-change (handle-change-team-role msg)
|
||||
:team-membership-change (dcm/team-membership-change msg)
|
||||
:team-org-change (handle-change-team-org msg)
|
||||
:user-org-change (handle-user-org-change msg)
|
||||
:organization-deleted (handle-organization-deleted msg)
|
||||
:organization-change-sso (handle-nitrate-change-sso msg)
|
||||
nil))
|
||||
|
||||
|
||||
|
||||
@ -97,6 +97,31 @@
|
||||
(swap! storage/session assoc
|
||||
:plugin-url plugin))))
|
||||
|
||||
(defn- check-sso-and-navigate
|
||||
"Authorization filter for dashboard and workspace routes.
|
||||
Checks if the team being navigated to has an organization with SSO
|
||||
active. If so, calls :check-nitrate-sso and either proceeds with navigation
|
||||
or redirects to the SSO provider URL."
|
||||
[match send-event-info? url]
|
||||
(let [route-name (name (get-in match [:data :name]))
|
||||
relevant? (and (contains? cf/flags :nitrate)
|
||||
(or (str/starts-with? route-name "dashboard")
|
||||
(str/starts-with? route-name "workspace")))
|
||||
team-id-str (when relevant?
|
||||
(or (get-in match [:query-params :team-id])
|
||||
(get-in match [:params :path :team-id])))
|
||||
team-id (some-> team-id-str uuid/parse*)]
|
||||
(if (some? team-id)
|
||||
(->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url})
|
||||
(rx/subs!
|
||||
(fn [{:keys [authorized redirect-uri]}]
|
||||
(if authorized
|
||||
(st/emit! (rt/navigated match send-event-info?))
|
||||
(when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri))))))
|
||||
(fn [cause]
|
||||
(errors/on-error cause))))
|
||||
(st/emit! (rt/navigated match send-event-info?)))))
|
||||
|
||||
(defn on-navigate
|
||||
[router path send-event-info?]
|
||||
(let [location (.-location js/document)
|
||||
@ -112,7 +137,7 @@
|
||||
(st/emit! (rt/assign-exception {:type :not-found}))
|
||||
|
||||
(some? match)
|
||||
(st/emit! (rt/navigated match send-event-info?))
|
||||
(check-sso-and-navigate match send-event-info? (rt/get-current-href))
|
||||
|
||||
:else
|
||||
;; We just recheck with an additional profile request; this
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user