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:
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class DefaultCollectionSemaphore
|
||||
{
|
||||
public Guid OrganizationUserId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user