Browse Source

Merge f72f5014da into d2660324e6

pull/7499/merge
Tom 2 days ago
committed by GitHub
parent
commit
8e80863ee5
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. 69
      src/api/core/emergency_access.rs
  4. 41
      src/api/core/mod.rs
  5. 261
      src/api/core/organizations.rs
  6. 10
      src/api/identity.rs
  7. 39
      src/api/notifications.rs
  8. 33
      src/config.rs
  9. 2
      src/db/models/mod.rs
  10. 147
      src/db/models/org_policy.rs
  11. 75
      src/db/models/organization.rs
  12. 16
      src/mail.rs
  13. 10
      src/static/templates/email/emergency_access_grantees_removed.hbs
  14. 21
      src/static/templates/email/emergency_access_grantees_removed.html.hbs

7
.env.template

@ -495,6 +495,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},
@ -257,7 +257,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();
@ -359,7 +359,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()
@ -438,7 +438,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;
@ -480,13 +480,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)

69
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;
@ -12,14 +14,66 @@ use crate::{
db::{
DbConn, DbPool,
models::{
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
AutoConfirmRequirement, Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
},
},
mail,
util::NumberOrString,
};
/// Drops every emergency access of `user_id`, granted as well as held, and tells the grantors which
/// contacts they lost: those are other users which would otherwise silently lose part of their setup.
/// Like Bitwarden, 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 {
// The same rows `EmergencyAccess::delete_all_by_user` would load, loaded once and deleted below.
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);
// Group by grantor so that each of them gets a single mail listing all of its removed contacts.
// Collected before deleting, which consumes the rows.
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> {
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 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 {
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 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.
// The uuid of the grantee gets stored once accepted.
let Some(mut emergency_access) =

41
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,9 @@ 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 policy when this flag is on; newer ones 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 +280,45 @@ fn api_not_found() -> Json<Value> {
}))
}
/// Tells everybody who can 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 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.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 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 = 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(
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 +332,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.")

261
src/api/core/organizations.rs

