From 3a27382c2de1464cee5639be604250e41683b0dc Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:33:19 +0200 Subject: [PATCH] Add default organization for SSO users --- .env.template | 4 ++++ src/config.rs | 8 ++++++++ src/sso.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 0d922774..02417b5f 100644 --- a/.env.template +++ b/.env.template @@ -501,6 +501,10 @@ ## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. # SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false +## Automatically add users on their first SSO sign-in as accepted members of this organization. +## An administrator must confirm users and assign collections or groups. No invitation email is sent. +# SSO_DEFAULT_ORGANIZATION_UUID=00000000-0000-0000-0000-000000000000 + ## Base URL of the OIDC server (auto-discovery is used) ## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` ## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse diff --git a/src/config.rs b/src/config.rs index 49281b6c..3eca80ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -805,6 +805,8 @@ make_config! { sso_signups_match_email: bool, true, def, true; /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. sso_allow_unknown_email_verification: bool, true, def, false; + /// Default organization UUID |> Automatically add users on their first SSO sign-in as accepted members of this organization. An administrator must confirm them before they can access assigned organization data. + sso_default_organization_uuid: String, true, option; /// Client ID sso_client_id: String, true, def, String::new(); /// Client Key @@ -1086,6 +1088,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + if let Some(org_uuid) = &cfg.sso_default_organization_uuid + && uuid::Uuid::parse_str(org_uuid).is_err() + { + err!("`SSO_DEFAULT_ORGANIZATION_UUID` must be a valid UUID") + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") diff --git a/src/sso.rs b/src/sso.rs index 01fbd906..d254284c 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -12,7 +12,10 @@ use crate::{ auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper}, db::{ DbConn, - models::{Device, OIDCAuthenticatedUser, SsoAuth, SsoUser, User}, + models::{ + Device, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, OrganizationId, + SsoAuth, SsoUser, User, UserId, + }, }, sso_client::Client, }; @@ -328,6 +331,8 @@ pub async fn redeem( sso_auth.delete(conn).await?; if sso_user.is_none() { + enroll_user_in_default_organization(user, conn).await?; + let user_sso = SsoUser { user_uuid: user.uuid.clone(), identifier: auth_user.identifier.clone(), @@ -354,6 +359,54 @@ pub async fn redeem( } } +async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiResult<()> { + let Some(org_uuid) = CONFIG.sso_default_organization_uuid() else { + return Ok(()); + }; + let org_id = OrganizationId::from(org_uuid); + + if Membership::find_by_user_and_org(&user.uuid, &org_id, conn).await.is_some() { + return Ok(()); + } + + if Organization::find_by_uuid(&org_id, conn).await.is_none() { + err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist") + } + + let membership = new_default_sso_membership(user.uuid.clone(), org_id.clone()); + membership.save(conn).await?; + + info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id); + Ok(()) +} + +fn new_default_sso_membership(user_uuid: UserId, org_id: OrganizationId) -> Membership { + let mut membership = Membership::new(user_uuid, org_id, None); + membership.status = MembershipStatus::Accepted as i32; + membership.atype = MembershipType::User as i32; + membership +} + +#[cfg(test)] +mod default_organization_tests { + use crate::db::models::{MembershipStatus, MembershipType, OrganizationId, UserId}; + + use super::*; + + #[test] + fn default_sso_membership_is_accepted_user_without_full_access() { + let membership = new_default_sso_membership( + UserId::from("00000000-0000-0000-0000-000000000001"), + OrganizationId::from("00000000-0000-0000-0000-000000000002"), + ); + + assert!(!membership.access_all); + assert_eq!(membership.status, MembershipStatus::Accepted as i32); + assert_eq!(membership.atype, MembershipType::User as i32); + assert!(membership.invited_by_email.is_none()); + } +} + // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). // If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity pub fn create_auth_tokens(