From 80eec2df853c111af3455d48769f32624818043f Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 28 Jan 2026 04:11:15 +1000 Subject: [PATCH] [PM-23768] Public API - add restore and revoke member endpoint (#6859) * Add restore and revoke to public api * Follow naming conventions * Use POST instead of PUT * hello claude * Update test names * Actually fix test names * Add JsonConstructor attr * Fix test --- .../Public/Controllers/MembersController.cs | 67 ++++++++- .../Public/Response/ErrorResponseModel.cs | 13 +- .../Controllers/MembersControllerTests.cs | 134 ++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 58e5db18c2..220c812cae 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. + [HttpPost("{id}/revoke")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Revoke(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. + [HttpPost("{id}/restore")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Restore(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/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs index c5bb06d02e..a40b0c9569 100644 --- a/src/Api/Models/Public/Response/ErrorResponseModel.cs +++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs @@ -1,7 +1,5 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Models.Public.Response; @@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel { } public ErrorResponseModel(string errorKey, string errorValue) - : this(errorKey, new string[] { errorValue }) + : this(errorKey, [errorValue]) { } public ErrorResponseModel(string errorKey, IEnumerable errorValues) : this(new Dictionary> { { errorKey, errorValues } }) { } + [JsonConstructor] public ErrorResponseModel(string message, Dictionary> errors) { Message = message; @@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel /// /// The request model is invalid. [Required] - public string Message { get; set; } + public string Message { get; init; } /// /// If multiple errors occurred, they are listed in dictionary. Errors related to a specific /// request parameter will include a dictionary key describing that parameter. /// - public Dictionary> Errors { get; set; } + public Dictionary>? Errors { get; } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index 9f2512038e..e4bdbdb174 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture, IAsy new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, orgUser.GetPermissions()); } + + [Fact] + public async Task Revoke_Member_Success() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/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 Revoke_AlreadyRevoked_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + var response = await _client.PostAsync($"/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 Revoke_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Revoke_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.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_Member_Success() + { + // Invite a user to revoke + var email = $"integration-test{Guid.NewGuid()}@example.com"; + var inviteRequest = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.User, + }; + + var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest)); + Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode); + var invitedMember = await inviteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(invitedMember); + + // Revoke the invited user + var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + // Restore the user + var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify user is restored to Invited state + var updatedUser = await _factory.GetService() + .GetByIdAsync(invitedMember.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status); + } + + [Fact] + public async Task Restore_AlreadyActive_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/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 Restore_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_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.PostAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } }