diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 58e5db18c2..40b215f6be 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,12 +2,16 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Models.Data; 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.RevokeUser.v2; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Services; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -30,6 +34,8 @@ public class MembersController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +48,9 @@ public class MembersController : Controller IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2, + IRestoreOrganizationUserCommand restoreOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -55,6 +63,8 @@ public class MembersController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; } /// @@ -258,4 +268,59 @@ public class MembersController : Controller await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id); return new OkResult(); } + + /// + /// Revoke a member's access to an organization. + /// + /// The ID of the member to be revoked. + [HttpPut("{id}/revoke")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task PutRevoke(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + var request = new RevokeOrganizationUsersRequest( + _currentContext.OrganizationId!.Value, + [id], + new SystemUser(EventSystemUser.PublicApi) + ); + + var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request); + var result = results.Single(); + + return result.Result.Match( + error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)), + _ => new OkResult() + ); + } + + /// + /// Restore a member. + /// + /// + /// Restores a previously revoked member of the organization. + /// + /// The identifier of the member to be restored. + [HttpPut("{id}/restore")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task PutRestore(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi); + + return new OkResult(); + } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index 9f2512038e..9727bbf5fc 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -264,4 +264,126 @@ public class MembersControllerTests : IClassFixture, IAsy new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, orgUser.GetPermissions()); } + + [Fact] + public async Task PutRevoke_Member_Success() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _factory.GetService() + .GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status); + } + + [Fact] + public async Task PutRevoke_AlreadyRevoked_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var revokeResponse = await _client.PutAsync($"/public/members/{orgUser.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already revoked.", error?.Message); + } + + [Fact] + public async Task PutRevoke_NotFound_ReturnsNotFound() + { + var response = await _client.PutAsync($"/public/members/{Guid.NewGuid()}/revoke", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PutRevoke_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to revoke the user from the other organization + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PutRestore_Member_Success() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var revokeResponse = await _client.PutAsync($"/public/members/{orgUser.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _factory.GetService() + .GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status); + } + + [Fact] + public async Task PutRestore_AlreadyActive_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already active.", error?.Message); + } + + [Fact] + public async Task PutRestore_NotFound_ReturnsNotFound() + { + var response = await _client.PutAsync($"/public/members/{Guid.NewGuid()}/restore", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PutRestore_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to restore the user from the other organization + var response = await _client.PutAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } }