From 1a7046e5e09987604b2abc067307f6bfab524b07 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 23 Jan 2026 16:39:04 -0500 Subject: [PATCH] feat(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Initial implementation --- ...omaticUserConfirmationPolicyRequirement.cs | 20 +- .../EmergencyAccess/EmergencyAccessService.cs | 32 +- ...cUserConfirmationPolicyRequirementTests.cs | 152 +++++++++ .../Services/EmergencyAccessServiceTests.cs | 319 +++++++++++++++++- 4 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs index 3430f33a77..ea74a7ba74 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -19,7 +19,25 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements /// Collection of policy details that apply to this user id public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement { - public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + /// + /// Returns true if the user cannot grant emergency access because they are in an + /// auto-confirm organization with status Accepted, Confirmed, or Revoked. + /// + public bool CannotGrantEmergencyAccess() => policyDetails.Any(p => + p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed or + OrganizationUserStatusType.Revoked); + + /// + /// Returns true if the user cannot be granted emergency access because they are in an + /// auto-confirm organization with status Accepted, Confirmed, or Revoked. + /// + public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(p => + p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed or + OrganizationUserStatusType.Revoked); public bool CannotJoinProvider() => policyDetails.Any(); diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index 0072f85e61..ed7e9e09f8 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -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 _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 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 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(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(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."); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs new file mode 100644 index 0000000000..8a5d1f6f6f --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs @@ -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()); + } +} diff --git a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs index 006515aafd..62b1e62893 100644 --- a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs @@ -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( () => 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 sutProvider, + User invitingUser, + string email, + int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .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() + .GetAsync(invitingUser.Id) + .Returns(requirement); + + var exception = await Assert.ThrowsAsync( + () => 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() + .DidNotReceiveWithAnyArgs().CreateAsync(default); + } + + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagEnabled_UserNotInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User invitingUser, + string email, + int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .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() + .GetAsync(invitingUser.Id) + .Returns(requirement); + + var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime); + + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagDisabled_UserInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User invitingUser, + string email, + int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime); + + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetAsync(default); + } + + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagEnabled_UserInvitedToAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User invitingUser, + string email, + int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .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() + .GetAsync(invitingUser.Id) + .Returns(requirement); + + var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.View, waitTime); + + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).CreateAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public async Task AcceptUserAsync_FeatureFlagEnabled_UserInAutoConfirmOrg_ThrowsBadRequest( + OrganizationUserStatusType status, + SutProvider sutProvider, + User acceptingUser, + EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + + sutProvider.GetDependency() + .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() + .GetAsync(acceptingUser.Id) + .Returns(requirement); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency())); + + 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() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagEnabled_UserNotInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User acceptingUser, + User invitingUser, + EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + sutProvider.GetDependency() + .GetUserByIdAsync(Arg.Any()) + .Returns(invitingUser); + + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + + sutProvider.GetDependency() + .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() + .GetAsync(acceptingUser.Id) + .Returns(requirement); + + await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency()); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagDisabled_UserInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User acceptingUser, + User invitingUser, + EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + sutProvider.GetDependency() + .GetUserByIdAsync(Arg.Any()) + .Returns(invitingUser); + + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency()); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetAsync(default); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagEnabled_UserInvitedToAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User acceptingUser, + User invitingUser, + EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + + sutProvider.GetDependency() + .GetUserByIdAsync(Arg.Any()) + .Returns(invitingUser); + + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + + sutProvider.GetDependency() + .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() + .GetAsync(acceptingUser.Id) + .Returns(requirement); + + await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency()); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + } }