Browse Source

Merge 9f5c619895 into 0cefa4cca7

pull/7499/merge
Tom 6 days ago
committed by GitHub
parent
commit
d5f3584ee4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      .env.template
  2. 12
      src/api/core/accounts.rs
  3. 65
      src/api/core/emergency_access.rs
  4. 51
      src/api/core/mod.rs
  5. 293
      src/api/core/organizations.rs
  6. 10
      src/api/identity.rs
  7. 39
      src/api/notifications.rs
  8. 36
      src/config.rs
  9. 66
      src/db/models/org_policy.rs
  10. 2
      src/db/models/organization.rs
  11. 16
      src/mail.rs
  12. 10
      src/static/templates/email/emergency_access_grantees_removed.hbs
  13. 21
      src/static/templates/email/emergency_access_grantees_removed.html.hbs

7
.env.template

@ -491,6 +491,13 @@
## KNOW WHAT YOU ARE DOING!
# 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!)
## 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!

12
src/api/core/accounts.rs

@ -12,7 +12,7 @@ use crate::{
CONFIG,
api::{
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, register_push_device, unregister_push_device,
},
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
@ -255,7 +255,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
false
}
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: RegisterData = data.into_inner();
let email = data.email.to_lowercase();
@ -357,7 +357,7 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
err!("Registration email does not match invite email")
}
} else if Invitation::take(&email, &conn).await {
Membership::accept_user_invitations(&user.uuid, &conn).await?;
accept_user_invitations(&user.uuid, &conn, &nt).await?;
user
} else if CONFIG.is_signup_allowed(&email)
|| (CONFIG.emergency_access_allowed()
@ -436,7 +436,7 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
}
#[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 mut user = headers.user;
@ -478,13 +478,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
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() {
mail::send_welcome(&user.email.to_lowercase()).await?;
} else {
Membership::accept_user_invitations(&user.uuid, &conn).await?;
accept_user_invitations(&user.uuid, &conn, &nt).await?;
}
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)

65
src/api/core/emergency_access.rs

