diff --git a/src/api/admin.rs b/src/api/admin.rs index 2a84f611..8d55050a 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -356,6 +356,9 @@ async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json { let users = User::get_all(&conn).await; let mut users_json = Vec::with_capacity(users.len()); for (u, _) in users { + if u.is_stage_only_placeholder(&conn).await { + continue; + } let mut usr = u.to_json(&conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); @@ -374,6 +377,9 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult ApiResult")] async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult { if let Some(u) = User::find_by_mail(mail, &conn).await { + if u.is_stage_only_placeholder(&conn).await { + err_code!("User doesn't exist", Status::NotFound.code); + } let mut usr = u.to_json(&conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 5813ee69..33f3fc7b 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -688,7 +688,7 @@ async fn password_emergency_access( // Remove grantor from all organisations unless Owner for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await { if member.atype != MembershipType::Owner as i32 { - member.delete(&conn).await?; + member.delete_with_staged_user_cleanup(&conn).await?; } } Ok(()) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 6fccc850..a2f8fce1 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,7 +8,7 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, @@ -50,6 +50,7 @@ pub fn routes() -> Vec { get_org_domain_sso_verified, get_members, send_invite, + send_invite_to_staged_members, reinvite_member, bulk_reinvite_members, confirm_invite, @@ -1024,9 +1025,14 @@ async fn send_invite( && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - let mut user_created: bool; + let mut invited_emails = HashSet::new(); for email in &data.emails { + if !invited_emails.insert(email.to_lowercase()) { + continue; + } let mut member_status = MembershipStatus::Invited as i32; + let mut staged_member = None; + let user_created; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -1047,8 +1053,14 @@ async fn send_invite( new_user } Some(user) => { - if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { - err!(format!("User already in organization: {}", email.escape_debug())) + if let Some(member) = Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await { + if member.status != MembershipStatus::Staged as i32 { + err!(format!("User already in organization: {}", email.escape_debug())) + } + if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { + err!("Invitations are not allowed") + } + staged_member = Some(member); } if !CONFIG.mail_enabled() { @@ -1064,7 +1076,12 @@ async fn send_invite( } }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); + let was_staged = staged_member.is_some(); + let mut new_member = staged_member + .unwrap_or_else(|| Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()))); + let previous_membership = + (new_member.status, new_member.atype, new_member.access_all, new_member.invited_by_email.clone()); + new_member.invited_by_email = Some(headers.user.email.clone()); new_member.access_all = access_all; new_member.atype = new_type; new_member.status = member_status; @@ -1087,7 +1104,13 @@ async fn send_invite( .await { // Upon error delete the user, invite and org member records when needed - if user_created { + if was_staged { + new_member.status = previous_membership.0; + new_member.atype = previous_membership.1; + new_member.access_all = previous_membership.2; + new_member.invited_by_email = previous_membership.3; + new_member.save(&conn).await?; + } else if user_created { user.delete(&conn).await?; } else { new_member.delete(&conn).await?; @@ -1108,6 +1131,12 @@ async fn send_invite( ) .await; + if was_staged { + // The invite dialog replaces staged access, even when no collections or groups were selected. + CollectionUser::delete_all_by_user_and_org(&user.uuid, &org_id, &conn).await?; + GroupUser::delete_all_by_member(&new_member.uuid, &conn).await?; + } + // If no accessAll, add the collections received if !access_all { for col in data.collections.iter().flatten() { @@ -1140,6 +1169,143 @@ async fn send_invite( Ok(()) } +#[post("/organizations//users/send-invite", data = "")] +async fn send_invite_to_staged_members( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + let mut requested = Vec::new(); + let mut eligible = Vec::new(); + let mut seen = HashSet::new(); + for member_id in data.into_inner().ids { + if !seen.insert(member_id.clone()) { + continue; + } + let error = match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { + None => "Member not found.", + Some(member) if member.status != MembershipStatus::Staged as i32 => { + "Only staged members can be sent an invitation." + } + Some(_) => { + eligible.push(member_id.clone()); + "" + } + }; + requested.push((member_id, error)); + } + + let mut promoted = Vec::new(); + for member_id in eligible { + match invite_staged_member_impl(&org_id, &member_id, &headers, &conn).await { + Ok(previous_inviter) => promoted.push((member_id, previous_inviter)), + Err(e) => { + if CONFIG.mail_enabled() { + for (promoted_id, previous_inviter) in promoted { + if let Some(mut member) = Membership::find_by_uuid_and_org(&promoted_id, &org_id, &conn).await { + member.status = MembershipStatus::Staged as i32; + member.invited_by_email = previous_inviter; + member.save(&conn).await?; + } + } + } + return Err(e); + } + } + } + + for (member_id, _) in &promoted { + log_event( + EventType::OrganizationUserInvited, + member_id, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + } + + let results: Vec = requested + .into_iter() + .map(|(member_id, error)| { + json!({ + "object": "OrganizationBulkConfirmResponseModel", + "id": member_id, + "error": error, + }) + }) + .collect(); + + Ok(Json(json!({ + "data": results, + "object": "list", + "continuationToken": null, + }))) +} + +async fn invite_staged_member_impl( + org_id: &OrganizationId, + member_id: &MembershipId, + headers: &AdminHeaders, + conn: &DbConn, +) -> ApiResult> { + let Some(mut member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + err!("Member not found.") + }; + if member.status != MembershipStatus::Staged as i32 { + err!("Only staged members can be sent an invitation.") + } + + let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else { + err!("User not found") + }; + if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { + err!("Invitations are not allowed") + } + + let invited_status = if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { + MembershipStatus::Accepted as i32 + } else { + MembershipStatus::Invited as i32 + }; + + let previous_inviter = member.invited_by_email.clone(); + + if CONFIG.mail_enabled() { + let Some(org) = Organization::find_by_uuid(org_id, conn).await else { + err!("Organization not found") + }; + member.status = invited_status; + member.invited_by_email = Some(headers.user.email.clone()); + member.save(conn).await?; + if let Err(e) = + mail::send_invite(&user, org_id.clone(), member.uuid.clone(), &org.name, Some(headers.user.email.clone())) + .await + { + member.status = MembershipStatus::Staged as i32; + member.invited_by_email = previous_inviter; + member.save(conn).await?; + err!(format!("Error sending invite: {e:?}")) + } + } else { + if user.password_hash.is_empty() { + Invitation::new(&user.email).save(conn).await?; + } + member.status = invited_status; + member.invited_by_email = Some(headers.user.email.clone()); + member.save(conn).await?; + } + + Ok(previous_inviter) +} + #[post("/organizations//users/reinvite", data = "")] async fn bulk_reinvite_members( org_id: OrganizationId, @@ -1690,7 +1856,7 @@ async fn delete_member_impl( } } - member_to_delete.delete(conn).await + member_to_delete.delete_with_staged_user_cleanup(conn).await } #[post("/organizations//users/public-keys", data = "")] @@ -2090,7 +2256,8 @@ async fn put_policy( // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. if member.atype < MembershipType::Admin - && member.status != MembershipStatus::Invited as i32 + && (member.status == MembershipStatus::Accepted as i32 + || member.status == MembershipStatus::Confirmed as i32) && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await > 0 { @@ -2262,6 +2429,7 @@ async fn revoke_member_impl( err!("Only owners can revoke other owners") } if member.atype == MembershipType::Owner + && member.status == MembershipStatus::Confirmed as i32 && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { err!("Organization must have at least one confirmed owner") @@ -2354,7 +2522,7 @@ async fn restore_member_impl( err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { - Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { + Some(mut member) if member.status <= MembershipStatus::Revoked as i32 => { if member.user_uuid == headers.user.uuid { err!("You cannot restore yourself") } @@ -2961,6 +3129,10 @@ async fn check_reset_password_applicable_and_permissions( err!("Reset target user not found") }; + if target_user.status != MembershipStatus::Confirmed as i32 { + err!("Organization user must be confirmed for password reset functionality") + } + // Resetting user must be higher/equal to user to reset match headers.membership_type { MembershipType::Owner => Ok(()), diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 1ee80515..d79c7ebf 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -14,8 +14,8 @@ use crate::{ db::{ DbConn, models::{ - Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, - OrganizationApiKey, OrganizationId, User, + Event, EventType, Group, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, + OrgPolicy, Organization, OrganizationApiKey, OrganizationId, User, }, }, mail, @@ -47,9 +47,33 @@ struct OrgImportData { groups: Vec, members: Vec, overwrite_existing: bool, + #[serde(default = "default_invite_users_after_provisioning")] + invite_users_after_provisioning: bool, // largeImport: bool, // For now this will not be used, upstream uses this to prevent syncs of more then 2000 users or groups without the flag set. } +fn default_invite_users_after_provisioning() -> bool { + true +} + +async fn log_directory_member_event( + event_type: EventType, + member_id: &MembershipId, + org_id: &OrganizationId, + conn: &DbConn, +) { + if !CONFIG.org_events_enabled() { + return; + } + + let mut event = Event::new(event_type as i32, None); + event.org_uuid = Some(org_id.clone()); + event.org_user_uuid = Some(member_id.clone()); + if let Err(e) = event.save(conn).await { + warn!("Could not log directory member event: {e:?}"); + } +} + #[post("/public/organization/import", data = "")] async fn ldap_import(data: Json, token: PublicToken, conn: DbConn) -> EmptyResult { // Most of the logic for this function can be found here @@ -58,20 +82,47 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn let org_id = token.0; let data = data.into_inner(); + let mut imported_emails = HashSet::new(); + let mut imported_external_ids = HashSet::new(); + for member in &data.members { + if (!member.email.is_empty() && !imported_emails.insert(member.email.to_lowercase())) + || !imported_external_ids.insert(member.external_id.as_str()) + { + err!("Duplicate member email or external ID in directory import") + } + } + // Like upstream, the import never removes Owners, so it doesn't revoke or restore them either. for user_data in &data.members { let mut user_created: bool = false; if user_data.deleted { - // If user is marked for deletion and it exists, revoke it - if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { - let revoked = member.atype != MembershipType::Owner && member.revoke(); - let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); - if revoked || ext_modified { - member.save(&conn).await?; - } + let member = if let Some(member) = + Membership::find_by_external_id_and_org(&user_data.external_id, &org_id, &conn).await + { + Some(member) + } else { + Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await + }; + if let Some(member) = member + && member.atype != MembershipType::Owner + { + let member_id = member.uuid.clone(); + member.delete_with_staged_user_cleanup(&conn).await?; + log_directory_member_event(EventType::OrganizationUserRemoved, &member_id, &org_id, &conn).await; } // If user is part of the organization, restore it - } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { + } else if let Some(mut member) = match Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await + { + Some(member) => Some(member), + None => Membership::find_by_external_id_and_org(&user_data.external_id, &org_id, &conn).await, + } { + if member.external_id.as_deref() != Some(user_data.external_id.as_str()) + && let Some(other) = + Membership::find_by_external_id_and_org(&user_data.external_id, &org_id, &conn).await + && other.uuid != member.uuid + { + err!("Directory external ID belongs to another member") + } let mut restored = member.atype != MembershipType::Owner && member.restore(); let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); // Enforce org policies as every other restore path does. @@ -91,15 +142,20 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn } else { // User does not exist yet let mut new_user = User::new(&user_data.email, None); + if !data.invite_users_after_provisioning { + new_user.mark_staged_placeholder(); + } new_user.save(&conn).await?; - if !CONFIG.mail_enabled() { + if data.invite_users_after_provisioning && !CONFIG.mail_enabled() { Invitation::new(&new_user.email).save(&conn).await?; } user_created = true; new_user }; - let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { + let member_status = if !data.invite_users_after_provisioning { + MembershipStatus::Staged as i32 + } else if CONFIG.mail_enabled() || user.password_hash.is_empty() { MembershipStatus::Invited as i32 } else { MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites @@ -111,7 +167,8 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn err!("Error looking up organization") }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); + let inviter = (member_status != MembershipStatus::Staged as i32).then(|| org_email.clone()); + let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), inviter); new_member.set_external_id(Some(user_data.external_id.clone())); new_member.access_all = false; new_member.atype = MembershipType::User as i32; @@ -119,7 +176,8 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn new_member.save(&conn).await?; - if CONFIG.mail_enabled() + if data.invite_users_after_provisioning + && CONFIG.mail_enabled() && let Err(e) = mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await { @@ -132,6 +190,13 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn err!(format!("Error sending invite: {e:?} ")); } + + let event_type = if member_status == MembershipStatus::Staged as i32 { + EventType::OrganizationUserStaged + } else { + EventType::OrganizationUserInvited + }; + log_directory_member_event(event_type, &new_member.uuid, &org_id, &conn).await; } } @@ -174,7 +239,9 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn && !sync_members.contains(user_external_id) && member.atype != MembershipType::Owner { - member.delete(&conn).await?; + let member_id = member.uuid.clone(); + member.delete_with_staged_user_cleanup(&conn).await?; + log_directory_member_event(EventType::OrganizationUserRemoved, &member_id, &org_id, &conn).await; } } } diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..a5f2cc1e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -216,6 +216,14 @@ async fn sso_login(data: ConnectData, user_id: &mut Option, conn: &DbCon } ) } + Some((user, None)) if user.is_stage_only_placeholder(conn).await => { + err!( + "This account is staged. Ask your organization administrator to send an invitation before signing in with SSO.", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } Some((user, None)) if user.private_key.is_none() && !CONFIG.sso_signups_allowed() diff --git a/src/auth.rs b/src/auth.rs index 57720382..653da941 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -737,7 +737,10 @@ impl OrgHeaders { fn is_member(&self) -> bool { // Only allow not revoked members, we can not use the Confirmed status here // as some endpoints can be triggered by invited users during joining - self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User + matches!( + self.membership_status, + MembershipStatus::Invited | MembershipStatus::Accepted | MembershipStatus::Confirmed + ) && self.membership_type >= MembershipType::User } fn is_confirmed_and_admin(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin @@ -814,7 +817,9 @@ impl<'r> FromRequest<'r> for OrgHeaders { } }, membership_status: { - if let Some(member_status) = MembershipStatus::from_i32(membership.status) { + if let Some(member_status) = MembershipStatus::from_i32(membership.status) + && member_status != MembershipStatus::Staged + { // NOTE: add additional check for revoked if from_i32 is ever changed // to return Revoked status. member_status diff --git a/src/db/models/event.rs b/src/db/models/event.rs index cc0eb504..ffd550d8 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -114,6 +114,7 @@ pub enum EventType { // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, OrganizationUserNotificationBannerActionClicked = 1522, + OrganizationUserStaged = 1523, // Organization OrganizationUpdated = 1600, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 31938f76..5b983d3d 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -309,7 +309,9 @@ impl OrgPolicy { } pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { - if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { + if m.atype < MembershipType::Admin + && [MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32].contains(&m.status) + { // Enforce TwoFactor/TwoStep login if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await && p.enabled diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72684782..3df14d51 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -74,6 +74,7 @@ pub enum MembershipStatus { Invited = 0, Accepted = 1, Confirmed = 2, + Staged = 3, } impl MembershipStatus { @@ -82,6 +83,7 @@ impl MembershipStatus { 0 => Some(Self::Invited), 1 => Some(Self::Accepted), 2 => Some(Self::Confirmed), + 3 => Some(Self::Staged), // NOTE: we don't care about revoked members where this is used // if this ever changes also adapt the OrgHeaders check. _ => None, @@ -407,7 +409,11 @@ impl Organization { .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(MembershipStatus::Revoked as i32)) + .filter(users_organizations::status.eq_any([ + MembershipStatus::Invited as i32, + MembershipStatus::Accepted as i32, + MembershipStatus::Confirmed as i32, + ])) .order(users_organizations::atype.asc()) .select(organizations::all_columns) .first::(conn) @@ -424,7 +430,11 @@ impl Organization { .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(MembershipStatus::Revoked as i32)) + .filter(users_organizations::status.eq_any([ + MembershipStatus::Invited as i32, + MembershipStatus::Accepted as i32, + MembershipStatus::Confirmed as i32, + ])) .order(users_organizations::atype.asc()) .select(organizations::all_columns) .load::(conn) @@ -549,7 +559,8 @@ impl Membership { self.status }; - let twofactor_enabled = !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(); + let staged = self.get_unrevoked_status() == MembershipStatus::Staged as i32; + let twofactor_enabled = !staged && !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(); let groups: Vec = if include_groups && CONFIG.org_groups_enabled() { GroupUser::find_by_member(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() @@ -615,19 +626,20 @@ impl Membership { json!({ "id": self.uuid, - "userId": self.user_uuid, - "name": if self.get_unrevoked_status() >= MembershipStatus::Accepted as i32 { Some(user.name) } else { None }, + // Vaultwarden keeps a User row for staging; Bitwarden exposes no linked user until invitation. + "userId": if staged { None } else { Some(&self.user_uuid) }, + "name": if [MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32].contains(&self.get_unrevoked_status()) { Some(user.name) } else { None }, "email": user.email, "externalId": self.external_id, - "avatarColor": user.avatar_color, + "avatarColor": if staged { None } else { Some(user.avatar_color) }, "groups": groups, "collections": collections, "status": status, "type": membership_type, "twoFactorEnabled": twofactor_enabled, - "resetPasswordEnrolled": self.reset_password_key.is_some(), - "hasMasterPassword": !user.password_hash.is_empty(), + "resetPasswordEnrolled": !staged && self.reset_password_key.is_some(), + "hasMasterPassword": !staged && !user.password_hash.is_empty(), "permissions": permissions, @@ -678,7 +690,7 @@ impl Membership { json!({ "id": self.uuid, - "userId": self.user_uuid, + "userId": if self.get_unrevoked_status() == MembershipStatus::Staged as i32 { None } else { Some(&self.user_uuid) }, "status": status, "type": self.atype, @@ -690,6 +702,7 @@ impl Membership { pub async fn to_json_mini_details(&self, conn: &DbConn) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); + let staged = self.get_unrevoked_status() == MembershipStatus::Staged as i32; // Because Bitwarden wants the status to be -1 for revoked users we need to catch that here. // We subtract/add a number so we can restore/activate the user to it's previous state again. @@ -701,10 +714,10 @@ impl Membership { json!({ "id": self.uuid, - "userId": self.user_uuid, + "userId": if staged { None } else { Some(&self.user_uuid) }, "type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type "status": status, - "name": user.name, + "name": if staged { None } else { Some(user.name) }, "email": user.email, "object": "organizationUserUserMiniDetails", }) @@ -749,9 +762,33 @@ impl Membership { .await } + pub async fn delete_with_staged_user_cleanup(self, conn: &DbConn) -> EmptyResult { + let placeholder = if self.get_unrevoked_status() == MembershipStatus::Staged as i32 { + if let Some(user) = User::find_by_uuid(&self.user_uuid, conn).await + && user.has_staged_placeholder_marker() + && user.is_stage_only_placeholder(conn).await + { + Some(user) + } else { + None + } + } else { + None + }; + + // This also removes collection and group assignments belonging to the membership. + self.delete(conn).await?; + + if let Some(user) = placeholder { + user.delete_staged_placeholder_if_orphaned(conn).await?; + } + + Ok(()) + } + pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { for member in Self::find_by_org(org_uuid, conn).await { - member.delete(conn).await?; + member.delete_with_staged_user_cleanup(conn).await?; } Ok(()) } @@ -1134,7 +1171,11 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .filter(users_organizations::status.eq_any([ + MembershipStatus::Invited as i32, + MembershipStatus::Accepted as i32, + MembershipStatus::Confirmed as i32, + ])) .order(users_organizations::atype.asc()) .first::(conn) .ok() diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 2f3afc02..dd2436ff 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -10,7 +10,10 @@ use crate::{ db::{ DbConn, models::DeviceId, - schema::{invitations, sso_users, twofactor_incomplete, users}, + schema::{ + archives, auth_requests, ciphers, devices, emergency_access, favorites, folders, invitations, sends, + sso_users, twofactor, twofactor_incomplete, users, users_collections, users_organizations, + }, }, error::MapResult, sso::OIDCIdentifier, @@ -19,10 +22,69 @@ use crate::{ use macros::UuidFromParam; use super::{ - AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, - TwoFactorIncomplete, + AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipStatus, MembershipType, + TwoFactor, TwoFactorIncomplete, }; +// Keep the placeholder checks in one database statement, including the delete's final guard. +macro_rules! uninitialized_staged_user { + ($user_uuid:expr, $email:expr) => { + users::table + .filter(users::uuid.eq($user_uuid)) + .filter(users::email.eq($email)) + .filter(users::enabled.eq(true)) + .filter(users::name.eq(users::email)) + .filter(users::password_hash.eq(Vec::::new())) + .filter(users::akey.eq("")) + .filter(users::private_key.is_null()) + .filter(users::public_key.is_null()) + .filter(users::api_key.is_null()) + .filter(users::key_id.is_null()) + .filter(users::verified_at.is_null()) + .filter(users::last_verifying_at.is_null()) + .filter(users::login_verify_count.eq(0)) + .filter(users::email_new.is_null()) + .filter(users::email_new_token.is_null()) + .filter(users::password_hint.is_null()) + .filter(users::totp_secret.is_null()) + .filter(users::totp_recover.is_null()) + .filter(users::stamp_exception.is_null()) + .filter(users::equivalent_domains.eq("[]")) + .filter(users::excluded_globals.eq("[]")) + .filter(users::client_kdf_type.eq(User::CLIENT_KDF_TYPE_DEFAULT)) + .filter(users::client_kdf_iter.eq(User::CLIENT_KDF_ITER_DEFAULT)) + .filter(users::client_kdf_memory.is_null()) + .filter(users::client_kdf_parallelism.is_null()) + .filter(users::avatar_color.is_null()) + .filter(diesel::dsl::not(diesel::dsl::exists(invitations::table.filter(invitations::email.eq($email))))) + .filter(diesel::dsl::not(diesel::dsl::exists(sso_users::table.filter(sso_users::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(ciphers::table.filter(ciphers::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(devices::table.filter(devices::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(folders::table.filter(folders::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(sends::table.filter(sends::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(twofactor::table.filter(twofactor::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists( + twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq($user_uuid)), + ))) + .filter(diesel::dsl::not(diesel::dsl::exists( + auth_requests::table.filter(auth_requests::user_uuid.eq($user_uuid)), + ))) + .filter(diesel::dsl::not(diesel::dsl::exists(favorites::table.filter(favorites::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists(archives::table.filter(archives::user_uuid.eq($user_uuid))))) + .filter(diesel::dsl::not(diesel::dsl::exists( + users_collections::table.filter(users_collections::user_uuid.eq($user_uuid)), + ))) + .filter(diesel::dsl::not(diesel::dsl::exists( + emergency_access::table.filter( + emergency_access::grantor_uuid + .eq($user_uuid) + .or(emergency_access::grantee_uuid.eq($user_uuid)) + .or(emergency_access::email.eq($email)), + ), + ))) + }; +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] @@ -50,8 +112,8 @@ pub struct User { pub private_key: Option, pub public_key: Option, - #[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User - _totp_secret: Option, + #[diesel(column_name = "totp_secret")] + totp_secret: Option, pub totp_recover: Option, pub security_stamp: String, @@ -112,6 +174,19 @@ impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; + fn staged_placeholder_marker(&self) -> String { + format!("vaultwarden:staged-placeholder:{}", self.uuid) + } + + pub fn mark_staged_placeholder(&mut self) { + // This otherwise unused User field records provenance without changing the database schema. + self.external_id = Some(self.staged_placeholder_marker()); + } + + pub fn has_staged_placeholder_marker(&self) -> bool { + self.external_id.as_ref().is_some_and(|marker| marker == &self.staged_placeholder_marker()) + } + pub fn new(email: &str, name: Option) -> Self { let now = Utc::now().naive_utc(); let email = email.to_lowercase(); @@ -141,7 +216,7 @@ impl User { private_key: None, public_key: None, - _totp_secret: None, + totp_secret: None, totp_recover: None, equivalent_domains: "[]".to_owned(), @@ -262,6 +337,58 @@ impl User { /// Database methods impl User { + /// Staged provisioning creates a bare User row because memberships require a user UUID. + /// Other invited or initialized accounts must remain visible and must never be cleaned up as placeholders. + pub async fn is_stage_only_placeholder(&self, conn: &DbConn) -> bool { + // Initialized users cannot be staging placeholders; avoid database lookups for them. + if !self.password_hash.is_empty() || !self.akey.is_empty() { + return false; + } + let memberships = Membership::find_any_state_by_user(&self.uuid, conn).await; + !memberships.is_empty() + && memberships.iter().all(|member| member.get_unrevoked_status() == MembershipStatus::Staged as i32) + && self.is_uninitialized_stage_placeholder(conn).await + } + + pub async fn is_uninitialized_stage_placeholder(&self, conn: &DbConn) -> bool { + let user_uuid = self.uuid.clone(); + let email = self.email.clone(); + let marker = self.staged_placeholder_marker(); + conn.run(move |conn| { + let query = uninitialized_staged_user!(&user_uuid, &email) + .filter(users::external_id.is_null().or(users::external_id.eq(&marker))); + diesel::select(diesel::dsl::exists(query)).get_result::(conn).unwrap_or(false) + }) + .await + } + + /// Only delete the bare User row when it is still an orphaned staged placeholder. + /// Concurrent changes leave the User intact, even if a new foreign key blocks the delete. + pub async fn delete_staged_placeholder_if_orphaned(&self, conn: &DbConn) -> EmptyResult { + let user_uuid = self.uuid.clone(); + let email = self.email.clone(); + let marker = self.staged_placeholder_marker(); + conn.run(move |conn| { + let result = diesel::delete( + uninitialized_staged_user!(&user_uuid, &email).filter(users::external_id.eq(&marker)).filter( + diesel::dsl::not(diesel::dsl::exists( + users_organizations::table.filter(users_organizations::user_uuid.eq(&user_uuid)), + )), + ), + ) + .execute(conn); + match result { + Ok(_) + | Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, + _, + )) => Ok(()), + Err(e) => Err::<(), _>(e).map_res("Error deleting orphaned staged placeholder"), + } + }) + .await + } + pub fn verified(&self) -> bool { !CONFIG.mail_enabled() || self.verified_at.is_some() } @@ -338,6 +465,18 @@ impl User { err!(format!("User email {} is not a valid email address", self.email.escape_debug())) } + if self.has_staged_placeholder_marker() + && (!self.password_hash.is_empty() + || !self.akey.is_empty() + || self.verified_at.is_some() + || self.private_key.is_some() + || self.public_key.is_some() + || self.api_key.is_some() + || self.key_id.is_some()) + { + self.external_id = None; + } + self.updated_at = Utc::now().naive_utc(); db_run! { conn: diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..ca2eaac1 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -392,6 +392,7 @@ allow_tables_to_appear_in_same_query!( ciphers_collections, collections, devices, + favorites, folders, folders_ciphers, invitations, @@ -400,6 +401,7 @@ allow_tables_to_appear_in_same_query!( sends, sso_users, twofactor, + twofactor_incomplete, users, users_collections, users_organizations,