diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 9494fec0ec..f7a4081b73 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -8,6 +8,7 @@ public interface ICollectionCipherRepository { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task> GetManySharedByOrganizationIdAsync(Guid organizationId); Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId); Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable collectionIds); Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index 945fdb7e3c..62b055b417 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -24,7 +24,7 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var orgCiphers = ciphers.Where(c => c.OrganizationId == organizationId).ToList(); var orgCipherIds = orgCiphers.Select(c => c.Id); - var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(organizationId); + var collectionCiphers = await _collectionCipherRepository.GetManySharedByOrganizationIdAsync(organizationId); var collectionCiphersGroupDict = collectionCiphers .Where(c => orgCipherIds.Contains(c.CipherId)) .GroupBy(c => c.CipherId).ToDictionary(s => s.Key); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 5ed82a9a2c..64b1a74072 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -45,6 +45,19 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[CollectionCipher_ReadSharedByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index d0787f7303..6e2805f987 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -47,6 +47,21 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var data = await (from cc in dbContext.CollectionCiphers + join c in dbContext.Collections + on cc.CollectionId equals c.Id + where c.OrganizationId == organizationId + && c.Type == Core.Enums.CollectionType.SharedCollection + select cc).ToArrayAsync(); + return data; + } + } + public async Task> GetManyByUserIdAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql new file mode 100644 index 0000000000..d35dabb0e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 03064fd978..2f0d3b943b 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -14,6 +14,6 @@ GO CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection]([OrganizationId] ASC) - INCLUDE([CreationDate], [Name], [RevisionDate]); + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs new file mode 100644 index 0000000000..1579e5c329 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Vault.Repositories; + +public class CollectionCipherRepositoryTests +{ + [Theory, DatabaseData] + public async Task GetManySharedByOrganizationIdAsync_OnlyReturnsSharedCollections( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ICipherRepository cipherRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Enterprise", + BillingEmail = "billing@example.com" + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default User Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var defaultCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { sharedCipher.Id }, + new[] { sharedCollection.Id }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { defaultCipher.Id }, + new[] { defaultUserCollection.Id }); + + // Act + var result = await collectionCipherRepository.GetManySharedByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Single(result); + Assert.Equal(sharedCollection.Id, result.First().CollectionId); + Assert.DoesNotContain(result, cc => cc.CollectionId == defaultUserCollection.Id); + + // Cleanup + await cipherRepository.DeleteAsync(sharedCipher); + await cipherRepository.DeleteAsync(defaultCipher); + await collectionRepository.DeleteAsync(sharedCollection); + await collectionRepository.DeleteAsync(defaultUserCollection); + await organizationRepository.DeleteAsync(organization); + } +} diff --git a/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql new file mode 100644 index 0000000000..d29856ca00 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql @@ -0,0 +1,30 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END +GO + +-- Update [IX_Collection_OrganizationId_IncludeAll] index to include [Type] column +IF EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_Collection_OrganizationId_IncludeAll' AND object_id = OBJECT_ID('[dbo].[Collection]')) +BEGIN + DROP INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection] +END +GO + +CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] + ON [dbo].[Collection]([OrganizationId] ASC) + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]) +GO