1
0
mirror of https://github.com/bitwarden/server synced 2026-02-21 20:03:40 +00:00

First pass at reverting semaphore approach

This commit is contained in:
Thomas Rittson
2026-01-06 13:52:34 +10:00
parent fc351ceb60
commit 8f345e0689
29 changed files with 113 additions and 11091 deletions

View File

@@ -1,5 +0,0 @@
namespace Bit.Core.AdminConsole.Collections;
public class DuplicateDefaultCollectionException()
: Exception("A My Items collection already exists for one or more of the specified organization members.");

View File

@@ -7,21 +7,19 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
public static class CollectionUtils
{
/// <summary>
/// Arranges semaphore, Collection and CollectionUser objects to create default user collections.
/// Arranges Collection and CollectionUser objects to create default user collections.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="organizationUserIds">The IDs for organization users who need default collections.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns>A tuple containing the semaphores, collections, and collection users.</returns>
public static (IEnumerable<DefaultCollectionSemaphore> semaphores,
IEnumerable<Collection> collections,
/// <returns>A tuple containing the collections and collection users.</returns>
public static (IEnumerable<Collection> collections,
IEnumerable<CollectionUser> collectionUsers)
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName)
{
var now = DateTime.UtcNow;
var semaphores = new List<DefaultCollectionSemaphore>();
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();
@@ -29,12 +27,6 @@ public static class CollectionUtils
{
var collectionId = CoreHelpers.GenerateComb();
semaphores.Add(new DefaultCollectionSemaphore
{
OrganizationUserId = orgUserId,
CreationDate = now
});
collections.Add(new Collection
{
Id = collectionId,
@@ -57,6 +49,6 @@ public static class CollectionUtils
});
}
return (semaphores, collections, collectionUsers);
return (collections, collectionUsers);
}
}

View File

@@ -72,18 +72,9 @@ public class OrganizationDataOwnershipPolicyValidator(
return;
}
// Filter out users who already have default collections
var existingSemaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(userOrgIds);
var usersNeedingDefaultCollections = userOrgIds.Except(existingSemaphores).ToList();
if (!usersNeedingDefaultCollections.Any())
{
return;
}
await collectionRepository.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
usersNeedingDefaultCollections,
userOrgIds,
defaultCollectionName);
}
}

View File

@@ -1,7 +0,0 @@
namespace Bit.Core.Entities;
public class DefaultCollectionSemaphore
{
public Guid OrganizationUserId { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
}

View File

@@ -65,7 +65,7 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
/// <summary>
/// Creates default user collections for the specified organization users.
/// Throws an exception if any user already has a default collection for the organization.
/// Filters internally to only create collections for users who don't already have one.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
@@ -75,27 +75,11 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
/// <summary>
/// Creates default user collections for the specified organization users using bulk insert operations.
/// Use this if you need to create collections for > ~1k users.
/// Throws an exception if any user already has a default collection for the organization.
/// Filters internally to only create collections for users who don't already have one.
/// </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>
/// <remarks>
/// If any of the OrganizationUsers may already have default collections, the caller should first filter out these
/// users using GetDefaultCollectionSemaphoresAsync before calling this method.
/// </remarks>
Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
/// <summary>
/// Gets default collection semaphores for the given organizationUserIds.
/// If an organizationUserId is missing from the result set, they do not have a semaphore set.
/// </summary>
/// <param name="organizationUserIds">The organization User IDs to check semaphores for.</param>
/// <returns>Collection of organization user IDs that have default collection semaphores.</returns>
/// <remarks>
/// The semaphore table is used to ensure that an organizationUser can only have 1 default collection.
/// (That is, a user may only have 1 default collection per organization.)
/// If a semaphore is returned, that user already has a default collection for that organization.
/// </remarks>
Task<HashSet<Guid>> GetDefaultCollectionSemaphoresAsync(IEnumerable<Guid> organizationUserIds);
}

