Browse Source

Merge 3f75a06c9d into 58a980f850

pull/7818/merge
Tom 2 days ago
committed by GitHub
parent
commit
0232a60905
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      src/api/admin.rs
  2. 2
      src/api/core/emergency_access.rs
  3. 190
      src/api/core/organizations.rs
  4. 97
      src/api/core/public.rs
  5. 8
      src/api/identity.rs
  6. 9
      src/auth.rs
  7. 1
      src/db/models/event.rs
  8. 4
      src/db/models/org_policy.rs
  9. 67
      src/db/models/organization.rs
  10. 151
      src/db/models/user.rs
  11. 2
      src/db/schema.rs

9
src/api/admin.rs

@ -356,6 +356,9 @@ async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json<Value> {
let users = User::get_all(&conn).await; let users = User::get_all(&conn).await;
let mut users_json = Vec::with_capacity(users.len()); let mut users_json = Vec::with_capacity(users.len());
for (u, _) in users { for (u, _) in users {
if u.is_stage_only_placeholder(&conn).await {
continue;
}
let mut usr = u.to_json(&conn).await; let mut usr = u.to_json(&conn).await;
usr["userEnabled"] = json!(u.enabled); usr["userEnabled"] = json!(u.enabled);
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); 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<Html<Stri
let users = User::get_all(&conn).await; let users = User::get_all(&conn).await;
let mut users_json = Vec::with_capacity(users.len()); let mut users_json = Vec::with_capacity(users.len());
for (u, sso_u) in users { for (u, sso_u) in users {
if u.is_stage_only_placeholder(&conn).await {
continue;
}
let mut usr = u.to_json(&conn).await; let mut usr = u.to_json(&conn).await;
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await); usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await);
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await); usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await);
@ -397,6 +403,9 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
#[get("/users/by-mail/<mail>")] #[get("/users/by-mail/<mail>")]
async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult { 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 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; let mut usr = u.to_json(&conn).await;
usr["userEnabled"] = json!(u.enabled); usr["userEnabled"] = json!(u.enabled);
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));

2
src/api/core/emergency_access.rs

