diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 0e766769ba..6ca998a197 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -456,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) @@ -511,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) @@ -887,6 +887,7 @@ ::http/client ::setup/props ::db/pool + [:app.nitrate/client {:optional true} [: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 93481aea19..e9f4b5679a 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -226,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 fe7522d817..4436e9d9cd 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -265,7 +265,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) @@ -325,7 +326,7 @@ {::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) ::rds/pool (ig/ref ::rds/pool) - :app.nitrate/client (ig/ref :app.nitrate/client) + :app.nitrate/client (ig/ref :app.nitrate/client) ::wrk/executor (ig/ref ::wrk/netty-executor) ::session/manager (ig/ref ::session/manager) ::ldap/provider (ig/ref ::ldap/provider) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 2439d2b077..a3aa97fff4 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -20,7 +20,6 @@ [app.http.session :as session] [app.rpc :as-alias rpc] [app.setup :as-alias setup] - [app.util.cache :as cache] [clojure.core :as c] [integrant.core :as ig])) @@ -510,12 +509,12 @@ ;; UTILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defonce ^:private sso-auth-cache - (cache/create :expire "1h" :max-size 1024)) - -(defn- compute-sso-authorization - [cfg team-id session] - (let [sso (call cfg :get-org-sso-by-team {:team-id team-id})] +(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)) @@ -529,23 +528,6 @@ {:authorized authorized :sso sso}) {:authorized false :sso sso})))) -(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. Positive results are cached for 1h - by [team-id session-id]; negative results are never cached so that - a completed SSO login is reflected immediately." - [cfg team-id request] - (let [session (session/get-session request) - session-id (:id session)] - (if (some? session-id) - (or (cache/get sso-auth-cache [team-id session-id]) - (let [result (compute-sso-authorization cfg team-id session)] - (when (:authorized result) - (cache/get sso-auth-cache [team-id session-id] (constantly result))) - result)) - (compute-sso-authorization cfg team-id session)))) - (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. diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 3ce16ef6ee..049e961afc 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,78 @@ ::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- coerce-uuid [v] + (cond (uuid? v) v + (string? v) (uuid/parse* v))) + +(defn- wrap-org-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 :org-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/org-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 (coerce-uuid (:id params)) + team-id (or (coerce-uuid (:team-id params)) + (when (= id-type :team) id)) + project-id (or (coerce-uuid (:project-id params)) + (when (= id-type :project) id)) + file-id (or (coerce-uuid (: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 :org-sso-required + :hint "organization SSO authentication required"))) + (f cfg params)))) + f)) + (defn- wrap [cfg f mdata] (as-> f $ @@ -220,7 +295,8 @@ (wrap-audit cfg $ mdata) (wrap-spec-conform cfg $ mdata) (wrap-params-validation cfg $ mdata) - (wrap-authentication cfg $ mdata))) + (wrap-authentication cfg $ mdata) + (wrap-org-sso cfg $ mdata))) (defn- wrap-management [cfg f mdata] @@ -232,7 +308,10 @@ (wrap-audit cfg $ mdata) (wrap-spec-conform cfg $ mdata) (wrap-params-validation cfg $ mdata) - (wrap-authentication cfg $ mdata))) + (wrap-authentication cfg $ mdata) + (wrap-org-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 f000b94922..95cf9f0ee3 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -633,7 +633,8 @@ re-authentication transparently if the user already has an active SSO session." {::rpc/auth true ::doc/added "2.19" - ::sm/params schema:auth-sso} + ::sm/params schema:auth-sso + ::nitrate/org-sso false} [cfg {:keys [team-id url] :as params}] (let [request (rph/get-request params) {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)] 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..0d591a81c4 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")) @@ -72,6 +73,10 @@ (.invalidateAll ^Cache cache)) (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/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 454b4f1931..96427712b1 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-organization-change-sso + [{:keys [organization-id]}] + (ptk/reify ::handle-organization-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! :auth-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-organization-change-sso msg) nil))