@ -9,16 +9,19 @@ 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::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
AutoConfirmRequirement, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
OrganizationId, TwoFactor, TwoFactorType, User, UserId,
},
},
mail,
@ -56,6 +59,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,
@ -196,6 +202,14 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, conn: DbConn
if !CONFIG.is_org_creation_allowed(&headers.user.email) {
err!("User not allowed to create organizations")
}
// Stricter than the SingleOrg policy below, which exempts owners and admins: an organization which
// confirms members automatically forbids every one of them, in any role and status, another membership.
// 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 {
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."
@ -1045,6 +1059,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");
@ -1186,6 +1201,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 no accept request follows.
// 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(())
@ -1197,6 +1217,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");
@ -1205,7 +1226,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:?}"),
};
@ -1232,11 +1253,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(
@ -1244,6 +1266,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.")
@ -1277,6 +1300,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(())
@ -1296,6 +1320,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();
@ -1334,7 +1359,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();
@ -1365,18 +1390,39 @@ async fn bulk_confirm_invite(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
if org_id != headers.org_id {
bulk_confirm(&org_id, data.into_inner(), &headers, &conn, &nt, false).await
}
/// Shared by the manual and the automatic bulk confirmation, which only differ in the checks a member
/// has to pass: see `confirm_invite_impl` and `auto_confirm_member_impl`.
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");
}
let data = data.into_inner();
let mut bulk_response = Vec::new();
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 {
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(),
Err(e) => format!("{e:?}"),
};
@ -1429,7 +1475,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")
};
@ -1437,6 +1483,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")
}
@ -1447,10 +1507,18 @@ 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?;
// Emergency access would let a grantee take over the account of a member nobody vetted and reach
// the organization vault. Enabling the policy drops existing grants, this covers a member which
// brings one along afterwards. Like Bitwarden, this applies 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(
EventType::OrganizationUserConfirmed,
&member_to_confirm.uuid,
org_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
@ -1459,7 +1527,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.")
@ -1481,6 +1549,97 @@ async fn confirm_invite_impl(
save_result
}
// Automatic user confirmation. The server can never confirm a member itself: that means encrypting the
// organization key with the public key of the member, and the server does not have the organization key.
// All we do is tell an admin client which members are waiting, the client does the work 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, 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 {
@ -2125,6 +2284,47 @@ async fn put_policy(
}
}
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
// Automatic confirmation hands out organization access unattended, so it needs the server wide
// config option and the Single Org policy on top.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs
let enables_auto_confirm = pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled;
// Checked on every save, even when the policy is still stored as enabled from before the option was turned off.
if enables_auto_confirm && !CONFIG.org_auto_confirm_enabled() {
err!("Automatic user confirmation is not enabled on this server.")
}
// Only the step from disabled to enabled validates and has side effects. The web vault saves on
// every edit, and re-running the below would keep wiping newly created 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.")
}
// Every member has to be compliant already. Contrary to the Single Org policy below we do not
// revoke the others: this policy also binds owners and admins, which could lock the org out.
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.")
}
}
}
// 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(
@ -2172,15 +2372,27 @@ async fn put_policy(
}
}
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
policy.enabled = data.enabled;
policy.data = serde_json::to_string(&data.data)?;
policy.save(&conn).await?;
// Emergency access would hand a member account to somebody outside this organization, which
// defeats vetting members; Bitwarden drops these on enable and blocks new ones (`emergency_access.rs`).
// Runs after the policy is stored so a failed save destroys nothing, and skips invited members: an
// invitation is created without their consent and must never 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.counts_for_auto_confirm() {
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,
policy.uuid.as_ref(),
@ -2446,6 +2658,15 @@ 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 need to be done after restoring to work with the correct status
OrgPolicy::check_user_allowed(&member, "restore", conn).await?;
// A restore adds no second accept step, so emergency access created while revoked would
// outlive the revocation. Like enabling the policy this leaves a merely invited member alone,
// an invitation must never delete data of an account that 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?;
log_event(

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,
@ -1066,8 +1066,8 @@ async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn)
}
#[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)]
@ -1141,8 +1141,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 the server
/// never has, which is why 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,
}

33
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;
@ -1754,6 +1759,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");
@ -1873,3 +1879,30 @@ 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 the sender does not fill only blows up when the
/// mail is actually sent. Render both templates with the data the sender 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 = 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");
assert!(
body.contains("first@example.com") && body.contains("second@example.com"),
"{name} misses a contact"
);
}
}
}

2
src/db/models/mod.rs

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

