Add nitrate sso auth wrapper to the api endpoints

This commit is contained in:
Pablo Alba 2026-06-16 13:11:41 +02:00
parent 42760c9703
commit 5d65a52a53
14 changed files with 184 additions and 40 deletions

View File

@ -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

View File

@ -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<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)

View File

@ -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)

View File

@ -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.

View File

@ -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]]

View File

@ -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}]

View File

@ -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

View File

@ -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)]

View File

@ -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}

View File

@ -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}]

View File

@ -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)

View File

@ -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})))

View File

@ -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 [_]

View File

@ -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))