1
0
mirror of https://github.com/bitwarden/server synced 2026-01-28 15:23:38 +00:00

feat(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Initial implementation

This commit is contained in:
Patrick Pimentel
2026-01-23 16:39:04 -05:00
parent bab4750caa
commit 1a7046e5e0
4 changed files with 520 additions and 3 deletions

View File

@@ -19,7 +19,25 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
/// <param name="policyDetails">Collection of policy details that apply to this user id</param>
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
{
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
/// <summary>
/// Returns true if the user cannot grant emergency access because they are in an
/// auto-confirm organization with status Accepted, Confirmed, or Revoked.
/// </summary>
public bool CannotGrantEmergencyAccess() => policyDetails.Any(p =>
p.OrganizationUserStatus is
OrganizationUserStatusType.Accepted or
OrganizationUserStatusType.Confirmed or
OrganizationUserStatusType.Revoked);
/// <summary>
/// Returns true if the user cannot be granted emergency access because they are in an
/// auto-confirm organization with status Accepted, Confirmed, or Revoked.
/// </summary>
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(p =>
p.OrganizationUserStatus is
OrganizationUserStatusType.Accepted or
OrganizationUserStatusType.Confirmed or
OrganizationUserStatusType.Revoked);
public bool CannotJoinProvider() => policyDetails.Any();

View File

@@ -3,6 +3,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@@ -34,6 +36,8 @@ public class EmergencyAccessService : IEmergencyAccessService
private readonly GlobalSettings _globalSettings;
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _dataProtectorTokenizer;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IFeatureService _featureService;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
public EmergencyAccessService(
IEmergencyAccessRepository emergencyAccessRepository,
@@ -46,7 +50,9 @@ public class EmergencyAccessService : IEmergencyAccessService
IUserService userService,
GlobalSettings globalSettings,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer,
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery)
{
_emergencyAccessRepository = emergencyAccessRepository;
_organizationUserRepository = organizationUserRepository;
@@ -59,6 +65,8 @@ public class EmergencyAccessService : IEmergencyAccessService
_globalSettings = globalSettings;
_dataProtectorTokenizer = dataProtectorTokenizer;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_featureService = featureService;
_policyRequirementQuery = policyRequirementQuery;
}
public async Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
@@ -73,6 +81,17 @@ public class EmergencyAccessService : IEmergencyAccessService
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var requirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(grantorUser.Id);
if (requirement.CannotGrantEmergencyAccess())
{
throw new BadRequestException("You cannot invite emergency contacts because you are a member of an organization that uses Automatic User Confirmation.");
}
}
var emergencyAccess = new EmergencyAccess
{
GrantorId = grantorUser.Id,
@@ -131,6 +150,17 @@ public class EmergencyAccessService : IEmergencyAccessService
throw new BadRequestException("Invalid token.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var requirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(granteeUser.Id);
if (requirement.CannotBeGrantedEmergencyAccess())
{
throw new BadRequestException("You cannot accept emergency access invitations because you are a member of an organization that uses Automatic User Confirmation.");
}
}
if (emergencyAccess.Status == EmergencyAccessStatusType.Accepted)
{
throw new BadRequestException("Invitation already accepted. You will receive an email when the grantor confirms you as an emergency access contact.");

View File

@@ -0,0 +1,152 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Enums;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
public class AutomaticUserConfirmationPolicyRequirementTests
{
[Theory]
[InlineData(OrganizationUserStatusType.Accepted)]
[InlineData(OrganizationUserStatusType.Confirmed)]
[InlineData(OrganizationUserStatusType.Revoked)]
public void CannotGrantEmergencyAccess_WithActiveStatus_ReturnsTrue(OrganizationUserStatusType status)
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = status
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.True(sut.CannotGrantEmergencyAccess());
}
[Fact]
public void CannotGrantEmergencyAccess_WithInvitedStatus_ReturnsFalse()
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.False(sut.CannotGrantEmergencyAccess());
}
[Fact]
public void CannotGrantEmergencyAccess_WithNoPolicies_ReturnsFalse()
{
var sut = new AutomaticUserConfirmationPolicyRequirement([]);
Assert.False(sut.CannotGrantEmergencyAccess());
}
[Theory]
[InlineData(OrganizationUserStatusType.Accepted)]
[InlineData(OrganizationUserStatusType.Confirmed)]
[InlineData(OrganizationUserStatusType.Revoked)]
public void CannotBeGrantedEmergencyAccess_WithActiveStatus_ReturnsTrue(OrganizationUserStatusType status)
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = status
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.True(sut.CannotBeGrantedEmergencyAccess());
}
[Fact]
public void CannotBeGrantedEmergencyAccess_WithInvitedStatus_ReturnsFalse()
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.False(sut.CannotBeGrantedEmergencyAccess());
}
[Fact]
public void CannotBeGrantedEmergencyAccess_WithNoPolicies_ReturnsFalse()
{
var sut = new AutomaticUserConfirmationPolicyRequirement([]);
Assert.False(sut.CannotBeGrantedEmergencyAccess());
}
[Fact]
public void CannotGrantEmergencyAccess_WithMultiplePolicies_OneActive_ReturnsTrue()
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
},
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Confirmed
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.True(sut.CannotGrantEmergencyAccess());
}
[Fact]
public void CannotBeGrantedEmergencyAccess_WithMultiplePolicies_OneActive_ReturnsTrue()
{
var policyDetails = new[]
{
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
},
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Confirmed
}
};
var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
Assert.True(sut.CannotBeGrantedEmergencyAccess());
}
}