147
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)
OrganizationUserNotification = 20,
}
@ -302,6 +302,43 @@ impl OrgPolicy {
false
}
/// Returns every membership of the user, in any status and of any role, in an organization which has
/// `policy_type` enabled. Contrary to the queries above this filters nothing away, the caller decides
/// which memberships it cares about, like Bitwarden does per policy.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/BasePolicyRequirementFactory.cs
pub async fn find_memberships_by_user_and_active_policy(
user_uuid: &UserId,
policy_type: OrgPolicyType,
conn: &DbConn,
) -> Vec<Membership> {
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(policy_type as i32))
.filter(org_policies::enabled.eq(true))
.select(users_organizations::all_columns)
.load::<Membership>(conn)
.expect("Error loading memberships by org_policy")
})
.await
}
/// 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 config option and the policy of this organization, mirroring
/// Bitwarden where support has to enable the feature on top of the 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 {
if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) {
// Enforce TwoFactor/TwoStep login
@ -335,6 +372,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(())
}
@ -393,3 +448,93 @@ impl OrgPolicy {
#[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OrgPolicyId(String);
/// The memberships of a user in organizations which confirm their members automatically.
///
/// Bitwarden models this as a policy requirement: a value answering what the policy forbids this user.
/// Contrary to every other policy this one exempts no role and no status, so an owner or an admin is
/// bound just like a plain member. Not every operation looks at every status though, which is why each
/// question below states which memberships it counts.
/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
pub struct AutoConfirmRequirement(Vec<Membership>);
impl AutoConfirmRequirement {
/// Always empty while the server wide config option is off: the policy can not be enabled anywhere
/// then and so enforces nothing.
pub async fn for_user(user_uuid: &UserId, conn: &DbConn) -> Self {
if !CONFIG.org_auto_confirm_enabled() {
return Self(Vec::new());
}
Self(
OrgPolicy::find_memberships_by_user_and_active_policy(
user_uuid,
OrgPolicyType::AutomaticUserConfirmation,
conn,
)
.await,
)
}
/// The user may not create another organization. Every membership counts, an open invitation and any
/// role included, which is what makes this stricter than SingleOrg. Mirrors `CannotCreateNewOrganization()`.
pub fn forbids_creating_organization(&self) -> bool {
!self.0.is_empty()
}
/// A membership in an organization other than `org_uuid` forbids the user to be part of `org_uuid`.
/// Mirrors `IsEnabledForOrganizationsOtherThan(organizationId)`.
pub fn forbids_membership_outside(&self, org_uuid: &OrganizationId) -> bool {
self.0.iter().any(|m| &m.org_uuid != org_uuid)
}
/// The user may neither grant nor accept emergency access, which would hand its account, and with
/// it the organization vault, to somebody the organization never vetted. Only an open invitation is
/// exempt, see [`Membership::counts_for_auto_confirm`].
/// Mirrors `GrantorCannotInviteToEmergencyAccess()` and `GranteeCannotAcceptEmergencyAccess()`.
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(UserId::from(String::from("user")), OrganizationId::from(String::from(org_uuid)), None);
member.atype = atype as i32;
member.status = status as i32;
member
}
/// Unlike SingleOrg, which lets owners, admins and open invitations through, this policy exempts no
/// role and no status. Only emergency access leaves an open invitation alone, that account did not join yet.
#[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());
// A revoked membership counts as well, a restore adds no second accept step. One is enough, next to
// an open invitation which does not restrict by itself.
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());
}
}

75
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(),
@ -489,6 +490,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,
@ -915,6 +917,46 @@ impl Membership {
.await
}
/// Whether this membership counts for the automatic user confirmation policy, which exempts no role
/// and only one status: an open invitation, because that account did not join yet and may still
/// decline. Every revoked membership counts, it is restored without another 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
}
/// Whether an admin client may confirm this membership without a human looking at it: only a member
/// which accepted its invitation and holds the plain User role. Every elevated role keeps needing a
/// manual confirmation by an Owner.
pub fn can_be_auto_confirmed(&self) -> bool {
self.status == MembershipStatus::Accepted as i32 && self.atype == MembershipType::User
}
/// The same rule as [`Membership::counts_for_auto_confirm`] as a query: how many organizations besides
/// `excluded_org` the user belongs to. Contrary to `count_accepted_and_confirmed_by_user`, which the
/// SingleOrg policy uses, this counts revoked memberships, which are stored below `Invited`.
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> {
conn.run(move |conn| {
users_organizations::table
@ -1274,4 +1316,37 @@ mod tests {
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
}
fn member_with_status(status: i32) -> Membership {
let mut member =
Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None);
member.status = status;
member
}
/// Every membership but an open invitation counts for the auto confirm policy, revoked ones included,
/// while only an accepted plain member may be confirmed without a human looking at it.
#[test]
fn auto_confirm_membership_rules() {
for status in
[MembershipStatus::Invited as i32, MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32]
{
let mut member = member_with_status(status);
let accepted = status == MembershipStatus::Accepted as i32;
assert_eq!(member.counts_for_auto_confirm(), status != MembershipStatus::Invited as i32, "status {status}");
assert_eq!(member.can_be_auto_confirmed(), accepted, "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");
}
let mut member = member_with_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);
}
}
}

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 emergency access contacts were dropped. Deliberately does not name the reason:
/// the recipient can be outside the organization which triggered this and 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