From f069fafea1e9cbcd26862d4c14cf1a7ffb5efe4f Mon Sep 17 00:00:00 2001 From: Thomas Rittson Date: Tue, 30 Dec 2025 14:21:38 +1000 Subject: [PATCH] First pass at semaphore --- .../Entities/DefaultCollectionSemaphore.cs | 8 + .../Repositories/ICollectionRepository.cs | 11 + .../Repositories/CollectionRepository.cs | 84 ++++ ...lectionSemaphoreEntityTypeConfiguration.cs | 31 ++ .../Models/DefaultCollectionSemaphore.cs | 21 + .../Repositories/CollectionRepository.cs | 18 + .../Repositories/DatabaseContext.cs | 3 +- .../Collection_UpsertDefaultCollections.sql | 86 ++++ .../Tables/DefaultCollectionSemaphore.sql | 12 + ...RepositoryUpsertDefaultCollectionsTests.cs | 378 ++++++++++++++++++ ...25-12-30_00_DefaultCollectionSemaphore.sql | 23 ++ ...01_Collection_UpsertDefaultCollections.sql | 83 ++++ ..._02_PopulateDefaultCollectionSemaphore.sql | 29 ++ 13 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 src/Core/Entities/DefaultCollectionSemaphore.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs create mode 100644 src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql create mode 100644 src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryUpsertDefaultCollectionsTests.cs create mode 100644 util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql create mode 100644 util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql create mode 100644 util/Migrator/DbScripts/2025-12-30_02_PopulateDefaultCollectionSemaphore.sql diff --git a/src/Core/Entities/DefaultCollectionSemaphore.cs b/src/Core/Entities/DefaultCollectionSemaphore.cs new file mode 100644 index 0000000000..808227e61e --- /dev/null +++ b/src/Core/Entities/DefaultCollectionSemaphore.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Entities; + +public class DefaultCollectionSemaphore +{ + public Guid OrganizationId { get; set; } + public Guid OrganizationUserId { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; +} diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index f86147ca7d..6bf1839bc0 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -65,10 +65,21 @@ public interface ICollectionRepository : IRepository /// /// Creates default user collections for the specified organization users if they do not already have one. + /// Uses the stored procedure approach with semaphore-based duplicate prevention. /// /// The Organization ID. /// The Organization User IDs to create default collections for. /// The encrypted string to use as the default collection name. /// Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + + /// + /// Creates default user collections for the specified organization users using bulk insert operations. + /// Inserts semaphore entries before collections to prevent duplicates. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + /// + Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 9985b41d56..edc238add6 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -368,6 +368,29 @@ public class CollectionRepository : Repository, ICollectionRep return; } + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + var organizationUserIdsJson = JsonSerializer.Serialize(organizationUserIds); + await connection.ExecuteAsync( + "[dbo].[Collection_UpsertDefaultCollections]", + new + { + OrganizationId = organizationId, + DefaultCollectionName = defaultCollectionName, + OrganizationUserIdsJson = organizationUserIdsJson + }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + { + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) + { + return; + } + await using var connection = new SqlConnection(ConnectionString); connection.Open(); await using var transaction = connection.BeginTransaction(); @@ -384,6 +407,17 @@ public class CollectionRepository : Repository, ICollectionRep return; } + // CRITICAL: Insert semaphore entries BEFORE collections + // TODO: this will result in a creation date of the semaphore AFTER that of the collection, which is weird + var now = DateTime.UtcNow; + var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore + { + OrganizationId = organizationId, + OrganizationUserId = c.OrganizationUserId, + CreationDate = now + }).ToList(); + + await BulkInsertDefaultCollectionSemaphoresAsync(connection, transaction, semaphores); await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); @@ -455,6 +489,56 @@ public class CollectionRepository : Repository, ICollectionRep return (collectionUsers, collections); } + private async Task BulkInsertDefaultCollectionSemaphoresAsync(SqlConnection connection, SqlTransaction transaction, List semaphores) + { + if (!semaphores.Any()) + { + return; + } + + // Sort by composite key to reduce deadlocks + var sortedSemaphores = semaphores + .OrderBy(s => s.OrganizationId) + .ThenBy(s => s.OrganizationUserId) + .ToList(); + + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[DefaultCollectionSemaphore]"; + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + + var dataTable = new DataTable("DefaultCollectionSemaphoreDataTable"); + + var organizationIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationId), typeof(Guid)); + dataTable.Columns.Add(organizationIdColumn); + var organizationUserIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationUserId), typeof(Guid)); + dataTable.Columns.Add(organizationUserIdColumn); + var creationDateColumn = new DataColumn(nameof(DefaultCollectionSemaphore.CreationDate), typeof(DateTime)); + dataTable.Columns.Add(creationDateColumn); + + foreach (DataColumn col in dataTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[2]; + keys[0] = organizationIdColumn; + keys[1] = organizationUserIdColumn; + dataTable.PrimaryKey = keys; + + foreach (var semaphore in sortedSemaphores) + { + var row = dataTable.NewRow(); + row[organizationIdColumn] = semaphore.OrganizationId; + row[organizationUserIdColumn] = semaphore.OrganizationUserId; + row[creationDateColumn] = semaphore.CreationDate; + dataTable.Rows.Add(row); + } + + await bulkCopy.WriteToServerAsync(dataTable); + } + public class CollectionWithGroupsAndUsers : Collection { public CollectionWithGroupsAndUsers() { } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs new file mode 100644 index 0000000000..33d59bae4a --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs @@ -0,0 +1,31 @@ +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations; + +public class DefaultCollectionSemaphoreEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .HasKey(dcs => new { dcs.OrganizationId, dcs.OrganizationUserId }); + + // Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE) + // Organization FK uses NoAction to avoid competing cascade paths + builder + .HasOne(dcs => dcs.Organization) + .WithMany() + .HasForeignKey(dcs => dcs.OrganizationId) + .OnDelete(DeleteBehavior.NoAction); + + // OrganizationUser FK cascades deletions to ensure automatic cleanup + builder + .HasOne(dcs => dcs.OrganizationUser) + .WithMany() + .HasForeignKey(dcs => dcs.OrganizationUserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.ToTable(nameof(DefaultCollectionSemaphore)); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs new file mode 100644 index 0000000000..e900b36d15 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; + +public class DefaultCollectionSemaphore : Core.Entities.DefaultCollectionSemaphore +{ + public virtual Organization? Organization { get; set; } + public virtual OrganizationUser? OrganizationUser { get; set; } +} + +public class DefaultCollectionSemaphoreMapperProfile : Profile +{ + public DefaultCollectionSemaphoreMapperProfile() + { + CreateMap() + .ForMember(dcs => dcs.Organization, opt => opt.Ignore()) + .ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore()) + .ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 5aa156d1f8..c5a501c827 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/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.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using LinqToDB.EntityFrameworkCore; @@ -815,12 +816,29 @@ public class CollectionRepository : Repository new DefaultCollectionSemaphore + { + OrganizationId = organizationId, + OrganizationUserId = c.OrganizationUserId, + CreationDate = now + }).ToList(); + + await dbContext.BulkCopyAsync(semaphores); await dbContext.BulkCopyAsync(collections); await dbContext.BulkCopyAsync(collectionUsers); await dbContext.SaveChangesAsync(); } + public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + { + // EF uses the same bulk copy approach as the main method + await UpsertDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName); + } + private async Task> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId) { var results = await dbContext.OrganizationUsers diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index b748a26db2..f48bbc6c24 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using DP = Microsoft.AspNetCore.DataProtection; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public class DatabaseContext : DbContext @@ -45,6 +43,7 @@ public class DatabaseContext : DbContext public DbSet CollectionCiphers { get; set; } public DbSet CollectionGroups { get; set; } public DbSet CollectionUsers { get; set; } + public DbSet DefaultCollectionSemaphores { get; set; } public DbSet Devices { get; set; } public DbSet EmergencyAccesses { get; set; } public DbSet Events { get; set; } diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql new file mode 100644 index 0000000000..b0adce75c1 --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql @@ -0,0 +1,86 @@ +-- Creates default user collections for organization users +-- Uses semaphore table to prevent duplicate default collections at database level +-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE) +-- Organization FK uses NoAction to avoid competing cascade paths +CREATE PROCEDURE [dbo].[Collection_UpsertDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER, + @DefaultCollectionName VARCHAR(MAX), + @OrganizationUserIdsJson NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + -- Parse JSON once into table variable with pre-generated collection IDs + DECLARE @OrganizationUserIds TABLE + ( + [OrganizationUserId] UNIQUEIDENTIFIER, + [CollectionId] UNIQUEIDENTIFIER + ); + + INSERT INTO @OrganizationUserIds + ( + [OrganizationUserId], + [CollectionId] + ) + SELECT + CAST([value] AS UNIQUEIDENTIFIER), + NEWID() + FROM + OPENJSON(@OrganizationUserIdsJson); + + -- Insert semaphore entries first to obtain the "lock" + INSERT INTO [dbo].[DefaultCollectionSemaphore] + ( + [OrganizationId], + [OrganizationUserId], + [CreationDate] + ) + SELECT + @OrganizationId, + ou.[OrganizationUserId], + GETUTCDATE() + FROM + @OrganizationUserIds ou; + + -- Insert collections for users who obtained semaphore entries + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [CreationDate], + [RevisionDate], + [Type], + [ExternalId], + [DefaultUserCollectionEmail] + ) + SELECT + ou.[CollectionId], + @OrganizationId, + @DefaultCollectionName, + GETUTCDATE(), + GETUTCDATE(), + 1, -- CollectionType.DefaultUserCollection + NULL, + NULL + FROM + @OrganizationUserIds ou; + + -- Insert collection user mappings + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + ou.[CollectionId], + ou.[OrganizationUserId], + 0, -- ReadOnly = false + 0, -- HidePasswords = false + 1 -- Manage = true + FROM + @OrganizationUserIds ou; +END diff --git a/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql b/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql new file mode 100644 index 0000000000..34f81f237c --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql @@ -0,0 +1,12 @@ +-- Semaphore table to prevent duplicate default collections per organization user +-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE) +-- OrganizationId FK has NO ACTION to avoid competing cascade paths +CREATE TABLE [dbo].[DefaultCollectionSemaphore] +( + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationUserId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationId] ASC, [OrganizationUserId] ASC), + CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades + CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE -- Cascades from OrganizationUser deletion +); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryUpsertDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryUpsertDefaultCollectionsTests.cs new file mode 100644 index 0000000000..54ae9cb19d --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryUpsertDefaultCollectionsTests.cs @@ -0,0 +1,378 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; + +public class CollectionRepositoryUpsertDefaultCollectionsTests +{ + /// + /// Test that UpsertDefaultCollectionsAsync successfully creates default collections for new users + /// + [DatabaseTheory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_CreatesDefaultCollections_Success( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + // Act + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser1.Id, orgUser2.Id }, + "My Items"); + + // Assert + var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id); + var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList(); + + Assert.Equal(2, defaultCollections.Count); + Assert.All(defaultCollections, c => Assert.Equal("My Items", c.Name)); + Assert.All(defaultCollections, c => Assert.Equal(organization.Id, c.OrganizationId)); + } + + /// + /// Test that calling UpsertDefaultCollectionsAsync multiple times does NOT create duplicates + /// + [DatabaseTheory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_CalledMultipleTimes_DoesNotCreateDuplicates( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + // Act - Call twice + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items"); + + // Second call should not create duplicate + await Assert.ThrowsAnyAsync(() => + collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items")); + + // Assert - Only one collection should exist + var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id); + var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList(); + + Assert.Single(defaultCollections); + } + + /// + /// Test that UpsertDefaultCollectionsBulkAsync creates semaphores before collections + /// + [DatabaseTheory, DatabaseData] + public async Task UpsertDefaultCollectionsBulkAsync_CreatesSemaphoresBeforeCollections_Success( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, + DatabaseContext databaseContext) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + // Act + await collectionRepository.UpsertDefaultCollectionsBulkAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items"); + + // Assert - Verify semaphore was created + var semaphore = await databaseContext.DefaultCollectionSemaphores + .FirstOrDefaultAsync(s => s.OrganizationId == organization.Id && s.OrganizationUserId == orgUser.Id); + + Assert.NotNull(semaphore); + Assert.Equal(organization.Id, semaphore.OrganizationId); + Assert.Equal(orgUser.Id, semaphore.OrganizationUserId); + + // Verify collection was created + var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id); + var defaultCollections = collections.Where(c => c.Type == CollectionType.DefaultUserCollection).ToList(); + + Assert.Single(defaultCollections); + } + + /// + /// Test that deleting an OrganizationUser cascades to DefaultCollectionSemaphore + /// + [DatabaseTheory, DatabaseData] + public async Task DeleteOrganizationUser_CascadesToSemaphore_Success( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, + DatabaseContext databaseContext) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items"); + + // Verify semaphore exists + var semaphoreBefore = await databaseContext.DefaultCollectionSemaphores + .FirstOrDefaultAsync(s => s.OrganizationUserId == orgUser.Id); + Assert.NotNull(semaphoreBefore); + + // Act - Delete organization user + await organizationUserRepository.DeleteAsync(orgUser); + + // Assert - Semaphore should be cascade deleted + var semaphoreAfter = await databaseContext.DefaultCollectionSemaphores + .FirstOrDefaultAsync(s => s.OrganizationUserId == orgUser.Id); + Assert.Null(semaphoreAfter); + } + + /// + /// Test that deleting an Organization cascades through OrganizationUser to DefaultCollectionSemaphore + /// Note: Cascade path is Organization -> OrganizationUser -> DefaultCollectionSemaphore (not direct) + /// + [DatabaseTheory, DatabaseData] + public async Task DeleteOrganization_CascadesThroughOrganizationUser_Success( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository, + DatabaseContext databaseContext) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items"); + + // Verify semaphore exists + var semaphoreBefore = await databaseContext.DefaultCollectionSemaphores + .FirstOrDefaultAsync(s => s.OrganizationId == organization.Id); + Assert.NotNull(semaphoreBefore); + + // Act - Delete organization (which cascades to OrganizationUser, which cascades to semaphore) + await organizationRepository.DeleteAsync(organization); + + // Assert - Semaphore should be cascade deleted via OrganizationUser + var semaphoreAfter = await databaseContext.DefaultCollectionSemaphores + .FirstOrDefaultAsync(s => s.OrganizationId == organization.Id); + Assert.Null(semaphoreAfter); + } + + /// + /// Test that UpsertDefaultCollectionsAsync with empty user list does nothing + /// + [DatabaseTheory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_WithEmptyList_DoesNothing( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + // Act + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + Array.Empty(), + "My Items"); + + // Assert - No collections should be created + var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id); + Assert.Empty(collections); + } + + /// + /// Test that UpsertDefaultCollectionsAsync creates CollectionUser entries with correct permissions + /// + [DatabaseTheory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_CreatesCollectionUsersWithCorrectPermissions( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + // Act + await collectionRepository.UpsertDefaultCollectionsAsync( + organization.Id, + new[] { orgUser.Id }, + "My Items"); + + // Assert + var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id); + var defaultCollection = collections.First(c => c.Type == CollectionType.DefaultUserCollection); + + var collectionUsers = await collectionRepository.GetManyUsersByIdAsync(defaultCollection.Id); + var collectionUser = collectionUsers.Single(); + + Assert.Equal(orgUser.Id, collectionUser.Id); + Assert.False(collectionUser.ReadOnly); + Assert.False(collectionUser.HidePasswords); + Assert.True(collectionUser.Manage); + } +} diff --git a/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql b/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql new file mode 100644 index 0000000000..421c6e2388 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql @@ -0,0 +1,23 @@ +-- Create DefaultCollectionSemaphore table +-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE) +-- OrganizationId FK has NO ACTION to avoid competing cascade paths +IF OBJECT_ID('[dbo].[DefaultCollectionSemaphore]') IS NULL +BEGIN + CREATE TABLE [dbo].[DefaultCollectionSemaphore] + ( + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [OrganizationUserId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED + ( + [OrganizationId] ASC, + [OrganizationUserId] ASC + ), + CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) + REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades + CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) + REFERENCES [dbo].[OrganizationUser] ([Id]) + ON DELETE CASCADE -- Cascades from OrganizationUser deletion + ); +END +GO diff --git a/util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql b/util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql new file mode 100644 index 0000000000..e773920442 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql @@ -0,0 +1,83 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpsertDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER, + @DefaultCollectionName VARCHAR(MAX), + @OrganizationUserIdsJson NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + -- Parse JSON once into table variable with pre-generated collection IDs + DECLARE @OrganizationUserIds TABLE + ( + [OrganizationUserId] UNIQUEIDENTIFIER, + [CollectionId] UNIQUEIDENTIFIER + ); + + INSERT INTO @OrganizationUserIds + ( + [OrganizationUserId], + [CollectionId] + ) + SELECT + CAST([value] AS UNIQUEIDENTIFIER), + NEWID() + FROM + OPENJSON(@OrganizationUserIdsJson); + + -- Insert semaphore entries first to obtain the "lock" + INSERT INTO [dbo].[DefaultCollectionSemaphore] + ( + [OrganizationId], + [OrganizationUserId], + [CreationDate] + ) + SELECT + @OrganizationId, + ou.[OrganizationUserId], + GETUTCDATE() + FROM + @OrganizationUserIds ou; + + -- Insert collections for users who obtained semaphore entries + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [CreationDate], + [RevisionDate], + [Type], + [ExternalId], + [DefaultUserCollectionEmail] + ) + SELECT + ou.[CollectionId], + @OrganizationId, + @DefaultCollectionName, + GETUTCDATE(), + GETUTCDATE(), + 1, -- CollectionType.DefaultUserCollection + NULL, + NULL + FROM + @OrganizationUserIds ou; + + -- Insert collection user mappings + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + ou.[CollectionId], + ou.[OrganizationUserId], + 0, -- ReadOnly = false + 0, -- HidePasswords = false + 1 -- Manage = true + FROM + @OrganizationUserIds ou; +END +GO diff --git a/util/Migrator/DbScripts/2025-12-30_02_PopulateDefaultCollectionSemaphore.sql b/util/Migrator/DbScripts/2025-12-30_02_PopulateDefaultCollectionSemaphore.sql new file mode 100644 index 0000000000..488377f0df --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-30_02_PopulateDefaultCollectionSemaphore.sql @@ -0,0 +1,29 @@ +-- Populate DefaultCollectionSemaphore from existing Type=1 (DefaultUserCollection) collections +-- This migration is idempotent and can be run multiple times safely +INSERT INTO [dbo].[DefaultCollectionSemaphore] +( + [OrganizationId], + [OrganizationUserId], + [CreationDate] +) +SELECT DISTINCT + c.[OrganizationId], + cu.[OrganizationUserId], + c.[CreationDate] +FROM + [dbo].[Collection] c +INNER JOIN + [dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId] +WHERE + c.[Type] = 1 -- CollectionType.DefaultUserCollection + AND NOT EXISTS + ( + SELECT + 1 + FROM + [dbo].[DefaultCollectionSemaphore] dcs + WHERE + dcs.[OrganizationId] = c.[OrganizationId] + AND dcs.[OrganizationUserId] = cu.[OrganizationUserId] + ); +GO