mirror of
https://github.com/bitwarden/server.git
synced 2025-07-03 09:02:48 -05: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,36 +1,42 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Controllers.v2;
|
||||
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/groups")]
|
||||
[ExceptionHandlerFilter]
|
||||
public class GroupsController : Controller
|
||||
{
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly IGetGroupsListQuery _getGroupsListQuery;
|
||||
private readonly IDeleteGroupCommand _deleteGroupCommand;
|
||||
private readonly IPatchGroupCommand _patchGroupCommand;
|
||||
private readonly IPostGroupCommand _postGroupCommand;
|
||||
private readonly IPutGroupCommand _putGroupCommand;
|
||||
private readonly ILogger<GroupsController> _logger;
|
||||
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
IScimContext scimContext,
|
||||
IGetGroupsListQuery getGroupsListQuery,
|
||||
IDeleteGroupCommand deleteGroupCommand,
|
||||
IPatchGroupCommand patchGroupCommand,
|
||||
IPostGroupCommand postGroupCommand,
|
||||
IPutGroupCommand putGroupCommand,
|
||||
ILogger<GroupsController> logger)
|
||||
{
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_scimContext = scimContext;
|
||||
_getGroupsListQuery = getGroupsListQuery;
|
||||
_deleteGroupCommand = deleteGroupCommand;
|
||||
_patchGroupCommand = patchGroupCommand;
|
||||
_postGroupCommand = postGroupCommand;
|
||||
_putGroupCommand = putGroupCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -40,13 +46,9 @@ public class GroupsController : Controller
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
return Ok(new ScimGroupResponseModel(group));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -56,272 +58,45 @@ public class GroupsController : Controller
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string nameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
|
||||
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
|
||||
{
|
||||
if (filter.StartsWith("displayName eq "))
|
||||
{
|
||||
nameFilter = filter.Substring(15).Trim('"');
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var groupList = new List<ScimGroupResponseModel>();
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(nameFilter))
|
||||
{
|
||||
var group = groups.FirstOrDefault(g => g.Name == nameFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (group != null)
|
||||
{
|
||||
groupList.Add(new ScimGroupResponseModel(group));
|
||||
}
|
||||
totalResults = groupList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
groupList = groups.OrderBy(g => g.Name)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(g => new ScimGroupResponseModel(g))
|
||||
.ToList();
|
||||
totalResults = groups.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimGroupResponseModel>
|
||||
{
|
||||
Resources = groupList,
|
||||
ItemsPerPage = count.GetValueOrDefault(groupList.Count),
|
||||
TotalResults = totalResults,
|
||||
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
|
||||
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
|
||||
TotalResults = groupsListQueryResult.totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
return Ok(scimListResponseModel);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId))
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var group = model.ToGroup(organizationId);
|
||||
await _groupService.SaveAsync(group, null);
|
||||
await UpdateGroupMembersAsync(group, model, true);
|
||||
var response = new ScimGroupResponseModel(group);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), response);
|
||||
var group = await _postGroupCommand.PostGroupAsync(organizationId, model);
|
||||
var scimGroupResponseModel = new ScimGroupResponseModel(group);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), scimGroupResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
var group = await _putGroupCommand.PutGroupAsync(organizationId, id, model);
|
||||
var response = new ScimGroupResponseModel(group);
|
||||
|
||||
group.Name = model.DisplayName;
|
||||
await _groupService.SaveAsync(group);
|
||||
await UpdateGroupMembersAsync(group, model, false);
|
||||
return new ObjectResult(new ScimGroupResponseModel(group));
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
await _groupService.SaveAsync(group);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
orgUserIds.Add(addId.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
await _patchGroupCommand.PatchGroupAsync(organizationId, id, model);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "Group not found."
|
||||
});
|
||||
}
|
||||
await _groupService.DeleteAsync(group);
|
||||
await _deleteGroupCommand.DeleteGroupAsync(organizationId, id);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
{
|
||||
var ids = new List<Guid>();
|
||||
foreach (var obj in objArray.EnumerateArray())
|
||||
{
|
||||
if (obj.TryGetProperty("value", out var valueProperty))
|
||||
{
|
||||
if (valueProperty.TryGetGuid(out var guid))
|
||||
{
|
||||
ids.Add(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Guid? GetOperationPathId(string path)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model, bool skipIfEmpty)
|
||||
{
|
||||
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.Members == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var memberIds = new List<Guid>();
|
||||
foreach (var id in model.Members.Select(i => i.Value))
|
||||
{
|
||||
if (Guid.TryParse(id, out var guidId))
|
||||
{
|
||||
memberIds.Add(guidId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!memberIds.Any() && skipIfEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user