View File

@@ -1,7 +1,6 @@
using System.Data;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Bit.Core.AdminConsole.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
@@ -392,11 +391,6 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
await transaction.CommitAsync();
}
catch (Exception ex) when (DatabaseExceptionHelpers.IsDuplicateKeyException(ex))
{
await transaction.RollbackAsync();
throw new DuplicateDefaultCollectionException();
}
catch
{
await transaction.RollbackAsync();
@@ -406,97 +400,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
{
return;
}
var (semaphores, collections, collectionUsers) =
CollectionUtils.BuildDefaultUserCollections(organizationId, organizationUserIds, defaultCollectionName);
await using var connection = new SqlConnection(ConnectionString);
connection.Open();
await using var transaction = connection.BeginTransaction();
try
{
// CRITICAL: Insert semaphore entries BEFORE collections
// Database will throw on duplicate primary key (OrganizationUserId)
await BulkInsertDefaultCollectionSemaphoresAsync(connection, transaction, semaphores);
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
await transaction.CommitAsync();
}
catch (Exception ex) when (DatabaseExceptionHelpers.IsDuplicateKeyException(ex))
{
await transaction.RollbackAsync();
throw new DuplicateDefaultCollectionException();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<HashSet<Guid>> GetDefaultCollectionSemaphoresAsync(IEnumerable<Guid> organizationUserIds)
{
await using var connection = new SqlConnection(ConnectionString);
var results = await connection.QueryAsync<Guid>(
"[dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds]",
new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToHashSet();
}
private async Task BulkInsertDefaultCollectionSemaphoresAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<DefaultCollectionSemaphore> semaphores)
{
semaphores = semaphores.ToList();
if (!semaphores.Any())
{
return;
}
// Sort by composite key to reduce deadlocks
var sortedSemaphores = semaphores
.OrderBy(s => s.OrganizationUserId)
.ToList();
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity | SqlBulkCopyOptions.CheckConstraints, transaction);
bulkCopy.DestinationTableName = "[dbo].[DefaultCollectionSemaphore]";
bulkCopy.BatchSize = 500;
bulkCopy.BulkCopyTimeout = 120;
bulkCopy.EnableStreaming = true;
var dataTable = new DataTable("DefaultCollectionSemaphoreDataTable");
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[1];
keys[0] = organizationUserIdColumn;
dataTable.PrimaryKey = keys;
foreach (var semaphore in sortedSemaphores)
{
var row = dataTable.NewRow();
row[organizationUserIdColumn] = semaphore.OrganizationUserId;
row[creationDateColumn] = semaphore.CreationDate;
dataTable.Rows.Add(row);
}
await bulkCopy.WriteToServerAsync(dataTable);
// Use the stored procedure approach which handles filtering internally
await CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}
public class CollectionWithGroupsAndUsers : Collection

View File

@@ -1,23 +0,0 @@
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.OrganizationUserId });
// 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

@@ -1,19 +0,0 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class DefaultCollectionSemaphore : Core.Entities.DefaultCollectionSemaphore
{
public virtual OrganizationUser? OrganizationUser { get; set; }
}
public class DefaultCollectionSemaphoreMapperProfile : Profile
{
public DefaultCollectionSemaphoreMapperProfile()
{
CreateMap<Core.Entities.DefaultCollectionSemaphore, DefaultCollectionSemaphore>()
.ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore())
.ReverseMap();
}
}

View File

@@ -1,5 +1,4 @@
using AutoMapper;
using Bit.Core.AdminConsole.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -804,28 +803,38 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
return;
}
var (semaphores, collections, collectionUsers) =
CollectionUtils.BuildDefaultUserCollections(organizationId, organizationUserIds, defaultCollectionName);
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
// Query for users who already have default collections
var organizationUserIdsHashSet = organizationUserIds.ToHashSet();
var existingOrgUserIds = await dbContext.CollectionUsers
.Where(cu => organizationUserIdsHashSet.Contains(cu.OrganizationUserId))
.Where(cu => cu.Collection.Type == CollectionType.DefaultUserCollection)
.Where(cu => cu.Collection.OrganizationId == organizationId)
.Select(cu => cu.OrganizationUserId)
.ToListAsync();
// Filter to only users who need collections
var filteredOrgUserIds = organizationUserIds.Except(existingOrgUserIds).ToList();
if (!filteredOrgUserIds.Any())
{
return;
}
var (collections, collectionUsers) =
CollectionUtils.BuildDefaultUserCollections(organizationId, filteredOrgUserIds, defaultCollectionName);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
// CRITICAL: Insert semaphore entries BEFORE collections
// Database will throw on duplicate primary key (OrganizationUserId)
await dbContext.BulkCopyAsync(Mapper.Map<IEnumerable<DefaultCollectionSemaphore>>(semaphores));
await dbContext.BulkCopyAsync(Mapper.Map<IEnumerable<Collection>>(collections));
await dbContext.BulkCopyAsync(Mapper.Map<IEnumerable<CollectionUser>>(collectionUsers));
await transaction.CommitAsync();
}
catch (Exception ex) when (DatabaseExceptionHelpers.IsDuplicateKeyException(ex))
{
await transaction.RollbackAsync();
throw new DuplicateDefaultCollectionException();
}
catch
{
await transaction.RollbackAsync();
@@ -839,18 +848,4 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
await CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}
public async Task<HashSet<Guid>> GetDefaultCollectionSemaphoresAsync(IEnumerable<Guid> organizationUserIds)
{
var organizationUserIdsHashSet = organizationUserIds.ToHashSet();
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var result = await dbContext.DefaultCollectionSemaphores
.Where(s => organizationUserIdsHashSet.Contains(s.OrganizationUserId))
.Select(s => s.OrganizationUserId)
.ToListAsync();
return result.ToHashSet();
}
}

View File

@@ -43,7 +43,6 @@ 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

@@ -1,6 +1,6 @@
-- Creates default user collections for organization users
-- Uses semaphore table to prevent duplicate default collections at database level
-- NOTE: this MUST be executed in a single transaction to obtain semaphore protection
-- Filters out existing default collections at database level
-- NOTE: this MUST be executed in a single transaction to ensure consistency
CREATE PROCEDURE [dbo].[Collection_CreateDefaultCollections]
@OrganizationId UNIQUEIDENTIFIER,
@DefaultCollectionName VARCHAR(MAX),
@@ -11,20 +11,20 @@ BEGIN
DECLARE @Now DATETIME2(7) = GETUTCDATE()
-- Insert semaphore entries first to obtain the "lock"
-- If this fails due to duplicate key, the entire transaction will be rolled back
INSERT INTO [dbo].[DefaultCollectionSemaphore]
(
[OrganizationUserId],
[CreationDate]
)
SELECT
ids.[Id1], -- OrganizationUserId
@Now
FROM
@OrganizationUserCollectionIds ids;
-- Filter to only users who don't have default collections
SELECT ids.Id1, ids.Id2
INTO #FilteredIds
FROM @OrganizationUserCollectionIds ids
WHERE NOT EXISTS (
SELECT 1
FROM [dbo].[CollectionUser] cu
INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId
WHERE c.OrganizationId = @OrganizationId
AND c.[Type] = 1 -- CollectionType.DefaultUserCollection
AND cu.OrganizationUserId = ids.Id1
);
-- Insert collections for users who obtained semaphore entries
-- Insert collections only for users who don't have default collections yet
INSERT INTO [dbo].[Collection]
(
[Id],
@@ -37,7 +37,7 @@ BEGIN
[DefaultUserCollectionEmail]
)
SELECT
ids.[Id2], -- CollectionId
ids.Id2, -- CollectionId
@OrganizationId,
@DefaultCollectionName,
@Now,
@@ -46,7 +46,7 @@ BEGIN
NULL,
NULL
FROM
@OrganizationUserCollectionIds ids;
#FilteredIds ids;
-- Insert collection user mappings
INSERT INTO [dbo].[CollectionUser]
@@ -58,11 +58,13 @@ BEGIN
[Manage]
)
SELECT
ids.[Id2], -- CollectionId
ids.[Id1], -- OrganizationUserId
ids.Id2, -- CollectionId
ids.Id1, -- OrganizationUserId
0, -- ReadOnly = false
0, -- HidePasswords = false
1 -- Manage = true
FROM
@OrganizationUserCollectionIds ids;
#FilteredIds ids;
DROP TABLE #FilteredIds;
END

View File

@@ -1,11 +0,0 @@
-- 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]
(
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationUserId] ASC),
CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId])
REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE
);

View File

@@ -1,13 +0,0 @@
CREATE PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds]
@OrganizationUserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
[OrganizationUserId]
FROM
[dbo].[DefaultCollectionSemaphore] DCS
INNER JOIN
@OrganizationUserIds OU ON [OU].[Id] = [DCS].[OrganizationUserId]
END

View File

@@ -198,22 +198,13 @@ public class OrganizationDataOwnershipPolicyValidatorTests
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
var collectionRepository = Substitute.For<ICollectionRepository>();
// Mock GetDefaultCollectionSemaphoresAsync to return empty set (no existing collections)
collectionRepository
.GetDefaultCollectionSemaphoresAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new HashSet<Guid>());
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await collectionRepository
.Received(1)
.GetDefaultCollectionSemaphoresAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3));
// Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
.CreateDefaultCollectionsBulkAsync(
@@ -224,7 +215,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
[Theory]
[BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))]
public async Task ExecuteSideEffectsAsync_FiltersOutUsersWithExistingCollections(
public async Task ExecuteSideEffectsAsync_PassesAllUsers_RepositoryFiltersInternally(
Policy postUpdatedPolicy,
Policy? previousPolicyState,
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
@@ -241,34 +232,24 @@ public class OrganizationDataOwnershipPolicyValidatorTests
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
var collectionRepository = Substitute.For<ICollectionRepository>();
// Mock GetDefaultCollectionSemaphoresAsync to return one existing user
var existingUserId = orgPolicyDetailsList[0].OrganizationUserId;
collectionRepository
.GetDefaultCollectionSemaphoresAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([existingUserId]);
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert - Should filter out the existing user
await collectionRepository
.Received(1)
.GetDefaultCollectionSemaphoresAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3));
// Assert - Should pass all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 2 && !ids.Contains(existingUserId)),
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
_defaultUserCollectionName);
}
[Theory]
[BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))]
public async Task ExecuteSideEffectsAsync_DoesNotCallRepository_WhenAllUsersHaveExistingCollections(
public async Task ExecuteSideEffectsAsync_CallsRepositoryWithAllUsers_EvenIfAllHaveCollections(
Policy postUpdatedPolicy,
Policy? previousPolicyState,
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
@@ -285,26 +266,19 @@ public class OrganizationDataOwnershipPolicyValidatorTests
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
var collectionRepository = Substitute.For<ICollectionRepository>();
// Mock GetDefaultCollectionSemaphoresAsync to return all users
var allUserIds = orgPolicyDetailsList.Select(p => p.OrganizationUserId).ToHashSet();
collectionRepository
.GetDefaultCollectionSemaphoresAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(allUserIds);
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert - Should not call CreateDefaultCollectionsBulkAsync when all users already have collections
// Assert - Should call repository with all user IDs (repository filters internally)
await collectionRepository
.Received(1)
.GetDefaultCollectionSemaphoresAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3));
await collectionRepository
.DidNotReceive()
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
_defaultUserCollectionName);
}
private static IEnumerable<object?[]> WhenDefaultCollectionsDoesNotExistTestCases()
@@ -497,22 +471,13 @@ public class OrganizationDataOwnershipPolicyValidatorTests
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
var collectionRepository = Substitute.For<ICollectionRepository>();
// Mock GetDefaultCollectionSemaphoresAsync to return empty set (no existing collections)
collectionRepository
.GetDefaultCollectionSemaphoresAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(new HashSet<Guid>());
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await collectionRepository
.Received(1)
.GetDefaultCollectionSemaphoresAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3));
// Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
.CreateDefaultCollectionsBulkAsync(

View File

@@ -1,5 +1,4 @@

using Bit.Core.AdminConsole.Collections;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -33,13 +32,12 @@ public class CreateDefaultCollectionsBulkTests
// Assert
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds);
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_WhenCallerFiltersExisting(
public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_AutoFiltersExisting(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -66,20 +64,17 @@ public class CreateDefaultCollectionsBulkTests
var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers);
var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id).ToList();
// Act - Caller filters out existing users (new pattern)
var existingSemaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(affectedOrgUserIds);
var usersNeedingCollections = affectedOrgUserIds.Except(existingSemaphores).ToList();
await collectionRepository.CreateDefaultCollectionsBulkAsync(organization.Id, usersNeedingCollections, defaultCollectionName);
// Act - Pass all user IDs, method should auto-filter existing users
await collectionRepository.CreateDefaultCollectionsBulkAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert - All users now have exactly one collection
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, affectedOrgUsers, organization.Id);
await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds);
await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_ThrowsException_WhenUsersAlreadyHaveOne(
public async Task CreateDefaultCollectionsBulkAsync_DoesNotCreateDuplicates_WhenUsersAlreadyHaveOne(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -98,19 +93,17 @@ public class CreateDefaultCollectionsBulkTests
await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
// Act - Try to create again, should throw specific duplicate collection exception
await Assert.ThrowsAsync<DuplicateDefaultCollectionException>(() =>
collectionRepository.CreateDefaultCollectionsBulkAsync(organization.Id, affectedOrgUserIds, defaultCollectionName));
// Act - Try to create again, should silently filter and not create duplicates
await collectionRepository.CreateDefaultCollectionsBulkAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert - Original collections should remain unchanged
// Assert - Original collections should remain unchanged, still only one per user
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds);
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_ThrowsException_WhenDuplicatesNotFiltered(
public async Task CreateDefaultCollectionsBulkAsync_AutoFilters_WhenMixedUsersProvided(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -126,24 +119,24 @@ public class CreateDefaultCollectionsBulkTests
// Create collection for existing user
await collectionRepository.CreateDefaultCollectionsBulkAsync(organization.Id, [existingUser.Id], defaultCollectionName);
// Act - Try to create for both without filtering (incorrect usage)
await Assert.ThrowsAsync<DuplicateDefaultCollectionException>(() =>
collectionRepository.CreateDefaultCollectionsBulkAsync(
organization.Id,
[existingUser.Id, newUser.Id],
defaultCollectionName));
// Act - Pass both users, method should auto-filter and only create for new user
await collectionRepository.CreateDefaultCollectionsBulkAsync(
organization.Id,
[existingUser.Id, newUser.Id],
defaultCollectionName);
// Assert - Verify existing user still has collection
// Assert - Verify existing user still has exactly one collection
var existingUserCollections = await collectionRepository.GetManyByUserIdAsync(existingUser.UserId!.Value);
var existingUserDefaultCollection = existingUserCollections
.SingleOrDefault(c => c.OrganizationId == organization.Id && c.Type == CollectionType.DefaultUserCollection);
Assert.NotNull(existingUserDefaultCollection);
var existingUserDefaultCollections = existingUserCollections
.Where(c => c.OrganizationId == organization.Id && c.Type == CollectionType.DefaultUserCollection)
.ToList();
Assert.Single(existingUserDefaultCollections);
// Verify new user does NOT have collection (transaction rolled back)
// Verify new user now has collection (was created)
var newUserCollections = await collectionRepository.GetManyByUserIdAsync(newUser.UserId!.Value);
var newUserDefaultCollection = newUserCollections
.FirstOrDefault(c => c.OrganizationId == organization.Id && c.Type == CollectionType.DefaultUserCollection);
Assert.Null(newUserDefaultCollection);
.SingleOrDefault(c => c.OrganizationId == organization.Id && c.Type == CollectionType.DefaultUserCollection);
Assert.NotNull(newUserDefaultCollection);
await CleanupAsync(organizationRepository, userRepository, organization, [existingUser, newUser]);
}
@@ -181,14 +174,6 @@ public class CreateDefaultCollectionsBulkTests
return orgUser;
}
private static async Task AssertSempahoresCreatedAsync(ICollectionRepository collectionRepository,
IEnumerable<Guid> organizationUserIds)
{
var organizationUserIdHashSet = organizationUserIds.ToHashSet();
var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organizationUserIdHashSet);
Assert.Equal(organizationUserIdHashSet, semaphores);
}
private static async Task CleanupAsync(IOrganizationRepository organizationRepository,
IUserRepository userRepository,
Organization organization,

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Collections;
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
@@ -41,9 +40,6 @@ public class CreateDefaultCollectionsTests
Assert.All(defaultCollections, c => Assert.Equal("My Items", c.Item1.Name));
Assert.All(defaultCollections, c => Assert.Equal(organization.Id, c.Item1.OrganizationId));
var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser1.Id, orgUser2.Id]);
Assert.Equal([orgUser1.Id, orgUser2.Id], semaphores);
// Verify each user has exactly 1 collection with correct permissions
var orgUser1Collection = Assert.Single(defaultCollections,
c => c.Item2.Users.FirstOrDefault()?.Id == orgUser1.Id);
@@ -71,7 +67,7 @@ public class CreateDefaultCollectionsTests
/// Test that calling CreateDefaultCollectionsAsync multiple times does NOT create duplicates
/// </summary>
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsAsync_CalledMultipleTimesForSameOrganizationUser_Throws(
public async Task CreateDefaultCollectionsAsync_CalledMultipleTimesForSameOrganizationUser_DoesNotCreateDuplicates(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
@@ -88,12 +84,11 @@ public class CreateDefaultCollectionsTests
[orgUser.Id],
"My Items");
// Second call should throw specific exception and should not create duplicate
await Assert.ThrowsAsync<DuplicateDefaultCollectionException>(() =>
collectionRepository.CreateDefaultCollectionsAsync(
organization.Id,
[orgUser.Id],
"My Items Duplicate"));
// Second call should silently filter and not create duplicate
await collectionRepository.CreateDefaultCollectionsAsync(
organization.Id,
[orgUser.Id],
"My Items Duplicate");
// Assert - Only one collection should exist
var collections = await collectionRepository.GetManyByOrganizationIdAsync(organization.Id);
@@ -101,9 +96,6 @@ public class CreateDefaultCollectionsTests
Assert.Single(defaultCollections);
var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]);
Assert.Equal([orgUser.Id], semaphores);
var access = await collectionRepository.GetManyUsersByIdAsync(defaultCollections.Single().Id);
var userAccess = Assert.Single(access);
Assert.Equal(orgUser.Id, userAccess.Id);

View File

@@ -1,72 +0,0 @@
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
/// <summary>
/// Tests for DefaultCollectionSemaphore table behavior including cascade deletions
/// </summary>
public class DefaultCollectionSemaphoreTests
{
[Theory, DatabaseData]
public async Task DeleteOrganizationUser_CascadeDeletesSemaphore(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
await collectionRepository.CreateDefaultCollectionsAsync(
organization.Id,
[orgUser.Id],
"My Items");
// Verify semaphore exists
var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]);
Assert.Single(semaphoreBefore, s => s == orgUser.Id);
// Act - Delete organization user
await organizationUserRepository.DeleteAsync(orgUser);
// Assert - Semaphore should be cascade deleted
var semaphoreAfter = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]);
Assert.Empty(semaphoreAfter);
}
/// <summary>
/// Test that deleting an Organization cascades through OrganizationUser to DefaultCollectionSemaphore
/// Note: Cascade path is Organization -> OrganizationUser -> DefaultCollectionSemaphore (not direct)
/// </summary>
[Theory, DatabaseData]
public async Task DeleteOrganization_CascadeDeletesSemaphore_ThroughOrganizationUser(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
await collectionRepository.CreateDefaultCollectionsAsync(
organization.Id,
[orgUser.Id],
"My Items");
// Verify semaphore exists
var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]);
Assert.Single(semaphoreBefore, s => s == orgUser.Id);
// 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 collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]);
Assert.Empty(semaphoreAfter);
}
}

