mirror of
https://github.com/bitwarden/server
synced 2026-02-08 20:50:13 +00:00
First pass at semaphore
This commit is contained in:
8
src/Core/Entities/DefaultCollectionSemaphore.cs
Normal file
8
src/Core/Entities/DefaultCollectionSemaphore.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
public class DefaultCollectionSemaphore
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public Guid OrganizationUserId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -65,10 +65,21 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
|
||||
|
||||
/// <summary>
|
||||
/// Creates default user collections for the specified organization users if they do not already have one.
|
||||
/// Uses the stored procedure approach with semaphore-based duplicate prevention.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The Organization ID.</param>
|
||||
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns></returns>
|
||||
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Creates default user collections for the specified organization users using bulk insert operations.
|
||||
/// Inserts semaphore entries before collections to prevent duplicates.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The Organization ID.</param>
|
||||
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns></returns>
|
||||
Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
|
||||
}
|
||||
|
||||
@@ -368,6 +368,29 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var organizationUserIdsJson = JsonSerializer.Serialize(organizationUserIds);
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[Collection_UpsertDefaultCollections]",
|
||||
new
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
DefaultCollectionName = defaultCollectionName,
|
||||
OrganizationUserIdsJson = organizationUserIdsJson
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
|
||||
{
|
||||
organizationUserIds = organizationUserIds.ToList();
|
||||
if (!organizationUserIds.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
connection.Open();
|
||||
await using var transaction = connection.BeginTransaction();
|
||||
@@ -384,6 +407,17 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Insert semaphore entries BEFORE collections
|
||||
// TODO: this will result in a creation date of the semaphore AFTER that of the collection, which is weird
|
||||
var now = DateTime.UtcNow;
|
||||
var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = c.OrganizationUserId,
|
||||
CreationDate = now
|
||||
}).ToList();
|
||||
|
||||
await BulkInsertDefaultCollectionSemaphoresAsync(connection, transaction, semaphores);
|
||||
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
|
||||
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
|
||||
|
||||
@@ -455,6 +489,56 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
|
||||
return (collectionUsers, collections);
|
||||
}
|
||||
|
||||
private async Task BulkInsertDefaultCollectionSemaphoresAsync(SqlConnection connection, SqlTransaction transaction, List<DefaultCollectionSemaphore> semaphores)
|
||||
{
|
||||
if (!semaphores.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by composite key to reduce deadlocks
|
||||
var sortedSemaphores = semaphores
|
||||
.OrderBy(s => s.OrganizationId)
|
||||
.ThenBy(s => s.OrganizationUserId)
|
||||
.ToList();
|
||||
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "[dbo].[DefaultCollectionSemaphore]";
|
||||
bulkCopy.BatchSize = 500;
|
||||
bulkCopy.BulkCopyTimeout = 120;
|
||||
bulkCopy.EnableStreaming = true;
|
||||
|
||||
var dataTable = new DataTable("DefaultCollectionSemaphoreDataTable");
|
||||
|
||||
var organizationIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationId), typeof(Guid));
|
||||
dataTable.Columns.Add(organizationIdColumn);
|
||||
var organizationUserIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationUserId), typeof(Guid));
|
||||
dataTable.Columns.Add(organizationUserIdColumn);
|
||||
var creationDateColumn = new DataColumn(nameof(DefaultCollectionSemaphore.CreationDate), typeof(DateTime));
|
||||
dataTable.Columns.Add(creationDateColumn);
|
||||
|
||||
foreach (DataColumn col in dataTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
}
|
||||
|
||||
var keys = new DataColumn[2];
|
||||
keys[0] = organizationIdColumn;
|
||||
keys[1] = organizationUserIdColumn;
|
||||
dataTable.PrimaryKey = keys;
|
||||
|
||||
foreach (var semaphore in sortedSemaphores)
|
||||
{
|
||||
var row = dataTable.NewRow();
|
||||
row[organizationIdColumn] = semaphore.OrganizationId;
|
||||
row[organizationUserIdColumn] = semaphore.OrganizationUserId;
|
||||
row[creationDateColumn] = semaphore.CreationDate;
|
||||
dataTable.Rows.Add(row);
|
||||
}
|
||||
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public class CollectionWithGroupsAndUsers : Collection
|
||||
{
|
||||
public CollectionWithGroupsAndUsers() { }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;
|
||||
|
||||
public class DefaultCollectionSemaphoreEntityTypeConfiguration : IEntityTypeConfiguration<DefaultCollectionSemaphore>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DefaultCollectionSemaphore> builder)
|
||||
{
|
||||
builder
|
||||
.HasKey(dcs => new { dcs.OrganizationId, dcs.OrganizationUserId });
|
||||
|
||||
// Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
// Organization FK uses NoAction to avoid competing cascade paths
|
||||
builder
|
||||
.HasOne(dcs => dcs.Organization)
|
||||
.WithMany()
|
||||
.HasForeignKey(dcs => dcs.OrganizationId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// OrganizationUser FK cascades deletions to ensure automatic cleanup
|
||||
builder
|
||||
.HasOne(dcs => dcs.OrganizationUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(dcs => dcs.OrganizationUserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.ToTable(nameof(DefaultCollectionSemaphore));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using AutoMapper;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
|
||||
public class DefaultCollectionSemaphore : Core.Entities.DefaultCollectionSemaphore
|
||||
{
|
||||
public virtual Organization? Organization { get; set; }
|
||||
public virtual OrganizationUser? OrganizationUser { get; set; }
|
||||
}
|
||||
|
||||
public class DefaultCollectionSemaphoreMapperProfile : Profile
|
||||
{
|
||||
public DefaultCollectionSemaphoreMapperProfile()
|
||||
{
|
||||
CreateMap<Core.Entities.DefaultCollectionSemaphore, DefaultCollectionSemaphore>()
|
||||
.ForMember(dcs => dcs.Organization, opt => opt.Ignore())
|
||||
.ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore())
|
||||
.ReverseMap();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
@@ -815,12 +816,29 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Insert semaphore entries BEFORE collections
|
||||
// TODO: this will result in a creation date of the semaphore AFTER that of the collection, which is weird
|
||||
var now = DateTime.UtcNow;
|
||||
var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = c.OrganizationUserId,
|
||||
CreationDate = now
|
||||
}).ToList();
|
||||
|
||||
await dbContext.BulkCopyAsync(semaphores);
|
||||
await dbContext.BulkCopyAsync(collections);
|
||||
await dbContext.BulkCopyAsync(collectionUsers);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
|
||||
{
|
||||
// EF uses the same bulk copy approach as the main method
|
||||
await UpsertDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
|
||||
}
|
||||
|
||||
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)
|
||||
{
|
||||
var results = await dbContext.OrganizationUsers
|
||||
|
||||
@@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using DP = Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class DatabaseContext : DbContext
|
||||
@@ -45,6 +43,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<CollectionCipher> CollectionCiphers { get; set; }
|
||||
public DbSet<CollectionGroup> CollectionGroups { get; set; }
|
||||
public DbSet<CollectionUser> CollectionUsers { get; set; }
|
||||
public DbSet<DefaultCollectionSemaphore> DefaultCollectionSemaphores { get; set; }
|
||||
public DbSet<Device> Devices { get; set; }
|
||||
public DbSet<EmergencyAccess> EmergencyAccesses { get; set; }
|
||||
public DbSet<Event> Events { get; set; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Creates default user collections for organization users
|
||||
-- Uses semaphore table to prevent duplicate default collections at database level
|
||||
-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
-- Organization FK uses NoAction to avoid competing cascade paths
|
||||
CREATE PROCEDURE [dbo].[Collection_UpsertDefaultCollections]
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@DefaultCollectionName VARCHAR(MAX),
|
||||
@OrganizationUserIdsJson NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
-- Parse JSON once into table variable with pre-generated collection IDs
|
||||
DECLARE @OrganizationUserIds TABLE
|
||||
(
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER,
|
||||
[CollectionId] UNIQUEIDENTIFIER
|
||||
);
|
||||
|
||||
INSERT INTO @OrganizationUserIds
|
||||
(
|
||||
[OrganizationUserId],
|
||||
[CollectionId]
|
||||
)
|
||||
SELECT
|
||||
CAST([value] AS UNIQUEIDENTIFIER),
|
||||
NEWID()
|
||||
FROM
|
||||
OPENJSON(@OrganizationUserIdsJson);
|
||||
|
||||
-- Insert semaphore entries first to obtain the "lock"
|
||||
INSERT INTO [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId],
|
||||
[OrganizationUserId],
|
||||
[CreationDate]
|
||||
)
|
||||
SELECT
|
||||
@OrganizationId,
|
||||
ou.[OrganizationUserId],
|
||||
GETUTCDATE()
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collections for users who obtained semaphore entries
|
||||
INSERT INTO [dbo].[Collection]
|
||||
(
|
||||
[Id],
|
||||
[OrganizationId],
|
||||
[Name],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[Type],
|
||||
[ExternalId],
|
||||
[DefaultUserCollectionEmail]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
@OrganizationId,
|
||||
@DefaultCollectionName,
|
||||
GETUTCDATE(),
|
||||
GETUTCDATE(),
|
||||
1, -- CollectionType.DefaultUserCollection
|
||||
NULL,
|
||||
NULL
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
|
||||
-- Insert collection user mappings
|
||||
INSERT INTO [dbo].[CollectionUser]
|
||||
(
|
||||
[CollectionId],
|
||||
[OrganizationUserId],
|
||||
[ReadOnly],
|
||||
[HidePasswords],
|
||||
[Manage]
|
||||
)
|
||||
SELECT
|
||||
ou.[CollectionId],
|
||||
ou.[OrganizationUserId],
|
||||
0, -- ReadOnly = false
|
||||
0, -- HidePasswords = false
|
||||
1 -- Manage = true
|
||||
FROM
|
||||
@OrganizationUserIds ou;
|
||||
END
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Semaphore table to prevent duplicate default collections per organization user
|
||||
-- Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE)
|
||||
-- OrganizationId FK has NO ACTION to avoid competing cascade paths
|
||||
CREATE TABLE [dbo].[DefaultCollectionSemaphore]
|
||||
(
|
||||
[OrganizationId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationId] ASC, [OrganizationUserId] ASC),
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades
|
||||
CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE -- Cascades from OrganizationUser deletion
|
||||
);
|
||||
Reference in New Issue
Block a user