Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f069faf
First pass at semaphore
eliykat Dec 30, 2025
a91f096
Make sqlbulkcopy check constraints
eliykat Dec 30, 2025
31eebe2
Update existing reads to use semaphore
eliykat Dec 31, 2025
e67727b
Update naming in existing bulk tests
eliykat Dec 31, 2025
ae9d18a
Add read sproc for semaphores
eliykat Dec 31, 2025
a53bb23
Add tests for cascade delete behavior
eliykat Dec 31, 2025
9cc89eb
Update existing bulk tests to assert semaphores
eliykat Dec 31, 2025
843fd7a
Fix validator using wrong method
eliykat Dec 31, 2025
90f2e2b
Rename upsert -> create to reflect behavior
eliykat Dec 31, 2025
7aef3da
Refine tests (failing)
eliykat Dec 31, 2025
53bc100
Assert semaphores in Create tests
eliykat Dec 31, 2025
36aad90
Explicit transaction for mssql
eliykat Dec 31, 2025
f668a0c
Fix sproc name
eliykat Dec 31, 2025
7ea237f
Remove redundant OrganizationId column; remove private read method usโ€ฆ
eliykat Dec 31, 2025
a6fcee7
Generate EF migrations
eliykat Dec 31, 2025
21d7276
Move filtering out of db layer
eliykat Jan 1, 2026
6bcc17f
Update existing code paths to use new method
eliykat Jan 1, 2026
8212af5
dotnet format
eliykat Jan 1, 2026
a9e8409
DRY arrangement code between repositories
eliykat Jan 1, 2026
ebbdc94
Return uniform error
eliykat Jan 1, 2026
897719e
Move semaphore into helper method for consistent creation date
eliykat Jan 1, 2026
38bab44
dotnet format
eliykat Jan 1, 2026
2bf8487
local code review feedback
eliykat Jan 1, 2026
8ab5ad1
Merge remote-tracking branch 'origin/main' into ac/pm-28555/add-semapโ€ฆ
eliykat Jan 1, 2026
415182a
Fix test
eliykat Jan 1, 2026
8427367
Use guid array instead of json
eliykat Jan 1, 2026
5180092
Trigger code review
eliykat Jan 2, 2026
0face3a
Add explicit transaction in EF
eliykat Jan 2, 2026
1a42f6f
Add general catch block
eliykat Jan 2, 2026
c84dd78
Rethrow exception
eliykat Jan 2, 2026
aa1c0a4
Rui PR feedback
eliykat Jan 3, 2026
2b7caf1
Use TwoGuidIdArray
eliykat Jan 3, 2026
2fc0257
Move transaction up to code layer
eliykat Jan 3, 2026
fc351ce
Rethrow exception
eliykat Jan 3, 2026
8f345e0
First pass at reverting semaphore approach
eliykat Jan 6, 2026
91c51fd
Merge remote-tracking branch 'origin/main' into ac/pm-28555/add-semapโ€ฆ
eliykat Jan 6, 2026
dc5c06f
Revert Bulk implementation
eliykat Jan 6, 2026
1f4fc9b
dotnet format
eliykat Jan 6, 2026
4c32e20
Revert unnecessary ef changes
eliykat Jan 6, 2026
32dd846
Bump migration date
eliykat Jan 6, 2026
3d801be
Revert unnecessary new tests
eliykat Jan 6, 2026
9d86aff
DRY up tests
eliykat Jan 6, 2026
88036ea
local review feedback
eliykat Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;

public static class CollectionUtils
{
/// <summary>
/// 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 collections and collection users.</returns>
public static (ICollection<Collection> collections, ICollection<CollectionUser> collectionUsers)
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName)
{
var now = DateTime.UtcNow;

var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();

foreach (var orgUserId in organizationUserIds)
{
var collectionId = CoreHelpers.GenerateComb();

collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null

});

collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}

return (collections, collectionUsers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
Expand Down Expand Up @@ -79,19 +77,10 @@ private async Task CreateDefaultCollectionsAsync(AutomaticallyConfirmOrganizatio
return;
}

await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
await collectionRepository.CreateDefaultCollectionsAsync(
request.Organization!.Id,
[request.OrganizationUser!.Id],
request.DefaultUserCollectionName);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
Expand Down Expand Up @@ -292,21 +291,10 @@ private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUse
return;
}

var defaultCollection = new Collection
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
};
var collectionUser = new CollectionAccessSelection
{
Id = organizationUser.Id,
ReadOnly = false,
HidePasswords = false,
Manage = true
};

await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
await _collectionRepository.CreateDefaultCollectionsAsync(
organizationUser.OrganizationId,
[organizationUser.Id],
defaultUserCollectionName);
}