View File

@@ -1,31 +0,0 @@
-- 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]
(
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationUserId] ASC),
CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId])
REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE
);
END
GO
-- Create stored procedure to read semaphores by OrganizationUserId
CREATE OR ALTER PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds]
@OrganizationUserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
[OrganizationUserId]
FROM
[dbo].[DefaultCollectionSemaphore] DCS
INNER JOIN
@OrganizationUserIds OU ON [OU].[Id] = [DCS].[OrganizationUserId]
END
GO

View File

@@ -1,6 +1,6 @@
-- Creates default user collections for organization users
-- Uses semaphore table to prevent duplicate default collections at database level
-- NOTE: this MUST be executed in a single transaction to obtain semaphore protection
-- Filters out existing default collections at database level
-- NOTE: this MUST be executed in a single transaction to ensure consistency
CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateDefaultCollections]
@OrganizationId UNIQUEIDENTIFIER,
@DefaultCollectionName VARCHAR(MAX),
@@ -11,20 +11,20 @@ BEGIN
DECLARE @Now DATETIME2(7) = GETUTCDATE()
-- Insert semaphore entries first to obtain the "lock"
-- If this fails due to duplicate key, the entire transaction will be rolled back
INSERT INTO [dbo].[DefaultCollectionSemaphore]
(
[OrganizationUserId],
[CreationDate]
)
SELECT
ids.[Id1], -- OrganizationUserId
@Now
FROM
@OrganizationUserCollectionIds ids;
-- Filter to only users who don't have default collections
SELECT ids.Id1, ids.Id2
INTO #FilteredIds
FROM @OrganizationUserCollectionIds ids
WHERE NOT EXISTS (
SELECT 1
FROM [dbo].[CollectionUser] cu
INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId
WHERE c.OrganizationId = @OrganizationId
AND c.[Type] = 1 -- CollectionType.DefaultUserCollection
AND cu.OrganizationUserId = ids.Id1
);
-- Insert collections for users who obtained semaphore entries
-- Insert collections only for users who don't have default collections yet
INSERT INTO [dbo].[Collection]
(
[Id],
@@ -37,7 +37,7 @@ BEGIN
[DefaultUserCollectionEmail]
)
SELECT
ids.[Id2], -- CollectionId
ids.Id2, -- CollectionId
@OrganizationId,
@DefaultCollectionName,
@Now,
@@ -46,7 +46,7 @@ BEGIN
NULL,
NULL
FROM
@OrganizationUserCollectionIds ids;
#FilteredIds ids;
-- Insert collection user mappings
INSERT INTO [dbo].[CollectionUser]
@@ -58,12 +58,14 @@ BEGIN
[Manage]
)
SELECT
ids.[Id2], -- CollectionId
ids.[Id1], -- OrganizationUserId
ids.Id2, -- CollectionId
ids.Id1, -- OrganizationUserId
0, -- ReadOnly = false
0, -- HidePasswords = false
1 -- Manage = true
FROM
@OrganizationUserCollectionIds ids;
#FilteredIds ids;
DROP TABLE #FilteredIds;
END
GO