@ -1,3 +1,5 @@
use std::collections::HashMap;
use chrono::{TimeDelta, Utc};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
@ -20,6 +22,58 @@ use crate::{
util::NumberOrString,
};
/// Drops every emergency access of `user_id`, the ones it granted as well as the ones it holds, and tells
/// the grantors which contacts they lost. The grantor of the second group is somebody else, so without a
/// mail that user would silently lose a part of its account setup.
/// Bitwarden notifies the same way, one mail per grantor listing all of its removed 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 {
// Read before deleting, afterwards the rows are gone.
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);
EmergencyAccess::delete_all_by_user(user_id, conn).await?;
if !CONFIG.mail_enabled() || removed.is_empty() {
return Ok(());
}
// Group by grantor so that each of them gets a single mail listing all of its removed contacts.
let mut by_grantor: HashMap<UserId, Vec<String>> = HashMap::new();
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).or_default().push(grantee_email);
}
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> {
routes![
get_contacts,
@ -222,6 +276,12 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
err!("You can not set yourself as an emergency contact.")
}
// Emergency access would hand this account to somebody the organization never vetted, which is why
// Bitwarden forbids it for members of an organization which confirms its members automatically.
if OrgPolicy::is_user_in_auto_confirm_org(&grantor_user.uuid, &conn).await {
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 {
None => {
if !CONFIG.invitations_allowed() {
@ -354,6 +414,11 @@ async fn accept_invite(
err!("Invited user not found")
};
// See `send_invite`, the same restriction applies to the grantee side of an emergency access.
if OrgPolicy::is_user_in_auto_confirm_org(&grantee_user.uuid, &conn).await {
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.
// The uuid of the grantee gets stored once accepted.
let Some(mut emergency_access) =

51
src/api/core/mod.rs

@ -24,7 +24,7 @@ use crate::{
auth::Headers,
db::{
DbConn,
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
models::{Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, User, UserId},
},
error::Error,
http_client::make_http_request,
@ -220,6 +220,10 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly,
);
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
// Web vaults up to 2026.4.x only offer the automatic user confirmation policy when this flag is on:
// `display$(org, config) => config.getFeatureFlag$(FeatureFlag.AutoConfirm).pipe(map(f => f && org.useAutomaticUserConfirmation))`
// Newer clients dropped the flag and look at `useAutomaticUserConfirmation` alone, so sending it stays harmless.
feature_states.insert("pm-19934-auto-confirm-organization-users".to_owned(), CONFIG.org_auto_confirm_enabled());
Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns
@ -277,11 +281,54 @@ fn api_not_found() -> Json<Value> {
}))
}
/// Tells everybody who is able to confirm this member that it accepted its invitation and is waiting.
/// Only the browser extension of an unlocked admin acts upon this, it holds the organization key which
/// is needed to confirm a member and which the server never has.
/// Call this wherever a membership reaches the accepted state.
pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: &Notify<'_>) {
if member.status != MembershipStatus::Accepted as i32
|| member.atype != MembershipType::User
|| !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await
{
return;
}
// Confirming requires `AdminHeaders`, so skip the managers this also returns. They could not act on
// the notification anyway and would only run into a rejected request.
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 every open invitation of a user at once, as done when mail is disabled, and notifies for each
/// of them. See [`notify_pending_auto_confirm`].
pub async fn accept_user_invitations(user_id: &UserId, conn: &DbConn, nt: &Notify<'_>) -> EmptyResult {
let invited: Vec<Membership> = Membership::find_any_state_by_user(user_id, conn)
.await
.into_iter()
.filter(|m| m.status == MembershipStatus::Invited as i32)
.collect();
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(
user: &User,
mut member: Membership,
reset_password_key: Option<String>,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
if member.status != MembershipStatus::Invited as i32 {
err!("User already accepted the invitation");
@ -295,6 +342,8 @@ async fn accept_org_invite(
member.save(conn).await?;
notify_pending_auto_confirm(&member, conn, nt).await;
if CONFIG.mail_enabled() {
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
err!("Organization not found.")

293
src/api/core/organizations.rs

@ -9,7 +9,10 @@ use crate::{
api::admin::FAKE_ADMIN_UUID,
api::{
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},
db::{
@ -55,6 +58,9 @@ pub fn routes() -> Vec<Route> {
bulk_reinvite_members,
confirm_invite,
bulk_confirm_invite,
get_pending_auto_confirm_members,
auto_confirm_member,
bulk_auto_confirm_members,
accept_invite,
get_org_user_mini_details,
get_user,
@ -1045,6 +1051,7 @@ async fn send_invite(
data: Json<InviteData>,
headers: AdminHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@ -1185,6 +1192,11 @@ async fn send_invite(
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?;
}
// With mail disabled an existing user is accepted right away, so there is no accept request later on.
// This is the last step on purpose: an admin client may confirm the member the moment it is told
// about it, and by then the collections and groups of the invite have to be in place.
notify_pending_auto_confirm(&new_member, &conn, &nt).await;
}
Ok(())
@ -1196,6 +1208,7 @@ async fn bulk_reinvite_members(
data: Json<BulkMembershipIds>,
headers: AdminHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@ -1204,7 +1217,7 @@ async fn bulk_reinvite_members(
let mut bulk_response = Vec::new();
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(),
Err(e) => format!("{e:?}"),
};
@ -1231,11 +1244,12 @@ async fn reinvite_member(
member_id: MembershipId,
headers: AdminHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
if org_id != headers.org_id {
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(
@ -1243,6 +1257,7 @@ async fn reinvite_member_impl(
member_id: &MembershipId,
invited_by_email: &str,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
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.")
@ -1276,6 +1291,7 @@ async fn reinvite_member_impl(
let mut member = member;
member.status = MembershipStatus::Accepted as i32;
member.save(conn).await?;
notify_pending_auto_confirm(&member, conn, nt).await;
}
Ok(())
@ -1295,6 +1311,7 @@ async fn accept_invite(
data: Json<AcceptData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
// 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();
@ -1333,7 +1350,7 @@ async fn accept_invite(
// 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);
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() {
// User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name();
@ -1373,7 +1390,11 @@ async fn bulk_confirm_invite(
match data.keys {
Some(keys) => {
for invite in keys {
let member_id = invite.id.unwrap();
// Never unwrap the id, this is client supplied and a missing one must not take the request down
let Some(member_id) = invite.id else {
error!("Ignoring a bulk confirm entry without a member id");
continue;
};
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 {
Ok(()) => String::new(),
@ -1428,7 +1449,7 @@ async fn confirm_invite_impl(
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")
};
@ -1436,6 +1457,20 @@ async fn confirm_invite_impl(
err!("Only Owners can confirm Managers, Admins or Owners")
}
confirm_member(member_to_confirm, key, headers, conn, nt).await
}
/// Shared by the manual and the automatic confirmation, both hand us the organization key encrypted
/// with the public key of the member to confirm.
async fn confirm_member(
mut member_to_confirm: Membership,
key: &str,
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 {
err!("User in invalid state")
}
@ -1446,10 +1481,20 @@ async fn confirm_invite_impl(
// 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?;
// An organization which confirms its members automatically does not tolerate emergency access: the
// grantee could take over the account of a member that nobody ever vetted and reach the organization
// vault through it. Enabling the policy drops the grants of the members present at that time, this
// covers the member which brings one along when it joins afterwards. Bitwarden does the same, and
// like there it applies to the 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(
EventType::OrganizationUserConfirmed as i32,
&member_to_confirm.uuid,
org_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
@ -1458,7 +1503,7 @@ async fn confirm_invite_impl(
.await;
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
} else {
err!("Error looking up organization.")
@ -1480,6 +1525,142 @@ async fn confirm_invite_impl(
save_result
}
// Automatic user confirmation. The server can never confirm a member by itself, confirming means
// encrypting the organization key with the public key of the member and the server does not have the
// organization key. So all we do here is telling an admin client which members are waiting, the client
// does the actual work in the background.
// https://bitwarden.com/help/automatic-confirmation/
/// Only a member which accepted its invitation and holds the plain User role is ever confirmed without
/// a human looking at it. Every elevated role keeps needing a manual confirmation by an Owner, and an
/// Owner can not lift that restriction here like it can for the manual confirmation.
fn may_be_confirmed_automatically(member: &Membership) -> bool {
member.status == MembershipStatus::Accepted as i32 && member.atype == MembershipType::User
}
#[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(may_be_confirmed_automatically)
.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 {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let data = data.into_inner();
let mut bulk_response = Vec::new();
match data.keys {
Some(keys) => {
for member in keys {
// Never unwrap the id, this is client supplied and a missing one must not take the request down
let Some(member_id) = member.id else {
error!("Ignoring a bulk auto confirm entry without a member id");
continue;
};
let user_key = member.key.unwrap_or_default();
let err_msg = match auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await
{
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
};
bulk_response.push(json!(
{
"object": "OrganizationBulkConfirmResponseModel",
"id": member_id,
"error": err_msg
}
));
}
}
None => error!("No keys to confirm"),
}
Ok(Json(json!({
"data": bulk_response,
"object": "list",
"continuationToken": null
})))
}
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 !may_be_confirmed_automatically(&member_to_confirm) {
err!("This member can not be confirmed automatically")
}
confirm_member(member_to_confirm, key, headers, conn, nt).await
}
#[get("/organizations/<org_id>/users/mini-details", rank = 1)]
async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
@ -2113,6 +2294,54 @@ async fn put_policy(
}
}
// The automatic user confirmation policy hands out organization access without anybody looking at it,
// so it needs to be allowed by the server first and it requires the Single Org policy on top.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs
let auto_confirm_turned_on = if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation
&& data.enabled
// Only the step from disabled to enabled validates and has side effects. The web vault saves a
// policy on every edit, and re-running the below on an already enabled policy would keep wiping
// emergency access that members created in the meantime. Bitwarden guards this the same way.
&& !OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await
{
if !CONFIG.org_auto_confirm_enabled() {
err!("Automatic user confirmation is not enabled on this server.")
}
let single_org_policy_enabled =
match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await {
Some(p) => p.enabled,
None => false,
};
if !single_org_policy_enabled {
err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.")
}
// Every member has to be compliant already. Contrary to the Single Org policy below we do not revoke
// the members that are not, because this policy also applies to owners and admins and revoking those
// could lock the organization out of itself.
for member in Membership::find_by_org(&org_id, &conn).await {
if member.status != MembershipStatus::Invited as i32
&& Membership::count_accepted_and_confirmed_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.")
}
}
true
} else {
false
};
// Also prevent the Single Org policy to be disabled while automatic user 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
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
two_factor::enforce_2fa_policy_for_org(
@ -2169,6 +2398,25 @@ async fn put_policy(
policy.data = serde_json::to_string(&data.data)?;
policy.save(&conn).await?;
// Emergency access would hand the account of a member to somebody outside of the control of this
// organization, which defeats the point of vetting members. Bitwarden drops these grants when the
// policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`).
// This runs after the policy is stored so that a failed save can not destroy data for nothing, and it
// skips invited members on purpose: an invitation is created by an admin without any consent of the
// invited user, so it must never be able to delete data of an account that never joined.
if auto_confirm_turned_on {
for member in Membership::find_by_org(&org_id, &conn).await {
if member.status == MembershipStatus::Invited as i32 {
continue;
}
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(
EventType::PolicyUpdated as i32,
policy.uuid.as_ref(),
@ -3251,3 +3499,32 @@ async fn rotate_api_key(
) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await
}
#[cfg(test)]
mod tests {
use super::*;
/// Automatic confirmation hands out access to the organization vault without anybody looking at it,
/// so it must stay limited to plain members which actually accepted their invitation.
#[test]
fn only_accepted_plain_members_are_confirmed_automatically() {
let mut member =
Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None);
for status in
[MembershipStatus::Revoked as i32, MembershipStatus::Invited as i32, MembershipStatus::Confirmed as i32]
{
member.status = status;
assert!(!may_be_confirmed_automatically(&member), "status {status} must not qualify");
}
member.status = MembershipStatus::Accepted as i32;
for atype in [MembershipType::Owner as i32, MembershipType::Admin as i32, MembershipType::Manager as i32] {
member.atype = atype;
assert!(!may_be_confirmed_automatically(&member), "type {atype} must not qualify");
}
member.atype = MembershipType::User as i32;
assert!(may_be_confirmed_automatically(&member));
}
}

10
src/api/identity.rs

@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{
ApiResult, EmptyResult, JsonResult,
ApiResult, EmptyResult, JsonResult, Notify,
core::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
@ -1034,8 +1034,8 @@ async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value
}
#[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, false, conn).await
async fn identity_register(data: Json<RegisterData>, conn: DbConn, nt: Notify<'_>) -> JsonResult {
register(data, false, conn, nt).await
}
#[derive(Debug, Deserialize)]
@ -1098,8 +1098,8 @@ async fn register_verification_email(
}
#[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await
async fn register_finish(data: Json<RegisterData>, conn: DbConn, nt: Notify<'_>) -> JsonResult {
register(data, true, conn, nt).await
}
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts

39
src/api/notifications.rs

@ -15,7 +15,10 @@ use crate::{
auth::{ClientIp, WsAccessTokenHeader},
db::{
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,
},
},
};
@ -510,6 +513,33 @@ impl WebSocketUsers {
}
}
/// Tells the clients of `recipient_id` that `member_id` accepted an invitation and is waiting to be
/// confirmed. Only the browser extension acts upon this, it holds the organization key needed to
/// confirm the member, which the server never has. Because of that 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) {
// Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED {
@ -700,6 +730,13 @@ pub enum UpdateType {
// NotificationStatus = 21, // 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,
}

36
src/config.rs

@ -795,6 +795,11 @@ make_config! {
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false;
/// Enable 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. Bitwarden only enables this per organization on request, we keep it off 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.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false;
@ -1739,6 +1744,7 @@ where
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".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_confirmed", ".html");
reg!("email/emergency_access_recovery_approved", ".html");
@ -1858,3 +1864,33 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String |
handlebars::handlebars_helper!(vwver: | vw_version: String |
semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION)
);
#[cfg(test)]
mod tests {
use super::*;
/// The registry runs in strict mode, so a placeholder which the sender does not fill only blows up
/// when the mail is actually sent. Render both templates of the emergency access removal notification
/// with the data `mail::send_emergency_access_grantees_removed` passes.
#[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 = match hb.render(name, &data) {
Ok(rendered) => rendered,
Err(e) => panic!("{name} failed to render: {e:?}"),
};
let (subject, body) = rendered.split_once("<!---------------->").expect("no subject separator");
assert_eq!(subject.trim(), "Emergency contacts removed");
assert!(body.contains("first@example.com"), "{name} does not list every removed contact");
assert!(body.contains("second@example.com"), "{name} does not list every removed contact");
}
}
}

66
src/db/models/org_policy.rs

@ -47,7 +47,7 @@ pub enum OrgPolicyType {
RestrictedItemTypes = 15,
UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet)
AutomaticUserConfirmation = 18,
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
}
@ -280,6 +280,50 @@ impl OrgPolicy {
false
}
/// Returns true if the user is a member of an organization other than `exclude_org_uuid` which has the
/// automatic user confirmation policy enabled. Contrary to `is_applicable_to_user` this does not exempt
/// owners and admins, the policy applies to every role and every status.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
pub async fn auto_confirm_enabled_for_other_org(
user_uuid: &UserId,
exclude_org_uuid: &OrganizationId,
conn: &DbConn,
) -> bool {
CONFIG.org_auto_confirm_enabled()
&& Self::find_accepted_and_confirmed_by_user_and_active_policy(
user_uuid,
OrgPolicyType::AutomaticUserConfirmation,
conn,
)
.await
.iter()
.any(|policy| &policy.org_uuid != exclude_org_uuid)
}
/// Returns true if the user is a member of an organization which confirms its members automatically.
/// Such a membership also restricts what the user may do outside of that organization.
pub async fn is_user_in_auto_confirm_org(user_uuid: &UserId, conn: &DbConn) -> bool {
CONFIG.org_auto_confirm_enabled()
&& !Self::find_accepted_and_confirmed_by_user_and_active_policy(
user_uuid,
OrgPolicyType::AutomaticUserConfirmation,
conn,
)
.await
.is_empty()
}
/// Returns true if members of this organization may be confirmed automatically. This requires both the
/// server wide config option and the policy of this organization to be enabled, which mirrors Bitwarden
/// where the organization needs the feature enabled by support on top of the policy.
pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
CONFIG.org_auto_confirm_enabled()
&& match Self::find_by_org_and_type(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await {
Some(p) => p.enabled,
None => false,
}
}
pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult {
if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
// Enforce TwoFactor/TwoStep login
@ -313,6 +357,26 @@ impl OrgPolicy {
}
}
// The automatic user confirmation policy is a stricter variant of the SingleOrg policy, it does not
// exempt owners and admins and it applies to every status. Therefore it is checked outside of the
// block above.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs
if Self::auto_confirm_enabled_for_other_org(&m.user_uuid, &m.org_uuid, conn).await {
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_and_confirmed_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(())
}

2
src/db/models/organization.rs

@ -204,6 +204,7 @@ impl Organization {
"maxCollections": null,
"maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side
"use2fa": true,
"useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(),
"useCustomPermissions": true,
"useDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
"useEvents": CONFIG.org_events_enabled(),
@ -498,6 +499,7 @@ impl Membership {
"useKeyConnector": false,
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"usePasswordManager": true,
"useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(),
"useCustomPermissions": true,
"useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false,

16
src/mail.rs

@ -394,6 +394,22 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email:
send_email(address, &subject, body_html, body_text).await
}
/// Tells a grantor which of its emergency access contacts were dropped. Deliberately does not name the
/// reason: the recipient can be somebody outside of the organization which triggered this, and it has no
/// business learning about the memberships of others. Bitwarden keeps this generic as well.
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 {
let (subject, body_html, body_text) = get_text(
"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