1
0
mirror of https://github.com/bitwarden/server synced 2026-01-27 06:43:19 +00:00

First pass at semaphore

This commit is contained in:
Thomas Rittson
2025-12-30 14:21:38 +10:00
parent 34b4dc3985
commit f069fafea1
13 changed files with 785 additions and 2 deletions

View File

@@ -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;
}

View File

@@ -65,10 +65,21 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
/// <summary>
/// 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.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns></returns>
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
/// <summary>
/// Creates default user collections for the specified organization users using bulk insert operations.
/// Inserts semaphore entries before collections to prevent duplicates.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns></returns>
Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
}

View File

@@ -368,6 +368,29 @@ public class CollectionRepository : Repository<Collection, Guid>, 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<Guid> 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<Collection, Guid>, 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<Collection, Guid>, ICollectionRep
return (collectionUsers, collections);
}
private async Task BulkInsertDefaultCollectionSemaphoresAsync(SqlConnection connection, SqlTransaction transaction, List<DefaultCollectionSemaphore> 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() { }

View File

@@ -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<DefaultCollectionSemaphore>
{
public void Configure(EntityTypeBuilder<DefaultCollectionSemaphore> 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));
}
}

View File

@@ -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<Core.Entities.DefaultCollectionSemaphore, DefaultCollectionSemaphore>()
.ForMember(dcs => dcs.Organization, opt => opt.Ignore())
.ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore())
.ReverseMap();
}
}

View File

@@ -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<Core.Entities.Collection, Collect
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 dbContext.BulkCopyAsync(semaphores);
await dbContext.BulkCopyAsync(collections);
await dbContext.BulkCopyAsync(collectionUsers);
await dbContext.SaveChangesAsync();
}
public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
// EF uses the same bulk copy approach as the main method
await UpsertDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)
{
var results = await dbContext.OrganizationUsers

View File

@@ -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<CollectionCipher> CollectionCiphers { get; set; }
public DbSet<CollectionGroup> CollectionGroups { get; set; }
public DbSet<CollectionUser> CollectionUsers { get; set; }
public DbSet<DefaultCollectionSemaphore> DefaultCollectionSemaphores { get; set; }
public DbSet<Device> Devices { get; set; }
public DbSet<EmergencyAccess> EmergencyAccesses { get; set; }
public DbSet<Event> Events { get; set; }

View File

@@ -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

View File

@@ -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
);

View File

@@ -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
{
/// <summary>
/// Test that UpsertDefaultCollectionsAsync successfully creates default collections for new users
/// </summary>
[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));
}
/// <summary>
/// Test that calling UpsertDefaultCollectionsAsync multiple times does NOT create duplicates
/// </summary>
[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<Exception>(() =>
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);
}
/// <summary>
/// Test that UpsertDefaultCollectionsBulkAsync creates semaphores before collections
/// </summary>
[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);
}
/// <summary>
/// Test that deleting an OrganizationUser cascades to DefaultCollectionSemaphore
/// </summary>
[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);
}
/// <summary>
/// Test that deleting an Organization cascades through OrganizationUser to DefaultCollectionSemaphore
/// Note: Cascade path is Organization -> OrganizationUser -> DefaultCollectionSemaphore (not direct)
/// </summary>
[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);
}
/// <summary>
/// Test that UpsertDefaultCollectionsAsync with empty user list does nothing
/// </summary>
[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<Guid>(),
"My Items");
// Assert - No collections should be created
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
Assert.Empty(collections);
}
/// <summary>
/// Test that UpsertDefaultCollectionsAsync creates CollectionUser entries with correct permissions
/// </summary>
[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);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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