View File

@@ -1,26 +0,0 @@
-- Populate DefaultCollectionSemaphore from existing Type=1 (DefaultUserCollection) collections
-- This migration is idempotent and can be run multiple times safely
INSERT INTO [dbo].[DefaultCollectionSemaphore]
(
[OrganizationUserId],
[CreationDate]
)
SELECT DISTINCT
cu.[OrganizationUserId],
GETUTCDATE()
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.[OrganizationUserId] = cu.[OrganizationUserId]
);
GO

View File

@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class DefaultCollectionSemaphore : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DefaultCollectionSemaphore",
columns: table => new
{
OrganizationUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreationDate = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DefaultCollectionSemaphore", x => x.OrganizationUserId);
table.ForeignKey(
name: "FK_DefaultCollectionSemaphore_OrganizationUser_OrganizationUser~",
column: x => x.OrganizationUserId,
principalTable: "OrganizationUser",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DefaultCollectionSemaphore");
}
}

View File

@@ -69,19 +69,6 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("OrganizationMemberBaseDetails");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.Property<Guid>("OrganizationUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.HasKey("OrganizationUserId");
b.ToTable("DefaultCollectionSemaphore", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
{
b.Property<Guid>("Id")
@@ -2617,17 +2604,6 @@ namespace Bit.MySqlMigrations.Migrations
b.HasDiscriminator().HasValue("user_service_account");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser")
.WithMany()
.HasForeignKey("OrganizationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationUser");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

View File

@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class DefaultCollectionSemaphore : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DefaultCollectionSemaphore",
columns: table => new
{
OrganizationUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DefaultCollectionSemaphore", x => x.OrganizationUserId);
table.ForeignKey(
name: "FK_DefaultCollectionSemaphore_OrganizationUser_OrganizationUse~",
column: x => x.OrganizationUserId,
principalTable: "OrganizationUser",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DefaultCollectionSemaphore");
}
}

View File

@@ -70,19 +70,6 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("OrganizationMemberBaseDetails");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.Property<Guid>("OrganizationUserId")
.HasColumnType("uuid");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.HasKey("OrganizationUserId");
b.ToTable("DefaultCollectionSemaphore", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
{
b.Property<Guid>("Id")
@@ -2623,17 +2610,6 @@ namespace Bit.PostgresMigrations.Migrations
b.HasDiscriminator().HasValue("user_service_account");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser")
.WithMany()
.HasForeignKey("OrganizationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationUser");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

View File

@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class DefaultCollectionSemaphore : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DefaultCollectionSemaphore",
columns: table => new
{
OrganizationUserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreationDate = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DefaultCollectionSemaphore", x => x.OrganizationUserId);
table.ForeignKey(
name: "FK_DefaultCollectionSemaphore_OrganizationUser_OrganizationUserId",
column: x => x.OrganizationUserId,
principalTable: "OrganizationUser",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DefaultCollectionSemaphore");
}
}

View File

@@ -64,19 +64,6 @@ namespace Bit.SqliteMigrations.Migrations
b.ToTable("OrganizationMemberBaseDetails");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.Property<Guid>("OrganizationUserId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationDate")
.HasColumnType("TEXT");
b.HasKey("OrganizationUserId");
b.ToTable("DefaultCollectionSemaphore", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b =>
{
b.Property<Guid>("Id")
@@ -2606,17 +2593,6 @@ namespace Bit.SqliteMigrations.Migrations
b.HasDiscriminator().HasValue("user_service_account");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.DefaultCollectionSemaphore", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser")
.WithMany()
.HasForeignKey("OrganizationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationUser");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")