From b984e7bbe86299991b7dd58f14e7d170bdf8c062 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 8 Jun 2026 17:30:08 +0200 Subject: [PATCH] :sparkles: Add nitrate sso wards to organization navigation --- backend/src/app/auth/oidc.clj | 149 +++++++++++------- backend/src/app/http/session.clj | 36 ++++- backend/src/app/main.clj | 3 +- backend/src/app/migrations.clj | 5 +- .../sql/0150-mod-http-session-v2.sql | 2 + backend/src/app/nitrate.clj | 42 +++++ backend/src/app/rpc.clj | 79 +++++++++- backend/src/app/rpc/commands/files.clj | 6 + backend/src/app/rpc/commands/files_update.clj | 3 +- backend/src/app/rpc/commands/nitrate.clj | 39 +++++ backend/src/app/rpc/commands/projects.clj | 4 + backend/src/app/rpc/commands/teams.clj | 4 + backend/src/app/rpc/management/nitrate.clj | 19 ++- backend/src/app/rpc/notifications.clj | 8 + backend/src/app/util/cache.clj | 11 +- common/src/app/common/types/organization.cljc | 3 +- common/src/app/common/uuid.cljc | 5 + frontend/src/app/main/data/dashboard.cljs | 33 +++- frontend/src/app/main/ui/routes.cljs | 27 +++- 19 files changed, 400 insertions(+), 78 deletions(-) create mode 100644 backend/src/app/migrations/sql/0150-mod-http-session-v2.sql diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 2a2f6aed31..d72efa811d 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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 diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index c3ec302e45..e9f4b5679a 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -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' 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) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 0a795bfd1c..ca8fba8af1 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -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) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 043502fc74..2cf444ad04 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -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] diff --git a/backend/src/app/migrations/sql/0150-mod-http-session-v2.sql b/backend/src/app/migrations/sql/0150-mod-http-session-v2.sql new file mode 100644 index 0000000000..2acf6b45e1 --- /dev/null +++ b/backend/src/app/migrations/sql/0150-mod-http-session-v2.sql @@ -0,0 +1,2 @@ +ALTER TABLE http_session_v2 + ADD COLUMN props jsonb NULL; diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index d4343b8195..b5f63add0e 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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. diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 3ce16ef6ee..d950d34e62 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -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]] diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index e8a4bc32b0..3851ea577b 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -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}] diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 3f69834051..0c19c1c315 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -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 diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index ba831c41d9..f7cbe5cb4f 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -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 } 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})) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 3f107346fc..0fdb9fb88f 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -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} diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 6abdae69b0..50458c27f8 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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}] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index f448ae630e..429205c1c7 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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) diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index 8151088804..d2410092aa 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -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}))) diff --git a/backend/src/app/util/cache.clj b/backend/src/app/util/cache.clj index e92cc57e34..0414d52c87 100644 --- a/backend/src/app/util/cache.clj +++ b/backend/src/app/util/cache.clj @@ -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 [_] diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index cec08459ac..62d77ac14c 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -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. diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc index ef1e957cd7..d094d4c06d 100644 --- a/common/src/app/common/uuid.cljc +++ b/common/src/app/common/uuid.cljc @@ -183,3 +183,8 @@ ;; (js/console.log "RES: " res)) ;; ;; ))) + + +(defn coerce [v] + (cond (uuid? v) v + (string? v) (parse* v))) \ No newline at end of file diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 454b4f1931..009ad4fe67 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index bbac87d452..2c62f5d1b4 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -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