diff --git a/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs new file mode 100644 index 0000000000..ed205524d1 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs @@ -0,0 +1,14 @@ +using Bit.Core.Context; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// Requires that the user is a member of the organization. +/// +public class MemberRequirement : IOrganizationRequirement +{ + public Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => Task.FromResult(organizationClaims is not null); +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index a380d2f0d9..5cdd857f3f 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -81,6 +82,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; + private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -112,7 +114,8 @@ public class OrganizationUsersController : BaseAdminConsoleController IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, - V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext) + V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext, + ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -145,6 +148,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _initPendingOrganizationCommand = initPendingOrganizationCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand; _adminRecoverAccountCommand = adminRecoverAccountCommand; + _selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand; } [HttpGet("{id}")] @@ -635,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } + [HttpPut("revoke-self")] + [Authorize] + public async Task RevokeSelfAsync(Guid orgId) + { + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new UnauthorizedAccessException(); + } + + var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value); + return Handle(result); + } + [HttpPatch("{id}/revoke")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] [Authorize] diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs new file mode 100644 index 0000000000..8c19544aa9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs @@ -0,0 +1,7 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public record OrganizationUserNotFound() : NotFoundError("Organization user not found."); +public record NotEligibleForSelfRevoke() : BadRequestError("User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member."); +public record LastOwnerCannotSelfRevoke() : BadRequestError("The last owner cannot revoke themselves."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..3153465a38 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +/// +/// Allows users to revoke themselves from an organization when declining to migrate personal items +/// under the OrganizationDataOwnership policy. +/// +public interface ISelfRevokeOrganizationUserCommand +{ + /// + /// Revokes a user from an organization. + /// + /// The organization ID. + /// The user ID to revoke. + /// A indicating success or containing an error. + /// + /// Validates the OrganizationDataOwnership policy is enabled and applies to the user (currently Owners/Admins are exempt), + /// the user is a confirmed member, and prevents the last owner from revoking themselves. + /// + Task SelfRevokeUserAsync(Guid organizationId, Guid userId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..afc0236af4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public class SelfRevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IPolicyRequirementQuery policyRequirementQuery, + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IEventService eventService, + IPushNotificationService pushNotificationService) + : ISelfRevokeOrganizationUserCommand +{ + public async Task SelfRevokeUserAsync(Guid organizationId, Guid userId) + { + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + if (organizationUser == null) + { + return new OrganizationUserNotFound(); + } + + var policyRequirement = await policyRequirementQuery.GetAsync(userId); + + if (!policyRequirement.EligibleForSelfRevoke(organizationId)) + { + return new NotEligibleForSelfRevoke(); + } + + // Prevent the last owner from revoking themselves, which would brick the organization + if (organizationUser.Type == OrganizationUserType.Owner) + { + var hasOtherOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( + organizationId, + [organizationUser.Id], + includeProvider: true); + + if (!hasOtherOwner) + { + return new LastOwnerCannotSelfRevoke(); + } + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId!.Value); + + return new None(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index c9653053ea..d30ba5c39f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -83,6 +83,24 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement return _policyDetails.Any(p => p.OrganizationId == organizationId && p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed); } + + /// + /// Determines if a user is eligible for self-revocation under the Organization Data Ownership policy. + /// A user is eligible if they are a confirmed member of the organization and the policy is enabled. + /// This also handles exempt roles (Owner/Admin) and policy disabled state via the factory's Enforce predicate. + /// + /// The organization ID to check. + /// True if the user is eligible for self-revocation (policy applies to them), false otherwise. + /// + /// Self-revoke is used to opt out of migrating the user's personal vault to the organization as required by this policy. + /// + public bool EligibleForSelfRevoke(Guid organizationId) + { + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + return policyDetail?.HasStatus([OrganizationUserStatusType.Confirmed]) ?? false; + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/Dirt/Enums/EventType.cs b/src/Core/Dirt/Enums/EventType.cs index 916f408fe6..61372fc4e0 100644 --- a/src/Core/Dirt/Enums/EventType.cs +++ b/src/Core/Dirt/Enums/EventType.cs @@ -61,6 +61,7 @@ public enum EventType : int OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted OrganizationUser_Left = 1516, // User voluntarily left the organization OrganizationUser_AutomaticallyConfirmed = 1517, + OrganizationUser_SelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b502cc6e4e..4d4ab23593 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -150,6 +151,8 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs new file mode 100644 index 0000000000..896fb3bd20 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUsersControllerSelfRevokeTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private string _ownerEmail = null!; + + public OrganizationUsersControllerSelfRevokeTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task SelfRevoke_WhenPolicyEnabledAndUserIsEligible_ReturnsOk() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + organization.Id, + OrganizationUserType.User); + await _loginHelper.LoginAsync(userEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.NoContent, result.StatusCode); + + var organizationUserRepository = _factory.GetService(); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id); + var revokedUser = organizationUsers.FirstOrDefault(u => u.Email == userEmail); + + Assert.NotNull(revokedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, revokedUser.Status); + } + + [Fact] + public async Task SelfRevoke_WhenUserNotMemberOfOrganization_ReturnsForbidden() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var nonMemberEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(nonMemberEmail); + await _loginHelper.LoginAsync(nonMemberEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); + } + + [Theory] + [InlineData(OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Admin)] + public async Task SelfRevoke_WhenUserIsOwnerOrAdmin_ReturnsBadRequest(OrganizationUserType userType) + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + string userEmail; + if (userType == OrganizationUserType.Owner) + { + userEmail = _ownerEmail; + } + else + { + (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + organization.Id, + userType); + } + + await _loginHelper.LoginAsync(userEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + } + + [Fact] + public async Task SelfRevoke_WhenUserIsProviderButNotMember_ReturnsForbidden() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync( + _factory, + organization.Id, + ProviderType.Msp, + ProviderStatusType.Billable); + + var providerUserEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(providerUserEmail); + await ProviderTestHelpers.CreateProviderUserAsync( + _factory, + provider.Id, + providerUserEmail, + ProviderUserType.ProviderAdmin); + + await _loginHelper.LoginAsync(providerUserEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs b/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs new file mode 100644 index 0000000000..bf06e7d576 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs @@ -0,0 +1,49 @@ +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization.Requirements; + +[SutProviderCustomize] +public class MemberRequirementTests +{ + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task AuthorizeAsync_WhenUserIsOrganizationMember_ThenRequestShouldBeAuthorized( + OrganizationUserType type, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + organization.Type = type; + + var actual = await sutProvider.Sut.AuthorizeAsync(organization, () => Task.FromResult(false)); + + Assert.True(actual); + } + + [Theory, BitAutoData] + public async Task AuthorizeAsync_WhenUserIsNotOrganizationMember_ThenRequestShouldBeDenied( + SutProvider sutProvider) + { + var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(false)); + + Assert.False(actual); + } + + [Theory, BitAutoData] + public async Task AuthorizeAsync_WhenUserIsProviderButNotMember_ThenRequestShouldBeDenied( + SutProvider sutProvider) + { + var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(true)); + + Assert.False(actual); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..9b9007e784 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs @@ -0,0 +1,233 @@ +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.OrganizationUsers.SelfRevokeUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +[SutProviderCustomize] +public class SelfRevokeOrganizationUserCommandTests +{ + [Theory] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + [BitAutoData(OrganizationUserType.Admin)] + public async Task SelfRevokeUser_Success( + OrganizationUserType userType, + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Type = userType; + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed user + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = userType, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .RevokeAsync(organizationUser.Id); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(userId); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenUserNotFound_ReturnsNotFoundError( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns((OrganizationUser)null); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenNotEligible_ReturnsBadRequestError( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Policy requirement with no policies (disabled) + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + Enumerable.Empty()); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenLastOwner_ReturnsBadRequestError( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed owner + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.Owner, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>(), true) + .Returns(false); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenOwnerButNotLastOwner_Success( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed owner + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.Owner, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>(), true) + .Returns(true); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .RevokeAsync(organizationUser.Id); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs index ab4788c808..c580052f2a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs @@ -36,6 +36,73 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType); } + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithConfirmedUser_ReturnsTrue( + Guid organizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = organizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithInvitedUser_ReturnsFalse( + Guid organizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Invited)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = organizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithNoPolicies_ReturnsFalse( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create([]); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithDifferentOrganization_ReturnsFalse( + Guid organizationId, + Guid differentOrganizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = differentOrganizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + [Theory, BitAutoData] public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue( [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies,