mirror of
https://github.com/bitwarden/server
synced 2025-12-20 02:03:46 +00:00
[EC-507 / EC-508] SCIM CQRS Refactor - Groups/Users (#2344)
* [EC-507] SCIM CQRS Refactor - Groups/Put (#2269) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-531] Implemented CQRS for Groups Put and added unit tests * [EC-507] Created ScimServiceCollectionExtensions * [EC-507] Renamed AddScimCommands to AddScimGroupCommands * [EC-507] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Update PutGroupCommand to return Group PutGroupCommand returns Group and GroupsController creates ScimGroupResponseModel response * [EC-507] Remove Queries/Commands folders from Scim and Scim.Tests * [EC-507] Remove unneeded check on empty provided memberIds * [EC-507] SCIM CQRS Refactor - Groups/GetList (#2272) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-508] Implemented CQRS for Groups GetList and added unit tests * [EC-507] Created ScimServiceCollectionExtensions and renamed GetGroupsListCommand to GetGroupsListQuery * [EC-507] Renamed AddScimCommands to AddScimGroupQueries * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Remove 'Queries' folder from Scim and Scim.Test * [EC-507] Move ScimListResponseModel from GetGroupsListQuery to Scim.GroupsController * [EC-507] Remove asserts on IGroupRepository.GetManyByOrganizationIdAsync from unit tests * [EC-507] SCIM CQRS Refactor - Groups/Get (#2271) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-507] Implemented CQRS for Groups Get and added unit tests * [EC-507] Created ScimServiceCollectionExtensions and renamed GetGroupCommand to GetGroupQuery * [EC-507] Renamed AddScimCommands to AddScimGroupQueries * [EC-507] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Sorted order of methods * [EC-507] Removed GetGroupQuery and moved logic to controller * [EC-507] Remove 'Queries' folder from Scim and Scim.Test * [EC-507] SCIM CQRS Refactor - Groups/Patch (#2268) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-532] Implemented CQRS for Groups Patch and added unit tests * [EC-507] Created ScimServiceCollectionExtensions * [EC-507] Renamed AddScimCommands to AddScimGroupCommands * [EC-507] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Remove Queries/Commands folders from Scim and Scim.Tests * [EC-507] Assert group.Name after saving. Assert userIds saved. * [EC-508] SCIM CQRS Refactor - Users/Delete (#2261) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-539] Implemented CQRS for Users Delete and added unit tests * [EC-508] Created ScimServiceCollectionExtensions * [EC-508] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-508] Removed unneeded model from DeleteUserCommand. Removed unneeded dependencies from UsersController * [EC-508] Removed Bit.Scim.Models dependency from DeleteUserCommandTests * [EC-508] Deleted 'DeleteUserCommand' from SCIM; Created commands on Core 'DeleteOrganizationUserCommand', 'PushDeleteUserRegistrationOrganizationCommand' and 'OrganizationHasConfirmedOwnersExceptQuery' * [EC-508] Changed DeleteOrganizationUserCommand back to using IOrganizationService * [EC-508] Fixed DeleteOrganizationUserCommand unit tests * [EC-508] Remove unneeded obsolete comments. Update DeleteUserAsync Obsolete comment with ticket reference * [EC-508] Move DeleteOrganizationUserCommand to OrganizationFeatures folder * [EC-508] SCIM CQRS Refactor - Users/Post (#2264) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-536] Implemented CQRS for Users Post and added unit tests * [EC-508] Created ScimServiceCollectionExtensions * [EC-508] Renamed AddScimCommands to AddScimUserCommands * [EC-508] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-508] Catching NotFoundException on ExceptionHandlerFilter * [EC-508] Remove Queries/Commands folders from Scim and Scim.Tests * [EC-508] SCIM CQRS Refactor - Users/Patch (#2262) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-538] Implemented CQRS for Users Patch and added unit tests * [EC-508] Added ScimServiceCollectionExtensions * [EC-508] Removed HandleActiveOperationAsync method from UsersController * [EC-508] Renamed AddScimCommands to AddScimUserCommands * [EC-508] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-508] Removed unneeded dependencies from UsersController * [EC-508] Remove 'Query' folder from Scim and Scim.Test * [EC-507] SCIM CQRS Refactor - Groups/Post (#2270) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-530] Implemented CQRS for Groups Post and added unit tests * [EC-507] Created ScimServiceCollectionExtensions * [EC-507] Renamed AddScimCommands to AddScimGroupCommands * [EC-507] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Remove Queries/Commands folders from Scim and Scim.Test * [EC-507] Remove unneeded skipIfEmpty argument. Updated unit test to check provided userIds * [EC-507] Remove UpdateGroupMembersAsync from GroupsController * [EC-508] SCIM CQRS Refactor - Users/GetList (#2265) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-535] Implemented CQRS for Users GetList and added unit tests * [EC-508] Created ScimServiceCollectionExtensions and renamed GetUsersListCommand to GetUsersListQuery * [EC-508] Renamed AddScimCommands to AddScimUserQueries * [EC-508] Removed unneeded IUserRepository and IOptions<ScimSettings> from UsersController * [EC-508] Sorted UsersController properties and dependencies * [EC-508] Remove 'Queries' folder from Scim and Scim.Test * [EC-508] Move ScimListResponseModel creation to Scim.UsersController * [EC-508] Move ScimUserResponseModel creation to Scim.UsersController Co-authored-by: Thomas Rittson <trittson@bitwarden.com> * [EC-507] SCIM CQRS Refactor - Groups/Delete (#2267) * [EC-390] Added Scim.Test unit tests project * [EC-390] Added ConflictException type. Updated BadRequestException to have parameterless constructor. Updated NotFoundException to have constructor with a message parameter * [EC-533] Implemented CQRS for Groups Delete and added unit tests * [EC-507] Created ScimServiceCollectionExtensions * [EC-507] Renamed AddScimCommands to AddScimGroupCommands * [EC-507] Created ExceptionHandlerFilterAttribute on SCIM project * [EC-507] Removed unneeded dependencies from GroupsController * [EC-507] Move DeleteGroupCommand to OrganizationFeatures/OrganizationUsers * [EC-507] Remove IGetUserQuery and move logic to UsersController. Remove unused references. * [EC-507] Move IDeleteGroupCommand to Groups folder Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Queries.Users.Interfaces;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
using Bit.Scim.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Controllers.v2;
|
||||
|
||||
@@ -19,39 +17,43 @@ namespace Bit.Scim.Controllers.v2;
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly IGetUserQuery _getUserQuery;
|
||||
private readonly IGetUsersListQuery _getUsersListQuery;
|
||||
private readonly IDeleteOrganizationUserCommand _deleteOrganizationUserCommand;
|
||||
private readonly IPatchUserCommand _patchUserCommand;
|
||||
private readonly IPostUserCommand _postUserCommand;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IScimContext scimContext,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
IGetUserQuery getUserQuery,
|
||||
IGetUsersListQuery getUsersListQuery,
|
||||
IDeleteOrganizationUserCommand deleteOrganizationUserCommand,
|
||||
IPatchUserCommand patchUserCommand,
|
||||
IPostUserCommand postUserCommand,
|
||||
ILogger<UsersController> logger)
|
||||
{
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_getUserQuery = getUserQuery;
|
||||
_getUsersListQuery = getUsersListQuery;
|
||||
_deleteOrganizationUserCommand = deleteOrganizationUserCommand;
|
||||
_patchUserCommand = patchUserCommand;
|
||||
_postUserCommand = postUserCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var scimUserResponseModel = await _getUserQuery.GetUserAsync(organizationId, id);
|
||||
return Ok(scimUserResponseModel);
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException("User not found.");
|
||||
}
|
||||
return Ok(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@@ -61,124 +63,23 @@ public class UsersController : Controller
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string emailFilter = null;
|
||||
string usernameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, filter, count, startIndex);
|
||||
var scimListResponseModel = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
if (filter.StartsWith("userName eq "))
|
||||
{
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var userList = new List<ScimUserResponseModel> { };
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(emailFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
userList = orgUsers.OrderBy(ou => ou.Email)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(ou => new ScimUserResponseModel(ou))
|
||||
.ToList();
|
||||
totalResults = orgUsers.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
Resources = userList,
|
||||
ItemsPerPage = count.GetValueOrDefault(userList.Count),
|
||||
TotalResults = totalResults,
|
||||
Resources = usersListQueryResult.userList.Select(u => new ScimUserResponseModel(u)).ToList(),
|
||||
ItemsPerPage = count.GetValueOrDefault(usersListQueryResult.userList.Count()),
|
||||
TotalResults = usersListQueryResult.totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
return Ok(scimListResponseModel);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var email = model.PrimaryEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
switch (_scimContext.RequestScimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
email = model.UserName?.ToLowerInvariant();
|
||||
break;
|
||||
default:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
string externalId = null;
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId))
|
||||
{
|
||||
externalId = model.ExternalId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserName))
|
||||
{
|
||||
externalId = model.UserName;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalId = CoreHelpers.RandomString(15);
|
||||
}
|
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
|
||||
if (orgUserByExternalId != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email,
|
||||
OrganizationUserType.User, false, externalId, new List<SelectionReadOnly>());
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var response = new ScimUserResponseModel(orgUser);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), response);
|
||||
var orgUser = await _postUserCommand.PostUserAsync(organizationId, model);
|
||||
var scimUserResponseModel = new ScimUserResponseModel(orgUser);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), scimUserResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
@@ -211,82 +112,14 @@ public class UsersController : Controller
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Active from path
|
||||
if (operation.Path?.ToLowerInvariant() == "active")
|
||||
{
|
||||
var active = operation.Value.ToString()?.ToLowerInvariant();
|
||||
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
// Active from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("active", out var activeProperty))
|
||||
{
|
||||
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
|
||||
if (!operationHandled)
|
||||
{
|
||||
operationHandled = handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("User patch operation not handled: {operation} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
await _patchUserCommand.PatchUserAsync(organizationId, id, model);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
await _organizationService.DeleteUserAsync(organizationId, id, null);
|
||||
await _deleteOrganizationUserCommand.DeleteUserAsync(organizationId, id, null);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user