Browse Source

Add automatic user confirmation (policy 18)

pull/7499/head
tom27052006 2 weeks ago
parent
commit
b069a3a66e
  1. 7
      .env.template
  2. 12
      src/api/core/accounts.rs
  3. 66
      src/api/core/emergency_access.rs
  4. 38
      src/api/core/mod.rs
  5. 239
      src/api/core/organizations.rs
  6. 6
      src/api/identity.rs
  7. 38
      src/api/notifications.rs
  8. 30
      src/config.rs
  9. 2
      src/db/models/event.rs
  10. 2
      src/db/models/mod.rs
  11. 117
      src/db/models/org_policy.rs
  12. 61
      src/db/models/organization.rs
  13. 14
      src/mail.rs
  14. 10
      src/static/templates/email/emergency_access_grantees_removed.hbs
  15. 21
      src/static/templates/email/emergency_access_grantees_removed.html.hbs

7
.env.template

@ -496,6 +496,13 @@
## KNOW WHAT YOU ARE DOING! ## KNOW WHAT YOU ARE DOING!
# ORG_GROUPS_ENABLED=false # ORG_GROUPS_ENABLED=false
## Automatic user confirmation (Know the risks!)
## Allows organizations to enable the automatic user confirmation policy.
## Members which accepted an invitation are then confirmed unattended by the browser extension
## of an unlocked admin, without any human reviewing the invitation.
## KNOW WHAT YOU ARE DOING!
# ORG_AUTO_CONFIRM_ENABLED=false
## Increase secure note size limit (Know the risks!) ## Increase secure note size limit (Know the risks!)
## Sets the secure note size limit to 100_000 instead of the default 10_000. ## Sets the secure note size limit to 100_000 instead of the default 10_000.
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!

12
src/api/core/accounts.rs

@ -12,7 +12,7 @@ use crate::{
CONFIG, CONFIG,
api::{ api::{
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{accept_org_invite, log_user_event, two_factor::email}, core::{accept_org_invite, accept_user_invitations, log_user_event, two_factor::email},
master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device,
}, },
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
@ -267,7 +267,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
false false
} }
pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult { pub async fn register(data: Json<RegisterData>, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let data: RegisterData = data.into_inner(); let data: RegisterData = data.into_inner();
let email = data.email.to_lowercase(); let email = data.email.to_lowercase();
@ -364,7 +364,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
err!("Registration email does not match invite email") err!("Registration email does not match invite email")
} }
} else if Invitation::take(&email, &conn).await { } else if Invitation::take(&email, &conn).await {
Membership::accept_user_invitations(&user.uuid, &conn).await?; accept_user_invitations(&user.uuid, &conn, &nt).await?;
user user
} else if CONFIG.is_signup_allowed(&email) } else if CONFIG.is_signup_allowed(&email)
|| (CONFIG.emergency_access_allowed() || (CONFIG.emergency_access_allowed()
@ -442,7 +442,7 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
} }
#[post("/accounts/set-password", data = "<data>")] #[post("/accounts/set-password", data = "<data>")]
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let data: SetPasswordData = data.into_inner(); let data: SetPasswordData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
@ -484,13 +484,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
err!("Failed to retrieve the invitation") err!("Failed to retrieve the invitation")
}; };
accept_org_invite(&user, membership, None, &conn).await?; accept_org_invite(&user, membership, None, &conn, &nt).await?;
} }
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
mail::send_welcome(&user.email.to_lowercase()).await?; mail::send_welcome(&user.email.to_lowercase()).await?;
} else { } else {
Membership::accept_user_invitations(&user.uuid, &conn).await?; accept_user_invitations(&user.uuid, &conn, &nt).await?;
} }
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;

66
src/api/core/emergency_access.rs

