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/4] 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/4] 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/4] 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()); + } +} From 7cbea9af419e895c81a599f4f49ea9289d3f291c Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:59:57 +0200 Subject: [PATCH 4/4] Address SSO default organization review feedback --- src/api/core/accounts.rs | 44 ++++++++++++++++++++++++++++++++++- src/api/core/organizations.rs | 18 ++++++++------ src/api/identity.rs | 2 +- src/sso.rs | 26 ++++++++++++++------- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 623edf24..979276d7 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -22,7 +22,8 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, - OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, + MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, + UserKdfType, }, }, mail, @@ -439,6 +440,15 @@ pub async fn register(data: Json, email_verification: bool, conn: async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SetPasswordData = data.into_inner(); let mut user = headers.user; + let default_org_id = match CONFIG.sso_default_organization_uuid() { + Some(org_uuid) => Some(crate::sso::normalize_organization_uuid(&org_uuid)?), + None => None, + }; + let enroll_in_default_organization = matches!( + (data.org_identifier.as_deref(), default_org_id.as_ref()), + (Some(identifier), Some(org_id)) + if identifier == crate::sso::FAKE_SSO_IDENTIFIER || identifier == org_id.as_ref() + ); if user.private_key.is_some() { err!("Account already initialized, cannot set password") @@ -467,6 +477,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: } if let Some(identifier) = data.org_identifier + && !enroll_in_default_organization && identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID { @@ -492,12 +503,43 @@ async fn post_set_password(data: Json, headers: Headers, conn: user.save(&conn).await?; + if enroll_in_default_organization && let Some(org_id) = default_org_id { + accept_sso_default_organization_invite(&user, &org_id, &conn).await?; + } + Ok(Json(json!({ "object": "set-password", "captchaBypassToken": "", }))) } +async fn accept_sso_default_organization_invite(user: &User, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + let Some(mut membership) = Membership::find_by_user_and_org(&user.uuid, org_id, conn).await else { + err!("Failed to retrieve the default organization invitation") + }; + if membership.status != MembershipStatus::Invited as i32 { + return Ok(()); + } + + let Some(org) = Organization::find_by_uuid(org_id, conn).await else { + err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist") + }; + + membership.status = MembershipStatus::Accepted as i32; + OrgPolicy::check_user_allowed(&membership, "join", conn).await?; + membership.save(conn).await?; + + if CONFIG.mail_enabled() { + let address = membership.invited_by_email.unwrap_or(org.billing_email); + if let Err(e) = mail::send_invite_accepted(&user.email, &address, &org.name).await { + error!("Error sending default organization enrollment notification: {e:#?}"); + } + } + + info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id); + Ok(()) +} + #[get("/accounts/profile")] async fn profile(headers: Headers, conn: DbConn) -> Json { Json(headers.user.to_json(&conn).await) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..50b4b693 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -910,18 +910,22 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } -// Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere -// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` +// Returning a Domain/Organization here allows the client to prefill it and prevents prompting the user. +// Use the configured default organization so its policies apply during SSO enrollment; otherwise return a dummy value. +// In use since `v2025.6.0`, the client appears to use only the first `organizationIdentifier`. #[post("/organizations/domain/sso/verified")] fn get_org_domain_sso_verified() -> JsonResult { - // Always return a dummy value, no matter if SSO is enabled or not + let organization_identifier = match CONFIG.sso_default_organization_uuid() { + Some(org_uuid) => crate::sso::normalize_organization_uuid(&org_uuid)?.to_string(), + None => FAKE_SSO_IDENTIFIER.to_owned(), + }; + Ok(Json(json!({ "object": "list", "data": [{ - "organizationIdentifier": FAKE_SSO_IDENTIFIER, - // These appear to be unused - "organizationName": FAKE_SSO_IDENTIFIER, + "organizationIdentifier": organization_identifier, + // This appears to be unused. + "organizationName": organization_identifier, "domainName": CONFIG.domain() }], "continuationToken": null diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..6de90b50 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -353,7 +353,7 @@ async fn sso_login( *user_id = Some(user.uuid.clone()); // We passed 2FA get auth tokens - let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; + let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, &ip.ip, conn).await?; authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } diff --git a/src/sso.rs b/src/sso.rs index 6fdb4ee2..da6df006 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,4 +1,4 @@ -use std::{sync::LazyLock, time::Duration}; +use std::{net::IpAddr, sync::LazyLock, time::Duration}; use chrono::Utc; use derive_more::{AsRef, Deref, Display, From, Into}; @@ -7,14 +7,14 @@ use url::Url; use crate::{ CONFIG, - api::ApiResult, + api::{ApiResult, core::log_event}, auth, auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper}, db::{ DbConn, models::{ - Device, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, OrganizationId, - SsoAuth, SsoUser, User, + Device, EventType, Membership, MembershipStatus, MembershipType, OIDCAuthenticatedUser, Organization, + OrganizationId, SsoAuth, SsoUser, User, }, }, sso_client::Client, @@ -319,6 +319,7 @@ pub async fn exchange_code( } // User has passed 2FA flow we can delete auth info from database +#[expect(clippy::too_many_arguments)] pub async fn redeem( device: &Device, user: &User, @@ -326,12 +327,13 @@ pub async fn redeem( sso_user: Option, sso_auth: SsoAuth, auth_user: OIDCAuthenticatedUser, + ip: &IpAddr, conn: &DbConn, ) -> ApiResult { sso_auth.delete(conn).await?; if sso_user.is_none() { - enroll_user_in_default_organization(user, conn).await?; + invite_user_to_default_organization(user, device.atype, ip, conn).await?; let user_sso = SsoUser { user_uuid: user.uuid.clone(), @@ -359,7 +361,12 @@ pub async fn redeem( } } -async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiResult<()> { +async fn invite_user_to_default_organization( + user: &User, + device_type: i32, + ip: &IpAddr, + conn: &DbConn, +) -> ApiResult<()> { let Some(org_uuid) = CONFIG.sso_default_organization_uuid() else { return Ok(()); }; @@ -374,11 +381,14 @@ async fn enroll_user_in_default_organization(user: &User, conn: &DbConn) -> ApiR } let mut membership = Membership::new(user.uuid.clone(), org_id.clone(), None); - membership.status = MembershipStatus::Accepted as i32; + membership.status = MembershipStatus::Invited 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); + log_event(EventType::OrganizationUserInvited as i32, &membership.uuid, &org_id, &user.uuid, device_type, ip, conn) + .await; + + info!("Invited SSO user {} to default organization {}", user.uuid, org_id); Ok(()) }