Add nitrate sso wards to organization navigation

This commit is contained in:
Pablo Alba 2026-06-08 17:30:08 +02:00 committed by Pablo Alba
parent 08721127a3
commit b984e7bbe8
19 changed files with 400 additions and 78 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE http_session_v2
ADD COLUMN props jsonb NULL;

View File

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

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

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

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

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

View File

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

View File

@ -183,3 +183,8 @@
;; (js/console.log "RES: " res))
;;
;; )))
(defn coerce [v]
(cond (uuid? v) v
(string? v) (parse* v)))

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

View File

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