@ -1,3 +1,5 @@
use std::collections::HashMap;
use chrono::Utc; use chrono::Utc;
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@ -15,14 +17,64 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, AutoConfirmRequirement, Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor,
User, UserId,
}, },
}, },
mail, mail,
util::NumberOrString, util::NumberOrString,
}; };
/// Deletes emergency access granted by or to `user_id` and sends each grantor one mail listing lost contacts.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs
pub async fn delete_all_emergency_access_of_user(user_id: &UserId, conn: &DbConn) -> EmptyResult {
// Keep the rows that `delete_all_by_user` would discard so notifications can name the contacts.
let mut removed = EmergencyAccess::find_all_by_grantor_uuid(user_id, conn).await;
removed.extend(EmergencyAccess::find_all_by_grantee_uuid(user_id, conn).await);
// Collect before deletion and group into one mail per grantor.
let mut by_grantor: HashMap<UserId, Vec<String>> = HashMap::new();
if CONFIG.mail_enabled() {
for emergency_access in &removed {
// An invitation that was never accepted only carries the address, the uuid is stored on accept.
let grantee_email = match &emergency_access.grantee_uuid {
Some(grantee_uuid) => User::find_by_uuid(grantee_uuid, conn).await.map(|u| u.email),
None => emergency_access.email.clone(),
};
let Some(grantee_email) = grantee_email else {
warn!(
"Not naming the grantee of emergency access {} in the removal notification, it has neither a known user nor an address",
emergency_access.uuid
);
continue;
};
by_grantor.entry(emergency_access.grantor_uuid.clone()).or_default().push(grantee_email);
}
}
for emergency_access in removed {
emergency_access.delete(conn).await?;
}
for (grantor_uuid, mut grantee_emails) in by_grantor {
let Some(grantor) = User::find_by_uuid(&grantor_uuid, conn).await else {
warn!("Skipping the emergency access removal notification for {grantor_uuid}, the account is gone");
continue;
};
grantee_emails.sort_unstable();
grantee_emails.dedup();
// The rows are already gone, so a failing mail must not take the whole request down with it.
if let Err(e) = mail::send_emergency_access_grantees_removed(&grantor.email, &grantee_emails).await {
error!("Failed to notify {} about its removed emergency access contacts: {e:?}", grantor.email);
}
}
Ok(())
}
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
get_contacts, get_contacts,
@ -227,6 +279,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!("You can not set yourself as an emergency contact.") err!("You can not set yourself as an emergency contact.")
} }
// Auto-confirm organizations forbid handing member accounts to unvetted emergency-access contacts.
if AutoConfirmRequirement::for_user(&grantor_user.uuid, &conn).await.forbids_emergency_access() {
err!("You are a member of an organization which does not allow emergency access.")
}
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { if !CONFIG.invitations_allowed() {
@ -361,6 +418,11 @@ async fn accept_invite(
err!("Invited user not found") err!("Invited user not found")
}; };
// See `send_invite`, the same restriction applies to the grantee side of an emergency access.
if AutoConfirmRequirement::for_user(&grantee_user.uuid, &conn).await.forbids_emergency_access() {
err!("You are a member of an organization which does not allow emergency access.")
}
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
// The uuid of the grantee gets stored once accepted. // The uuid of the grantee gets stored once accepted.
let Some(mut emergency_access) = let Some(mut emergency_access) =

38
src/api/core/mod.rs

@ -24,7 +24,7 @@ use crate::{
auth::Headers, auth::Headers,
db::{ db::{
DbConn, DbConn,
models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, models::{Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, User, UserId},
}, },
error::Error, error::Error,
http_client::make_http_request, http_client::make_http_request,
@ -220,6 +220,8 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly, &FeatureFlagFilter::ValidOnly,
); );
feature_states.insert("pm-19148-innovation-archive".to_owned(), true); feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
// Web vaults through 2026.4.x need this flag; newer clients use `useAutomaticUserConfirmation`.
feature_states.insert("pm-19934-auto-confirm-organization-users".to_owned(), CONFIG.org_auto_confirm_enabled());
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns
@ -278,11 +280,43 @@ fn api_not_found() -> Json<Value> {
})) }))
} }
/// Notifies eligible admins when a member reaches Accepted; their unlocked browser extension holds the
/// organization key and performs confirmation. Call this at every transition to Accepted.
pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: &Notify<'_>) {
if !member.can_be_auto_confirmed() || !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await {
return;
}
// Confirming requires `AdminHeaders`, so skip the managers this also returns.
for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn)
.await
.into_iter()
.filter(|m| m.atype >= MembershipType::Admin)
{
nt.send_auto_confirm_member(&admin.user_uuid, &member.org_uuid, &member.uuid, &member.user_uuid).await;
}
}
/// Accepts all invitations at once and notifies for each; used when mail is disabled.
pub async fn accept_user_invitations(user_id: &UserId, conn: &DbConn, nt: &Notify<'_>) -> EmptyResult {
let invited = Membership::find_invited_by_user(user_id, conn).await;
Membership::accept_user_invitations(user_id, conn).await?;
for mut member in invited {
member.status = MembershipStatus::Accepted as i32;
notify_pending_auto_confirm(&member, conn, nt).await;
}
Ok(())
}
async fn accept_org_invite( async fn accept_org_invite(
user: &User, user: &User,
mut member: Membership, mut member: Membership,
reset_password_key: Option<String>, reset_password_key: Option<String>,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
if member.status != MembershipStatus::Invited as i32 { if member.status != MembershipStatus::Invited as i32 {
err!("User already accepted the invitation"); err!("User already accepted the invitation");
@ -296,6 +330,8 @@ async fn accept_org_invite(
member.save(conn).await?; member.save(conn).await?;
notify_pending_auto_confirm(&member, conn, nt).await;
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
err!("Organization not found.") err!("Organization not found.")

239
src/api/core/organizations.rs

@ -9,15 +9,18 @@ use crate::{
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{
CipherSyncData, CipherSyncType, accept_org_invite, emergency_access::delete_all_emergency_access_of_user,
log_event, notify_pending_auto_confirm, two_factor,
},
}, },
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, AutoConfirmRequirement, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, CollectionUser, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
OrganizationId, TwoFactor, TwoFactorType, User, UserId, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
}, },
}, },
@ -54,6 +57,9 @@ pub fn routes() -> Vec<Route> {
bulk_reinvite_members, bulk_reinvite_members,
confirm_invite, confirm_invite,
bulk_confirm_invite, bulk_confirm_invite,
get_pending_auto_confirm_members,
auto_confirm_member,
bulk_auto_confirm_members,
accept_invite, accept_invite,
get_org_user_mini_details, get_org_user_mini_details,
get_user, get_user,
@ -186,6 +192,13 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, conn: DbConn
if !CONFIG.is_org_creation_allowed(&headers.user.email) { if !CONFIG.is_org_creation_allowed(&headers.user.email) {
err!("User not allowed to create organizations") err!("User not allowed to create organizations")
} }
// Unlike SingleOrg, auto-confirm forbids additional memberships for every role and status.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs
if AutoConfirmRequirement::for_user(&headers.user.uuid, &conn).await.forbids_creating_organization() {
err!(
"You may not create an organization. You belong to an organization which confirms its members automatically and prohibits you from being a member of any other organization."
)
}
if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await {
err!( err!(
"You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization."
@ -994,6 +1007,7 @@ async fn send_invite(
data: Json<InviteData>, data: Json<InviteData>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
@ -1135,6 +1149,10 @@ async fn send_invite(
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?; group_entry.save(&conn).await?;
} }
// With mail disabled this is the only Accepted transition. Notify last so collections and groups
// exist before an admin client can confirm the member.
notify_pending_auto_confirm(&new_member, &conn, &nt).await;
} }
Ok(()) Ok(())
@ -1146,6 +1164,7 @@ async fn bulk_reinvite_members(
data: Json<BulkMembershipIds>, data: Json<BulkMembershipIds>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
@ -1154,7 +1173,7 @@ async fn bulk_reinvite_members(
let mut bulk_response = Vec::new(); let mut bulk_response = Vec::new();
for member_id in data.ids { for member_id in data.ids {
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await {
Ok(()) => String::new(), Ok(()) => String::new(),
Err(e) => { Err(e) => {
warn!("Unable to reinvite member {member_id}: {e:?}"); warn!("Unable to reinvite member {member_id}: {e:?}");
@ -1184,11 +1203,12 @@ async fn reinvite_member(
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await
} }
async fn reinvite_member_impl( async fn reinvite_member_impl(
@ -1196,6 +1216,7 @@ async fn reinvite_member_impl(
member_id: &MembershipId, member_id: &MembershipId,
invited_by_email: &str, invited_by_email: &str,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
err!("The user hasn't been invited to the organization.") err!("The user hasn't been invited to the organization.")
@ -1229,6 +1250,7 @@ async fn reinvite_member_impl(
let mut member = member; let mut member = member;
member.status = MembershipStatus::Accepted as i32; member.status = MembershipStatus::Accepted as i32;
member.save(conn).await?; member.save(conn).await?;
notify_pending_auto_confirm(&member, conn, nt).await;
} }
Ok(()) Ok(())
@ -1248,6 +1270,7 @@ async fn accept_invite(
data: Json<AcceptData>, data: Json<AcceptData>,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
// The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead
let data: AcceptData = data.into_inner(); let data: AcceptData = data.into_inner();
@ -1286,7 +1309,7 @@ async fn accept_invite(
// In case the user was invited before the mail was saved in db. // In case the user was invited before the mail was saved in db.
membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email); membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email);
accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?; accept_org_invite(&headers.user, membership, reset_password_key, &conn, &nt).await?;
} else if CONFIG.mail_enabled() { } else if CONFIG.mail_enabled() {
// User was invited from /admin, so they are automatically confirmed // User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name(); let org_name = CONFIG.invitation_org_name();
@ -1317,10 +1340,21 @@ async fn bulk_confirm_invite(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { bulk_confirm(&org_id, data.into_inner(), &headers, &conn, &nt, false).await
}
/// Shared by manual and automatic bulk confirmation; their member checks remain separate.
async fn bulk_confirm(
org_id: &OrganizationId,
data: BulkConfirmData,
headers: &AdminHeaders,
conn: &DbConn,
nt: &Notify<'_>,
automatic: bool,
) -> JsonResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data = data.into_inner();
let mut bulk_response = Vec::new(); let mut bulk_response = Vec::new();
match data.keys { match data.keys {
@ -1330,7 +1364,12 @@ async fn bulk_confirm_invite(
err!("Member id is required") err!("Member id is required")
}; };
let user_key = invite.key.unwrap_or_default(); let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { let result = if automatic {
auto_confirm_member_impl(org_id, &member_id, &user_key, headers, conn, nt).await
} else {
confirm_invite_impl(org_id, &member_id, &user_key, headers, conn, nt).await
};
let err_msg = match result {
Ok(()) => String::new(), Ok(()) => String::new(),
Err(e) => { Err(e) => {
warn!("Unable to confirm member {member_id}: {e:?}"); warn!("Unable to confirm member {member_id}: {e:?}");
@ -1386,7 +1425,7 @@ async fn confirm_invite_impl(
err!("Key or UserId is not set, unable to process request"); err!("Key or UserId is not set, unable to process request");
} }
let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
err!("The specified user isn't a member of the organization") err!("The specified user isn't a member of the organization")
}; };
@ -1394,6 +1433,20 @@ async fn confirm_invite_impl(
err!("Only Owners can confirm Managers, Admins or Owners") err!("Only Owners can confirm Managers, Admins or Owners")
} }
confirm_member(member_to_confirm, key, EventType::OrganizationUserConfirmed, headers, conn, nt).await
}
/// Shared confirmation after the manual or automatic checks have passed.
async fn confirm_member(
mut member_to_confirm: Membership,
key: &str,
event_type: EventType,
headers: &AdminHeaders,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
let org_id = member_to_confirm.org_uuid.clone();
if member_to_confirm.status != MembershipStatus::Accepted as i32 { if member_to_confirm.status != MembershipStatus::Accepted as i32 {
err!("User in invalid state") err!("User in invalid state")
} }
@ -1404,10 +1457,17 @@ async fn confirm_invite_impl(
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
// Remove emergency access created after policy activation; it could expose the organization vault.
// Bitwarden applies this to manual confirmation as well.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
if OrgPolicy::is_auto_confirm_enabled(&org_id, conn).await {
delete_all_emergency_access_of_user(&member_to_confirm.user_uuid, conn).await?;
}
log_event( log_event(
EventType::OrganizationUserConfirmed, event_type,
&member_to_confirm.uuid, &member_to_confirm.uuid,
org_id, &org_id,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -1416,7 +1476,7 @@ async fn confirm_invite_impl(
.await; .await;
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, conn).await {
org.name org.name
} else { } else {
err!("Error looking up organization.") err!("Error looking up organization.")
@ -1438,6 +1498,95 @@ async fn confirm_invite_impl(
save_result save_result
} }
// The server lacks the organization key; an admin client performs auto-confirmation in the background.
// https://bitwarden.com/help/automatic-confirmation/
#[get("/organizations/<org_id>/users/pending-auto-confirm")]
async fn get_pending_auto_confirm_members(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Bitwarden responds with an empty list instead of an error when the feature or the policy is off.
let members = if OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await {
Membership::find_by_org(&org_id, &conn)
.await
.into_iter()
.filter(Membership::can_be_auto_confirmed)
.map(|m| {
json!({
"object": "organizationUserPendingAutoConfirm",
"id": m.uuid,
"userId": m.user_uuid,
})
})
.collect()
} else {
Vec::new()
};
Ok(Json(json!({
"data": members,
"object": "list",
"continuationToken": null
})))
}
#[post("/organizations/<org_id>/users/<member_id>/auto-confirm", data = "<data>")]
async fn auto_confirm_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<ConfirmData>,
headers: AdminHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let data = data.into_inner();
let user_key = data.key.unwrap_or_default();
auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await
}
#[post("/organizations/<org_id>/users/bulk-auto-confirm", data = "<data>")]
async fn bulk_auto_confirm_members(
org_id: OrganizationId,
data: Json<BulkConfirmData>,
headers: AdminHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
bulk_confirm(&org_id, data.into_inner(), &headers, &conn, &nt, true).await
}
async fn auto_confirm_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
key: &str,
headers: &AdminHeaders,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if key.is_empty() || member_id.is_empty() {
err!("Key or UserId is not set, unable to process request");
}
if !OrgPolicy::is_auto_confirm_enabled(org_id, conn).await {
err!("Automatic user confirmation is not enabled for this organization")
}
let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
err!("The specified user isn't a member of the organization")
};
if !member_to_confirm.can_be_auto_confirmed() {
err!("This member can not be confirmed automatically")
}
confirm_member(member_to_confirm, key, EventType::OrganizationUserAutomaticallyConfirmed, headers, conn, nt).await
}
#[get("/organizations/<org_id>/users/mini-details", rank = 1)] #[get("/organizations/<org_id>/users/mini-details", rank = 1)]
async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
@ -2103,6 +2252,33 @@ async fn put_policy(
} }
} }
let mut policy = match current_policy {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
// Unattended confirmation requires the server option and SingleOrg.
let enables_auto_confirm = pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled;
if enables_auto_confirm && !CONFIG.org_auto_confirm_enabled() {
err!("Automatic user confirmation is not enabled on this server.")
}
// Only disabled -> enabled has side effects, and repeating them would remove new emergency access.
let auto_confirm_turned_on = enables_auto_confirm && !policy.enabled;
if auto_confirm_turned_on {
if !OrgPolicy::is_enabled(&org_id, OrgPolicyType::SingleOrg, &conn).await {
err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.")
}
for member in Membership::find_by_org(&org_id, &conn).await {
if member.counts_for_auto_confirm()
&& Membership::count_accepted_confirmed_and_revoked_by_user(&member.user_uuid, &org_id, &conn).await > 0
{
err!("This policy forbids members to be part of other organizations, but at least one member still is.")
}
}
}
if pol_type_enum == OrgPolicyType::FillAssist { if pol_type_enum == OrgPolicyType::FillAssist {
let fill_assist_data = match data.data.as_ref() { let fill_assist_data = match data.data.as_ref() {
None | Some(Value::Null) => FillAssistPolicyData { None | Some(Value::Null) => FillAssistPolicyData {
@ -2134,6 +2310,14 @@ async fn put_policy(
} }
} }
// Keep SingleOrg enabled while automatic confirmation depends on it.
if pol_type_enum == OrgPolicyType::SingleOrg
&& !data.enabled
&& OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await
{
err!("Automatic user confirmation is enabled. It is not allowed to disable this policy.")
}
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
two_factor::enforce_2fa_policy_for_org( two_factor::enforce_2fa_policy_for_org(
@ -2181,15 +2365,24 @@ async fn put_policy(
} }
} }
let mut policy = match current_policy {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
policy.enabled = data.enabled; policy.enabled = data.enabled;
policy.data = serde_json::to_string(&data.data)?; policy.data = serde_json::to_string(&data.data)?;
policy.save(&conn).await?; policy.save(&conn).await?;
// Remove emergency access only after saving and only for joined members. A failed save must delete
// nothing, and an unaccepted invitation must not delete account data. New relationships are blocked elsewhere.
if auto_confirm_turned_on {
for member in Membership::find_by_org(&org_id, &conn).await {
if member.counts_for_auto_confirm() {
info!(
"Removing emergency access of {} because automatic user confirmation was enabled for {org_id}",
member.user_uuid
);
delete_all_emergency_access_of_user(&member.user_uuid, &conn).await?;
}
}
}
log_event( log_event(
EventType::PolicyUpdated, EventType::PolicyUpdated,
policy.uuid.as_ref(), policy.uuid.as_ref(),
@ -2435,6 +2628,14 @@ async fn restore_member_impl(
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// This check need to be done after restoring to work with the correct status // This check need to be done after restoring to work with the correct status
OrgPolicy::check_user_allowed(&member, "restore", conn).await?; OrgPolicy::check_user_allowed(&member, "restore", conn).await?;
// Restore has no Accepted transition, so remove emergency access created while revoked.
// Invited remains exempt because that account never joined.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs
if member.counts_for_auto_confirm() && OrgPolicy::is_auto_confirm_enabled(org_id, conn).await {
delete_all_emergency_access_of_user(&member.user_uuid, conn).await?;
}
member.save(conn).await?; member.save(conn).await?;
log_event( log_event(

6
src/api/identity.rs

@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ api::{
ApiResult, EmptyResult, JsonResult, ApiResult, EmptyResult, JsonResult, Notify,
core::{ core::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event, log_user_event,
@ -1124,9 +1124,9 @@ async fn register_verification_email_clicked(
} }
#[post("/accounts/register/finish", data = "<data>")] #[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult { async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn, nt: Notify<'_>) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
register(data, conn).await register(data, conn, nt).await
} }
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts

38
src/api/notifications.rs

@ -15,7 +15,10 @@ use crate::{
auth::{ClientIp, WsAccessTokenHeader}, auth::{ClientIp, WsAccessTokenHeader},
db::{ db::{
DbConn, DbConn,
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, models::{
AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, MembershipId, OrganizationId, PushId,
Send as DbSend, User, UserId,
},
}, },
}; };
@ -539,6 +542,32 @@ impl WebSocketUsers {
} }
} }
/// Tells `recipient_id` that `member_id` awaits confirmation. The browser extension holds the
/// organization key, so this is WebSocket only.
/// https://github.com/bitwarden/clients/blob/main/libs/auto-confirm/README.md
pub async fn send_auto_confirm_member(
&self,
recipient_id: &UserId,
org_id: &OrganizationId,
member_id: &MembershipId,
member_user_id: &UserId,
) {
if !CONFIG.enable_websocket() {
return;
}
let data = create_update(
vec![
("UserId".into(), recipient_id.to_string().into()),
("OrganizationId".into(), org_id.to_string().into()),
("TargetUserId".into(), member_user_id.to_string().into()),
("TargetOrganizationUserId".into(), member_id.to_string().into()),
],
UpdateType::AutoConfirmMember,
None,
);
self.send_update(recipient_id, &data).await;
}
pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
@ -725,6 +754,13 @@ pub enum UpdateType {
// NotificationStatus = 21, // Not supported // NotificationStatus = 21, // Not supported
// RefreshSecurityTasks = 22, // Not supported // RefreshSecurityTasks = 22, // Not supported
// OrganizationBankAccountVerified = 23, // Not supported (Not AGPLv3 Licensed)
// ProviderBankAccountVerified = 24, // Not supported (Not AGPLv3 Licensed)
// SyncPolicy = 25, // Not supported
AutoConfirmMember = 26,
// PremiumStatusChanged = 27, // Not supported
None = 100, None = 100,
} }

30
src/config.rs

@ -817,6 +817,10 @@ make_config! {
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
/// Enable automatic user confirmation (Know the risks!) |> Allows organizations to enable the automatic user confirmation policy.
/// Accepted members can be confirmed unattended by an unlocked admin extension; disabled by default.
org_auto_confirm_enabled: bool, false, def, false;
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false; increase_note_size_limit: bool, true, def, false;
@ -1823,6 +1827,7 @@ where
reg!("email/change_email_invited", ".html"); reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");
reg!("email/delete_account", ".html"); reg!("email/delete_account", ".html");
reg!("email/emergency_access_grantees_removed", ".html");
reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_accepted", ".html");
reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_invite_confirmed", ".html");
reg!("email/emergency_access_recovery_approved", ".html"); reg!("email/emergency_access_recovery_approved", ".html");
@ -1943,3 +1948,28 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String |
handlebars::handlebars_helper!(vwver: | vw_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String |
semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION)
); );
#[cfg(test)]
mod tests {
use super::*;
/// Render both strict templates with exactly the data supplied by the sender.
#[test]
fn emergency_access_grantees_removed_renders() {
let hb = load_templates(std::env::temp_dir());
let data = serde_json::json!({
"url": "https://vault.example.com",
"img_src": "https://vault.example.com/mail/",
"grantee_emails": ["first@example.com", "second@example.com"],
});
for name in ["email/emergency_access_grantees_removed", "email/emergency_access_grantees_removed.html"] {
let rendered = hb.render(name, &data).unwrap_or_else(|e| panic!("{name} failed to render: {e:?}"));
let (subject, body) = rendered.split_once("<!---------------->").expect("no subject separator");
assert_eq!(subject.trim(), "Emergency contacts removed");
for email in ["first@example.com", "second@example.com"] {
assert!(body.contains(email), "{name} misses {email}");
}
}
}
}

2
src/db/models/event.rs

@ -108,7 +108,7 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514, OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517, OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519, OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520,

2
src/db/models/mod.rs

@ -31,7 +31,7 @@ pub use self::event::{Event, EventType};
pub use self::favorite::Favorite; pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{AutoConfirmRequirement, FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId, OrganizationId,

117
src/db/models/org_policy.rs

@ -50,7 +50,7 @@ pub enum OrgPolicyType {
RestrictedItemTypes = 15, RestrictedItemTypes = 15,
UriMatchDefaults = 16, UriMatchDefaults = 16,
AutotypeDefaultSetting = 17, AutotypeDefaultSetting = 17,
// AutoConfirm = 18, // Not supported (not implemented yet) AutomaticUserConfirmation = 18,
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20, OrganizationUserNotification = 20,
// SendControls = 21, // Not supported yet // SendControls = 21, // Not supported yet
@ -318,6 +318,17 @@ impl OrgPolicy {
false false
} }
/// Whether the policy is stored as enabled for this organization, regardless of any config option.
pub async fn is_enabled(org_uuid: &OrganizationId, policy_type: OrgPolicyType, conn: &DbConn) -> bool {
Self::find_by_org_and_type(org_uuid, policy_type, conn).await.is_some_and(|p| p.enabled)
}
/// Requires both the server-wide option and this organization's policy.
pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
CONFIG.org_auto_confirm_enabled()
&& Self::is_enabled(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await
}
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 && m.status > (MembershipStatus::Invited as i32) {
// Enforce TwoFactor/TwoStep login // Enforce TwoFactor/TwoStep login
@ -351,6 +362,24 @@ impl OrgPolicy {
} }
} }
// Stricter than the SingleOrg block above: this policy exempts no role and no status.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs
if AutoConfirmRequirement::for_user(&m.user_uuid, conn).await.forbids_membership_outside(&m.org_uuid) {
err!(format!(
"Cannot {} because another organization confirms its members automatically and forbids other memberships (membership {})",
action, m.uuid
));
}
if Self::is_auto_confirm_enabled(&m.org_uuid, conn).await
&& Membership::count_accepted_confirmed_and_revoked_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0
{
err!(format!(
"Cannot {} because the organization confirms its members automatically and forbids being part of other organizations (membership {})",
action, m.uuid
));
}
Ok(()) Ok(())
} }
@ -409,3 +438,89 @@ impl OrgPolicy {
#[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OrgPolicyId(String); pub struct OrgPolicyId(String);
/// Active auto-confirm policy memberships. No role or status is exempt; each check below decides which
/// statuses matter for its operation.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
pub struct AutoConfirmRequirement(Vec<Membership>);
impl AutoConfirmRequirement {
/// Loads every role and status in organizations with this policy; empty when the server option is off.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/BasePolicyRequirementFactory.cs
pub async fn for_user(user_uuid: &UserId, conn: &DbConn) -> Self {
if !CONFIG.org_auto_confirm_enabled() {
return Self(Vec::new());
}
Self(
conn.run(move |conn| {
org_policies::table
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(org_policies::atype.eq(OrgPolicyType::AutomaticUserConfirmation as i32))
.filter(org_policies::enabled.eq(true))
.select(users_organizations::all_columns)
.load::<Membership>(conn)
.expect("Error loading memberships by org_policy")
})
.await,
)
}
/// Any membership, including Invited and elevated roles, forbids creating another organization.
pub fn forbids_creating_organization(&self) -> bool {
!self.0.is_empty()
}
/// Any policy membership outside `org_uuid` forbids membership in `org_uuid`.
pub fn forbids_membership_outside(&self, org_uuid: &OrganizationId) -> bool {
self.0.iter().any(|m| &m.org_uuid != org_uuid)
}
/// Accepted, Confirmed, or Revoked members may neither grant nor accept emergency access; Invited is exempt.
pub fn forbids_emergency_access(&self) -> bool {
self.0.iter().any(Membership::counts_for_auto_confirm)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn membership(org_uuid: &str, atype: MembershipType, status: MembershipStatus) -> Membership {
let mut member = Membership::new("user".to_owned().into(), org_uuid.to_owned().into(), None);
member.atype = atype as i32;
member.status = status as i32;
member
}
/// Every role and status is covered; only emergency access exempts Invited.
#[test]
fn auto_confirm_requirement() {
let auto_confirm_org = OrganizationId::from(String::from("auto-confirm"));
let other_org = OrganizationId::from(String::from("other"));
let none = AutoConfirmRequirement(Vec::new());
assert!(!none.forbids_creating_organization());
assert!(!none.forbids_membership_outside(&other_org));
assert!(!none.forbids_emergency_access());
let invited_owner =
AutoConfirmRequirement(vec![membership("auto-confirm", MembershipType::Owner, MembershipStatus::Invited)]);
assert!(invited_owner.forbids_creating_organization());
assert!(invited_owner.forbids_membership_outside(&other_org));
assert!(!invited_owner.forbids_membership_outside(&auto_confirm_org));
assert!(!invited_owner.forbids_emergency_access());
// Revoked counts because restore has no second accept step; Invited alone does not restrict emergency access.
let mut revoked_owner = membership("auto-confirm", MembershipType::Owner, MembershipStatus::Confirmed);
assert!(revoked_owner.revoke());
let mut requirement = AutoConfirmRequirement(vec![revoked_owner]);
assert!(requirement.forbids_creating_organization());
requirement.0.push(membership("invited-to", MembershipType::User, MembershipStatus::Invited));
assert!(requirement.forbids_emergency_access());
}
}

61
src/db/models/organization.rs

@ -201,6 +201,7 @@ impl Organization {
"maxCollections": null, "maxCollections": null,
"maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side
"use2fa": true, "use2fa": true,
"useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(),
"useCustomPermissions": true, "useCustomPermissions": true,
"useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
"useEvents": CONFIG.org_events_enabled(), "useEvents": CONFIG.org_events_enabled(),
@ -486,6 +487,7 @@ impl Membership {
"useKeyConnector": false, "useKeyConnector": false,
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"usePasswordManager": true, "usePasswordManager": true,
"useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(),
"useCustomPermissions": true, "useCustomPermissions": true,
"useActivateAutofillPolicy": false, "useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false, "useAdminSponsoredFamilies": false,
@ -886,6 +888,40 @@ impl Membership {
.await .await
} }
/// Every role and status except Invited counts; Revoked counts because restore has no accept step.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs
pub fn counts_for_auto_confirm(&self) -> bool {
self.status != MembershipStatus::Invited as i32
}
/// Only an Accepted plain User may be auto-confirmed; elevated roles require manual confirmation.
pub fn can_be_auto_confirmed(&self) -> bool {
self.status == MembershipStatus::Accepted as i32 && self.atype == MembershipType::User
}
/// Counts non-Invited memberships outside `excluded_org`, including Revoked; stricter than SingleOrg.
pub async fn count_accepted_confirmed_and_revoked_by_user(
user_uuid: &UserId,
excluded_org: &OrganizationId,
conn: &DbConn,
) -> i64 {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.ne(excluded_org))
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.or(users_organizations::status.lt(MembershipStatus::Invited as i32)),
)
.count()
.first::<i64>(conn)
.unwrap_or(0)
})
.await
}
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> { pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
@ -1237,4 +1273,29 @@ mod tests {
assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
} }
/// All but Invited count for the policy; only an Accepted plain User may be auto-confirmed.
#[test]
fn auto_confirm_membership_rules() {
let mut member = Membership::new("user".to_owned().into(), "org".to_owned().into(), None);
for status in
[MembershipStatus::Invited as i32, MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32]
{
member.status = status;
assert_eq!(member.counts_for_auto_confirm(), status != MembershipStatus::Invited as i32, "status {status}");
assert_eq!(member.can_be_auto_confirmed(), status == MembershipStatus::Accepted as i32, "status {status}");
assert!(member.revoke(), "status {status} can not be revoked");
assert!(member.counts_for_auto_confirm(), "revoked {status} must count");
assert!(!member.can_be_auto_confirmed(), "revoked {status} must not be confirmed automatically");
// `count_accepted_confirmed_and_revoked_by_user` relies on this.
assert!(member.status < MembershipStatus::Invited as i32, "revoked {status} must stay below Invited");
}
member.status = MembershipStatus::Accepted as i32;
for atype in [MembershipType::Owner, MembershipType::Admin, MembershipType::Manager] {
member.atype = atype as i32;
assert!(!member.can_be_auto_confirmed(), "type {} must not be confirmed automatically", atype as i32);
}
}
} }

14
src/mail.rs

@ -394,6 +394,20 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email:
send_email(address, &subject, body_html, body_text).await send_email(address, &subject, body_html, body_text).await
} }
/// Lists removed emergency-access contacts without leaking the triggering organization or memberships.
pub async fn send_emergency_access_grantees_removed(address: &str, grantee_emails: &[String]) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/emergency_access_grantees_removed",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"grantee_emails": grantee_emails,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult { pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/emergency_access_invite_confirmed", "email/emergency_access_invite_confirmed",

10
src/static/templates/email/emergency_access_grantees_removed.hbs

@ -0,0 +1,10 @@
Emergency contacts removed
<!---------------->
The following emergency contacts have been removed from your account:
{{#each grantee_emails}}
* {{this}}
{{/each}}
You can set up emergency access again from the web vault ({{url}}).
{{> email/email_footer_text }}

21
src/static/templates/email/emergency_access_grantees_removed.html.hbs

@ -0,0 +1,21 @@
Emergency contacts removed
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
The following emergency contacts have been removed from your account:
<ul style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
{{#each grantee_emails}}
<li style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{this}}</li>
{{/each}}
</ul>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
You can set up emergency access again from the <a href="{{url}}/">web vault</a>.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save