@ -688,7 +688,7 @@ async fn password_emergency_access(
// Remove grantor from all organisations unless Owner // Remove grantor from all organisations unless Owner
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await { for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await {
if member.atype != MembershipType::Owner as i32 { if member.atype != MembershipType::Owner as i32 {
member.delete(&conn).await?; member.delete_with_staged_user_cleanup(&conn).await?;
} }
} }
Ok(()) Ok(())

190
src/api/core/organizations.rs

@ -8,7 +8,7 @@ use crate::{
CONFIG, CONFIG,
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
@ -50,6 +50,7 @@ pub fn routes() -> Vec<Route> {
get_org_domain_sso_verified, get_org_domain_sso_verified,
get_members, get_members,
send_invite, send_invite,
send_invite_to_staged_members,
reinvite_member, reinvite_member,
bulk_reinvite_members, bulk_reinvite_members,
confirm_invite, confirm_invite,
@ -1024,9 +1025,14 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == 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 { for email in &data.emails {
if !invited_emails.insert(email.to_lowercase()) {
continue;
}
let mut member_status = MembershipStatus::Invited as i32; 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 { let user = match User::find_by_mail(email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { if !CONFIG.invitations_allowed() {
@ -1047,8 +1053,14 @@ async fn send_invite(
new_user new_user
} }
Some(user) => { Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { if let Some(member) = Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await {
err!(format!("User already in organization: {}", email.escape_debug())) 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() { 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.access_all = access_all;
new_member.atype = new_type; new_member.atype = new_type;
new_member.status = member_status; new_member.status = member_status;
@ -1087,7 +1104,13 @@ async fn send_invite(
.await .await
{ {
// Upon error delete the user, invite and org member records when needed // 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?; user.delete(&conn).await?;
} else { } else {
new_member.delete(&conn).await?; new_member.delete(&conn).await?;
@ -1108,6 +1131,12 @@ async fn send_invite(
) )
.await; .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 no accessAll, add the collections received
if !access_all { if !access_all {
for col in data.collections.iter().flatten() { for col in data.collections.iter().flatten() {
@ -1140,6 +1169,143 @@ async fn send_invite(
Ok(()) Ok(())
} }
#[post("/organizations/<org_id>/users/send-invite", data = "<data>")]
async fn send_invite_to_staged_members(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
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<Value> = 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<Option<String>> {
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/<org_id>/users/reinvite", data = "<data>")] #[post("/organizations/<org_id>/users/reinvite", data = "<data>")]
async fn bulk_reinvite_members( async fn bulk_reinvite_members(
org_id: OrganizationId, 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/<org_id>/users/public-keys", data = "<data>")] #[post("/organizations/<org_id>/users/public-keys", data = "<data>")]
@ -2090,7 +2256,8 @@ async fn put_policy(
// Exclude invited and revoked users when checking for this 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. // Those users will not be allowed to accept or be activated because of the policy checks done there.
if member.atype < MembershipType::Admin 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 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await
> 0 > 0
{ {
@ -2262,6 +2429,7 @@ async fn revoke_member_impl(
err!("Only owners can revoke other owners") err!("Only owners can revoke other owners")
} }
if member.atype == MembershipType::Owner 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 && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
{ {
err!("Organization must have at least one confirmed owner") 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"); err!("Organization not found", "Organization id's do not match");
} }
match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { 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 { if member.user_uuid == headers.user.uuid {
err!("You cannot restore yourself") err!("You cannot restore yourself")
} }
@ -2961,6 +3129,10 @@ async fn check_reset_password_applicable_and_permissions(
err!("Reset target user not found") 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 // Resetting user must be higher/equal to user to reset
match headers.membership_type { match headers.membership_type {
MembershipType::Owner => Ok(()), MembershipType::Owner => Ok(()),

97
src/api/core/public.rs

@ -14,8 +14,8 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, Event, EventType, Group, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrganizationApiKey, OrganizationId, User, OrgPolicy, Organization, OrganizationApiKey, OrganizationId, User,
}, },
}, },
mail, mail,
@ -47,9 +47,33 @@ struct OrgImportData {
groups: Vec<OrgImportGroupData>, groups: Vec<OrgImportGroupData>,
members: Vec<OrgImportUserData>, members: Vec<OrgImportUserData>,
overwrite_existing: bool, 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. // 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 = "<data>")] #[post("/public/organization/import", data = "<data>")]
async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn) -> EmptyResult { async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn) -> EmptyResult {
// Most of the logic for this function can be found here // Most of the logic for this function can be found here
@ -58,20 +82,47 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0; let org_id = token.0;
let data = data.into_inner(); 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. // Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members { for user_data in &data.members {
let mut user_created: bool = false; let mut user_created: bool = false;
if user_data.deleted { if user_data.deleted {
// If user is marked for deletion and it exists, revoke it let member = if let Some(member) =
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { Membership::find_by_external_id_and_org(&user_data.external_id, &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())); Some(member)
if revoked || ext_modified { } else {
member.save(&conn).await?; 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 // 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 mut restored = member.atype != MembershipType::Owner && member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does. // Enforce org policies as every other restore path does.
@ -91,15 +142,20 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
} else { } else {
// User does not exist yet // User does not exist yet
let mut new_user = User::new(&user_data.email, None); 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?; 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?; Invitation::new(&new_user.email).save(&conn).await?;
} }
user_created = true; user_created = true;
new_user 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 MembershipStatus::Invited as i32
} else { } else {
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
@ -111,7 +167,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
err!("Error looking up organization") 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.set_external_id(Some(user_data.external_id.clone()));
new_member.access_all = false; new_member.access_all = false;
new_member.atype = MembershipType::User as i32; new_member.atype = MembershipType::User as i32;
@ -119,7 +176,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
new_member.save(&conn).await?; new_member.save(&conn).await?;
if CONFIG.mail_enabled() if data.invite_users_after_provisioning
&& CONFIG.mail_enabled()
&& let Err(e) = && let Err(e) =
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await 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<OrgImportData>, token: PublicToken, conn: DbConn
err!(format!("Error sending invite: {e:?} ")); 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<OrgImportData>, token: PublicToken, conn: DbConn
&& !sync_members.contains(user_external_id) && !sync_members.contains(user_external_id)
&& member.atype != MembershipType::Owner && 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;
} }
} }
} }

8
src/api/identity.rs

@ -216,6 +216,14 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, 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)) Some((user, None))
if user.private_key.is_none() if user.private_key.is_none()
&& !CONFIG.sso_signups_allowed() && !CONFIG.sso_signups_allowed()

9
src/auth.rs

@ -737,7 +737,10 @@ impl OrgHeaders {
fn is_member(&self) -> bool { fn is_member(&self) -> bool {
// Only allow not revoked members, we can not use the Confirmed status here // Only allow not revoked members, we can not use the Confirmed status here
// as some endpoints can be triggered by invited users during joining // 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 { fn is_confirmed_and_admin(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
@ -814,7 +817,9 @@ impl<'r> FromRequest<'r> for OrgHeaders {
} }
}, },
membership_status: { 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 // NOTE: add additional check for revoked if from_i32 is ever changed
// to return Revoked status. // to return Revoked status.
member_status member_status

1
src/db/models/event.rs

@ -114,6 +114,7 @@ pub enum EventType {
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522, OrganizationUserNotificationBannerActionClicked = 1522,
OrganizationUserStaged = 1523,
// Organization // Organization
OrganizationUpdated = 1600, OrganizationUpdated = 1600,

4
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 { 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 // Enforce TwoFactor/TwoStep login
if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await
&& p.enabled && p.enabled

67
src/db/models/organization.rs

@ -74,6 +74,7 @@ pub enum MembershipStatus {
Invited = 0, Invited = 0,
Accepted = 1, Accepted = 1,
Confirmed = 2, Confirmed = 2,
Staged = 3,
} }
impl MembershipStatus { impl MembershipStatus {
@ -82,6 +83,7 @@ impl MembershipStatus {
0 => Some(Self::Invited), 0 => Some(Self::Invited),
1 => Some(Self::Accepted), 1 => Some(Self::Accepted),
2 => Some(Self::Confirmed), 2 => Some(Self::Confirmed),
3 => Some(Self::Staged),
// NOTE: we don't care about revoked members where this is used // NOTE: we don't care about revoked members where this is used
// if this ever changes also adapt the OrgHeaders check. // if this ever changes also adapt the OrgHeaders check.
_ => None, _ => None,
@ -407,7 +409,11 @@ impl Organization {
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .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))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
.filter(users::email.eq(lower_mail)) .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()) .order(users_organizations::atype.asc())
.select(organizations::all_columns) .select(organizations::all_columns)
.first::<Self>(conn) .first::<Self>(conn)
@ -424,7 +430,11 @@ impl Organization {
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .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))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
.filter(users::email.eq(lower_mail)) .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()) .order(users_organizations::atype.asc())
.select(organizations::all_columns) .select(organizations::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
@ -549,7 +559,8 @@ impl Membership {
self.status 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<GroupId> = if include_groups && CONFIG.org_groups_enabled() { let groups: Vec<GroupId> = if include_groups && CONFIG.org_groups_enabled() {
GroupUser::find_by_member(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() GroupUser::find_by_member(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect()
@ -615,19 +626,20 @@ impl Membership {
json!({ json!({
"id": self.uuid, "id": self.uuid,
"userId": self.user_uuid, // Vaultwarden keeps a User row for staging; Bitwarden exposes no linked user until invitation.
"name": if self.get_unrevoked_status() >= MembershipStatus::Accepted as i32 { Some(user.name) } else { None }, "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, "email": user.email,
"externalId": self.external_id, "externalId": self.external_id,
"avatarColor": user.avatar_color, "avatarColor": if staged { None } else { Some(user.avatar_color) },
"groups": groups, "groups": groups,
"collections": collections, "collections": collections,
"status": status, "status": status,
"type": membership_type, "type": membership_type,
"twoFactorEnabled": twofactor_enabled, "twoFactorEnabled": twofactor_enabled,
"resetPasswordEnrolled": self.reset_password_key.is_some(), "resetPasswordEnrolled": !staged && self.reset_password_key.is_some(),
"hasMasterPassword": !user.password_hash.is_empty(), "hasMasterPassword": !staged && !user.password_hash.is_empty(),
"permissions": permissions, "permissions": permissions,
@ -678,7 +690,7 @@ impl Membership {
json!({ json!({
"id": self.uuid, "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, "status": status,
"type": self.atype, "type": self.atype,
@ -690,6 +702,7 @@ impl Membership {
pub async fn to_json_mini_details(&self, conn: &DbConn) -> Value { pub async fn to_json_mini_details(&self, conn: &DbConn) -> Value {
let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); 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. // 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. // 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!({ json!({
"id": self.uuid, "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 "type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type
"status": status, "status": status,
"name": user.name, "name": if staged { None } else { Some(user.name) },
"email": user.email, "email": user.email,
"object": "organizationUserUserMiniDetails", "object": "organizationUserUserMiniDetails",
}) })
@ -749,9 +762,33 @@ impl Membership {
.await .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 { pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
for member in Self::find_by_org(org_uuid, conn).await { for member in Self::find_by_org(org_uuid, conn).await {
member.delete(conn).await?; member.delete_with_staged_user_cleanup(conn).await?;
} }
Ok(()) Ok(())
} }
@ -1134,7 +1171,11 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid)) .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()) .order(users_organizations::atype.asc())
.first::<Self>(conn) .first::<Self>(conn)
.ok() .ok()

151
src/db/models/user.rs

@ -10,7 +10,10 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::DeviceId, 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, error::MapResult,
sso::OIDCIdentifier, sso::OIDCIdentifier,
@ -19,10 +22,69 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipStatus, MembershipType,
TwoFactorIncomplete, 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::<u8>::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)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@ -50,8 +112,8 @@ pub struct User {
pub private_key: Option<String>, pub private_key: Option<String>,
pub public_key: Option<String>, pub public_key: Option<String>,
#[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User #[diesel(column_name = "totp_secret")]
_totp_secret: Option<String>, totp_secret: Option<String>,
pub totp_recover: Option<String>, pub totp_recover: Option<String>,
pub security_stamp: String, 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_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; 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<String>) -> Self { pub fn new(email: &str, name: Option<String>) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let email = email.to_lowercase(); let email = email.to_lowercase();
@ -141,7 +216,7 @@ impl User {
private_key: None, private_key: None,
public_key: None, public_key: None,
_totp_secret: None, totp_secret: None,
totp_recover: None, totp_recover: None,
equivalent_domains: "[]".to_owned(), equivalent_domains: "[]".to_owned(),
@ -262,6 +337,58 @@ impl User {
/// Database methods /// Database methods
impl User { 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::<bool>(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 { pub fn verified(&self) -> bool {
!CONFIG.mail_enabled() || self.verified_at.is_some() !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())) 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(); self.updated_at = Utc::now().naive_utc();
db_run! { conn: db_run! { conn:

2
src/db/schema.rs

@ -392,6 +392,7 @@ allow_tables_to_appear_in_same_query!(
ciphers_collections, ciphers_collections,
collections, collections,
devices, devices,
favorites,
folders, folders,
folders_ciphers, folders_ciphers,
invitations, invitations,
@ -400,6 +401,7 @@ allow_tables_to_appear_in_same_query!(
sends, sends,
sso_users, sso_users,
twofactor, twofactor,
twofactor_incomplete,
users, users,
users_collections, users_collections,
users_organizations, users_organizations,

Loading…
Cancel
Save