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);
+ }
}