1
0
mirror of https://github.com/bitwarden/server synced 2026-01-30 16:23:37 +00:00

[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
This commit is contained in:
Thomas Rittson
2026-01-28 04:11:15 +10:00
committed by GitHub
parent 898904a673
commit 80eec2df85
3 changed files with 206 additions and 8 deletions

View File

@@ -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;
}
/// <summary>
@@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult();
}
/// <summary>
/// Revoke a member's access to an organization.
/// </summary>
/// <param name="id">The ID of the member to be revoked.</param>
[HttpPost("{id}/revoke")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> 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<IActionResult>(
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
_ => new OkResult()
);
}
/// <summary>
/// Restore a member.
/// </summary>
/// <remarks>
/// Restores a previously revoked member of the organization.
/// </remarks>
/// <param name="id">The identifier of the member to be restored.</param>
[HttpPost("{id}/restore")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> 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();
}
}

View File

@@ -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<string> errorValues)
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
{ }
[JsonConstructor]
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
{
Message = message;
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
/// </summary>
/// <example>The request model is invalid.</example>
[Required]
public string Message { get; set; }
public string Message { get; init; }
/// <summary>
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
/// request parameter will include a dictionary key describing that parameter.
/// </summary>
public Dictionary<string, IEnumerable<string>> Errors { get; set; }
public Dictionary<string, IEnumerable<string>>? Errors { get; }
}

View File

@@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, 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<IOrganizationUserRepository>()
.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<ErrorResponseModel>();
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<MemberResponseModel>();
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<IOrganizationUserRepository>()
.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<ErrorResponseModel>();
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);
}
}