From af3797c540ee2acbf75770cf1f78b3382dccf0b9 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:33:33 -0400 Subject: [PATCH] [AC-2614] Member Access Report Endpoint (#4599) * Initial draft of moving the org user controller details method into a query * Removing comments and addressing pr items * Adding the org users query to core * Adding the member access report * Addressing some pr concerns and refactoring to be more efficient * Some minor changes to the way properties are spelled * Setting authorization to organization * Adding the permissions check for reports and comments * removing unnecessary usings * Removing ciphers controller change that was a mistake * There was a duplication issue in getting collections for users grabbing groups * Adding comments to the CreateReport method * Only get the user collections by userId * Some finaly refactoring * Adding the no group, no collection, and no perms local strings * Modifying and adding query test cases * Removing unnecessary permissions code in query * Added mapping for id and UsesKeyConnector to MemberAccessReportModel (#4681) * Moving test cases from controller fully into the query. --------- Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: aj-rosado <109146700+aj-rosado@users.noreply.github.com> --- .../OrganizationUsersController.cs | 51 +++--- .../OrganizationUserResponseModel.cs | 4 +- src/Api/Startup.cs | 1 + .../Tools/Controllers/ReportsController.cs | 77 ++++++++ .../Response/MemberAccessReportModel.cs | 172 ++++++++++++++++++ .../Enums/OrganizationUserType.cs | 36 +++- .../IOrganizationUserUserDetailsQuery.cs | 9 + .../OrganizationUserUserDetailsQuery.cs | 50 +++++ ...OrganizationUserUserDetailsQueryRequest.cs | 8 + ...OrganizationServiceCollectionExtensions.cs | 3 + .../OrganizationUsersControllerTests.cs | 70 +------ .../OrganizationUserUserDetailsQueryTests.cs | 117 ++++++++++++ 12 files changed, 503 insertions(+), 95 deletions(-) create mode 100644 src/Api/Tools/Controllers/ReportsController.cs create mode 100644 src/Api/Tools/Models/Response/MemberAccessReportModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserUserDetailsQueryRequest.cs create mode 100644 test/Api.Test/AdminConsole/Queries/OrganizationUserUserDetailsQueryTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 30def6de5..ce9fd5d8f 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -23,6 +23,8 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -49,8 +51,10 @@ public class OrganizationUsersController : Controller private readonly IApplicationCacheService _applicationCacheService; private readonly IFeatureService _featureService; private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + public OrganizationUsersController( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -69,6 +73,7 @@ public class OrganizationUsersController : Controller IApplicationCacheService applicationCacheService, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, + IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) { _organizationRepository = organizationRepository; @@ -88,6 +93,7 @@ public class OrganizationUsersController : Controller _applicationCacheService = applicationCacheService; _featureService = featureService; _ssoConfigRepository = ssoConfigRepository; + _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; } @@ -135,23 +141,21 @@ public class OrganizationUsersController : Controller return await Get_vNext(orgId, includeGroups, includeCollections); } - var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); + var organizationUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( + new OrganizationUserUserDetailsQueryRequest + { + OrganizationId = orgId, + IncludeGroups = includeGroups, + IncludeCollections = includeCollections + } + ); + var responseTasks = organizationUsers .Select(async o => { var orgUser = new OrganizationUserUserDetailsResponseModel(o, await _userService.TwoFactorIsEnabledAsync(o)); - // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User - orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions); - - // Set 'Edit/Delete Assigned Collections' custom permissions to false - if (orgUser.Permissions is not null) - { - orgUser.Permissions.EditAssignedCollections = false; - orgUser.Permissions.DeleteAssignedCollections = false; - } - return orgUser; }); var responses = await Task.WhenAll(responseTasks); @@ -666,28 +670,23 @@ public class OrganizationUsersController : Controller private async Task> Get_vNext(Guid orgId, bool includeGroups = false, bool includeCollections = false) { - var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); + var organizationUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( + new OrganizationUserUserDetailsQueryRequest + { + OrganizationId = orgId, + IncludeGroups = includeGroups, + IncludeCollections = includeCollections + } + ); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); - var responseTasks = organizationUsers - .Select(async o => + var responses = organizationUsers + .Select(o => { var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled); - // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User - orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions); - - // Set 'Edit/Delete Assigned Collections' custom permissions to false - if (orgUser.Permissions is not null) - { - orgUser.Permissions.EditAssignedCollections = false; - orgUser.Permissions.DeleteAssignedCollections = false; - } - return orgUser; }); - var responses = await Task.WhenAll(responseTasks); - return new ListResponseModel(responses); } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index bf095c1f4..dcf5119d2 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -29,7 +29,8 @@ public class OrganizationUserResponseModel : ResponseModel ResetPasswordEnrolled = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); } - public OrganizationUserResponseModel(OrganizationUserUserDetails organizationUser, string obj = "organizationUser") + public OrganizationUserResponseModel(OrganizationUserUserDetails organizationUser, + string obj = "organizationUser") : base(obj) { if (organizationUser == null) @@ -105,7 +106,6 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; } - public string Name { get; set; } public string Email { get; set; } public string AvatarColor { get; set; } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index fd2a4dbe6..8a7721bcb 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -33,6 +33,7 @@ using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Core.Auth.Models.Data; + #if !OSS using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.Utilities; diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Tools/Controllers/ReportsController.cs new file mode 100644 index 000000000..5beb320e4 --- /dev/null +++ b/src/Api/Tools/Controllers/ReportsController.cs @@ -0,0 +1,77 @@ +using Bit.Api.Tools.Models.Response; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Queries; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Tools.Controllers; + +[Route("reports")] +[Authorize("Application")] +public class ReportsController : Controller +{ + private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; + private readonly IGroupRepository _groupRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationCiphersQuery _organizationCiphersQuery; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + + public ReportsController( + IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository, + ICurrentContext currentContext, + IOrganizationCiphersQuery organizationCiphersQuery, + IApplicationCacheService applicationCacheService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery + ) + { + _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; + _groupRepository = groupRepository; + _collectionRepository = collectionRepository; + _currentContext = currentContext; + _organizationCiphersQuery = organizationCiphersQuery; + _applicationCacheService = applicationCacheService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + } + + [HttpGet("member-access/{orgId}")] + public async Task> GetMemberAccessReport(Guid orgId) + { + if (!await _currentContext.AccessReports(orgId)) + { + throw new NotFoundException(); + } + + var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( + new OrganizationUserUserDetailsQueryRequest + { + OrganizationId = orgId, + IncludeCollections = true, + IncludeGroups = true + }); + + var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId); + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId); + var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); + var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId); + var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + + var reports = MemberAccessReportResponseModel.CreateReport( + orgGroups, + orgCollectionsWithAccess, + orgItems, + organizationUsersTwoFactorEnabled, + orgAbility); + return reports; + } +} diff --git a/src/Api/Tools/Models/Response/MemberAccessReportModel.cs b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs new file mode 100644 index 000000000..0b28b8707 --- /dev/null +++ b/src/Api/Tools/Models/Response/MemberAccessReportModel.cs @@ -0,0 +1,172 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Api.Tools.Models.Response; + +/// +/// Member access details. The individual item for the detailed member access +/// report. A collection can be assigned directly to a user without a group or +/// the user can be assigned to a collection through a group. Group level permissions +/// can override collection level permissions. +/// +public class MemberAccessReportAccessDetails +{ + public Guid? CollectionId { get; set; } + public Guid? GroupId { get; set; } + public string GroupName { get; set; } + public string CollectionName { get; set; } + public int ItemCount { get; set; } + public bool? ReadOnly { get; set; } + public bool? HidePasswords { get; set; } + public bool? Manage { get; set; } +} + +/// +/// Contains the collections and group collections a user has access to including +/// the permission level for the collection and group collection. +/// +public class MemberAccessReportResponseModel +{ + public string UserName { get; set; } + public string Email { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool AccountRecoveryEnabled { get; set; } + public int GroupsCount { get; set; } + public int CollectionsCount { get; set; } + public int TotalItemCount { get; set; } + public Guid? UserGuid { get; set; } + public bool UsesKeyConnector { get; set; } + public IEnumerable AccessDetails { get; set; } + + /// + /// Generates a report for all members of an organization. Containing summary information + /// such as item, collection, and group counts. As well as detailed information on the + /// user and group collections along with their permissions + /// + /// Organization groups collection + /// Collections for the organization and the groups/users and permissions + /// Cipher items for the organization with the collections associated with them + /// Organization users and two factor status + /// Organization ability for account recovery status + /// List of the MemberAccessReportResponseModel; + public static IEnumerable CreateReport( + ICollection orgGroups, + ICollection> orgCollectionsWithAccess, + IEnumerable orgItems, + IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, + OrganizationAbility orgAbility) + { + var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); + // Create a dictionary to lookup the group names later. + var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); + + // Get collections grouped and into a dictionary for counts + var collectionItems = orgItems + .SelectMany(x => x.CollectionIds, + (x, b) => new { CipherId = x.Id, CollectionId = b }) + .GroupBy(y => y.CollectionId, + (key, g) => new { CollectionId = key, Ciphers = g }); + var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count()); + + // Take the collections/groups and create the access details items + var groupAccessDetails = new List(); + var userCollectionAccessDetails = new List(); + foreach (var tCollect in orgCollectionsWithAccess) + { + var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0; + if (tCollect.Item2.Groups.Count() > 0) + { + var groupDetails = tCollect.Item2.Groups.Select(x => + new MemberAccessReportAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + GroupId = x.Id, + GroupName = groupNameDictionary[x.Id], + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + }); + groupAccessDetails.AddRange(groupDetails); + } + + // All collections assigned to users and their permissions + if (tCollect.Item2.Users.Count() > 0) + { + var userCollectionDetails = tCollect.Item2.Users.Select(x => + new MemberAccessReportAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + }); + userCollectionAccessDetails.AddRange(userCollectionDetails); + } + } + + // Loop through the org users and populate report and access data + var memberAccessReport = new List(); + foreach (var user in orgUsers) + { + var report = new MemberAccessReportResponseModel + { + UserName = user.Name, + Email = user.Email, + TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled, + // Both the user's ResetPasswordKey must be set and the organization can UseResetPassword + AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword, + UserGuid = user.Id, + UsesKeyConnector = user.UsesKeyConnector + }; + + var userAccessDetails = new List(); + if (user.Groups.Any()) + { + var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault())); + userAccessDetails.AddRange(userGroups); + } + + // There can be edge cases where groups don't have a collection + var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); + if (groupsWithoutCollections.Count() > 0) + { + var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails + { + GroupId = x, + GroupName = groupNameDictionary[x], + ItemCount = 0 + }); + userAccessDetails.AddRange(emptyGroups); + } + + if (user.Collections.Any()) + { + var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id)); + userAccessDetails.AddRange(userCollections); + } + report.AccessDetails = userAccessDetails; + + report.TotalItemCount = collectionItems + .Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId)) + .SelectMany(x => x.Ciphers) + .GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault()) + .Count(); + + // Distinct items only + var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); + report.CollectionsCount = distinctItems.Count(); + report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); + memberAccessReport.Add(report); + } + return memberAccessReport; + } + +} diff --git a/src/Core/AdminConsole/Enums/OrganizationUserType.cs b/src/Core/AdminConsole/Enums/OrganizationUserType.cs index be5986a65..ac3393eea 100644 --- a/src/Core/AdminConsole/Enums/OrganizationUserType.cs +++ b/src/Core/AdminConsole/Enums/OrganizationUserType.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Core.Enums; public enum OrganizationUserType : byte { @@ -8,3 +10,35 @@ public enum OrganizationUserType : byte // Manager = 3 has been intentionally permanently deleted Custom = 4, } + +public static class OrganizationUserTypeExtensions +{ + public static OrganizationUserType GetFlexibleCollectionsUserType(this OrganizationUserType type, Permissions permissions) + { + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + if (type == OrganizationUserType.Custom && permissions is not null) + { + if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) && + permissions is + { + AccessEventLogs: false, + AccessImportExport: false, + AccessReports: false, + CreateNewCollections: false, + EditAnyCollection: false, + DeleteAnyCollection: false, + ManageGroups: false, + ManagePolicies: false, + ManageSso: false, + ManageUsers: false, + ManageResetPassword: false, + ManageScim: false + }) + { + return OrganizationUserType.User; + } + } + + return type; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs new file mode 100644 index 000000000..8494a6d4c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IOrganizationUserUserDetailsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IOrganizationUserUserDetailsQuery +{ + Task> GetOrganizationUserUserDetails(OrganizationUserUserDetailsQueryRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs new file mode 100644 index 000000000..8322bbb47 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -0,0 +1,50 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuery +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + + public OrganizationUserUserDetailsQuery( + IOrganizationUserRepository organizationUserRepository + ) + { + _organizationUserRepository = organizationUserRepository; + } + + /// + /// Gets the organization user user details for the provided request + /// + /// Request details for the query + /// List of OrganizationUserUserDetails + public async Task> GetOrganizationUserUserDetails(OrganizationUserUserDetailsQueryRequest request) + { + var organizationUsers = await _organizationUserRepository + .GetManyDetailsByOrganizationAsync(request.OrganizationId, request.IncludeGroups, request.IncludeCollections); + + return organizationUsers + .Select(o => + { + var userPermissions = o.GetPermissions(); + + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + o.Type = o.Type.GetFlexibleCollectionsUserType(userPermissions); + + if (userPermissions is not null) + { + userPermissions.EditAssignedCollections = false; + userPermissions.DeleteAssignedCollections = false; + } + + o.Permissions = CoreHelpers.ClassToJsonData(userPermissions); + + return o; + }); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserUserDetailsQueryRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserUserDetailsQueryRequest.cs new file mode 100644 index 000000000..66b64205e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/OrganizationUserUserDetailsQueryRequest.cs @@ -0,0 +1,8 @@ +namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public class OrganizationUserUserDetailsQueryRequest +{ + public Guid OrganizationId { get; set; } + public bool IncludeGroups { get; set; } = false; + public bool IncludeCollections { get; set; } = false; +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index c954f561b..a18d9f1f5 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -26,6 +26,8 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -136,6 +138,7 @@ public static class OrganizationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 1a97e6999..efd70d80c 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -13,7 +13,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -22,6 +21,8 @@ using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using NSubstitute; using Xunit; @@ -194,71 +195,6 @@ public class OrganizationUsersControllerTests Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); } - [Theory] - [BitAutoData] - public async Task Get_HandlesNullPermissionsObject( - ICollection organizationUsers, OrganizationAbility organizationAbility, - SutProvider sutProvider) - { - Get_Setup(organizationAbility, organizationUsers, sutProvider); - organizationUsers.First().Permissions = "null"; - var response = await sutProvider.Sut.Get(organizationAbility.Id); - - Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); - } - - [Theory] - [BitAutoData] - public async Task Get_SetsDeprecatedCustomPermissionstoFalse( - ICollection organizationUsers, OrganizationAbility organizationAbility, - SutProvider sutProvider) - { - Get_Setup(organizationAbility, organizationUsers, sutProvider); - - var customUser = organizationUsers.First(); - customUser.Type = OrganizationUserType.Custom; - customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions - { - AccessReports = true, - EditAssignedCollections = true, - DeleteAssignedCollections = true, - AccessEventLogs = true - }); - - var response = await sutProvider.Sut.Get(organizationAbility.Id); - - var customUserResponse = response.Data.First(r => r.Id == organizationUsers.First().Id); - Assert.Equal(OrganizationUserType.Custom, customUserResponse.Type); - Assert.True(customUserResponse.Permissions.AccessReports); - Assert.True(customUserResponse.Permissions.AccessEventLogs); - Assert.False(customUserResponse.Permissions.EditAssignedCollections); - Assert.False(customUserResponse.Permissions.DeleteAssignedCollections); - } - - [Theory] - [BitAutoData] - public async Task Get_DowngradesCustomUsersWithDeprecatedPermissions( - ICollection organizationUsers, OrganizationAbility organizationAbility, - SutProvider sutProvider) - { - Get_Setup(organizationAbility, organizationUsers, sutProvider); - - var customUser = organizationUsers.First(); - customUser.Type = OrganizationUserType.Custom; - customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions - { - EditAssignedCollections = true, - DeleteAssignedCollections = true, - }); - - var response = await sutProvider.Sut.Get(organizationAbility.Id); - - var customUserResponse = response.Data.First(r => r.Id == organizationUsers.First().Id); - Assert.Equal(OrganizationUserType.User, customUserResponse.Type); - Assert.False(customUserResponse.Permissions.EditAssignedCollections); - Assert.False(customUserResponse.Permissions.DeleteAssignedCollections); - } - [Theory] [BitAutoData] public async Task GetAccountRecoveryDetails_ReturnsDetails( @@ -309,6 +245,8 @@ public class OrganizationUsersControllerTests sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) .Returns(organizationAbility); + sutProvider.GetDependency().GetOrganizationUserUserDetails(Arg.Any()).Returns(organizationUsers); + sutProvider.GetDependency().AuthorizeAsync( user: Arg.Any(), resource: Arg.Any(), diff --git a/test/Api.Test/AdminConsole/Queries/OrganizationUserUserDetailsQueryTests.cs b/test/Api.Test/AdminConsole/Queries/OrganizationUserUserDetailsQueryTests.cs new file mode 100644 index 000000000..f7aba6a38 --- /dev/null +++ b/test/Api.Test/AdminConsole/Queries/OrganizationUserUserDetailsQueryTests.cs @@ -0,0 +1,117 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using NSubstitute; +using Xunit; + +namespace Api.Test.AdminConsole.Queries; + +[SutProviderCustomize] +public class OrganizationUserUserDetailsQueryTests +{ + [Theory] + [BitAutoData] + public async Task Get_DowngradesCustomUsersWithDeprecatedPermissions( + ICollection organizationUsers, + SutProvider sutProvider, + Guid organizationId) + { + Get_Setup(organizationUsers, sutProvider, organizationId); + + var customUser = organizationUsers.First(); + customUser.Type = OrganizationUserType.Custom; + customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + EditAssignedCollections = true, + DeleteAssignedCollections = true, + }); + + var response = await sutProvider.Sut.GetOrganizationUserUserDetails(new OrganizationUserUserDetailsQueryRequest { OrganizationId = organizationId }); + + var customUserResponse = response.First(r => r.Id == organizationUsers.First().Id); + Assert.Equal(OrganizationUserType.User, customUserResponse.Type); + + var customUserPermissions = customUserResponse.GetPermissions(); + Assert.False(customUserPermissions.EditAssignedCollections); + Assert.False(customUserPermissions.DeleteAssignedCollections); + } + + [Theory] + [BitAutoData] + public async Task Get_HandlesNullPermissionsObject( + ICollection organizationUsers, + SutProvider sutProvider, + Guid organizationId) + { + Get_Setup(organizationUsers, sutProvider, organizationId); + organizationUsers.First().Permissions = "null"; + var response = await sutProvider.Sut.GetOrganizationUserUserDetails(new OrganizationUserUserDetailsQueryRequest { OrganizationId = organizationId }); + + Assert.True(response.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); + } + + [Theory] + [BitAutoData] + public async Task Get_SetsDeprecatedCustomPermissionstoFalse( + ICollection organizationUsers, + SutProvider sutProvider, + Guid organizationId) + { + Get_Setup(organizationUsers, sutProvider, organizationId); + + var customUser = organizationUsers.First(); + customUser.Type = OrganizationUserType.Custom; + customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + AccessReports = true, + EditAssignedCollections = true, + DeleteAssignedCollections = true, + AccessEventLogs = true + }); + + var response = await sutProvider.Sut.GetOrganizationUserUserDetails(new OrganizationUserUserDetailsQueryRequest { OrganizationId = organizationId }); + + var customUserResponse = response.First(r => r.Id == organizationUsers.First().Id); + Assert.Equal(OrganizationUserType.Custom, customUserResponse.Type); + + var customUserPermissions = customUserResponse.GetPermissions(); + Assert.True(customUserPermissions.AccessReports); + Assert.True(customUserPermissions.AccessEventLogs); + Assert.False(customUserPermissions.EditAssignedCollections); + Assert.False(customUserPermissions.DeleteAssignedCollections); + } + + [Theory] + [BitAutoData] + public async Task Get_ReturnsUsers( + ICollection organizationUsers, + SutProvider sutProvider, + Guid organizationId) + { + Get_Setup(organizationUsers, sutProvider, organizationId); + var response = await sutProvider.Sut.GetOrganizationUserUserDetails(new OrganizationUserUserDetailsQueryRequest { OrganizationId = organizationId }); + + Assert.True(response.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); + } + + private void Get_Setup( + ICollection organizationUsers, + SutProvider sutProvider, + Guid organizationId) + { + foreach (var orgUser in organizationUsers) + { + orgUser.Permissions = null; + } + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId, Arg.Any(), Arg.Any()) + .Returns(organizationUsers); + } +}