From 0a01051d8399c0733287daed7bacb6634390b1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 31 Oct 2022 09:58:21 +0000 Subject: [PATCH] [EC-507 / EC-508] SCIM CQRS Refactor - Groups/Users (#2344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 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 * [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 --- .../Scim/Controllers/v2/GroupsController.cs | 297 +++--------------- .../Scim/Controllers/v2/UsersController.cs | 231 ++------------ .../src/Scim/Groups/GetGroupsListQuery.cs | 64 ++++ .../Groups/Interfaces/IGetGroupsListQuery.cs | 8 + .../Groups/Interfaces/IPatchGroupCommand.cs | 8 + .../Groups/Interfaces/IPostGroupCommand.cs | 9 + .../Groups/Interfaces/IPutGroupCommand.cs | 9 + .../src/Scim/Groups/PatchGroupCommand.cs | 147 +++++++++ .../src/Scim/Groups/PostGroupCommand.cs | 75 +++++ .../src/Scim/Groups/PutGroupCommand.cs | 65 ++++ .../src/Scim/Queries/Users/GetUserQuery.cs | 27 -- .../Queries/Users/Interfaces/IGetUserQuery.cs | 8 - bitwarden_license/src/Scim/Startup.cs | 3 + .../src/Scim/Users/GetUsersListQuery.cs | 69 ++++ .../Users/Interfaces/IGetUsersListQuery.cs | 8 + .../Users/Interfaces/IPatchUserCommand.cs | 8 + .../Scim/Users/Interfaces/IPostUserCommand.cs | 9 + .../src/Scim/Users/PatchUserCommand.cs | 87 +++++ .../src/Scim/Users/PostUserCommand.cs | 88 ++++++ .../ExceptionHandlerFilterAttribute.cs | 8 + .../ScimServiceCollectionExtensions.cs | 32 +- .../Groups/GetGroupsListQueryTests.cs | 129 ++++++++ .../Groups/PatchGroupCommandTests.cs | 274 ++++++++++++++++ .../Scim.Test/Groups/PostGroupCommandTests.cs | 121 +++++++ .../Scim.Test/Groups/PutGroupCommandTests.cs | 122 +++++++ .../Queries/Users/GetUserQueryTests.cs | 66 ---- .../Scim.Test/Users/GetUsersListQueryTests.cs | 139 ++++++++ .../Scim.Test/Users/PatchUserCommandTests.cs | 186 +++++++++++ .../Scim.Test/Users/PostUserCommandTests.cs | 112 +++++++ .../Groups/DeleteGroupCommand.cs | 30 ++ .../Groups/Interfaces/IDeleteGroupCommand.cs | 6 + .../DeleteOrganizationUserCommand.cs | 32 ++ .../IDeleteOrganizationUserCommand.cs | 6 + src/Core/Services/IGroupService.cs | 1 + src/Core/Services/IOrganizationService.cs | 1 + .../Services/Implementations/GroupService.cs | 1 + .../Implementations/OrganizationService.cs | 1 + .../Groups/DeleteGroupCommandTests.cs | 51 +++ .../DeleteOrganizationUserCommandTests.cs | 54 ++++ 39 files changed, 2028 insertions(+), 564 deletions(-) create mode 100644 bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs create mode 100644 bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs create mode 100644 bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs create mode 100644 bitwarden_license/src/Scim/Groups/Interfaces/IPostGroupCommand.cs create mode 100644 bitwarden_license/src/Scim/Groups/Interfaces/IPutGroupCommand.cs create mode 100644 bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs create mode 100644 bitwarden_license/src/Scim/Groups/PostGroupCommand.cs create mode 100644 bitwarden_license/src/Scim/Groups/PutGroupCommand.cs delete mode 100644 bitwarden_license/src/Scim/Queries/Users/GetUserQuery.cs delete mode 100644 bitwarden_license/src/Scim/Queries/Users/Interfaces/IGetUserQuery.cs create mode 100644 bitwarden_license/src/Scim/Users/GetUsersListQuery.cs create mode 100644 bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs create mode 100644 bitwarden_license/src/Scim/Users/Interfaces/IPatchUserCommand.cs create mode 100644 bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs create mode 100644 bitwarden_license/src/Scim/Users/PatchUserCommand.cs create mode 100644 bitwarden_license/src/Scim/Users/PostUserCommand.cs create mode 100644 bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs delete mode 100644 bitwarden_license/test/Scim.Test/Queries/Users/GetUserQueryTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs create mode 100644 bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs create mode 100644 src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs create mode 100644 src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteOrganizationUserCommand.cs create mode 100644 test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs create mode 100644 test/Core.Test/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommandTests.cs diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index 6fe47db87f..d78e2f7fdf 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -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 _logger; public GroupsController( IGroupRepository groupRepository, - IGroupService groupService, - IOptions scimSettings, - IScimContext scimContext, + IGetGroupsListQuery getGroupsListQuery, + IDeleteGroupCommand deleteGroupCommand, + IPatchGroupCommand patchGroupCommand, + IPostGroupCommand postGroupCommand, + IPutGroupCommand putGroupCommand, ILogger 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 { - 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(); - 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 - { - 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 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 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 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 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 GetOperationValueIds(JsonElement objArray) - { - var ids = new List(); - 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(); - 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); - } } diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index c0c6ed1e77..5ad158f820 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -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 _logger; public UsersController( IUserService userService, - IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, - IScimContext scimContext, - IOptions scimSettings, - IGetUserQuery getUserQuery, + IGetUsersListQuery getUsersListQuery, + IDeleteOrganizationUserCommand deleteOrganizationUserCommand, + IPatchUserCommand patchUserCommand, + IPostUserCommand postUserCommand, ILogger 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 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 { - 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 { }; - 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 - { - 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 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()); - 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 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 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 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; - } } diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs new file mode 100644 index 0000000000..1afab3a0fc --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -0,0 +1,64 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Scim.Groups.Interfaces; + +namespace Bit.Scim.Groups; + +public class GetGroupsListQuery : IGetGroupsListQuery +{ + private readonly IGroupRepository _groupRepository; + + public GetGroupsListQuery(IGroupRepository groupRepository) + { + _groupRepository = groupRepository; + } + + public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex) + { + string nameFilter = null; + string externalIdFilter = null; + if (!string.IsNullOrWhiteSpace(filter)) + { + 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(); + 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(group); + } + totalResults = groupList.Count; + } + else if (!string.IsNullOrWhiteSpace(externalIdFilter)) + { + var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter); + if (group != null) + { + groupList.Add(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) + .ToList(); + totalResults = groups.Count; + } + + return (groupList, totalResults); + } +} diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs new file mode 100644 index 0000000000..d2cf5ef0a2 --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Scim.Groups.Interfaces; + +public interface IGetGroupsListQuery +{ + Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex); +} diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs new file mode 100644 index 0000000000..61dc064de4 --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs @@ -0,0 +1,8 @@ +using Bit.Scim.Models; + +namespace Bit.Scim.Groups.Interfaces; + +public interface IPatchGroupCommand +{ + Task PatchGroupAsync(Guid organizationId, Guid id, ScimPatchModel model); +} diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IPostGroupCommand.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IPostGroupCommand.cs new file mode 100644 index 0000000000..6863b999ff --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IPostGroupCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Entities; +using Bit.Scim.Models; + +namespace Bit.Scim.Groups.Interfaces; + +public interface IPostGroupCommand +{ + Task PostGroupAsync(Guid organizationId, ScimGroupRequestModel model); +} diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IPutGroupCommand.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IPutGroupCommand.cs new file mode 100644 index 0000000000..4e37cb5c8b --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IPutGroupCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Entities; +using Bit.Scim.Models; + +namespace Bit.Scim.Groups.Interfaces; + +public interface IPutGroupCommand +{ + Task PutGroupAsync(Guid organizationId, Guid id, ScimGroupRequestModel model); +} diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs new file mode 100644 index 0000000000..b48ff222d9 --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; + +namespace Bit.Scim.Groups; + +public class PatchGroupCommand : IPatchGroupCommand +{ + private readonly IGroupRepository _groupRepository; + private readonly IGroupService _groupService; + private readonly ILogger _logger; + + public PatchGroupCommand( + IGroupRepository groupRepository, + IGroupService groupService, + ILogger logger) + { + _groupRepository = groupRepository; + _groupService = groupService; + _logger = logger; + } + + public async Task PatchGroupAsync(Guid organizationId, Guid id, ScimPatchModel model) + { + var group = await _groupRepository.GetByIdAsync(id); + if (group == null || group.OrganizationId != organizationId) + { + throw new NotFoundException("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}"))); + } + } + + private List GetOperationValueIds(JsonElement objArray) + { + var ids = new List(); + 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; + } +} diff --git a/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs new file mode 100644 index 0000000000..33aea9c7f9 --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs @@ -0,0 +1,75 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Context; +using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; + +namespace Bit.Scim.Groups; + +public class PostGroupCommand : IPostGroupCommand +{ + private readonly IGroupRepository _groupRepository; + private readonly IGroupService _groupService; + private readonly IScimContext _scimContext; + + public PostGroupCommand( + IGroupRepository groupRepository, + IGroupService groupService, + IScimContext scimContext) + { + _groupRepository = groupRepository; + _groupService = groupService; + _scimContext = scimContext; + } + + public async Task PostGroupAsync(Guid organizationId, ScimGroupRequestModel model) + { + if (string.IsNullOrWhiteSpace(model.DisplayName)) + { + throw new BadRequestException(); + } + + var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); + if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId)) + { + throw new ConflictException(); + } + + var group = model.ToGroup(organizationId); + await _groupService.SaveAsync(group, null); + await UpdateGroupMembersAsync(group, model); + + return group; + } + + private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model) + { + if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta) + { + return; + } + + if (model.Members == null) + { + return; + } + + var memberIds = new List(); + foreach (var id in model.Members.Select(i => i.Value)) + { + if (Guid.TryParse(id, out var guidId)) + { + memberIds.Add(guidId); + } + } + + if (!memberIds.Any()) + { + return; + } + + await _groupRepository.UpdateUsersAsync(group.Id, memberIds); + } +} diff --git a/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs new file mode 100644 index 0000000000..f15db7b2d7 --- /dev/null +++ b/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Context; +using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; + +namespace Bit.Scim.Groups; + +public class PutGroupCommand : IPutGroupCommand +{ + private readonly IGroupRepository _groupRepository; + private readonly IGroupService _groupService; + private readonly IScimContext _scimContext; + + public PutGroupCommand( + IGroupRepository groupRepository, + IGroupService groupService, + IScimContext scimContext) + { + _groupRepository = groupRepository; + _groupService = groupService; + _scimContext = scimContext; + } + + public async Task PutGroupAsync(Guid organizationId, Guid id, ScimGroupRequestModel model) + { + var group = await _groupRepository.GetByIdAsync(id); + if (group == null || group.OrganizationId != organizationId) + { + throw new NotFoundException("Group not found."); + } + + group.Name = model.DisplayName; + await _groupService.SaveAsync(group); + await UpdateGroupMembersAsync(group, model); + + return group; + } + + private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model) + { + if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta) + { + return; + } + + if (model.Members == null) + { + return; + } + + var memberIds = new List(); + foreach (var id in model.Members.Select(i => i.Value)) + { + if (Guid.TryParse(id, out var guidId)) + { + memberIds.Add(guidId); + } + } + + await _groupRepository.UpdateUsersAsync(group.Id, memberIds); + } +} diff --git a/bitwarden_license/src/Scim/Queries/Users/GetUserQuery.cs b/bitwarden_license/src/Scim/Queries/Users/GetUserQuery.cs deleted file mode 100644 index ed434078eb..0000000000 --- a/bitwarden_license/src/Scim/Queries/Users/GetUserQuery.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Scim.Models; -using Bit.Scim.Queries.Users.Interfaces; - -namespace Bit.Scim.Queries.Users; - -public class GetUserQuery : IGetUserQuery -{ - private readonly IOrganizationUserRepository _organizationUserRepository; - - public GetUserQuery(IOrganizationUserRepository organizationUserRepository) - { - _organizationUserRepository = organizationUserRepository; - } - - public async Task GetUserAsync(Guid organizationId, Guid id) - { - var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id); - if (orgUser == null || orgUser.OrganizationId != organizationId) - { - throw new NotFoundException("User not found."); - } - - return new ScimUserResponseModel(orgUser); - } -} diff --git a/bitwarden_license/src/Scim/Queries/Users/Interfaces/IGetUserQuery.cs b/bitwarden_license/src/Scim/Queries/Users/Interfaces/IGetUserQuery.cs deleted file mode 100644 index 7de37f30b5..0000000000 --- a/bitwarden_license/src/Scim/Queries/Users/Interfaces/IGetUserQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Scim.Models; - -namespace Bit.Scim.Queries.Users.Interfaces; - -public interface IGetUserQuery -{ - Task GetUserAsync(Guid organizationId, Guid id); -} diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index bd926bf106..bf6ccc1cbd 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -76,7 +76,10 @@ public class Startup }); services.Configure(options => options.LowercaseUrls = true); + services.AddScimGroupCommands(); + services.AddScimGroupQueries(); services.AddScimUserQueries(); + services.AddScimUserCommands(); } public void Configure( diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs new file mode 100644 index 0000000000..51250250fe --- /dev/null +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -0,0 +1,69 @@ +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Scim.Users.Interfaces; + +namespace Bit.Scim.Users; + +public class GetUsersListQuery : IGetUsersListQuery +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + + public GetUsersListQuery(IOrganizationUserRepository organizationUserRepository) + { + _organizationUserRepository = organizationUserRepository; + } + + public async Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex) + { + string emailFilter = null; + string usernameFilter = null; + string externalIdFilter = null; + if (!string.IsNullOrWhiteSpace(filter)) + { + 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(); + 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(orgUser); + } + totalResults = userList.Count; + } + else if (!string.IsNullOrWhiteSpace(externalIdFilter)) + { + var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter); + if (orgUser != null) + { + userList.Add(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) + .ToList(); + totalResults = orgUsers.Count; + } + + return (userList, totalResults); + } +} diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs new file mode 100644 index 0000000000..265c6a8e79 --- /dev/null +++ b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Data.Organizations.OrganizationUsers; + +namespace Bit.Scim.Users.Interfaces; + +public interface IGetUsersListQuery +{ + Task<(IEnumerable userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex); +} diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IPatchUserCommand.cs b/bitwarden_license/src/Scim/Users/Interfaces/IPatchUserCommand.cs new file mode 100644 index 0000000000..dcc349a0ca --- /dev/null +++ b/bitwarden_license/src/Scim/Users/Interfaces/IPatchUserCommand.cs @@ -0,0 +1,8 @@ +using Bit.Scim.Models; + +namespace Bit.Scim.Users.Interfaces; + +public interface IPatchUserCommand +{ + Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model); +} diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs new file mode 100644 index 0000000000..05dd05510c --- /dev/null +++ b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Scim.Models; + +namespace Bit.Scim.Users.Interfaces; + +public interface IPostUserCommand +{ + Task PostUserAsync(Guid organizationId, ScimUserRequestModel model); +} diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs new file mode 100644 index 0000000000..7d1173e374 --- /dev/null +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -0,0 +1,87 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Models; +using Bit.Scim.Users.Interfaces; + +namespace Bit.Scim.Users; + +public class PatchUserCommand : IPatchUserCommand +{ + private readonly IUserService _userService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly ILogger _logger; + + public PatchUserCommand( + IUserService userService, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + ILogger logger) + { + _userService = userService; + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _logger = logger; + } + + public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model) + { + var orgUser = await _organizationUserRepository.GetByIdAsync(id); + if (orgUser == null || orgUser.OrganizationId != organizationId) + { + throw new NotFoundException("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}"))); + } + } + + private async Task 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; + } +} diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs new file mode 100644 index 0000000000..a7c4042d05 --- /dev/null +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -0,0 +1,88 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Scim.Context; +using Bit.Scim.Models; +using Bit.Scim.Users.Interfaces; + +namespace Bit.Scim.Users; + +public class PostUserCommand : IPostUserCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly IScimContext _scimContext; + + public PostUserCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IScimContext scimContext) + { + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _scimContext = scimContext; + } + + public async Task PostUserAsync(Guid organizationId, 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) + { + throw new BadRequestException(); + } + + var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); + if (orgUserByEmail != null) + { + throw new ConflictException(); + } + + 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) + { + throw new ConflictException(); + } + + var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email, + OrganizationUserType.User, false, externalId, new List()); + var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); + + return orgUser; + } +} diff --git a/bitwarden_license/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs b/bitwarden_license/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs index a7e932a0e2..004a8b8445 100644 --- a/bitwarden_license/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/bitwarden_license/src/Scim/Utilities/ExceptionHandlerFilterAttribute.cs @@ -26,6 +26,14 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute { statusCode = StatusCodes.Status404NotFound; } + else if (exception is BadRequestException) + { + statusCode = StatusCodes.Status400BadRequest; + } + else if (exception is ConflictException) + { + statusCode = StatusCodes.Status409Conflict; + } scimErrorResponseModel.Status = statusCode; diff --git a/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs b/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs index 1356bb79f4..56761ac936 100644 --- a/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs @@ -1,12 +1,38 @@ -using Bit.Scim.Queries.Users; -using Bit.Scim.Queries.Users.Interfaces; +using Bit.Core.OrganizationFeatures.Groups; +using Bit.Core.OrganizationFeatures.Groups.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationUsers; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Scim.Groups; +using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Users; +using Bit.Scim.Users.Interfaces; namespace Bit.Scim.Utilities; public static class ScimServiceCollectionExtensions { + public static void AddScimGroupCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + public static void AddScimGroupQueries(this IServiceCollection services) + { + services.AddScoped(); + } + + public static void AddScimUserCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + public static void AddScimUserQueries(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); } } diff --git a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs new file mode 100644 index 0000000000..fa8997580c --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs @@ -0,0 +1,129 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Scim.Groups; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Groups; + +[SutProviderCustomize] +public class GetGroupsListCommandTests +{ + [Theory] + [BitAutoData(10, 1)] + [BitAutoData(2, 1)] + [BitAutoData(1, 3)] + public async Task GetGroupsList_Success(int count, int startIndex, SutProvider sutProvider, Guid organizationId, IList groups) + { + groups = SetGroupsOrganizationId(groups, organizationId); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex); + + AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList); + AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetGroupsList_FilterDisplayName_Success(SutProvider sutProvider, Guid organizationId, IList groups) + { + groups = SetGroupsOrganizationId(groups, organizationId); + string name = groups.First().Name; + string filter = $"displayName eq {name}"; + + var expectedGroupList = groups + .Where(g => g.Name == name) + .ToList(); + var expectedTotalResults = expectedGroupList.Count; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + + AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetGroupsList_FilterDisplayName_Empty(string name, SutProvider sutProvider, Guid organizationId, IList groups) + { + groups = SetGroupsOrganizationId(groups, organizationId); + string filter = $"displayName eq {name}"; + + var expectedGroupList = new List(); + var expectedTotalResults = expectedGroupList.Count; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + + AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetGroupsList_FilterExternalId_Success(SutProvider sutProvider, Guid organizationId, IList groups) + { + groups = SetGroupsOrganizationId(groups, organizationId); + string externalId = groups.First().ExternalId; + string filter = $"externalId eq {externalId}"; + + var expectedGroupList = groups + .Where(ou => ou.ExternalId == externalId) + .ToList(); + var expectedTotalResults = expectedGroupList.Count; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + + AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetGroupsList_FilterExternalId_Empty(string externalId, SutProvider sutProvider, Guid organizationId, IList groups) + { + groups = SetGroupsOrganizationId(groups, organizationId); + string filter = $"externalId eq {externalId}"; + + var expectedGroupList = groups + .Where(ou => ou.ExternalId == externalId) + .ToList(); + var expectedTotalResults = expectedGroupList.Count; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + + AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + private IList SetGroupsOrganizationId(IList groups, Guid organizationId) + { + return groups.Select(g => + { + g.OrganizationId = organizationId; + return g; + }).ToList(); + } +} diff --git a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs new file mode 100644 index 0000000000..441955022f --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs @@ -0,0 +1,274 @@ +using System.Text.Json; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Groups; +using Bit.Scim.Models; +using Bit.Scim.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Groups; + +[SutProviderCustomize] +public class PatchGroupCommandTests +{ + [Theory] + [BitAutoData] + public async Task PatchGroup_ReplaceListMembers_Success(SutProvider sutProvider, Group group, IEnumerable userIds) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Path = "members", + Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => userIds.Contains(id)))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(SutProvider sutProvider, Group group, string displayName) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Path = "displayname", + Value = JsonDocument.Parse($"\"{displayName}\"").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).SaveAsync(group); + Assert.Equal(displayName, group.Name); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider sutProvider, Group group, string displayName) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).SaveAsync(group); + Assert.Equal(displayName, group.Name); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_AddSingleMember_Success(SutProvider sutProvider, Group group, ICollection existingMembers, Guid userId) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id) + .Returns(existingMembers); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "add", + Path = $"members[value eq \"{userId}\"]", + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Append(userId).Contains(id)))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_AddListMembers_Success(SutProvider sutProvider, Group group, ICollection existingMembers, ICollection userIds) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id) + .Returns(existingMembers); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "add", + Path = $"members", + Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Concat(userIds).Contains(id)))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_RemoveSingleMember_Success(SutProvider sutProvider, Group group, Guid userId) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "remove", + Path = $"members[value eq \"{userId}\"]", + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).DeleteUserAsync(group, userId); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_RemoveListMembers_Success(SutProvider sutProvider, Group group, ICollection existingMembers) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id) + .Returns(existingMembers); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "remove", + Path = $"members", + Value = JsonDocument.Parse(JsonSerializer.Serialize(existingMembers.Select(uid => new { value = uid }).ToArray())).RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Contains(id)))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_NoAction_Success(SutProvider sutProvider, Group group) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group.OrganizationId, group.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(0).UpdateUsersAsync(group.Id, Arg.Any>()); + await sutProvider.GetDependency().Received(0).GetManyUserIdsByIdAsync(group.Id); + await sutProvider.GetDependency().Received(0).SaveAsync(group); + await sutProvider.GetDependency().Received(0).DeleteUserAsync(group, Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId) + { + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchGroupAsync(organizationId, groupId, scimPatchModel)); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId) + { + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetByIdAsync(groupId) + .Returns(new Group + { + Id = groupId, + OrganizationId = Guid.NewGuid() + }); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchGroupAsync(organizationId, groupId, scimPatchModel)); + } +} diff --git a/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs new file mode 100644 index 0000000000..9eaba94b85 --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs @@ -0,0 +1,121 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Context; +using Bit.Scim.Groups; +using Bit.Scim.Models; +using Bit.Scim.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Groups; + +[SutProviderCustomize] +public class PostGroupCommandTests +{ + [Theory] + [BitAutoData] + public async Task PostGroup_Success(SutProvider sutProvider, string displayName, string externalId, Guid organizationId, ICollection groups) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + ExternalId = externalId, + Members = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var expectedResult = new Group + { + OrganizationId = organizationId, + Name = displayName, + ExternalId = externalId, + }; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + var group = await sutProvider.Sut.PostGroupAsync(organizationId, scimGroupRequestModel); + + await sutProvider.GetDependency().Received(1).SaveAsync(group, null); + await sutProvider.GetDependency().Received(0).UpdateUsersAsync(Arg.Any(), Arg.Any>()); + + AssertHelper.AssertPropertyEqual(expectedResult, group, "Id", "CreationDate", "RevisionDate"); + } + + [Theory] + [BitAutoData] + public async Task PostGroup_WithMembers_Success(SutProvider sutProvider, string displayName, string externalId, Guid organizationId, ICollection groups, IEnumerable membersUserIds) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + ExternalId = externalId, + Members = membersUserIds.Select(uid => new ScimGroupRequestModel.GroupMembersModel { Value = uid.ToString() }).ToList(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var expectedResult = new Group + { + OrganizationId = organizationId, + Name = displayName, + ExternalId = externalId + }; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + sutProvider.GetDependency() + .RequestScimProvider + .Returns(Core.Enums.ScimProviderType.Okta); + + var group = await sutProvider.Sut.PostGroupAsync(organizationId, scimGroupRequestModel); + + await sutProvider.GetDependency().Received(1).SaveAsync(group, null); + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(Arg.Any(), Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id)))); + + AssertHelper.AssertPropertyEqual(expectedResult, group, "Id", "CreationDate", "RevisionDate"); + } + + [Theory] + [BitAutoData((string)null)] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task PostGroup_NullDisplayName_Throws(string displayName, SutProvider sutProvider, Guid organizationId) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + ExternalId = Guid.NewGuid().ToString(), + Members = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostGroupAsync(organizationId, scimGroupRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostGroup_ExistingExternalId_Throws(string displayName, SutProvider sutProvider, Guid organizationId, ICollection groups) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + ExternalId = groups.First().ExternalId, + Members = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organizationId) + .Returns(groups); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostGroupAsync(organizationId, scimGroupRequestModel)); + } +} diff --git a/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs new file mode 100644 index 0000000000..cb489f1803 --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs @@ -0,0 +1,122 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Context; +using Bit.Scim.Groups; +using Bit.Scim.Models; +using Bit.Scim.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Groups; + +[SutProviderCustomize] +public class PutGroupCommandTests +{ + [Theory] + [BitAutoData] + public async Task PutGroup_Success(SutProvider sutProvider, Group group, string displayName) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + var inputModel = new ScimGroupRequestModel + { + DisplayName = displayName, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var expectedResult = new Group + { + Id = group.Id, + AccessAll = group.AccessAll, + ExternalId = group.ExternalId, + Name = displayName, + OrganizationId = group.OrganizationId + }; + + var result = await sutProvider.Sut.PutGroupAsync(group.OrganizationId, group.Id, inputModel); + + AssertHelper.AssertPropertyEqual(expectedResult, result, "CreationDate", "RevisionDate"); + Assert.Equal(displayName, group.Name); + + await sutProvider.GetDependency().Received(1).SaveAsync(group); + await sutProvider.GetDependency().Received(0).UpdateUsersAsync(group.Id, Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task PutGroup_ChangeMembers_Success(SutProvider sutProvider, Group group, string displayName, IEnumerable membersUserIds) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + sutProvider.GetDependency() + .RequestScimProvider + .Returns(Core.Enums.ScimProviderType.Okta); + + var inputModel = new ScimGroupRequestModel + { + DisplayName = displayName, + Members = membersUserIds.Select(uid => new ScimGroupRequestModel.GroupMembersModel { Value = uid.ToString() }).ToList(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var expectedResult = new Group + { + Id = group.Id, + AccessAll = group.AccessAll, + ExternalId = group.ExternalId, + Name = displayName, + OrganizationId = group.OrganizationId + }; + + var result = await sutProvider.Sut.PutGroupAsync(group.OrganizationId, group.Id, inputModel); + + AssertHelper.AssertPropertyEqual(expectedResult, result, "CreationDate", "RevisionDate"); + Assert.Equal(displayName, group.Name); + + await sutProvider.GetDependency().Received(1).SaveAsync(group); + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id)))); + } + + [Theory] + [BitAutoData] + public async Task PutGroup_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId, string displayName) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PutGroupAsync(organizationId, groupId, scimGroupRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PutGroup_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId, string displayName) + { + var scimGroupRequestModel = new ScimGroupRequestModel + { + DisplayName = displayName, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetByIdAsync(groupId) + .Returns(new Group + { + Id = groupId, + OrganizationId = Guid.NewGuid() + }); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PutGroupAsync(organizationId, groupId, scimGroupRequestModel)); + } +} diff --git a/bitwarden_license/test/Scim.Test/Queries/Users/GetUserQueryTests.cs b/bitwarden_license/test/Scim.Test/Queries/Users/GetUserQueryTests.cs deleted file mode 100644 index 2ff42c22de..0000000000 --- a/bitwarden_license/test/Scim.Test/Queries/Users/GetUserQueryTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Repositories; -using Bit.Scim.Queries.Users; -using Bit.Scim.Utilities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Bit.Test.Common.Helpers; -using NSubstitute; -using Xunit; - -namespace Bit.Scim.Test.Queries.Users; - -[SutProviderCustomize] -public class GetUserQueryTests -{ - [Theory] - [BitAutoData] - public async Task GetUser_Success(SutProvider sutProvider, OrganizationUserUserDetails organizationUserUserDetails) - { - var expectedResult = new Models.ScimUserResponseModel - { - Id = organizationUserUserDetails.Id.ToString(), - UserName = organizationUserUserDetails.Email, - Name = new Models.BaseScimUserModel.NameModel(organizationUserUserDetails.Name), - Emails = new List { new Models.BaseScimUserModel.EmailModel(organizationUserUserDetails.Email) }, - DisplayName = organizationUserUserDetails.Name, - Active = organizationUserUserDetails.Status != Core.Enums.OrganizationUserStatusType.Revoked ? true : false, - Groups = new List(), - ExternalId = organizationUserUserDetails.ExternalId, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - sutProvider.GetDependency() - .GetDetailsByIdAsync(organizationUserUserDetails.Id) - .Returns(organizationUserUserDetails); - - var result = await sutProvider.Sut.GetUserAsync(organizationUserUserDetails.OrganizationId, organizationUserUserDetails.Id); - - await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(organizationUserUserDetails.Id); - AssertHelper.AssertPropertyEqual(expectedResult, result); - } - - [Theory] - [BitAutoData] - public async Task GetUser_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) - { - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetUserAsync(organizationId, organizationUserId)); - } - - [Theory] - [BitAutoData] - public async Task GetUser_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) - { - sutProvider.GetDependency() - .GetByIdAsync(organizationUserId) - .Returns(new OrganizationUser - { - Id = organizationUserId, - OrganizationId = Guid.NewGuid() - }); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetUserAsync(organizationId, organizationUserId)); - } -} diff --git a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs new file mode 100644 index 0000000000..b7497e281d --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs @@ -0,0 +1,139 @@ +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Scim.Users; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Users; + +[SutProviderCustomize] +public class GetUsersListQueryTests +{ + [Theory] + [BitAutoData(10, 1)] + [BitAutoData(2, 1)] + [BitAutoData(1, 3)] + public async Task GetUsersList_Success(int count, int startIndex, SutProvider sutProvider, Guid organizationId, IList organizationUserUserDetails) + { + organizationUserUserDetails = SetUsersOrganizationId(organizationUserUserDetails, organizationId); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUserUserDetails); + + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, null, count, startIndex); + + await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); + + AssertHelper.AssertPropertyEqual(organizationUserUserDetails.Skip(startIndex - 1).Take(count).ToList(), result.userList); + AssertHelper.AssertPropertyEqual(organizationUserUserDetails.Count, result.totalResults); + } + + [Theory] + [BitAutoData("user1@example.com")] + public async Task GetUsersList_FilterUserName_Success(string email, SutProvider sutProvider, Guid organizationId, IList organizationUserUserDetails) + { + organizationUserUserDetails = SetUsersOrganizationId(organizationUserUserDetails, organizationId); + organizationUserUserDetails.First().Email = email; + string filter = $"userName eq {email}"; + + var expectedUserList = organizationUserUserDetails + .Where(u => u.Email == email) + .ToList(); + var expectedTotalResults = expectedUserList.Count; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUserUserDetails); + + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + + await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); + + AssertHelper.AssertPropertyEqual(expectedUserList, result.userList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData("user1@example.com")] + public async Task GetUsersList_FilterUserName_Empty(string email, SutProvider sutProvider, Guid organizationId, IList organizationUserUserDetails) + { + organizationUserUserDetails = SetUsersOrganizationId(organizationUserUserDetails, organizationId); + string filter = $"userName eq {email}"; + + var expectedUserList = new List(); + var expectedTotalResults = expectedUserList.Count; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUserUserDetails); + + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + + await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); + + AssertHelper.AssertPropertyEqual(expectedUserList, result.userList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetUsersList_FilterExternalId_Success(SutProvider sutProvider, Guid organizationId, IList organizationUserUserDetails) + { + organizationUserUserDetails = SetUsersOrganizationId(organizationUserUserDetails, organizationId); + string externalId = organizationUserUserDetails.First().ExternalId; + string filter = $"externalId eq {externalId}"; + + var expectedUserList = organizationUserUserDetails + .Where(u => u.ExternalId == externalId) + .ToList(); + var expectedTotalResults = expectedUserList.Count; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUserUserDetails); + + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + + await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); + + AssertHelper.AssertPropertyEqual(expectedUserList, result.userList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + [Theory] + [BitAutoData] + public async Task GetUsersList_FilterExternalId_Empty(string externalId, SutProvider sutProvider, Guid organizationId, IList organizationUserUserDetails) + { + organizationUserUserDetails = SetUsersOrganizationId(organizationUserUserDetails, organizationId); + string filter = $"externalId eq {externalId}"; + + var expectedUserList = organizationUserUserDetails + .Where(u => u.ExternalId == externalId) + .ToList(); + var expectedTotalResults = expectedUserList.Count; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUserUserDetails); + + var result = await sutProvider.Sut.GetUsersListAsync(organizationId, filter, null, null); + + await sutProvider.GetDependency().Received(1).GetManyDetailsByOrganizationAsync(organizationId); + + AssertHelper.AssertPropertyEqual(expectedUserList, result.userList); + AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); + } + + private IList SetUsersOrganizationId(IList organizationUserUserDetails, Guid organizationId) + { + return organizationUserUserDetails.Select(ouud => + { + ouud.OrganizationId = organizationId; + return ouud; + }).ToList(); + } +} diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs new file mode 100644 index 0000000000..284c16ab55 --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -0,0 +1,186 @@ +using System.Text.Json; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Models; +using Bit.Scim.Users; +using Bit.Scim.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Users; + +[SutProviderCustomize] +public class PatchUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task PatchUser_RestorePath_Success(SutProvider sutProvider, OrganizationUser organizationUser) + { + organizationUser.Status = Core.Enums.OrganizationUserStatusType.Revoked; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Path = "active", + Value = JsonDocument.Parse("true").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, null, Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_RestoreValue_Success(SutProvider sutProvider, OrganizationUser organizationUser) + { + organizationUser.Status = Core.Enums.OrganizationUserStatusType.Revoked; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Value = JsonDocument.Parse("{\"active\":true}").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, null, Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_RevokePath_Success(SutProvider sutProvider, OrganizationUser organizationUser) + { + organizationUser.Status = Core.Enums.OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Path = "active", + Value = JsonDocument.Parse("false").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, null); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_RevokeValue_Success(SutProvider sutProvider, OrganizationUser organizationUser) + { + organizationUser.Status = Core.Enums.OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List + { + new ScimPatchModel.OperationModel + { + Op = "replace", + Value = JsonDocument.Parse("{\"active\":false}").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, null); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_NoAction_Success(SutProvider sutProvider, OrganizationUser organizationUser) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); + + await sutProvider.GetDependency().Received(0).RestoreUserAsync(organizationUser, null, Arg.Any()); + await sutProvider.GetDependency().Received(0).RevokeUserAsync(organizationUser, null); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) + { + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchUserAsync(organizationId, organizationUserId, scimPatchModel)); + } + + [Theory] + [BitAutoData] + public async Task PatchUser_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) + { + var scimPatchModel = new Models.ScimPatchModel + { + Operations = new List(), + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUserId) + .Returns(new OrganizationUser + { + Id = organizationUserId, + OrganizationId = Guid.NewGuid() + }); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchUserAsync(organizationId, organizationUserId, scimPatchModel)); + } +} diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs new file mode 100644 index 0000000000..f519f839b9 --- /dev/null +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -0,0 +1,112 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Scim.Models; +using Bit.Scim.Users; +using Bit.Scim.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Scim.Test.Users; + +[SutProviderCustomize] +public class PostUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task PostUser_Success(SutProvider sutProvider, string externalId, Guid organizationId, List emails, ICollection organizationUsers, Core.Entities.OrganizationUser newUser) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = emails, + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + sutProvider.GetDependency() + .InviteUserAsync(organizationId, null, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), OrganizationUserType.User, false, externalId, Arg.Any>()) + .Returns(newUser); + + var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); + + await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, null, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), + OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>()); + await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(newUser.Id); + } + + [Theory] + [BitAutoData] + public async Task PostUser_NullEmail_Throws(SutProvider sutProvider, Guid organizationId) + { + var scimUserRequestModel = new ScimUserRequestModel + { + Emails = new List(), + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostUser_Inactive_Throws(SutProvider sutProvider, Guid organizationId, List emails) + { + var scimUserRequestModel = new ScimUserRequestModel + { + Emails = emails, + Active = false, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostUser_DuplicateExternalId_Throws(SutProvider sutProvider, Guid organizationId, List emails, ICollection organizationUsers) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = organizationUsers.First().ExternalId, + Emails = emails, + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostUser_DuplicateUserName_Throws(SutProvider sutProvider, Guid organizationId, List emails, ICollection organizationUsers) + { + var scimUserRequestModel = new ScimUserRequestModel + { + UserName = organizationUsers.First().ExternalId, + Emails = emails, + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + } +} diff --git a/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs new file mode 100644 index 0000000000..42dfe29a73 --- /dev/null +++ b/src/Core/OrganizationFeatures/Groups/DeleteGroupCommand.cs @@ -0,0 +1,30 @@ +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.Groups.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.OrganizationFeatures.Groups; + +public class DeleteGroupCommand : IDeleteGroupCommand +{ + private readonly IEventService _eventService; + private readonly IGroupRepository _groupRepository; + + public DeleteGroupCommand(IEventService eventService, IGroupRepository groupRepository) + { + _eventService = eventService; + _groupRepository = groupRepository; + } + + public async Task DeleteGroupAsync(Guid organizationId, Guid id) + { + var group = await _groupRepository.GetByIdAsync(id); + if (group == null || group.OrganizationId != organizationId) + { + throw new NotFoundException("Group not found."); + } + + await _groupRepository.DeleteAsync(group); + await _eventService.LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted); + } +} diff --git a/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs b/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs new file mode 100644 index 0000000000..3161fdd06f --- /dev/null +++ b/src/Core/OrganizationFeatures/Groups/Interfaces/IDeleteGroupCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.OrganizationFeatures.Groups.Interfaces; + +public interface IDeleteGroupCommand +{ + Task DeleteGroupAsync(Guid organizationId, Guid id); +} diff --git a/src/Core/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommand.cs b/src/Core/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommand.cs new file mode 100644 index 0000000000..21a6a51dd4 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommand.cs @@ -0,0 +1,32 @@ +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.OrganizationFeatures.OrganizationUsers; + +public class DeleteOrganizationUserCommand : IDeleteOrganizationUserCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + + public DeleteOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService + ) + { + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + } + + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) + { + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (orgUser == null || orgUser.OrganizationId != organizationId) + { + throw new NotFoundException("User not found."); + } + + await _organizationService.DeleteUserAsync(organizationId, organizationUserId, deletingUserId); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteOrganizationUserCommand.cs b/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteOrganizationUserCommand.cs new file mode 100644 index 0000000000..8b5c301eda --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteOrganizationUserCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IDeleteOrganizationUserCommand +{ + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); +} diff --git a/src/Core/Services/IGroupService.cs b/src/Core/Services/IGroupService.cs index 494d3e6c08..1e962350e6 100644 --- a/src/Core/Services/IGroupService.cs +++ b/src/Core/Services/IGroupService.cs @@ -6,6 +6,7 @@ namespace Bit.Core.Services; public interface IGroupService { Task SaveAsync(Group group, IEnumerable collections = null); + [Obsolete("IDeleteGroupCommand should be used instead. To be removed by EC-608.")] Task DeleteAsync(Group group); Task DeleteUserAsync(Group group, Guid organizationUserId); } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 45f91c8bbc..0e4571418f 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -43,6 +43,7 @@ public interface IOrganizationService Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId, IUserService userService); Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections); + [Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")] Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); Task DeleteUserAsync(Guid organizationId, Guid userId); Task>> DeleteUsersAsync(Guid organizationId, diff --git a/src/Core/Services/Implementations/GroupService.cs b/src/Core/Services/Implementations/GroupService.cs index d5f0ebb944..bc676e9a36 100644 --- a/src/Core/Services/Implementations/GroupService.cs +++ b/src/Core/Services/Implementations/GroupService.cs @@ -75,6 +75,7 @@ public class GroupService : IGroupService } } + [Obsolete("IDeleteGroupCommand should be used instead. To be removed by EC-608.")] public async Task DeleteAsync(Group group) { await _groupRepository.DeleteAsync(group); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index ce2b33ec58..146162f4f7 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -1645,6 +1645,7 @@ public class OrganizationService : IOrganizationService await _eventService.LogOrganizationUserEventAsync(user, EventType.OrganizationUser_Updated); } + [Obsolete("IDeleteOrganizationUserCommand should be used instead. To be removed by EC-607.")] public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) { var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); diff --git a/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs b/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs new file mode 100644 index 0000000000..4f4d5e7ced --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/Groups/DeleteGroupCommandTests.cs @@ -0,0 +1,51 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.Groups; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.Groups; + +[SutProviderCustomize] +public class DeleteGroupCommandTests +{ + [Theory] + [BitAutoData] + public async Task DeleteGroup_Success(SutProvider sutProvider, Group group) + { + sutProvider.GetDependency() + .GetByIdAsync(group.Id) + .Returns(group); + + await sutProvider.Sut.DeleteGroupAsync(group.OrganizationId, group.Id); + + await sutProvider.GetDependency().Received(1).DeleteAsync(group); + await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Core.Enums.EventType.Group_Deleted); + } + + [Theory] + [BitAutoData] + public async Task DeleteGroup_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteGroupAsync(organizationId, groupId)); + } + + [Theory] + [BitAutoData] + public async Task DeleteGroup_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid groupId) + { + sutProvider.GetDependency() + .GetByIdAsync(groupId) + .Returns(new Core.Entities.Group + { + Id = groupId, + OrganizationId = Guid.NewGuid() + }); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteGroupAsync(organizationId, groupId)); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..1ea171d1a6 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationUsers/DeleteOrganizationUserCommandTests.cs @@ -0,0 +1,54 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class DeleteOrganizationUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task DeleteUser_Success(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationUserId) + .Returns(new OrganizationUser + { + Id = organizationUserId, + OrganizationId = organizationId + }); + + await sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null); + + await sutProvider.GetDependency().Received(1).DeleteUserAsync(organizationId, organizationUserId, null); + } + + [Theory] + [BitAutoData] + public async Task DeleteUser_NotFound_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); + } + + [Theory] + [BitAutoData] + public async Task DeleteUser_MismatchingOrganizationId_Throws(SutProvider sutProvider, Guid organizationId, Guid organizationUserId) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationUserId) + .Returns(new OrganizationUser + { + Id = organizationUserId, + OrganizationId = Guid.NewGuid() + }); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); + } +}