From d965166a37d8ed112cfeb3d878ab424e459aae39 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 3 May 2024 06:33:06 -0700 Subject: [PATCH] [AC-2084] Include Collection permissions for admin endpoints (#3793) * [AC-2084] Add documentation to existing collection repository getters * [AC-2084] Add new CollectionAdminDetails model * [AC-2084] Add SQL and migration scripts * [AC-2084] Introduce new repository methods to include permission details for collections * [AC-2084] Add EF repository methods and integration tests * [AC-2084] Update CollectionsController and response models * [AC-2084] Fix failing SqlServer test * [AC-2084] Clean up admin endpoint response models - vNext endpoints should now always return CollectionDetailsResponse models - Update constructors in CollectionDetailsResponseModel to be more explicit and add named static constructors for additional clarity * [AC-2084] Fix failing tests * [AC-2084] Fix potential provider/member bug * [AC-2084] Fix broken collections controller * [AC-2084] Cleanup collection response model types and constructors * [AC-2084] Remove redundant authorization check * [AC-2084] Cleanup ambiguous model name * [AC-2084] Add GroupBy clause to sprocs * [AC-2084] Add GroupBy logic to EF repository * [AC-2084] Update collection repository tests * [AC-2084] Update migration script date * Update migration script date --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: kejaeger <138028972+kejaeger@users.noreply.github.com> --- src/Api/Controllers/CollectionsController.cs | 64 +-- .../Response/CollectionResponseModel.cs | 47 ++ .../Models/Data/CollectionAdminDetails.cs | 17 + src/Core/Models/Data/CollectionDetails.cs | 3 + .../Repositories/ICollectionRepository.cs | 50 ++ .../Repositories/CollectionRepository.cs | 69 +++ .../Repositories/CollectionRepository.cs | 204 ++++++++ .../Queries/CollectionAdminDetailsQuery.cs | 87 ++++ .../Collection_ReadByIdWithPermissions.sql | 62 +++ ...on_ReadByOrganizationIdWithPermissions.sql | 62 +++ .../Controllers/CollectionsControllerTests.cs | 23 +- .../Vault/AutoFixture/CollectionFixture.cs | 4 + .../Repositories/CollectionRepositoryTests.cs | 459 ++++++++++++++++++ ...1_00_CollectionsWithPermissionsQueries.sql | 126 +++++ 14 files changed, 1232 insertions(+), 45 deletions(-) create mode 100644 src/Core/Models/Data/CollectionAdminDetails.cs create mode 100644 src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs create mode 100644 src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql create mode 100644 src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql create mode 100644 test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2024-05-01_00_CollectionsWithPermissionsQueries.sql diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 1f320c1617..ba270ceb81 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -553,47 +553,38 @@ public class CollectionsController : Controller private async Task GetDetails_vNext(Guid id) { // New flexible collections logic - var (collection, access) = await _collectionRepository.GetByIdWithAccessAsync(id); - var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ReadWithAccess)).Succeeded; + var collectionAdminDetails = + await _collectionRepository.GetByIdWithPermissionsAsync(id, _currentContext.UserId, true); + + var authorized = (await _authorizationService.AuthorizeAsync(User, collectionAdminDetails, BulkCollectionOperations.ReadWithAccess)).Succeeded; if (!authorized) { throw new NotFoundException(); } - return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users); + return new CollectionAccessDetailsResponseModel(collectionAdminDetails); } private async Task> GetManyWithDetails_vNext(Guid orgId) { - // We always need to know which collections the current user is assigned to - var assignedOrgCollections = await _collectionRepository - .GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, true); + var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithPermissionsAsync( + orgId, _currentContext.UserId.Value, true); var readAllAuthorized = (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded; if (readAllAuthorized) { - // The user can view all collections, but they may not always be assigned to all of them - var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId); - - return new ListResponseModel(allOrgCollections.Select(c => - new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) - { - // Manually determine which collections they're assigned to - Assigned = assignedOrgCollections.Any(ac => ac.Item1.Id == c.Item1.Id) - }) + return new ListResponseModel( + allOrgCollections.Select(c => new CollectionAccessDetailsResponseModel(c)) ); } - // Filter the assigned collections to only return those where the user has Manage permission - var manageableOrgCollections = assignedOrgCollections.Where(c => c.Item1.Manage).ToList(); + // Filter collections to only return those where the user has Manage permission + var manageableOrgCollections = allOrgCollections.Where(c => c.Manage).ToList(); return new ListResponseModel(manageableOrgCollections.Select(c => - new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) - { - Assigned = true // Mapping from manageableOrgCollections implies they're all assigned - }) - ); + new CollectionAccessDetailsResponseModel(c) + )); } private async Task> GetByOrgId_vNext(Guid orgId) @@ -629,7 +620,7 @@ public class CollectionsController : Controller return responses; } - private async Task Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model) + private async Task Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model) { var collection = model.ToCollection(orgId); @@ -644,21 +635,18 @@ public class CollectionsController : Controller await _collectionService.SaveAsync(collection, groups, users); - if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId)) + if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(orgId) == null && await _currentContext.ProviderUserForOrgAsync(orgId))) { - return new CollectionResponseModel(collection); + return new CollectionAccessDetailsResponseModel(collection); } - // If we have a user, fetch the collection to get the latest permission details - var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, - _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + // If we have a user, fetch the latest collection permission details + var collectionWithPermissions = await _collectionRepository.GetByIdWithPermissionsAsync(collection.Id, _currentContext.UserId.Value, false); - return userCollectionDetails == null - ? new CollectionResponseModel(collection) - : new CollectionDetailsResponseModel(userCollectionDetails); + return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } - private async Task Put_vNext(Guid id, CollectionRequestModel model) + private async Task Put_vNext(Guid id, CollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded; @@ -671,17 +659,15 @@ public class CollectionsController : Controller var users = model.Users?.Select(g => g.ToSelectionReadOnly()); await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); - if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)) + if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(collection.OrganizationId) == null && await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId))) { - return new CollectionResponseModel(collection); + return new CollectionAccessDetailsResponseModel(collection); } - // If we have a user, fetch the collection details to get the latest permission details for the user - var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + // If we have a user, fetch the latest collection permission details + var collectionWithPermissions = await _collectionRepository.GetByIdWithPermissionsAsync(collection.Id, _currentContext.UserId.Value, false); - return updatedCollectionDetails == null - ? new CollectionResponseModel(collection) - : new CollectionDetailsResponseModel(updatedCollectionDetails); + return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } private async Task PutUsers_vNext(Guid id, IEnumerable model) diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index 7eb48ebb2a..253acbfdff 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -26,8 +26,15 @@ public class CollectionResponseModel : ResponseModel public string ExternalId { get; set; } } +/// +/// Response model for a collection that is always assigned to the requesting user, including permissions. +/// public class CollectionDetailsResponseModel : CollectionResponseModel { + /// + /// Create a response model for when the user is assumed to be assigned to the collection with permissions. + /// e.g. The collection details comes from a repository method that only returns collections the user is assigned to. + /// public CollectionDetailsResponseModel(CollectionDetails collectionDetails) : base(collectionDetails, "collectionDetails") { @@ -43,6 +50,27 @@ public class CollectionDetailsResponseModel : CollectionResponseModel public class CollectionAccessDetailsResponseModel : CollectionResponseModel { + /// + /// Create a response model for when the requesting user is assumed not assigned to the collection. + /// No user permissions are included. + /// + /// Ideally, the CollectionAdminDetails constructor should be used instead wherever possible. This is only + /// used in the case of MSPs where the Provider user will likely never be assigned to the collection. + /// + /// + public CollectionAccessDetailsResponseModel(Collection collection) + : base(collection, "collectionAccessDetails") + { } + + /// + /// Create a response model for when the requesting user is assumed not assigned to the collection. Includes + /// the other groups and user relationships for the collection. + /// No user permissions are included. + /// + /// + /// + /// + [Obsolete("Use the CollectionAdminDetails constructor instead.")] public CollectionAccessDetailsResponseModel(Collection collection, IEnumerable groups, IEnumerable users) : base(collection, "collectionAccessDetails") { @@ -50,6 +78,21 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel Users = users.Select(g => new SelectionReadOnlyResponseModel(g)); } + /// + /// Create a response model for when the requesting user's assignment is available via CollectionAdminDetails. + /// + /// + public CollectionAccessDetailsResponseModel(CollectionAdminDetails collection) + : base(collection, "collectionAccessDetails") + { + Assigned = collection.Assigned; + ReadOnly = collection.ReadOnly; + HidePasswords = collection.HidePasswords; + Manage = collection.Manage; + Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); + Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); + } + public IEnumerable Groups { get; set; } public IEnumerable Users { get; set; } @@ -57,4 +100,8 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel /// True if the acting user is explicitly assigned to the collection /// public bool Assigned { get; set; } + + public bool ReadOnly { get; set; } + public bool HidePasswords { get; set; } + public bool Manage { get; set; } } diff --git a/src/Core/Models/Data/CollectionAdminDetails.cs b/src/Core/Models/Data/CollectionAdminDetails.cs new file mode 100644 index 0000000000..8b96eb4dbd --- /dev/null +++ b/src/Core/Models/Data/CollectionAdminDetails.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace Bit.Core.Models.Data; + +/// +/// Collection information that includes permission details for a particular user along with optional +/// access relationships for Groups/Users. Used for collection management. +/// +public class CollectionAdminDetails : CollectionDetails +{ + public IEnumerable? Groups { get; set; } = new List(); + public IEnumerable? Users { get; set; } = new List(); + + /// + /// Flag for whether the user has been explicitly assigned to the collection either directly or through a group. + /// + public bool Assigned { get; set; } +} diff --git a/src/Core/Models/Data/CollectionDetails.cs b/src/Core/Models/Data/CollectionDetails.cs index 05dd108afb..d00e211952 100644 --- a/src/Core/Models/Data/CollectionDetails.cs +++ b/src/Core/Models/Data/CollectionDetails.cs @@ -2,6 +2,9 @@ namespace Bit.Core.Models.Data; +/// +/// Collection information that includes permission details for a particular user +/// public class CollectionDetails : Collection { public bool ReadOnly { get; set; } diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index fc48bded5d..6ef6e53cc1 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -6,14 +6,64 @@ namespace Bit.Core.Repositories; public interface ICollectionRepository : IRepository { Task GetCountByOrganizationIdAsync(Guid organizationId); + + /// + /// Returns a collection and fetches group/user associations for the collection. + /// Task> GetByIdWithAccessAsync(Guid id); + + /// + /// Returns a collection with permission details for the provided userId and fetches group/user associations for + /// the collection. + /// If the user does not have a relationship with the collection, nothing is returned. + /// Task> GetByIdWithAccessAsync(Guid id, Guid userId, bool useFlexibleCollections); + + /// + /// Return all collections that belong to the organization. Does not include any permission details or group/user + /// access relationships. + /// Task> GetManyByOrganizationIdAsync(Guid organizationId); + + /// + /// Return all collections that belong to the organization. Includes group/user access relationships for each collection. + /// Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId); + + /// + /// Returns collections that both, belong to the organization AND have an access relationship with the provided user. + /// Includes permission details for the provided user and group/user access relationships for each collection. + /// Task>> GetManyByUserIdWithAccessAsync(Guid userId, Guid organizationId, bool useFlexibleCollections); + + /// + /// Returns a collection with permission details for the provided userId. Does not include group/user access + /// relationships. + /// If the user does not have a relationship with the collection, nothing is returned. + /// Task GetByIdAsync(Guid id, Guid userId, bool useFlexibleCollections); Task> GetManyByManyIdsAsync(IEnumerable collectionIds); + + /// + /// Return all collections a user has access to across all of the organization they're a member of. Includes permission + /// details for each collection. + /// Task> GetManyByUserIdAsync(Guid userId, bool useFlexibleCollections); + + /// + /// Returns all collections for an organization, including permission info for the specified user. + /// This does not perform any authorization checks internally! + /// Optionally, you can include access relationships for other Groups/Users and the collections. + /// + Task> GetManyByOrganizationIdWithPermissionsAsync(Guid organizationId, Guid userId, bool includeAccessRelationships); + + /// + /// Returns the collection by Id, including permission info for the specified user. + /// This does not perform any authorization checks internally! + /// Optionally, you can include access relationships for other Groups/Users and the collection. + /// + Task GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships); + Task CreateAsync(Collection obj, IEnumerable groups, IEnumerable users); Task ReplaceAsync(Collection obj, IEnumerable groups, IEnumerable users); Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 081df55eb0..a49bc02c7a 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -225,6 +225,75 @@ public class CollectionRepository : Repository, ICollectionRep } } + public async Task> GetManyByOrganizationIdWithPermissionsAsync(Guid organizationId, Guid userId, bool includeAccessRelationships) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryMultipleAsync( + $"[{Schema}].[Collection_ReadByOrganizationIdWithPermissions]", + new { OrganizationId = organizationId, UserId = userId, IncludeAccessRelationships = includeAccessRelationships }, + commandType: CommandType.StoredProcedure); + + var collections = (await results.ReadAsync()).ToList(); + + if (!includeAccessRelationships) + { + return collections; + } + + var groups = (await results.ReadAsync()) + .GroupBy(g => g.CollectionId) + .ToList(); + var users = (await results.ReadAsync()) + .GroupBy(u => u.CollectionId) + .ToList(); + + foreach (var collection in collections) + { + collection.Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly, + Manage = g.Manage + }).ToList() ?? new List(); + collection.Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly, + Manage = c.Manage + }).ToList() ?? new List(); + } + + return collections; + } + } + + public async Task GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryMultipleAsync( + $"[{Schema}].[Collection_ReadByIdWithPermissions]", + new { CollectionId = collectionId, UserId = userId, IncludeAccessRelationships = includeAccessRelationships }, + commandType: CommandType.StoredProcedure); + + var collectionDetails = await results.ReadFirstOrDefaultAsync(); + + if (!includeAccessRelationships) return collectionDetails; + + collectionDetails.Groups = (await results.ReadAsync()).ToList(); + collectionDetails.Users = (await results.ReadAsync()).ToList(); + + return collectionDetails; + } + } + public async Task CreateAsync(Collection obj, IEnumerable groups, IEnumerable users) { obj.SetNewId(); diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index b3d16c8cb5..ee6f1e8794 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -370,6 +370,210 @@ public class CollectionRepository : Repository> GetManyByOrganizationIdWithPermissionsAsync( + Guid organizationId, Guid userId, bool includeAccessRelationships) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = CollectionAdminDetailsQuery.ByOrganizationId(organizationId, userId).Run(dbContext); + + ICollection collections; + + // SQLite does not support the GROUP BY clause + if (dbContext.Database.IsSqlite()) + { + collections = (await query.ToListAsync()) + .GroupBy(c => new + { + c.Id, + c.OrganizationId, + c.Name, + c.CreationDate, + c.RevisionDate, + c.ExternalId + }).Select(collectionGroup => new CollectionAdminDetails + { + Id = collectionGroup.Key.Id, + OrganizationId = collectionGroup.Key.OrganizationId, + Name = collectionGroup.Key.Name, + CreationDate = collectionGroup.Key.CreationDate, + RevisionDate = collectionGroup.Key.RevisionDate, + ExternalId = collectionGroup.Key.ExternalId, + ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), + HidePasswords = + Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + }).ToList(); + } + else + { + collections = await (from c in query + group c by new + { + c.Id, + c.OrganizationId, + c.Name, + c.CreationDate, + c.RevisionDate, + c.ExternalId + } + into collectionGroup + select new CollectionAdminDetails + { + Id = collectionGroup.Key.Id, + OrganizationId = collectionGroup.Key.OrganizationId, + Name = collectionGroup.Key.Name, + CreationDate = collectionGroup.Key.CreationDate, + RevisionDate = collectionGroup.Key.RevisionDate, + ExternalId = collectionGroup.Key.ExternalId, + ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), + HidePasswords = + Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + }).ToListAsync(); + } + + if (!includeAccessRelationships) + { + return collections; + } + + var groups = (from c in collections + join cg in dbContext.CollectionGroups on c.Id equals cg.CollectionId + group cg by cg.CollectionId into g + select g).ToList(); + + var users = (from c in collections + join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId + group cu by cu.CollectionId into u + select u).ToList(); + + foreach (var collection in collections) + { + collection.Groups = groups + .FirstOrDefault(g => g.Key == collection.Id)? + .Select(g => new CollectionAccessSelection + { + Id = g.GroupId, + HidePasswords = g.HidePasswords, + ReadOnly = g.ReadOnly, + Manage = g.Manage, + }).ToList() ?? new List(); + collection.Users = users + .FirstOrDefault(u => u.Key == collection.Id)? + .Select(c => new CollectionAccessSelection + { + Id = c.OrganizationUserId, + HidePasswords = c.HidePasswords, + ReadOnly = c.ReadOnly, + Manage = c.Manage + }).ToList() ?? new List(); + } + + return collections; + } + } + + public async Task GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, + bool includeAccessRelationships) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = CollectionAdminDetailsQuery.ByCollectionId(collectionId, userId).Run(dbContext); + + CollectionAdminDetails collectionDetails; + + // SQLite does not support the GROUP BY clause + if (dbContext.Database.IsSqlite()) + { + collectionDetails = (await query.ToListAsync()) + .GroupBy(c => new + { + c.Id, + c.OrganizationId, + c.Name, + c.CreationDate, + c.RevisionDate, + c.ExternalId + }).Select(collectionGroup => new CollectionAdminDetails + { + Id = collectionGroup.Key.Id, + OrganizationId = collectionGroup.Key.OrganizationId, + Name = collectionGroup.Key.Name, + CreationDate = collectionGroup.Key.CreationDate, + RevisionDate = collectionGroup.Key.RevisionDate, + ExternalId = collectionGroup.Key.ExternalId, + ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), + HidePasswords = + Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + }).FirstOrDefault(); + } + else + { + collectionDetails = await (from c in query + group c by new + { + c.Id, + c.OrganizationId, + c.Name, + c.CreationDate, + c.RevisionDate, + c.ExternalId + } + into collectionGroup + select new CollectionAdminDetails + { + Id = collectionGroup.Key.Id, + OrganizationId = collectionGroup.Key.OrganizationId, + Name = collectionGroup.Key.Name, + CreationDate = collectionGroup.Key.CreationDate, + RevisionDate = collectionGroup.Key.RevisionDate, + ExternalId = collectionGroup.Key.ExternalId, + ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))), + HidePasswords = + Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + }).FirstOrDefaultAsync(); + } + + if (!includeAccessRelationships) + { + return collectionDetails; + } + + var groupsQuery = from cg in dbContext.CollectionGroups + where cg.CollectionId.Equals(collectionId) + select new CollectionAccessSelection + { + Id = cg.GroupId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + Manage = cg.Manage + }; + collectionDetails.Groups = await groupsQuery.ToListAsync(); + + var usersQuery = from cg in dbContext.CollectionUsers + where cg.CollectionId.Equals(collectionId) + select new CollectionAccessSelection + { + Id = cg.OrganizationUserId, + ReadOnly = cg.ReadOnly, + HidePasswords = cg.HidePasswords, + Manage = cg.Manage + }; + collectionDetails.Users = await usersQuery.ToListAsync(); + + return collectionDetails; + } + } + public async Task> GetManyUsersByIdAsync(Guid id) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs new file mode 100644 index 0000000000..f7b4984946 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -0,0 +1,87 @@ +using Bit.Core.Models.Data; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +/// +/// Query to get collection details, including permissions for the specified user if provided. +/// +public class CollectionAdminDetailsQuery : IQuery +{ + private readonly Guid? _userId; + private readonly Guid? _organizationId; + private readonly Guid? _collectionId; + + private CollectionAdminDetailsQuery(Guid? userId, Guid? organizationId, Guid? collectionId) + { + _userId = userId; + _organizationId = organizationId; + _collectionId = collectionId; + } + + public virtual IQueryable Run(DatabaseContext dbContext) + { + var baseCollectionQuery = from c in dbContext.Collections + join ou in dbContext.OrganizationUsers + on new { c.OrganizationId, UserId = _userId } equals + new { ou.OrganizationId, ou.UserId } into ou_g + from ou in ou_g.DefaultIfEmpty() + + join cu in dbContext.CollectionUsers + on new { CollectionId = c.Id, OrganizationUserId = ou.Id } equals + new { cu.CollectionId, cu.OrganizationUserId } into cu_g + from cu in cu_g.DefaultIfEmpty() + + join gu in dbContext.GroupUsers + on new { CollectionId = (Guid?)cu.CollectionId, OrganizationUserId = ou.Id } equals + new { CollectionId = (Guid?)null, gu.OrganizationUserId } into gu_g + from gu in gu_g.DefaultIfEmpty() + + join g in dbContext.Groups + on gu.GroupId equals g.Id into g_g + from g in g_g.DefaultIfEmpty() + + join cg in dbContext.CollectionGroups + on new { CollectionId = c.Id, gu.GroupId } equals + new { cg.CollectionId, cg.GroupId } into cg_g + from cg in cg_g.DefaultIfEmpty() + select new { c, cu, cg }; + + if (_organizationId.HasValue) + { + baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId); + } + else if (_collectionId.HasValue) + { + baseCollectionQuery = baseCollectionQuery.Where(x => x.c.Id == _collectionId); + } + else + { + throw new InvalidOperationException("OrganizationId or CollectionId must be specified."); + } + + return baseCollectionQuery.Select(x => new CollectionAdminDetails + { + Id = x.c.Id, + OrganizationId = x.c.OrganizationId, + Name = x.c.Name, + ExternalId = x.c.ExternalId, + CreationDate = x.c.CreationDate, + RevisionDate = x.c.RevisionDate, + ReadOnly = (bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false, + HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, + Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, + Assigned = x.cu != null || x.cg != null, + }); + } + + public static CollectionAdminDetailsQuery ByCollectionId(Guid collectionId, Guid? userId) + { + return new CollectionAdminDetailsQuery(userId, null, collectionId); + } + + public static CollectionAdminDetailsQuery ByOrganizationId(Guid organizationId, Guid? userId) + { + return new CollectionAdminDetailsQuery(userId, organizationId, null); + } + +} diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql new file mode 100644 index 0000000000..5cd6cc93ae --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql @@ -0,0 +1,62 @@ +CREATE PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] + @CollectionId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN (CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[Id] = @CollectionId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId + EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId + END +END diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql new file mode 100644 index 0000000000..88905eb6c3 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql @@ -0,0 +1,62 @@ +CREATE PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN(CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[OrganizationId] = @OrganizationId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + END +END diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index 5aba61a4db..52062cb89f 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -115,15 +115,20 @@ public class CollectionsControllerTests await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); - await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any()); - await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id); + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true); } [Theory, BitAutoData] public async Task GetOrganizationCollectionsWithGroups_MissingReadAllPermissions_GetsAssignedCollections( - OrganizationAbility organizationAbility, Guid userId, SutProvider sutProvider) + OrganizationAbility organizationAbility, Guid userId, SutProvider sutProvider, List collections) { ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + collections.ForEach(c => c.Manage = false); + + var managedCollection = collections.First(); + managedCollection.Manage = true; + sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency() @@ -145,10 +150,16 @@ public class CollectionsControllerTests operation.Name == nameof(BulkCollectionOperations.ReadWithAccess)))) .Returns(AuthorizationResult.Success()); - await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); + sutProvider.GetDependency() + .GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true) + .Returns(collections); - await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any()); - await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id); + var response = await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); + + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdWithPermissionsAsync(organizationAbility.Id, userId, true); + Assert.Single(response.Data); + Assert.All(response.Data, c => Assert.Equal(organizationAbility.Id, c.OrganizationId)); + Assert.All(response.Data, c => Assert.Equal(managedCollection.Id, c.Id)); } [Theory, BitAutoData] diff --git a/test/Core.Test/Vault/AutoFixture/CollectionFixture.cs b/test/Core.Test/Vault/AutoFixture/CollectionFixture.cs index 51136acb6d..a1ba194bde 100644 --- a/test/Core.Test/Vault/AutoFixture/CollectionFixture.cs +++ b/test/Core.Test/Vault/AutoFixture/CollectionFixture.cs @@ -36,6 +36,10 @@ public class CollectionCustomization : ICustomization .With(o => o.OrganizationId, orgId) .WithGuidFromSeed(cd => cd.Id, _collectionIdSeed)); + fixture.Customize(composer => composer + .With(o => o.OrganizationId, orgId) + .WithGuidFromSeed(cd => cd.Id, _collectionIdSeed)); + fixture.Customize(c => c .WithGuidFromSeed(cu => cu.OrganizationUserId, _userIdSeed) .WithGuidFromSeed(cu => cu.CollectionId, _collectionIdSeed)); diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs new file mode 100644 index 0000000000..dc420c8764 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs @@ -0,0 +1,459 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Repositories; + +public class CollectionRepositoryTests +{ + /// + /// Test to ensure that access relationships are retrieved when requested + /// + [DatabaseTheory, DatabaseData] + public async Task GetByIdWithPermissionsAsync_WithRelationships_Success(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var group = await groupRepository.CreateAsync(new Group + { + Name = "Test Group", + OrganizationId = organization.Id, + }); + + var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection, groups: new[] + { + new CollectionAccessSelection + { + Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false + } + }, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true + } + }); + + var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true); + + Assert.NotNull(collectionWithPermissions); + Assert.Equal(1, collectionWithPermissions.Users?.Count()); + Assert.Equal(1, collectionWithPermissions.Groups?.Count()); + Assert.True(collectionWithPermissions.Assigned); + Assert.True(collectionWithPermissions.Manage); + Assert.False(collectionWithPermissions.ReadOnly); + Assert.False(collectionWithPermissions.HidePasswords); + } + + /// + /// Test to ensure that a user's explicitly assigned permissions replaces any group permissions + /// that user may belong to + /// + [DatabaseTheory, DatabaseData] + public async Task GetByIdWithPermissionsAsync_UserOverrideGroup_Success(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var group = await groupRepository.CreateAsync(new Group + { + Name = "Test Group", + OrganizationId = organization.Id, + }); + + // Assign the test user to the test group + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); + + var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection, groups: new[] + { + new CollectionAccessSelection + { + Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true // Group is Manage + } + }, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false // User is given ReadOnly (should override group) + } + }); + + var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true); + + Assert.NotNull(collectionWithPermissions); + Assert.Equal(1, collectionWithPermissions.Users?.Count()); + Assert.Equal(1, collectionWithPermissions.Groups?.Count()); + Assert.True(collectionWithPermissions.Assigned); + Assert.False(collectionWithPermissions.Manage); + Assert.True(collectionWithPermissions.ReadOnly); + Assert.False(collectionWithPermissions.HidePasswords); + } + + /// + /// Test to ensure that the returned permissions are the most permissive combination of group permissions when + /// multiple groups are assigned to the same collection with different permissions + /// + [DatabaseTheory, DatabaseData] + public async Task GetByIdWithPermissionsAsync_CombineGroupPermissions_Success(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var group = await groupRepository.CreateAsync(new Group + { + Name = "Test Group", + OrganizationId = organization.Id, + }); + + var group2 = await groupRepository.CreateAsync(new Group + { + Name = "Test Group 2", + OrganizationId = organization.Id, + }); + + // Assign the test user to the test groups + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }); + + var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection, groups: new[] + { + new CollectionAccessSelection + { + Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false // Group 1 is ReadOnly + }, + new CollectionAccessSelection + { + Id = group2.Id, HidePasswords = false, ReadOnly = false, Manage = true // Group 2 is Manage + } + }, users: new List()); // No explicit user permissions for this test + + var collectionWithPermissions = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true); + + Assert.NotNull(collectionWithPermissions); + Assert.Equal(2, collectionWithPermissions.Groups?.Count()); + Assert.True(collectionWithPermissions.Assigned); + + // Since Group2 is Manage the user should have Manage + Assert.True(collectionWithPermissions.Manage); + + // Similarly, ReadOnly and HidePassword should be false + Assert.False(collectionWithPermissions.ReadOnly); + Assert.False(collectionWithPermissions.HidePasswords); + } + + /// + /// Test to ensure the basic usage works as expected + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationIdWithPermissionsAsync_Success(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var group = await groupRepository.CreateAsync(new Group + { + Name = "Test Group", + OrganizationId = organization.Id, + }); + + var collection1 = new Collection { Name = "Collection 1", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection1, groups: new[] + { + new CollectionAccessSelection + { + Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false + } + }, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true + } + }); + + var collection2 = new Collection { Name = "Collection 2", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection2, null, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false + } + }); + + var collection3 = new Collection { Name = "Collection 3", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection3, groups: new[] + { + new CollectionAccessSelection() + { + Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true + } + }, null); + + var collections = await collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(organization.Id, user.Id, true); + + Assert.NotNull(collections); + + collections = collections.OrderBy(c => c.Name).ToList(); + + Assert.Collection(collections, c1 => + { + Assert.NotNull(c1); + Assert.Equal(1, c1.Users?.Count()); + Assert.Equal(1, c1.Groups?.Count()); + Assert.True(c1.Assigned); + Assert.True(c1.Manage); + Assert.False(c1.ReadOnly); + Assert.False(c1.HidePasswords); + }, c2 => + { + Assert.NotNull(c2); + Assert.Equal(1, c2.Users?.Count()); + Assert.Equal(0, c2.Groups?.Count()); + Assert.True(c2.Assigned); + Assert.False(c2.Manage); + Assert.True(c2.ReadOnly); + Assert.False(c2.HidePasswords); + }, c3 => + { + Assert.NotNull(c3); + Assert.Equal(0, c3.Users?.Count()); + Assert.Equal(1, c3.Groups?.Count()); + Assert.False(c3.Assigned); + Assert.False(c3.Manage); + Assert.False(c3.ReadOnly); + Assert.False(c3.HidePasswords); + }); + } + + /// + /// Test to ensure collections assigned to multiple groups do not duplicate in the results + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationIdWithPermissionsAsync_GroupBy_Success(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var group = await groupRepository.CreateAsync(new Group + { + Name = "Test Group", + OrganizationId = organization.Id, + }); + + var group2 = await groupRepository.CreateAsync(new Group + { + Name = "Test Group 2", + OrganizationId = organization.Id, + }); + + // Assign the test user to the test groups + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }); + + var collection1 = new Collection { Name = "Collection 1", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection1, groups: new[] + { + new CollectionAccessSelection + { + Id = group.Id, HidePasswords = false, ReadOnly = true, Manage = false + }, + }, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true + } + }); + + var collection2 = new Collection { Name = "Collection 2", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection2, null, users: new[] + { + new CollectionAccessSelection() + { + Id = orgUser.Id, HidePasswords = false, ReadOnly = true, Manage = false + } + }); + + var collection3 = new Collection { Name = "Collection 3", OrganizationId = organization.Id, }; + + await collectionRepository.CreateAsync(collection3, groups: new[] + { + new CollectionAccessSelection() + { + Id = group.Id, HidePasswords = false, ReadOnly = false, Manage = true + }, + new CollectionAccessSelection() + { + Id = group2.Id, HidePasswords = false, ReadOnly = true, Manage = false + } + }, null); + + var collections = await collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(organization.Id, user.Id, true); + + Assert.NotNull(collections); + + Assert.Equal(3, collections.Count); + + collections = collections.OrderBy(c => c.Name).ToList(); + + Assert.Collection(collections, c1 => + { + Assert.NotNull(c1); + Assert.Equal(1, c1.Users?.Count()); + Assert.Equal(1, c1.Groups?.Count()); + Assert.True(c1.Assigned); + Assert.True(c1.Manage); + Assert.False(c1.ReadOnly); + Assert.False(c1.HidePasswords); + }, c2 => + { + Assert.NotNull(c2); + Assert.Equal(1, c2.Users?.Count()); + Assert.Equal(0, c2.Groups?.Count()); + Assert.True(c2.Assigned); + Assert.False(c2.Manage); + Assert.True(c2.ReadOnly); + Assert.False(c2.HidePasswords); + }, c3 => + { + Assert.NotNull(c3); + Assert.Equal(0, c3.Users?.Count()); + Assert.Equal(2, c3.Groups?.Count()); + Assert.True(c3.Assigned); // User is a member of both Groups + Assert.True(c3.Manage); // Group 2 is Manage + Assert.False(c3.ReadOnly); + Assert.False(c3.HidePasswords); + }); + } +} diff --git a/util/Migrator/DbScripts/2024-05-01_00_CollectionsWithPermissionsQueries.sql b/util/Migrator/DbScripts/2024-05-01_00_CollectionsWithPermissionsQueries.sql new file mode 100644 index 0000000000..2d9b2f5fdd --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-01_00_CollectionsWithPermissionsQueries.sql @@ -0,0 +1,126 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] + @CollectionId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN (CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[Id] = @CollectionId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId + EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN(CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[OrganizationId] = @OrganizationId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + END +END +GO