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 1/3] 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( From 4e2bde6da33b9175e6891b15bdc07365802e49c7 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:11:09 +0200 Subject: [PATCH 2/3] Normalize SSO default organization UUID before lookup Uuid::parse_str also accepts uppercase, braced and non-hyphenated forms, but stored organization uuids are lowercase hyphenated and compared as strings. A non-canonical SSO_DEFAULT_ORGANIZATION_UUID passed validation but failed every first SSO sign-in with "organization does not exist". Normalize the value to the canonical form before the database lookup. --- src/sso.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/sso.rs b/src/sso.rs index d254284c..ad849365 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -363,7 +363,7 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR let Some(org_uuid) = CONFIG.sso_default_organization_uuid() else { return Ok(()); }; - let org_id = OrganizationId::from(org_uuid); + let org_id = normalize_organization_uuid(&org_uuid)?; if Membership::find_by_user_and_org(&user.uuid, &org_id, conn).await.is_some() { return Ok(()); @@ -380,6 +380,15 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR Ok(()) } +// `Uuid::parse_str` also accepts non-canonical forms (uppercase, braced, without hyphens), +// while stored organization uuids are always lowercase hyphenated and compared as strings. +fn normalize_organization_uuid(org_uuid: &str) -> ApiResult { + let Ok(parsed) = uuid::Uuid::parse_str(org_uuid) else { + err!("`SSO_DEFAULT_ORGANIZATION_UUID` must be a valid UUID") + }; + Ok(OrganizationId::from(parsed.to_string())) +} + 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; @@ -405,6 +414,23 @@ mod default_organization_tests { assert_eq!(membership.atype, MembershipType::User as i32); assert!(membership.invited_by_email.is_none()); } + + #[test] + fn normalizes_organization_uuid_to_canonical_form() { + for input in [ + "1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901", + "{1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901}", + "1b2c3d4e5f60718293a4b5c6d7e8f901", + ] { + let org_id = normalize_organization_uuid(input).expect("valid UUID form should be accepted"); + assert_eq!(org_id.to_string(), "1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901"); + } + } + + #[test] + fn rejects_invalid_organization_uuid() { + assert!(normalize_organization_uuid("not-a-uuid").is_err()); + } } // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). From 9fc9b7c6fa5ab4c7c9f45aa68a9e312f850b1214 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:53:07 +0200 Subject: [PATCH 3/3] Apply review feedback - Rename the test module to `tests` and move it to the bottom of the file - Remove the redundant membership defaults test and inline the helper - Align the SSO_DEFAULT_ORGANIZATION_UUID description between .env.template and config.rs - Reuse normalize_organization_uuid in the config validation so validation and organization lookup accept the same input --- .env.template | 2 +- src/config.rs | 8 +++--- src/sso.rs | 74 +++++++++++++++++++-------------------------------- 3 files changed, 31 insertions(+), 53 deletions(-) diff --git a/.env.template b/.env.template index 02417b5f..4be1d8a1 100644 --- a/.env.template +++ b/.env.template @@ -502,7 +502,7 @@ # 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. +## An administrator must confirm them 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) diff --git a/src/config.rs b/src/config.rs index 3eca80ef..5d1d5a93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -805,7 +805,7 @@ 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. + /// Default organization UUID |> Automatically add users on their first SSO sign-in as accepted members of this organization. An administrator must confirm them and assign collections or groups. No invitation email is sent. sso_default_organization_uuid: String, true, option; /// Client ID sso_client_id: String, true, def, String::new(); @@ -1088,10 +1088,8 @@ 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 let Some(org_uuid) = &cfg.sso_default_organization_uuid { + crate::sso::normalize_organization_uuid(org_uuid)?; } if cfg._enable_yubico { diff --git a/src/sso.rs b/src/sso.rs index ad849365..6fdb4ee2 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -14,7 +14,7 @@ use crate::{ DbConn, models::{ Device, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, OrganizationId, - SsoAuth, SsoUser, User, UserId, + SsoAuth, SsoUser, User, }, }, sso_client::Client, @@ -373,7 +373,9 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist") } - let membership = new_default_sso_membership(user.uuid.clone(), org_id.clone()); + let mut membership = Membership::new(user.uuid.clone(), org_id.clone(), None); + membership.status = MembershipStatus::Accepted as i32; + membership.atype = MembershipType::User as i32; membership.save(conn).await?; info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id); @@ -382,57 +384,13 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR // `Uuid::parse_str` also accepts non-canonical forms (uppercase, braced, without hyphens), // while stored organization uuids are always lowercase hyphenated and compared as strings. -fn normalize_organization_uuid(org_uuid: &str) -> ApiResult { +pub(crate) fn normalize_organization_uuid(org_uuid: &str) -> ApiResult { let Ok(parsed) = uuid::Uuid::parse_str(org_uuid) else { err!("`SSO_DEFAULT_ORGANIZATION_UUID` must be a valid UUID") }; Ok(OrganizationId::from(parsed.to_string())) } -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()); - } - - #[test] - fn normalizes_organization_uuid_to_canonical_form() { - for input in [ - "1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901", - "{1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901}", - "1b2c3d4e5f60718293a4b5c6d7e8f901", - ] { - let org_id = normalize_organization_uuid(input).expect("valid UUID form should be accepted"); - assert_eq!(org_id.to_string(), "1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901"); - } - } - - #[test] - fn rejects_invalid_organization_uuid() { - assert!(normalize_organization_uuid("not-a-uuid").is_err()); - } -} - // 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( @@ -550,3 +508,25 @@ pub async fn exchange_refresh_token( None => err!("No token present while in SSO"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_organization_uuid_to_canonical_form() { + for input in [ + "1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901", + "{1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901}", + "1b2c3d4e5f60718293a4b5c6d7e8f901", + ] { + let org_id = normalize_organization_uuid(input).expect("valid UUID form should be accepted"); + assert_eq!(org_id.to_string(), "1b2c3d4e-5f60-7182-93a4-b5c6d7e8f901"); + } + } + + #[test] + fn rejects_invalid_organization_uuid() { + assert!(normalize_organization_uuid("not-a-uuid").is_err()); + } +}