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