diff --git a/.env.template b/.env.template index b1b895a28a..c38368126c 100644 --- a/.env.template +++ b/.env.template @@ -386,6 +386,12 @@ # SSO_ROLES_DEFAULT_TO_USER=true ## Access token path to read roles # SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles +## Controls whether to add users to organization +# SSO_ORGANIZATIONS_INVITE=false +## Optional scope to retrieve user organizations +# SSO_ORGANIZATIONS_SCOPE=groups +## Access token path to read groups +# SSO_ORGANIZATIONS_TOKEN_PATH=/groups ## Set the lifetime of admin sessions to this value (in minutes). # ADMIN_SESSION_LIFETIME=20 diff --git a/README.md b/README.md index f620f55388..5015db3577 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,40 @@ This feature is controlled by the following conf: - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token + +### Group/Organization invitation mapping + +Allow to invite user to existing Oganization if they are listed in the Access token. +If activated it will check if the token contain a list of potential Orgnaization. +If an Oganization with a matching name (case sensitive) is found it will the start the invitation process for this user. +It will use the email associated with the Organization to send further notifications (admin side). + +The flow look like this: + +- Decode the JWT Access token and check if a list of organization is present (default path is `/groups`). +- Check if an Organization with a matching name exist and the user is not part of it. +- Depending on `SSO_ACCEPTALL_INVITES` : +- `false` - Invite the user to the Orgnization + - The user will need to click on the link in the mail he received + - A notification is sent tto he `email` associated with the Organization that a new user is ready to join + - An admin will have to validate the user to finalize the user joining the org. +- `true` - Add the user to the Organization + - A notification is sent to the user to inform of the enrollment in the org + - A notification is sent to the `email` associated with the Organization that a new user is ready to join + - An admin will have to validate the user to confirm the user joining the org. + +If email are disabled then the user will silently be enrolled and the admin will need to check the org to finish the process. + +One of the bonus of invitation is that if an organization define a specific password policy then it will apply to new user when they set their new master password. +If a user is part of two organizations then it will order them using the role of the user (`Owner`, `Admin`, `User` or `Manager` for now manager is last :() and return the password policy of the first one. + +This feature is controlled with the following conf: + +- `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false` +- `SSO_ORGANIZATIONS_SCOPE`: Optional scope to request if needed +- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token + + ## Docker Change the docker files to package both front-end from [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases). diff --git a/SSO.md b/SSO.md index 00ffc09602..6448ef2699 100644 --- a/SSO.md +++ b/SSO.md @@ -25,6 +25,9 @@ The following configurations are available - `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false` - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token + - `SSO_ORGANIZATIONS_INVIT`: control if the mapping is done, default is `false` + - `SSO_ORGANIZATIONS_SCOPE`: Optional scope to request if needed + - `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token The callback url is : `https://your.domain/identity/connect/oidc-signin` diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 7712ea824a..95f9d8efa3 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -3,7 +3,7 @@ mod ciphers; mod emergency_access; mod events; mod folders; -mod organizations; +pub mod organizations; mod public; mod sends; pub mod two_factor; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 73916e77df..d8457daa97 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -10,6 +10,7 @@ use crate::{ UpdateType, }, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, + business::organization_logic, db::{models::*, DbConn}, error::Error, mail, @@ -308,10 +309,19 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json } // Called during the SSO enrollment -#[get("/organizations/<_identifier>/auto-enroll-status")] -fn get_auto_enroll_status(_identifier: &str) -> JsonResult { +// We return the org_id if it exists ortherwise we return the first associated with the user +#[get("/organizations//auto-enroll-status")] +async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let org_id = match Organization::find_by_name(identifier, &mut conn).await.map(|o| o.uuid) { + Some(org_id) => org_id, + None => UserOrganization::find_main_user_org(&headers.user.uuid, &mut conn) + .await + .map(|uo| uo.org_uuid) + .unwrap_or_else(|| "null".to_string()), + }; + Ok(Json(json!({ - "Id": "null", + "Id": org_id, "ResetPasswordEnabled": false, // Not implemented }))) } @@ -793,13 +803,25 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } -// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrgDomainDetails { + Email: String, +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// VaultWarden sso login is not linked to Org so we set a dummy value. -#[post("/organizations/domain/sso/details")] -fn get_org_domain_sso_details() -> JsonResult { +// So we either return an Org name associated to the user or a dummy value. +#[post("/organizations/domain/sso/details", data = "")] +async fn get_org_domain_sso_details(data: JsonUpcase, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner().data; + + let identifier = match Organization::find_main_org_user_email(&data.Email, &mut conn).await { + Some(org) => org.name, + None => crate::sso::FAKE_IDENTIFIER.clone(), + }; + Ok(Json(json!({ - "organizationIdentifier": "vaultwarden", + "organizationIdentifier": identifier, "ssoAvailable": CONFIG.sso_enabled() }))) } @@ -871,10 +893,10 @@ async fn post_org_keys( #[derive(Deserialize)] #[allow(non_snake_case)] -struct CollectionData { - Id: String, - ReadOnly: bool, - HidePasswords: bool, +pub struct CollectionData { + pub Id: String, + pub ReadOnly: bool, + pub HidePasswords: bool, } #[derive(Deserialize)] @@ -897,7 +919,7 @@ async fn send_invite( let data: InviteData = data.into_inner().data; let new_type = match UserOrgType::from_str(&data.Type.into_string()) { - Some(new_type) => new_type as i32, + Some(new_type) => new_type, None => err!("Invalid type"), }; @@ -905,9 +927,15 @@ async fn send_invite( err!("Only Owners can invite Managers, Admins or Owners") } + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org, + None => err!("Error looking up organization"), + }; + + let collections = data.Collections.into_iter().flatten().collect(); + for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { @@ -930,70 +958,24 @@ async fn send_invite( Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await.is_some() { err!(format!("User already in organization: {email}")) - } else { - // automatically accept existing users if mail is disabled - if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - user_org_status = UserOrgStatus::Accepted as i32; - } - user } + user } }; - let mut new_user = - UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone())); - let access_all = data.AccessAll.unwrap_or(false); - new_user.access_all = access_all; - new_user.atype = new_type; - new_user.status = user_org_status; - - // If no accessAll, add the collections received - if !access_all { - for col in data.Collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.Id, org_id, &mut conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, &mut conn) - .await?; - } - } - } - } - - new_user.save(&mut conn).await?; - - for group in data.Groups.iter() { - let mut group_entry = GroupUser::new(String::from(group), user.uuid.clone()); - group_entry.save(&mut conn).await?; - } - - log_event( - EventType::OrganizationUserInvited as i32, - &new_user.uuid, - org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, + organization_logic::invite( + &user, + &headers.device, + &headers.ip, + &org, + new_type, + &data.Groups, + data.AccessAll.unwrap_or(false), + &collections, + headers.user.email.clone(), &mut conn, ) - .await; - - if CONFIG.mail_enabled() { - let org_name = match Organization::find_by_uuid(org_id, &mut conn).await { - Some(org) => org.name, - None => err!("Error looking up organization"), - }; - - mail::send_invite( - &email, - &user.uuid, - Some(String::from(org_id)), - Some(new_user.uuid), - &org_name, - Some(headers.user.email.clone()), - ) - .await?; - } + .await?; } Ok(()) @@ -1709,20 +1691,24 @@ async fn list_policies_invited_user(org_id: &str, userId: &str, mut conn: DbConn } // Called during the SSO enrollment. +// Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] -fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult { - let data = match CONFIG.sso_master_password_policy() { - Some(policy) => policy, - None => "null".to_string(), - }; +async fn get_policy_master_password(org_id: &str, _headers: Headers, mut conn: DbConn) -> JsonResult { + let policy = + OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; - let policy = OrgPolicy { - uuid: String::from(org_id), - org_uuid: String::from(org_id), - atype: OrgPolicyType::MasterPassword as i32, - enabled: CONFIG.sso_master_password_policy().is_some(), - data, - }; + OrgPolicy { + uuid: String::from(org_id), + org_uuid: String::from(org_id), + atype: OrgPolicyType::MasterPassword as i32, + enabled: CONFIG.sso_master_password_policy().is_some(), + data, + } + }); Ok(Json(policy.to_json())) } diff --git a/src/api/identity.rs b/src/api/identity.rs index 51c127f5b1..59e38dc38f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -206,6 +206,8 @@ async fn _sso_login( // Set the user_uuid here to be passed back used for event logging. *user_uuid = Some(user.uuid.clone()); + sso::sync_groups(&user, &device, ip, &auth_user.groups, conn).await?; + if auth_user.is_admin() { info!("User {} logged with admin cookie", user.email); cookies.add(admin::create_admin_cookie()); diff --git a/src/business/mod.rs b/src/business/mod.rs new file mode 100644 index 0000000000..ee5cb8e469 --- /dev/null +++ b/src/business/mod.rs @@ -0,0 +1 @@ +pub mod organization_logic; diff --git a/src/business/organization_logic.rs b/src/business/organization_logic.rs new file mode 100644 index 0000000000..c9d4ca0f83 --- /dev/null +++ b/src/business/organization_logic.rs @@ -0,0 +1,88 @@ +use crate::{ + api::{core::log_event, core::organizations::CollectionData, ApiResult}, + auth::ClientIp, + db::models::*, + db::DbConn, + mail, CONFIG, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn invite( + user: &User, + device: &Device, + ip: &ClientIp, + org: &Organization, + user_org_type: UserOrgType, + groups: &Vec, + access_all: bool, + collections: &Vec, + invited_by_email: String, + conn: &mut DbConn, +) -> ApiResult<()> { + let mut user_org_status = UserOrgStatus::Invited; + + // automatically accept existing users if mail is disabled + if (!CONFIG.mail_enabled() && !user.password_hash.is_empty()) + || (CONFIG.sso_enabled() && CONFIG.sso_acceptall_invites()) + { + user_org_status = UserOrgStatus::Accepted; + } + + let mut new_uo = UserOrganization::new(user.uuid.clone(), org.uuid.clone(), Some(invited_by_email.clone())); + new_uo.access_all = access_all; + new_uo.atype = user_org_type as i32; + new_uo.status = user_org_status as i32; + + // If no accessAll, add the collections received + if !access_all { + for col in collections { + match Collection::find_by_uuid_and_org(&col.Id, &org.uuid, conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => { + CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, conn).await?; + } + } + } + } + + new_uo.save(conn).await?; + + for group in groups { + let mut group_entry = GroupUser::new(group.clone(), user.uuid.clone()); + group_entry.save(conn).await?; + } + + log_event( + EventType::OrganizationUserInvited as i32, + &new_uo.uuid, + &org.uuid, + &user.uuid, + device.atype, + &ip.ip, + conn, + ) + .await; + + if CONFIG.mail_enabled() { + match user_org_status { + UserOrgStatus::Invited => { + mail::send_invite( + &user.email, + &user.uuid, + Some(org.uuid.clone()), + Some(new_uo.uuid), + &org.name, + new_uo.invited_by_email.clone(), + ) + .await? + } + UserOrgStatus::Accepted => { + mail::send_org_enrolled(&user.email, &org.name, Some(invited_by_email.clone())).await?; + mail::send_invite_accepted(&user.email, &invited_by_email, &org.name).await?; + } + UserOrgStatus::Revoked | UserOrgStatus::Confirmed => (), + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 46e14650ab..6befe8636f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -637,6 +637,12 @@ make_config! { sso_roles_default_to_user: bool, true, def, true; /// Access token path to read roles sso_roles_token_path: String, false, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id); + /// Invite users to Organizations + sso_organizations_invite: bool, true, def, false; + /// Optional scope to retrieve user Organization/Groups + sso_organizations_scope: String, true, option; + /// Access token path to read Organization/Groups + sso_organizations_token_path: String, true, def, "/groups".to_string(); }, /// Yubikey settings diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index b7f29143bf..843fc30c13 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -48,6 +48,7 @@ db_object! { } // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs +#[derive(Copy, Clone)] pub enum UserOrgStatus { Revoked = -1, Invited = 0, @@ -335,11 +336,36 @@ impl Organization { }} } + pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } + + pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } } impl UserOrganization { @@ -845,6 +871,17 @@ impl UserOrganization { .first::(conn).ok().from_db() }} } + + pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.ne(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/main.rs b/src/main.rs index 7fca0563ef..d146abc7cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,6 +77,7 @@ mod auth; mod config; mod crypto; #[macro_use] +mod business; mod db; mod mail; mod ratelimit; diff --git a/src/sso.rs b/src/sso.rs index bd023726b6..04374e261d 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,4 +1,5 @@ use chrono::Utc; +use std::collections::HashMap; use std::sync::RwLock; use std::time::Duration; use url::Url; @@ -14,15 +15,18 @@ use openidconnect::{ }; use crate::{ + api::core::organizations::CollectionData, api::ApiResult, auth, - auth::AuthMethodScope, - db::models::{Device, EventType, SsoNonce, User}, + auth::{AuthMethodScope, ClientIp}, + business::organization_logic, + db::models::{Device, EventType, Organization, SsoNonce, User, UserOrgType, UserOrganization}, db::DbConn, CONFIG, }; pub static COOKIE_NAME_REDIRECT: Lazy = Lazy::new(|| "sso_redirect_url".to_string()); +pub static FAKE_IDENTIFIER: Lazy = Lazy::new(|| "VaultWarden".to_string()); static AC_CACHE: Lazy> = Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); @@ -67,6 +71,14 @@ async fn cached_client() -> ApiResult { // The `nonce` allow to protect against replay attacks pub async fn authorize_url(mut conn: DbConn, state: String) -> ApiResult { + let mut scopes = vec![Scope::new("email".to_string()), Scope::new("profile".to_string())]; + + if CONFIG.sso_organizations_invite() { + if let Some(scope) = CONFIG.sso_organizations_scope() { + scopes.push(Scope::new(scope)); + } + } + let (auth_url, _csrf_state, nonce) = cached_client() .await? .authorize_url( @@ -74,8 +86,7 @@ pub async fn authorize_url(mut conn: DbConn, state: String) -> ApiResult { || CsrfToken::new(state), Nonce::new_random, ) - .add_scope(Scope::new("email".to_string())) - .add_scope(Scope::new("profile".to_string())) + .add_scopes(scopes) .url(); let sso_nonce = SsoNonce::new(nonce.secret().to_string()); @@ -107,6 +118,7 @@ impl BasicTokenPayload { #[derive(Debug)] struct AccessTokenPayload { role: Option, + groups: Vec, } #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] @@ -124,6 +136,7 @@ pub struct AuthenticatedUser { pub email: String, pub user_name: Option, pub role: Option, + pub groups: Vec, } impl AuthenticatedUser { @@ -181,10 +194,46 @@ impl Decoding { } } + // Errors are logged but will return None + fn roles(email: &str, token: &serde_json::Value) -> Option { + if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) { + match serde_json::from_value::>(json_roles.clone()) { + Ok(mut roles) => { + roles.sort(); + roles.into_iter().next() + } + Err(err) => { + debug!("Failed to parse user ({email}) roles: {err}"); + None + } + } + } else { + debug!("No roles in {email} access_token"); + None + } + } + + // Errors are logged but will return an empty Vec + fn groups(email: &str, token: &serde_json::Value) -> Vec { + if let Some(json_groups) = token.pointer(&CONFIG.sso_organizations_token_path()) { + match serde_json::from_value::>(json_groups.clone()) { + Ok(groups) => groups, + Err(err) => { + error!("Failed to parse user ({email}) groups: {err}"); + Vec::new() + } + } + } else { + debug!("No groups in {email} access_token"); + Vec::new() + } + } + fn access_token(&self, email: &str, access_token: &AccessToken) -> ApiResult { let mut role = None; + let mut groups = Vec::new(); - if CONFIG.sso_roles_enabled() { + if CONFIG.sso_roles_enabled() && CONFIG.sso_organizations_invite() { let access_token_str = access_token.secret(); self.log_debug("access_token", access_token_str); @@ -192,15 +241,21 @@ impl Decoding { match jsonwebtoken::decode::(access_token_str, &self.key, &self.access_validation) { Err(err) => err!(format!("Could not decode access token: {:?}", err)), Ok(payload) => { - role = decode_roles(email, &payload.claims); - if !CONFIG.sso_roles_default_to_user() && role.is_none() { - info!("User {email} failed to login due to missing/invalid role"); - err!( - "Invalid user role. Contact your administrator", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + if CONFIG.sso_roles_enabled() { + role = Self::roles(email, &payload.claims); + if !CONFIG.sso_roles_default_to_user() && role.is_none() { + info!("User {email} failed to login due to missing/invalid role"); + err!( + "Invalid user role. Contact your administrator", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + } + + if CONFIG.sso_organizations_invite() { + groups = Self::groups(email, &payload.claims); } } } @@ -208,6 +263,7 @@ impl Decoding { Ok(AccessTokenPayload { role, + groups, }) } @@ -275,27 +331,6 @@ pub struct UserInformation { pub user_name: Option, } -// Errors are logged but will return None -fn decode_roles(email: &str, token: &serde_json::Value) -> Option { - let roles_path = CONFIG.sso_roles_token_path(); - - if let Some(json_roles) = token.pointer(&roles_path) { - match serde_json::from_value::>(json_roles.clone()) { - Ok(mut roles) => { - roles.sort(); - roles.into_iter().next() - } - Err(err) => { - debug!("Failed to parse user ({email}) roles: {err}"); - None - } - } - } else { - debug!("No roles in {email} access_token"); - None - } -} - async fn retrieve_user_info(client: &CoreClient, access_token: AccessToken) -> ApiResult { let endpoint = match client.user_info(access_token, None) { Err(err) => err!(format!("No user_info endpoint: {err}")), @@ -352,6 +387,7 @@ pub async fn exchange_code(code: &String) -> ApiResult { email: email.clone(), user_name: user_name.clone(), role: access_token.role, + groups: access_token.groups, }; AC_CACHE.insert(code.clone(), authenticated_user); @@ -442,3 +478,42 @@ pub async fn exchange_refresh_token( err!("Impossible to retrieve new access token, refresh_token is missing") } } + +pub async fn sync_groups( + user: &User, + device: &Device, + ip: &ClientIp, + groups: &Vec, + conn: &mut DbConn, +) -> ApiResult<()> { + if CONFIG.sso_organizations_invite() { + let db_user_orgs = UserOrganization::find_any_state_by_user(&user.uuid, conn).await; + let user_orgs = db_user_orgs.iter().map(|uo| (uo.org_uuid.clone(), uo)).collect::>(); + + let org_groups: Vec = vec![]; + let org_collections: Vec = vec![]; + + for group in groups { + if let Some(org) = Organization::find_by_name(group, conn).await { + if user_orgs.get(&org.uuid).is_none() { + info!("Invitation to {} organization sent to {}", group, user.email); + organization_logic::invite( + user, + device, + ip, + &org, + UserOrgType::User, + &org_groups, + true, + &org_collections, + org.billing_email.clone(), + conn, + ) + .await?; + } + } + } + } + + Ok(()) +}