Browse Source

Add automatic user confirmation (Bitwarden policy 18)

Implements the server side of Bitwarden's "Automatic Confirmation", which
automates the confirm step of the invite -> accept -> confirm flow.

The server can never confirm a member itself: confirming means encrypting the
organization key with the public key of the new member, and the server does not
have that key. Bitwarden solves this by letting the unlocked browser extension
of an admin do the work in the background, so what is added here is everything
that extension needs:

- `ORG_AUTO_CONFIRM_ENABLED` (off by default) and the matching
  `useAutomaticUserConfirmation` flag in both organization JSONs. This mirrors
  Bitwarden, where the feature is enabled per organization on request.
- Policy type 18. Enabling it requires the Single Org policy, and every member
  has to be in this organization only. Contrary to the Single Org policy the
  non compliant members are not revoked, because this policy applies to owners
  and admins as well and revoking those could lock the organization out.
  Enabling it also drops the emergency access grants of all members, and new
  ones are refused while it is on.
- `GET  /organizations/<id>/users/pending-auto-confirm`
  `POST /organizations/<id>/users/<member>/auto-confirm`
  `POST /organizations/<id>/users/bulk-auto-confirm`
  Only members which accepted their invitation and hold the plain User role are
  ever confirmed this way, an elevated role always needs a human, and an owner
  can not lift that restriction like it can for the manual confirmation.
- Notification type 26 (AutoConfirmMember) to everybody who can confirm, sent
  wherever a membership reaches the accepted state. WebSocket only, the
  extension is the only consumer.
pull/7499/head
tom27052006 2 weeks ago
parent
commit
cbea60cd59
  1. 7
      .env.template
  2. 12
      src/api/core/accounts.rs
  3. 11
      src/api/core/emergency_access.rs
  4. 41
      src/api/core/mod.rs
  5. 255
      src/api/core/organizations.rs
  6. 10
      src/api/identity.rs
  7. 39
      src/api/notifications.rs
  8. 5
      src/config.rs
  9. 66
      src/db/models/org_policy.rs
  10. 2
      src/db/models/organization.rs

7
.env.template

@ -483,6 +483,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, register_push_device, unregister_push_device, master_password_policy, 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},
@ -255,7 +255,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
false 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 mut data: RegisterData = data.into_inner();
let email = data.email.to_lowercase(); 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") 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()
@ -436,7 +436,7 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
} }
#[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;
@ -478,13 +478,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 as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)

11
src/api/core/emergency_access.rs

@ -222,6 +222,12 @@ 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.")
} }
// 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 { let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => { None => {
if !CONFIG.invitations_allowed() { if !CONFIG.invitations_allowed() {
@ -354,6 +360,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 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. // 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) =

41
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,
@ -277,11 +277,48 @@ 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;
}
for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn).await {
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( 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");
@ -295,6 +332,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.")

255
src/api/core/organizations.rs

@ -9,15 +9,16 @@ 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, 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, EventType, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, EmergencyAccess, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
OrganizationId, User, UserId,
}, },
}, },
mail, mail,
@ -55,6 +56,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,
@ -1045,6 +1049,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");
@ -1120,6 +1125,9 @@ async fn send_invite(
new_member.status = member_status; new_member.status = member_status;
new_member.save(&conn).await?; new_member.save(&conn).await?;
// With mail disabled an existing user is accepted right away, so there is no accept request later on
notify_pending_auto_confirm(&new_member, &conn, &nt).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
@ -1196,6 +1204,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");
@ -1204,7 +1213,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) => format!("{e:?}"), Err(e) => format!("{e:?}"),
}; };
@ -1231,11 +1240,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(
@ -1243,6 +1253,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.")
@ -1276,6 +1287,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(())
@ -1295,6 +1307,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();
@ -1333,7 +1346,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();
@ -1428,7 +1441,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")
}; };
@ -1436,6 +1449,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, 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 { if member_to_confirm.status != MembershipStatus::Accepted as i32 {
err!("User in invalid state") err!("User in invalid state")
} }
@ -1449,7 +1476,7 @@ async fn confirm_invite_impl(
log_event( log_event(
EventType::OrganizationUserConfirmed as i32, EventType::OrganizationUserConfirmed as i32,
&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,
@ -1458,7 +1485,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.")
@ -1480,6 +1507,138 @@ async fn confirm_invite_impl(
save_result 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 {
let member_id = member.id.unwrap();
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)] #[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 {
@ -2113,6 +2272,53 @@ 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/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs
if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled {
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.
let members = Membership::find_by_org(&org_id, &conn).await;
for member in &members {
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.")
}
}
// 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`).
for member in &members {
info!("Removing emergency access of {} because automatic user confirmation was enabled", member.user_uuid);
EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?;
}
}
// 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 // 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(
@ -3251,3 +3457,32 @@ async fn rotate_api_key(
) -> JsonResult { ) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await 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::{ 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,
@ -1034,8 +1034,8 @@ async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value
} }
#[post("/accounts/register", data = "<data>")] #[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult { async fn identity_register(data: Json<RegisterData>, conn: DbConn, nt: Notify<'_>) -> JsonResult {
register(data, false, conn).await register(data, false, conn, nt).await
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -1098,8 +1098,8 @@ async fn register_verification_email(
} }
#[post("/accounts/register/finish", data = "<data>")] #[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult { async fn register_finish(data: Json<RegisterData>, conn: DbConn, nt: Notify<'_>) -> JsonResult {
register(data, true, conn).await register(data, true, 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

39
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,
},
}, },
}; };
@ -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) { 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 {
@ -700,6 +730,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,
} }

5
src/config.rs

@ -790,6 +790,11 @@ 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.
/// 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. /// 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;

66
src/db/models/org_policy.rs

@ -47,7 +47,7 @@ pub enum OrgPolicyType {
RestrictedItemTypes = 15, RestrictedItemTypes = 15,
UriMatchDefaults = 16, UriMatchDefaults = 16,
// AutotypeDefaultSetting = 17, // Not supported yet // AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet) AutomaticUserConfirmation = 18,
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
} }
@ -280,6 +280,50 @@ impl OrgPolicy {
false 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/9ebe16587175b1c0e9208f84397bb75d0d595510/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 { 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
@ -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/9ebe16587175b1c0e9208f84397bb75d0d595510/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(()) Ok(())
} }

2
src/db/models/organization.rs

@ -204,6 +204,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(),
@ -498,6 +499,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,

Loading…
Cancel
Save