/// <summary>
Expand Down Expand Up @@ -337,6 +325,6 @@ private async Task CreateManyDefaultCollectionsAsync(Guid organizationId,
return;
}

await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpda
var userOrgIds = requirements
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
.Where(request => request.ShouldCreateDefaultCollection)
.Select(request => request.OrganizationUserId);
.Select(request => request.OrganizationUserId)
.ToList();

if (!userOrgIds.Any())
{
return;
}

await collectionRepository.UpsertDefaultCollectionsAsync(
await collectionRepository.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
userOrgIds,
defaultCollectionName);
Expand Down
17 changes: 14 additions & 3 deletions src/Core/Repositories/ICollectionRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,22 @@ Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> col
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);

/// <summary>
/// Creates default user collections for the specified organization users if they do not already have one.
/// Creates default user collections for the specified organization users.
/// 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>
/// <returns></returns>
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);

/// <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.
/// 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>
Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);

}
15 changes: 15 additions & 0 deletions src/Infrastructure.Dapper/DapperHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ public static DataTable ToGuidIdArrayTVP(this IEnumerable<Guid> ids)
return ids.ToArrayTVP("GuidId");
}

public static DataTable ToTwoGuidIdArrayTVP(this IEnumerable<(Guid id1, Guid id2)> values)
{
var table = new DataTable();
table.SetTypeName("[dbo].[TwoGuidIdArray]");
table.Columns.Add("Id1", typeof(Guid));
table.Columns.Add("Id2", typeof(Guid));

foreach (var value in values)
{
table.Rows.Add(value.id1, value.id2);
}

return table;
}

public static DataTable ToArrayTVP<T>(this IEnumerable<T> values, string columnName)
{
var table = new DataTable();
Expand Down
82 changes: 44 additions & 38 deletions src/Infrastructure.Dapper/Repositories/CollectionRepository.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
๏ปฟusing System.Data;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
Expand Down Expand Up @@ -360,7 +361,45 @@ public async Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(
}
}

public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
{
return;
}

var organizationUserCollectionIds = organizationUserIds
.Select(ou => (ou, CoreHelpers.GenerateComb()))
.ToTwoGuidIdArrayTVP();

await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction();

try
{
await connection.ExecuteAsync(
"[dbo].[Collection_CreateDefaultCollections]",
new
{
OrganizationId = organizationId,
DefaultCollectionName = defaultCollectionName,
OrganizationUserCollectionIds = organizationUserCollectionIds
},
commandType: CommandType.StoredProcedure,
transaction: transaction);

await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}

public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
Expand All @@ -377,7 +416,8 @@ public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable

var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);

var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
var (collections, collectionUsers) =
CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);

if (!collectionUsers.Any() || !collections.Any())
{
Expand All @@ -387,11 +427,11 @@ public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);

transaction.Commit();
await transaction.CommitAsync();
}
catch
{
transaction.Rollback();
await transaction.RollbackAsync();
throw;
}
}
Expand Down Expand Up @@ -421,40 +461,6 @@ INNER JOIN
return organizationUserIds.ToHashSet();
}

private (List<CollectionUser> collectionUser, List<Collection> collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable<Guid> missingDefaultCollectionUserIds, string defaultCollectionName)
{
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();

foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = CoreHelpers.GenerateComb();

collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null

});

collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}

return (collectionUsers, collections);
}

public class CollectionWithGroupsAndUsers : Collection
{
public CollectionWithGroupsAndUsers() { }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
๏ปฟusing AutoMapper;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
using LinqToDB.EntityFrameworkCore;
Expand Down Expand Up @@ -794,7 +794,7 @@ private static async Task ReplaceCollectionUsersAsync(DatabaseContext dbContext,
// SaveChangesAsync is expected to be called outside this method
}

public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
Expand All @@ -808,15 +808,15 @@ public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable
var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(dbContext, organizationId);
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);

var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
var (collections, collectionUsers) = CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);

if (!collectionUsers.Any() || !collections.Any())
if (!collections.Any() || !collectionUsers.Any())
{
return;
}

await dbContext.BulkCopyAsync(collections);
await dbContext.BulkCopyAsync(collectionUsers);
await dbContext.BulkCopyAsync(Mapper.Map<IEnumerable<Collection>>(collections));
await dbContext.BulkCopyAsync(Mapper.Map<IEnumerable<CollectionUser>>(collectionUsers));

await dbContext.SaveChangesAsync();
}
Expand Down Expand Up @@ -844,37 +844,7 @@ private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(Databa
return results.ToHashSet();
}

private (List<CollectionUser> collectionUser, List<Collection> collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable<Guid> missingDefaultCollectionUserIds, string defaultCollectionName)
{
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();

foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = CoreHelpers.GenerateComb();

collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null

});

collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}

return (collectionUsers, collections);
}
public Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName) =>
CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}
Loading
Loading