Align policy enforcement and status codes with Bitwarden (#7970)

This commit is contained in:
Tom authored and GitHub committed 2026-11-15 21:20:46 +01:00
1 parent 00611ea80d
commit 4051a4d3a6
6 files changed
+95 -41

No files matched your search

+1 -1
View File
@@ -535,7 +535,7 @@ async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyRe
let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, true, &conn).await?;
user.totp_recover = None;
user.save(&conn).await
}
+35 -14
View File
@@ -590,23 +590,18 @@ async fn post_bulk_access_collections(
err!("Can't find organization details")
}
// The collections and members are checked below, the groups only here.
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
}
// As in Bitwarden, missing, repeated or unmanageable collections are Not Found. Invalid groups and members are
// checked after them and are a Bad Request.
if data.collection_ids.is_empty()
|| data.collection_ids.iter().collect::<HashSet<_>>().len() != data.collection_ids.len()
{
err!("Invalid collection list")
err_code!("One or more collections not found.", Status::NotFound.code)
}
let mut collections = Vec::with_capacity(data.collection_ids.len());
for col_id in &data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(col_id, &org_id, &conn).await else {
err!("Collection not found")
err_code!("One or more collections not found.", Status::NotFound.code)
};
let manageable = if headers.membership.has_type(MembershipType::Custom) {
@@ -617,12 +612,22 @@ async fn post_bulk_access_collections(
collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await
};
if !manageable {
err!("Collection not found", "The current user isn't a manager for this collection")
err_code!(
"Resource not found.",
"The current user isn't a manager for this collection",
Status::NotFound.code
)
}
collections.push(collection);
}
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
}
let mut members = Vec::with_capacity(data.users.len());
for user in &data.users {
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
@@ -2573,8 +2578,9 @@ async fn enforce_single_org_policy_for_org(
// Later membership activation still checks the enabled policy independently.
if member.atype < MembershipType::Admin
&& member.user_uuid != *acting_user_id
&& member.status != MembershipStatus::Invited as i32
&& Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, conn).await > 0
&& (member.has_status(MembershipStatus::Accepted) || member.has_status(MembershipStatus::Confirmed))
&& Membership::count_accepted_confirmed_or_revoked_by_user(&member.user_uuid, &member.org_uuid, conn).await
> 0
{
if CONFIG.mail_enabled() {
let org = Organization::find_by_uuid(&member.org_uuid, conn).await.unwrap();
@@ -2896,6 +2902,14 @@ async fn get_groups_data(
|| may_assign_collection_access(&headers.membership, &conn).await
};
if !allowed {
// Bitwarden's permission requirement on the details answers Forbidden
if details {
err_code!(
"You don't have permission to call this endpoint",
"User does not have access",
Status::Forbidden.code
);
}
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
@@ -3421,8 +3435,15 @@ async fn recover_account(
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
two_factor::enforce_2fa_policy(
&user,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
true,
&conn,
)
.await?;
}
}
+13 -7
View File
@@ -13,8 +13,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
Device, DeviceType, EventType, Membership, MembershipStatus, MembershipType, OrgPolicyType, Organization,
OrganizationId, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@@ -127,21 +127,25 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) {
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
enforce_2fa_policy(user, &user.uuid, device_type, ip, true, conn).await?;
}
Ok(())
}
/// Revokes the user's memberships that the 2FA policy no longer allows. With `accepted_too`, which is for removing the
/// user's last 2FA method, accepted memberships are revoked as well, as in Bitwarden. Otherwise only confirmed ones
/// are: an accepted member still has to set up 2FA before being confirmed.
pub async fn enforce_2fa_policy(
user: &User,
act_user_id: &UserId,
device_type: i32,
ip: &std::net::IpAddr,
accepted_too: bool,
conn: &DbConn,
) -> EmptyResult {
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
if member.atype < MembershipType::Admin {
if member.atype < MembershipType::Admin && (accepted_too || member.has_status(MembershipStatus::Confirmed)) {
if CONFIG.mail_enabled() {
let org = Organization::find_by_uuid(&member.org_uuid, conn).await.unwrap();
mail::send_2fa_removed_from_org(&user.email, &org.name).await?;
@@ -174,9 +178,11 @@ pub async fn enforce_2fa_policy_for_org(
conn: &DbConn,
) -> EmptyResult {
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
for member in Membership::find_confirmed_by_org(org_id, conn).await {
// Don't revoke Admins, Owners, or the authenticated administrator enabling the policy.
if member.atype < MembershipType::Admin
for member in Membership::find_by_org(org_id, conn).await {
// As in Bitwarden, accepted and confirmed members lose access, except Owners, Admins and the member enabling
// the policy.
if (member.has_status(MembershipStatus::Accepted) || member.has_status(MembershipStatus::Confirmed))
&& member.atype < MembershipType::Admin
&& member.user_uuid != *act_user_id
&& TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty()
{
+2 -2
View File
@@ -874,7 +874,7 @@ async fn twofactor_auth(
// No twofactor token if twofactor is disabled
if twofactors.is_empty() {
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, false, conn).await?;
return Ok(None);
}
@@ -970,7 +970,7 @@ async fn twofactor_auth(
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, true, conn).await?;
log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;