diff --git a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs index 44ead9c5a233..3066a4a64fa8 100644 --- a/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/CollectionResponseModel.cs @@ -51,11 +51,18 @@ public CollectionDetailsResponseModel(CollectionDetails collectionDetails) HidePasswords = collectionDetails.HidePasswords; Manage = collectionDetails.Manage; DefaultUserCollectionEmail = collectionDetails.DefaultUserCollectionEmail; + HasEnabledAccessRule = collectionDetails.HasEnabledAccessRule; } public bool ReadOnly { get; set; } public bool HidePasswords { get; set; } public bool Manage { get; set; } + + /// + /// True if the collection is governed by an access rule that is currently enabled. Lets a client + /// mark the collection as privileged without reading the organization's access rules. + /// + public bool HasEnabledAccessRule { get; set; } } public class CollectionAccessDetailsResponseModel : CollectionResponseModel @@ -100,6 +107,7 @@ public CollectionAccessDetailsResponseModel(CollectionAdminDetails collection) HidePasswords = collection.HidePasswords; Manage = collection.Manage; Unmanaged = collection.Unmanaged; + HasEnabledAccessRule = collection.HasEnabledAccessRule; Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); } @@ -116,4 +124,16 @@ public CollectionAccessDetailsResponseModel(CollectionAdminDetails collection) public bool HidePasswords { get; set; } public bool Manage { get; set; } public bool Unmanaged { get; set; } + + /// + /// True if the collection is governed by an access rule that is currently enabled. Lets a client + /// mark the collection as privileged without reading the organization's access rules. + /// + /// + /// Only the constructor can populate this — it is computed by + /// the collection read paths. The bare constructors are used for the + /// create/update responses and the provider fallbacks, which have no rule state to report and so + /// leave it false. + /// + public bool HasEnabledAccessRule { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/CollectionDetails.cs b/src/Core/AdminConsole/Models/Data/CollectionDetails.cs index 2fd162ceaecb..92d9c865778b 100644 --- a/src/Core/AdminConsole/Models/Data/CollectionDetails.cs +++ b/src/Core/AdminConsole/Models/Data/CollectionDetails.cs @@ -21,4 +21,15 @@ public class CollectionDetails : Collection /// deleting it, and assigning access for other users and groups. /// public bool Manage { get; set; } + /// + /// If true, the collection is governed by an AccessRule that is currently enabled, so + /// items in it are gated behind PAM leasing. + /// + /// + /// Derived, not stored: records the association, but a + /// disabled rule gates nothing, so the read paths join the rule and report whether it is switched + /// on. Computed by the collection read procedures and their Entity Framework equivalents; a + /// loaded outside those paths cannot tell you this. + /// + public bool HasEnabledAccessRule { get; set; } } diff --git a/src/Core/AdminConsole/Repositories/ICollectionRepository.cs b/src/Core/AdminConsole/Repositories/ICollectionRepository.cs index 53b60549414f..991a3888b9a9 100644 --- a/src/Core/AdminConsole/Repositories/ICollectionRepository.cs +++ b/src/Core/AdminConsole/Repositories/ICollectionRepository.cs @@ -71,6 +71,14 @@ public interface ICollectionRepository : IRepository Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); Task UpdateUsersAsync(Guid id, IEnumerable users); Task> GetManyUsersByIdAsync(Guid id); + + /// + /// Returns the distinct user ids of every confirmed member who can Manage the collection: direct Manage + /// assignments, Manage via group, org Owners/Admins (when the organization allows admin access to all collection + /// items), and Custom users with the EditAnyCollection permission. + /// + Task> GetManagingUserIdsAsync(Guid collectionId); + Task DeleteManyAsync(IEnumerable collectionIds); /// diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs index 4f6554b71a6d..70f747c8d5ff 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/CollectionRepository.cs @@ -398,6 +398,19 @@ public async Task> GetManyUsersByIdAsync( } } + public async Task> GetManagingUserIdsAsync(Guid collectionId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Collection_ReadManagingUserIds]", + new { CollectionId = collectionId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs index 5223f97b4a57..d10e42ce162b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; using Bit.Infrastructure.EntityFramework.Repositories; @@ -259,7 +260,8 @@ public async Task> GetManyByUserIdAsync(Guid user c.CreationDate, c.RevisionDate, c.ExternalId, - c.Type + c.Type, + c.HasEnabledAccessRule }) .Select(collectionGroup => new CollectionDetails { @@ -273,6 +275,7 @@ public async Task> GetManyByUserIdAsync(Guid user HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Type = collectionGroup.Key.Type, + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule, }) .ToList(); } @@ -286,7 +289,8 @@ public async Task> GetManyByUserIdAsync(Guid user c.CreationDate, c.RevisionDate, c.ExternalId, - c.Type + c.Type, + c.HasEnabledAccessRule } into collectionGroup select new CollectionDetails { @@ -300,6 +304,7 @@ public async Task> GetManyByUserIdAsync(Guid user HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Type = collectionGroup.Key.Type, + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule, }).ToListAsync(); } } @@ -327,7 +332,8 @@ public async Task> GetManySharedByOrganizati c.RevisionDate, c.ExternalId, c.Unmanaged, - c.DefaultUserCollectionEmail + c.DefaultUserCollectionEmail, + c.HasEnabledAccessRule }).Select(collectionGroup => new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -342,7 +348,8 @@ public async Task> GetManySharedByOrganizati Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), Unmanaged = collectionGroup.Key.Unmanaged, - DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail, + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule }).ToList(); } else @@ -357,7 +364,8 @@ public async Task> GetManySharedByOrganizati c.RevisionDate, c.ExternalId, c.Unmanaged, - c.DefaultUserCollectionEmail + c.DefaultUserCollectionEmail, + c.HasEnabledAccessRule } into collectionGroup select new CollectionAdminDetails @@ -374,7 +382,8 @@ into collectionGroup Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), Unmanaged = collectionGroup.Key.Unmanaged, - DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail, + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule }).ToListAsync(); } @@ -440,7 +449,8 @@ group cu by cu.CollectionId into u c.Name, c.CreationDate, c.RevisionDate, - c.ExternalId + c.ExternalId, + c.HasEnabledAccessRule }).Select(collectionGroup => new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -454,7 +464,8 @@ group cu by cu.CollectionId into u 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))), - Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault(), + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule }).FirstOrDefault(); } else @@ -467,7 +478,8 @@ group cu by cu.CollectionId into u c.Name, c.CreationDate, c.RevisionDate, - c.ExternalId + c.ExternalId, + c.HasEnabledAccessRule } into collectionGroup select new CollectionAdminDetails @@ -483,7 +495,8 @@ into collectionGroup 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))), - Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault(), + HasEnabledAccessRule = collectionGroup.Key.HasEnabledAccessRule }).FirstOrDefaultAsync(); } @@ -538,6 +551,60 @@ public async Task> GetManyUsersByIdAsync( } } + public async Task> GetManagingUserIdsAsync(Guid collectionId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var directManageUserIds = from cu in dbContext.CollectionUsers + where cu.CollectionId == collectionId && cu.Manage + join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id + where ou.Status == OrganizationUserStatusType.Confirmed && ou.UserId != null + select ou.UserId!.Value; + + var groupManageUserIds = from cg in dbContext.CollectionGroups + where cg.CollectionId == collectionId && cg.Manage + join gu in dbContext.GroupUsers on cg.GroupId equals gu.GroupId + join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id + where ou.Status == OrganizationUserStatusType.Confirmed && ou.UserId != null + select ou.UserId!.Value; + + var adminManageUserIds = from c in dbContext.Collections + where c.Id == collectionId + join o in dbContext.Organizations on c.OrganizationId equals o.Id + join ou in dbContext.OrganizationUsers on c.OrganizationId equals ou.OrganizationId + where ou.Status == OrganizationUserStatusType.Confirmed + && ou.UserId != null + && (ou.Type == OrganizationUserType.Owner + || ou.Type == OrganizationUserType.Admin) + && o.AllowAdminAccessToAllCollectionItems + select ou.UserId!.Value; + + var managerUserIds = await directManageUserIds + .Union(groupManageUserIds) + .Union(adminManageUserIds) + .ToListAsync(); + + // TODO: Update to JSON query after upgrading to EF 10. + var customMembers = await (from c in dbContext.Collections + where c.Id == collectionId + join ou in dbContext.OrganizationUsers on c.OrganizationId equals ou.OrganizationId + where ou.Status == OrganizationUserStatusType.Confirmed + && ou.UserId != null + && ou.Type == OrganizationUserType.Custom + select new { UserId = ou.UserId!.Value, ou.Permissions }) + .ToListAsync(); + + return managerUserIds + .Concat(customMembers + .Where(m => CoreHelpers.LoadClassFromJsonData(m.Permissions).EditAnyCollection) + .Select(m => m.UserId)) + .Distinct() + .ToList(); + } + } + public async Task ReplaceAsync(Core.Entities.Collection collection, IEnumerable? groups, IEnumerable? users) { diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/CollectionAdminDetailsQuery.cs index b956dbf3b982..f80fd8289798 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/CollectionAdminDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -90,6 +90,8 @@ where cg.Manage Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, Assigned = x.cu != null || x.cg != null, Unmanaged = !activeUserManageRights.Contains(x.c.Id) && !activeGroupManageRights.Contains(x.c.Id), + // A disabled rule gates nothing, so the association alone is not enough. + HasEnabledAccessRule = dbContext.AccessRules.Any(ar => ar.Id == x.c.AccessRuleId && ar.Enabled), }); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/UserCollectionDetailsQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/UserCollectionDetailsQuery.cs index 07e5f2dc8819..59b36adb7264 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/UserCollectionDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/UserCollectionDetailsQuery.cs @@ -61,7 +61,9 @@ from cg in cg_g.DefaultIfEmpty() ReadOnly = (bool?)row.cu.ReadOnly ?? (bool?)row.cg.ReadOnly ?? false, HidePasswords = (bool?)row.cu.HidePasswords ?? (bool?)row.cg.HidePasswords ?? false, Manage = (bool?)row.cu.Manage ?? (bool?)row.cg.Manage ?? false, - Type = row.c.Type + Type = row.c.Type, + // A disabled rule gates nothing, so the association alone is not enough. + HasEnabledAccessRule = dbContext.AccessRules.Any(ar => ar.Id == row.c.AccessRuleId && ar.Enabled) }); } } diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql index cb23b2cb4d9e..1167853d6777 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByIdWithPermissions.sql @@ -52,7 +52,8 @@ BEGIN ) THEN 1 ELSE 0 - END AS [Unmanaged] + END AS [Unmanaged], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] FROM [dbo].[CollectionView] C LEFT JOIN @@ -65,6 +66,8 @@ BEGIN [dbo].[Group] G ON G.[Id] = GU.[GroupId] LEFT JOIN [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = C.[AccessRuleId] WHERE C.[Id] = @CollectionId GROUP BY diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql index c9bd7e96bee1..c442105bd08a 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadByUserId.sql @@ -5,28 +5,31 @@ BEGIN SET NOCOUNT ON SELECT - Id, - OrganizationId, - [Name], - CreationDate, - RevisionDate, - ExternalId, - MIN([ReadOnly]) AS [ReadOnly], - MIN([HidePasswords]) AS [HidePasswords], - MAX([Manage]) AS [Manage], - [DefaultUserCollectionEmail], - [Type], - [AccessRuleId] + UCD.[Id], + UCD.[OrganizationId], + UCD.[Name], + UCD.[CreationDate], + UCD.[RevisionDate], + UCD.[ExternalId], + MIN(UCD.[ReadOnly]) AS [ReadOnly], + MIN(UCD.[HidePasswords]) AS [HidePasswords], + MAX(UCD.[Manage]) AS [Manage], + UCD.[DefaultUserCollectionEmail], + UCD.[Type], + UCD.[AccessRuleId], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] FROM - [dbo].[UserCollectionDetails](@UserId) + [dbo].[UserCollectionDetails](@UserId) UCD + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = UCD.[AccessRuleId] GROUP BY - Id, - OrganizationId, - [Name], - CreationDate, - RevisionDate, - ExternalId, - [DefaultUserCollectionEmail], - [Type], - [AccessRuleId] + UCD.[Id], + UCD.[OrganizationId], + UCD.[Name], + UCD.[CreationDate], + UCD.[RevisionDate], + UCD.[ExternalId], + UCD.[DefaultUserCollectionEmail], + UCD.[Type], + UCD.[AccessRuleId] END diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql new file mode 100644 index 000000000000..b0faaa9e1a03 --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadManagingUserIds.sql @@ -0,0 +1,50 @@ +CREATE PROCEDURE [dbo].[Collection_ReadManagingUserIds] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Every confirmed member who can Manage the collection: direct Manage assignments, Manage via group membership, + -- plus org Owners/Admins (when the org allows admin access to all collection items) and Custom users with the + -- EditAnyCollection permission. Returns distinct user ids. + DECLARE @OrganizationId UNIQUEIDENTIFIER + SELECT @OrganizationId = [OrganizationId] FROM [dbo].[Collection] WHERE [Id] = @CollectionId + + SELECT DISTINCT [UserId] + FROM + ( + SELECT OU.[UserId] + FROM [dbo].[CollectionUser] CU + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE CU.[CollectionId] = @CollectionId + AND CU.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE CG.[CollectionId] = @CollectionId + AND CG.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[OrganizationUser] OU + INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + WHERE OU.[OrganizationId] = @OrganizationId + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + AND ( + (O.[AllowAdminAccessToAllCollectionItems] = 1 AND OU.[Type] IN (0, 1)) -- Owner, Admin + OR (OU.[Type] = 4 -- Custom + AND ISJSON(OU.[Permissions]) = 1 + AND JSON_VALUE(OU.[Permissions], '$.editAnyCollection') = 'true') + ) + ) AS ManagingUsers +END diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql index 9310c102e023..3f8e751c992b 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationIdWithPermissions.sql @@ -52,7 +52,8 @@ BEGIN ) THEN 1 ELSE 0 - END AS [Unmanaged] + END AS [Unmanaged], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] FROM [dbo].[CollectionView] C LEFT JOIN @@ -65,6 +66,8 @@ BEGIN [dbo].[Group] G ON G.[Id] = GU.[GroupId] LEFT JOIN [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = C.[AccessRuleId] WHERE C.[OrganizationId] = @OrganizationId AND C.[Type] = 0 -- Only SharedCollection diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs new file mode 100644 index 000000000000..fff9e7cacbe9 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryGetManagingUserIdsTests.cs @@ -0,0 +1,247 @@ +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.AdminConsole.Repositories.CollectionRepository; + +public class CollectionRepositoryGetManagingUserIdsTests +{ + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_DirectManageUser_Included_NonManageExcluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var manager = await userRepository.CreateTestUserAsync("manager"); + var managerOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, manager); + + var viewer = await userRepository.CreateTestUserAsync("viewer"); + var viewerOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, viewer); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: [], users: + [ + new CollectionAccessSelection { Id = managerOrgUser.Id, Manage = true }, + new CollectionAccessSelection { Id = viewerOrgUser.Id, Manage = false, ReadOnly = true }, + ]); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(manager.Id, userIds); + Assert.DoesNotContain(viewer.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_GroupManageMember_Included( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var member = await userRepository.CreateTestUserAsync("groupmember"); + var memberOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, member); + + var group = await groupRepository.CreateTestGroupAsync(organization); + await groupRepository.UpdateUsersAsync(group.Id, [memberOrgUser.Id], DateTime.UtcNow); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: + [ + new CollectionAccessSelection { Id = group.Id, Manage = true }, + ], users: []); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(member.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_OwnerWithAdminAccess_Included( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + organization.AllowAdminAccessToAllCollectionItems = true; + await organizationRepository.ReplaceAsync(organization); + + var owner = await userRepository.CreateTestUserAsync("owner"); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = owner.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + // A collection the owner is not directly assigned to. + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(owner.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_OwnerWithoutAdminAccess_Excluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + // The test-org helper enables admin access by default, so turn it off for this case. + organization.AllowAdminAccessToAllCollectionItems = false; + await organizationRepository.ReplaceAsync(organization); + + var owner = await userRepository.CreateTestUserAsync("owner"); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = owner.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.DoesNotContain(owner.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_CustomEditAnyCollection_Included_WithoutPermissionExcluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + // Admin access is off so that EditAnyCollection is the only thing that can grant Manage here. + organization.AllowAdminAccessToAllCollectionItems = false; + await organizationRepository.ReplaceAsync(organization); + + var editor = await userRepository.CreateTestUserAsync("editany"); + await organizationUserRepository.CreateAsync(CreateCustomUser(organization, editor, + new Permissions { EditAnyCollection = true })); + + var manager = await userRepository.CreateTestUserAsync("managegroups"); + await organizationUserRepository.CreateAsync(CreateCustomUser(organization, manager, + new Permissions { ManageGroups = true })); + + // A collection neither user is directly assigned to. + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Contains(editor.Id, userIds); + Assert.DoesNotContain(manager.Id, userIds); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_UnconfirmedMembers_Excluded( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var member = await userRepository.CreateTestUserAsync("member"); + var memberOrgUser = await CreateConfirmedUserAsync(organizationUserRepository, organization, member); + + // An accepted Owner would manage by role, by assignment and by group if it were confirmed. + var accepted = await userRepository.CreateTestUserAsync("accepted"); + var acceptedOrgUser = + await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, accepted); + + var invitedOrgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); + + var group = await groupRepository.CreateTestGroupAsync(organization); + await groupRepository.UpdateUsersAsync(group.Id, [acceptedOrgUser.Id], DateTime.UtcNow); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: + [ + new CollectionAccessSelection { Id = group.Id, Manage = true }, + ], users: + [ + new CollectionAccessSelection { Id = memberOrgUser.Id, Manage = true }, + new CollectionAccessSelection { Id = acceptedOrgUser.Id, Manage = true }, + new CollectionAccessSelection { Id = invitedOrgUser.Id, Manage = true }, + ]); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Equal(member.Id, Assert.Single(userIds)); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManagingUserIdsAsync_ManageByEveryRoute_ReturnsTheUserOnce( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + // The test-org helper allows admin access, so a confirmed Owner manages by role as well. + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var owner = await userRepository.CreateTestUserAsync("owner"); + var ownerOrgUser = + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, owner); + + var group = await groupRepository.CreateTestGroupAsync(organization); + await groupRepository.UpdateUsersAsync(group.Id, [ownerOrgUser.Id], DateTime.UtcNow); + + var collection = new Collection { Name = "Leased", OrganizationId = organization.Id }; + await collectionRepository.CreateAsync(collection, groups: + [ + new CollectionAccessSelection { Id = group.Id, Manage = true }, + ], users: + [ + new CollectionAccessSelection { Id = ownerOrgUser.Id, Manage = true }, + ]); + + var userIds = await collectionRepository.GetManagingUserIdsAsync(collection.Id); + + Assert.Equal(owner.Id, Assert.Single(userIds)); + } + + private static OrganizationUser CreateCustomUser(Organization organization, User user, Permissions permissions) + { + var organizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Custom, + }; + + organizationUser.SetPermissions(permissions); + + return organizationUser; + } + + private static Task CreateConfirmedUserAsync( + IOrganizationUserRepository organizationUserRepository, Organization organization, User user) + => organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + }); +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryHasEnabledAccessRuleTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryHasEnabledAccessRuleTests.cs new file mode 100644 index 000000000000..1bf29c4d7fca --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryHasEnabledAccessRuleTests.cs @@ -0,0 +1,201 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; + +/// +/// Covers on the three collection read paths that feed a +/// client: the sync read, the organization listing behind the Admin Console vault, and the single-collection read. +/// +/// The flag is derived rather than stored — records the association, but a +/// rule that is switched off gates nothing, so each read joins the rule and reports whether it is enabled. MSSQL +/// computes this in the stored procedures and Entity Framework reimplements it in LINQ, so every test here runs the +/// same truth table against both: ungoverned, governed by an enabled rule, governed by a disabled rule. +/// +public class CollectionRepositoryHasEnabledAccessRuleTests +{ + private const string _conditions = """{"kind":"human_approval"}"""; + + [DatabaseTheory, DatabaseData] + public async Task GetManyByUserIdAsync_ReportsWhetherTheGoverningRuleIsEnabled( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + // Arrange + var (organization, orgUser, user) = await CreateMemberAsync( + userRepository, organizationRepository, organizationUserRepository); + var (ungoverned, enabled, disabled) = await CreateThreeCollectionsAsync( + collectionRepository, accessRuleRepository, organization, orgUser); + + // Act + var actual = await collectionRepository.GetManyByUserIdAsync(user.Id); + + // Assert + Assert.False(Single(actual, ungoverned.Id).HasEnabledAccessRule); + Assert.True(Single(actual, enabled.Id).HasEnabledAccessRule); + Assert.False(Single(actual, disabled.Id).HasEnabledAccessRule); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManySharedByOrganizationIdWithPermissionsAsync_ReportsWhetherTheGoverningRuleIsEnabled( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + // Arrange + var (organization, orgUser, user) = await CreateMemberAsync( + userRepository, organizationRepository, organizationUserRepository); + var (ungoverned, enabled, disabled) = await CreateThreeCollectionsAsync( + collectionRepository, accessRuleRepository, organization, orgUser); + + // Act + var actual = await collectionRepository.GetManySharedByOrganizationIdWithPermissionsAsync( + organization.Id, user.Id, false); + + // Assert + Assert.False(Single(actual, ungoverned.Id).HasEnabledAccessRule); + Assert.True(Single(actual, enabled.Id).HasEnabledAccessRule); + Assert.False(Single(actual, disabled.Id).HasEnabledAccessRule); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByIdWithPermissionsAsync_ReportsWhetherTheGoverningRuleIsEnabled( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + // Arrange + var (organization, orgUser, user) = await CreateMemberAsync( + userRepository, organizationRepository, organizationUserRepository); + var (ungoverned, enabled, disabled) = await CreateThreeCollectionsAsync( + collectionRepository, accessRuleRepository, organization, orgUser); + + // Act + Assert + Assert.False((await ReadAsync(ungoverned)).HasEnabledAccessRule); + Assert.True((await ReadAsync(enabled)).HasEnabledAccessRule); + Assert.False((await ReadAsync(disabled)).HasEnabledAccessRule); + + async Task ReadAsync(Collection collection) + { + var actual = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, false); + Assert.NotNull(actual); + return actual; + } + } + + /// + /// Switching a rule off ungoverns its collections for badge purposes without touching the association, so the + /// flag has to follow the rule's state rather than the collection's row. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyByUserIdAsync_AfterTheRuleIsDisabled_StopsReportingTheCollectionAsGoverned( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository) + { + // Arrange + var (organization, orgUser, user) = await CreateMemberAsync( + userRepository, organizationRepository, organizationUserRepository); + var rule = await CreateRuleAsync(accessRuleRepository, organization.Id, "Toggled", enabled: true); + var collection = await CreateAssignedCollectionAsync(collectionRepository, organization, orgUser); + await collectionRepository.SetAccessRuleAssociationsAsync( + organization.Id, rule.Id, [collection.Id], []); + + var before = await collectionRepository.GetManyByUserIdAsync(user.Id); + Assert.True(Single(before, collection.Id).HasEnabledAccessRule); + + // Act + rule.Enabled = false; + await accessRuleRepository.ReplaceAsync(rule); + + // Assert: still associated, no longer gating. + var after = await collectionRepository.GetManyByUserIdAsync(user.Id); + Assert.False(Single(after, collection.Id).HasEnabledAccessRule); + + var stored = await collectionRepository.GetByIdAsync(collection.Id); + Assert.NotNull(stored); + Assert.Equal(rule.Id, stored.AccessRuleId); + } + + private static T Single(IEnumerable collections, Guid collectionId) where T : Collection + => Assert.Single(collections, c => c.Id == collectionId); + + private static async Task<(Organization, OrganizationUser, User)> CreateMemberAsync( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + return (organization, orgUser, user); + } + + private static async Task<(Collection Ungoverned, Collection Enabled, Collection Disabled)> + CreateThreeCollectionsAsync( + ICollectionRepository collectionRepository, + IAccessRuleRepository accessRuleRepository, + Organization organization, + OrganizationUser orgUser) + { + var enabledRule = await CreateRuleAsync(accessRuleRepository, organization.Id, "Enabled", enabled: true); + var disabledRule = await CreateRuleAsync(accessRuleRepository, organization.Id, "Disabled", enabled: false); + + var ungoverned = await CreateAssignedCollectionAsync(collectionRepository, organization, orgUser); + var governedByEnabled = await CreateAssignedCollectionAsync(collectionRepository, organization, orgUser); + var governedByDisabled = await CreateAssignedCollectionAsync(collectionRepository, organization, orgUser); + + await collectionRepository.SetAccessRuleAssociationsAsync( + organization.Id, enabledRule.Id, [governedByEnabled.Id], []); + await collectionRepository.SetAccessRuleAssociationsAsync( + organization.Id, disabledRule.Id, [governedByDisabled.Id], []); + + return (ungoverned, governedByEnabled, governedByDisabled); + } + + /// + /// The collection must be assigned to the member, otherwise the user-scoped read paths do not return it at all. + /// + private static async Task CreateAssignedCollectionAsync( + ICollectionRepository collectionRepository, + Organization organization, + OrganizationUser orgUser) + { + var collection = new Collection + { + OrganizationId = organization.Id, + Name = $"Test Collection {Guid.NewGuid()}" + }; + + await collectionRepository.CreateAsync(collection, groups: [], users: + [ + new CollectionAccessSelection { Id = orgUser.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ]); + + return collection; + } + + private static Task CreateRuleAsync( + IAccessRuleRepository accessRuleRepository, Guid organizationId, string name, bool enabled) + => accessRuleRepository.CreateAsync(new AccessRule + { + OrganizationId = organizationId, + Name = $"{name} {Guid.NewGuid()}", + Conditions = _conditions, + Enabled = enabled, + }); +} diff --git a/util/Migrator/DbScripts/2026-08-25_00_AddPamCollectionReads.sql b/util/Migrator/DbScripts/2026-08-25_00_AddPamCollectionReads.sql new file mode 100644 index 000000000000..eb2a5131650e --- /dev/null +++ b/util/Migrator/DbScripts/2026-08-25_00_AddPamCollectionReads.sql @@ -0,0 +1,283 @@ +-- Add Collection_ReadManagingUserIds: the distinct confirmed members who can Manage a collection. PAM's approver +-- inbox notifier fans a RefreshApproverInbox push out to exactly this set. + +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadManagingUserIds] + @CollectionId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Every confirmed member who can Manage the collection: direct Manage assignments, Manage via group membership, + -- plus org Owners/Admins (when the org allows admin access to all collection items) and Custom users with the + -- EditAnyCollection permission. Returns distinct user ids. + DECLARE @OrganizationId UNIQUEIDENTIFIER + SELECT @OrganizationId = [OrganizationId] FROM [dbo].[Collection] WHERE [Id] = @CollectionId + + SELECT DISTINCT [UserId] + FROM + ( + SELECT OU.[UserId] + FROM [dbo].[CollectionUser] CU + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE CU.[CollectionId] = @CollectionId + AND CU.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE CG.[CollectionId] = @CollectionId + AND CG.[Manage] = 1 + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + + UNION + + SELECT OU.[UserId] + FROM [dbo].[OrganizationUser] OU + INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + WHERE OU.[OrganizationId] = @OrganizationId + AND OU.[Status] = 2 -- Confirmed + AND OU.[UserId] IS NOT NULL + AND ( + (O.[AllowAdminAccessToAllCollectionItems] = 1 AND OU.[Type] IN (0, 1)) -- Owner, Admin + OR (OU.[Type] = 4 -- Custom + AND ISJSON(OU.[Permissions]) = 1 + AND JSON_VALUE(OU.[Permissions], '$.editAnyCollection') = 'true') + ) + ) AS ManagingUsers +END +GO + +-- Surface [HasEnabledAccessRule] on the collection read paths that feed sync, the org collection +-- listing and the single-collection read. The Collection.AccessRuleId association was already +-- selected by these procedures, but "governed" only means "gated" while the rule itself is +-- switched on, so each now LEFT JOINs [dbo].[AccessRule] and reports whether the governing rule +-- is enabled rather than merely present. + +-- Collection_ReadByUserId +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + UCD.[Id], + UCD.[OrganizationId], + UCD.[Name], + UCD.[CreationDate], + UCD.[RevisionDate], + UCD.[ExternalId], + MIN(UCD.[ReadOnly]) AS [ReadOnly], + MIN(UCD.[HidePasswords]) AS [HidePasswords], + MAX(UCD.[Manage]) AS [Manage], + UCD.[DefaultUserCollectionEmail], + UCD.[Type], + UCD.[AccessRuleId], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] + FROM + [dbo].[UserCollectionDetails](@UserId) UCD + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = UCD.[AccessRuleId] + GROUP BY + UCD.[Id], + UCD.[OrganizationId], + UCD.[Name], + UCD.[CreationDate], + UCD.[RevisionDate], + UCD.[ExternalId], + UCD.[DefaultUserCollectionEmail], + UCD.[Type], + UCD.[AccessRuleId] +END +GO + +-- Collection_ReadByIdWithPermissions +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], + CASE + WHEN + -- No user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] + 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] + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = C.[AccessRuleId] + WHERE + C.[Id] = @CollectionId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId], + C.[DefaultUserCollectionEmail], + C.[Type], + C.[AccessRuleId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId + EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId + END +END +GO + +-- Collection_ReadSharedCollectionsByOrganizationIdWithPermissions +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadSharedCollectionsByOrganizationIdWithPermissions] + @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], + CASE + WHEN + -- No user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged], + MAX(CASE WHEN AR.[Enabled] = 1 THEN 1 ELSE 0 END) AS [HasEnabledAccessRule] + 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] + LEFT JOIN + [dbo].[AccessRule] AR ON AR.[Id] = C.[AccessRuleId] + WHERE + C.[OrganizationId] = @OrganizationId AND + C.[Type] = 0 -- Only SharedCollection + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId], + C.[DefaultUserCollectionEmail], + C.[Type], + C.[AccessRuleId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + END +END +GO