View File

@@ -1,4 +1,8 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@@ -1512,4 +1516,317 @@ public class EmergencyAccessServiceTests
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.GetAttachmentDownloadAsync(emergencyAccess.Id, default, default, granteeUser));
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Accepted)]
[BitAutoData(OrganizationUserStatusType.Confirmed)]
[BitAutoData(OrganizationUserStatusType.Revoked)]
public async Task InviteAsync_FeatureFlagEnabled_UserInAutoConfirmOrg_ThrowsBadRequest(
OrganizationUserStatusType status,
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
string email,
int waitTime)
{
sutProvider.GetDependency<IUserService>().CanAccessPremium(invitingUser).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with an active status that blocks emergency access
var requirement = new AutomaticUserConfirmationPolicyRequirement(
[
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = status
}
]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(invitingUser.Id)
.Returns(requirement);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime));
Assert.Contains("You cannot invite emergency contacts because you are a member of an organization that uses Automatic User Confirmation.", exception.Message);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs().CreateAsync(default);
}
[Theory, BitAutoData]
public async Task InviteAsync_FeatureFlagEnabled_UserNotInAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
string email,
int waitTime)
{
sutProvider.GetDependency<IUserService>().CanAccessPremium(invitingUser).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with no policies (user is not in auto-confirm org)
var requirement = new AutomaticUserConfirmationPolicyRequirement([]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(invitingUser.Id)
.Returns(requirement);
var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime);
Assert.NotNull(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1).CreateAsync(Arg.Any<EmergencyAccess>());
}
[Theory, BitAutoData]
public async Task InviteAsync_FeatureFlagDisabled_UserInAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
string email,
int waitTime)
{
sutProvider.GetDependency<IUserService>().CanAccessPremium(invitingUser).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime);
Assert.NotNull(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1).CreateAsync(Arg.Any<EmergencyAccess>());
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceiveWithAnyArgs()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(default);
}
[Theory, BitAutoData]
public async Task InviteAsync_FeatureFlagEnabled_UserInvitedToAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
string email,
int waitTime)
{
sutProvider.GetDependency<IUserService>().CanAccessPremium(invitingUser).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with Invited status (should not block emergency access)
var requirement = new AutomaticUserConfirmationPolicyRequirement(
[
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
}
]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(invitingUser.Id)
.Returns(requirement);
var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime);
Assert.NotNull(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1).CreateAsync(Arg.Any<EmergencyAccess>());
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Accepted)]
[BitAutoData(OrganizationUserStatusType.Confirmed)]
[BitAutoData(OrganizationUserStatusType.Revoked)]
public async Task AcceptUserAsync_FeatureFlagEnabled_UserInAutoConfirmOrg_ThrowsBadRequest(
OrganizationUserStatusType status,
SutProvider<EmergencyAccessService> sutProvider,
User acceptingUser,
EmergencyAccess emergencyAccess,
string token)
{
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
emergencyAccess.Email = acceptingUser.Email;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
sutProvider.GetDependency<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>()
.TryUnprotect(token, out Arg.Any<EmergencyAccessInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1);
return true;
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with an active status that blocks emergency access
var requirement = new AutomaticUserConfirmationPolicyRequirement(
[
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = status
}
]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(acceptingUser.Id)
.Returns(requirement);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency<IUserService>()));
Assert.Contains("You cannot accept emergency access invitations because you are a member of an organization that uses Automatic User Confirmation.", exception.Message);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs().ReplaceAsync(default);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_FeatureFlagEnabled_UserNotInAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User acceptingUser,
User invitingUser,
EmergencyAccess emergencyAccess,
string token)
{
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
emergencyAccess.Email = acceptingUser.Email;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(Arg.Any<Guid>())
.Returns(invitingUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>()
.TryUnprotect(token, out Arg.Any<EmergencyAccessInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1);
return true;
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with no policies (user is not in auto-confirm org)
var requirement = new AutomaticUserConfirmationPolicyRequirement([]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(acceptingUser.Id)
.Returns(requirement);
await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency<IUserService>());
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Accepted));
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_FeatureFlagDisabled_UserInAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User acceptingUser,
User invitingUser,
EmergencyAccess emergencyAccess,
string token)
{
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
emergencyAccess.Email = acceptingUser.Email;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(Arg.Any<Guid>())
.Returns(invitingUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>()
.TryUnprotect(token, out Arg.Any<EmergencyAccessInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1);
return true;
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency<IUserService>());
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Accepted));
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceiveWithAnyArgs()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(default);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_FeatureFlagEnabled_UserInvitedToAutoConfirmOrg_Succeeds(
SutProvider<EmergencyAccessService> sutProvider,
User acceptingUser,
User invitingUser,
EmergencyAccess emergencyAccess,
string token)
{
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
emergencyAccess.Email = acceptingUser.Email;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(Arg.Any<Guid>())
.Returns(invitingUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>()
.TryUnprotect(token, out Arg.Any<EmergencyAccessInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1);
return true;
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
// Create a real requirement with Invited status (should not block emergency access)
var requirement = new AutomaticUserConfirmationPolicyRequirement(
[
new PolicyDetails
{
OrganizationId = Guid.NewGuid(),
PolicyType = PolicyType.AutomaticUserConfirmation,
OrganizationUserStatus = OrganizationUserStatusType.Invited
}
]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(acceptingUser.Id)
.Returns(requirement);
await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency<IUserService>());
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Accepted));
}
}