Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f54a77d
First pass
eliykat Dec 2, 2025
dd39159
Merge remote-tracking branch 'origin/main' into ac/pm-28555/server-crโ€ฆ
eliykat Dec 3, 2025
5fd5c51
Update EF method to use query syntax and GenerateComb
eliykat Dec 3, 2025
63b4f1b
Adjust repository methods
eliykat Dec 3, 2025
cecb022
clean up tests
eliykat Dec 3, 2025
3bbf375
fix CREATE OR ALTER
eliykat Dec 3, 2025
25189b8
set serializable transaction to avoid race condition
eliykat Dec 3, 2025
f5393fa
linting
eliykat Dec 3, 2025
1c03ef1
Configure EF to gracefully handle deadlocks
eliykat Dec 3, 2025
d001aa4
Merge remote-tracking branch 'origin/main' into ac/pm-28555/server-crโ€ฆ
eliykat Dec 4, 2025
a9b24d7
Merge remote-tracking branch 'origin/main' into ac/pm-28555/server-crโ€ฆ
eliykat Dec 17, 2025
6adb30e
Merge remote-tracking branch 'origin/main' into ac/pm-28555/server-crโ€ฆ
eliykat Dec 20, 2025
0a28dbf
First pass at unique constraint
eliykat Dec 20, 2025
184677d
add xmldoc
eliykat Dec 20, 2025
b0a7884
Undo previous changes to EF configuration
eliykat Dec 20, 2025
a3369ab
Add constraint in EF
eliykat Dec 20, 2025
868ecf0
Resolve competing cascade action: manually set FKID to null
eliykat Dec 20, 2025
ed49d7d
dotnet format
eliykat Dec 20, 2025
7577fa6
fix sql style
eliykat Dec 20, 2025
aad01ec
Remove OR ALTER from sproc
eliykat Dec 20, 2025
dc1dda6
add todo
eliykat Dec 20, 2025
8582ab3
Merge remote-tracking branch 'origin/main' into ac/pm-28555/server-crโ€ฆ
eliykat Dec 26, 2025
5c56767
Rename DefaultCollectionOwner -> DefaultCollectionOwnerId
eliykat Dec 26, 2025
e2cd3c6
EF migrations
eliykat Dec 26, 2025
afaaea0
First pass at handling existing data and duplicates
eliykat Dec 26, 2025
b6ed81e
Catch errors from all db types
eliykat Dec 27, 2025
dca7695
Fix filter in EF dbs
eliykat Dec 27, 2025
1f931bd
Fix bulk tests
eliykat Dec 27, 2025
ce7d727
Move new sproc to separate PR
eliykat Dec 27, 2025
03690cf
dotnet format
eliykat Dec 27, 2025
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
Expand Up @@ -84,7 +84,8 @@ await collectionRepository.CreateAsync(
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
Type = CollectionType.DefaultUserCollection,
DefaultCollectionOwnerId = request.OrganizationUserId
},
groups: null,
[new CollectionAccessSelection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUse
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
Type = CollectionType.DefaultUserCollection,
DefaultCollectionOwnerId = organizationUser.Id
};
var collectionUser = new CollectionAccessSelection
{
Expand Down
50 changes: 50 additions & 0 deletions src/Core/Entities/Collection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,66 @@ namespace Bit.Core.Entities;

public class Collection : ITableObject<Guid>
{
/// <summary>
/// The unique identifier for the collection.
/// </summary>
public Guid Id { get; set; }

/// <summary>
/// The identifier of the organization that owns this collection.
/// </summary>
public Guid OrganizationId { get; set; }

/// <summary>
/// The name of the collection, encrypted with the organization symmetric key.
/// </summary>
public string Name { get; set; } = null!;

/// <summary>
/// An external identifier for integration with external systems.
/// </summary>
[MaxLength(300)]
public string? ExternalId { get; set; }

/// <summary>
/// The date and time when the collection was created.
/// </summary>
public DateTime CreationDate { get; set; } = DateTime.UtcNow;

/// <summary>
/// The date and time when the collection was last revised.
/// </summary>
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;

/// <summary>
/// The type of collection - see <see cref="CollectionType"/>.
/// </summary>
public CollectionType Type { get; set; } = CollectionType.SharedCollection;

/// <summary>
/// The email address for the OrganizationUser associated with a DefaultUserCollection collection type.
/// </summary>
/// <remarks>
/// This is only populated at the time the OrganizationUser leaves or is removed from the organization.
/// It is then used as the collection name so that administrators can identify the collection.
/// It is null for all other collection types and at all other times.
/// </remarks>
public string? DefaultUserCollectionEmail { get; set; }

/// <summary>
/// The ID for the OrganizationUser associated with this default collection.
/// INTERNAL DATABASE USE ONLY - DO NOT USE - SEE REMARKS.
/// </summary>
/// <remarks>
/// This is used to enforce uniqueness so that an OrganizationUser can only have 1 default collection
/// in a given organization.
/// It should NOT be used for any other purpose, used in the application code, or exposed to the front-end.
/// In particular, refer to the <see cref="CollectionUser"/> to evaluate user assignment and permissions.
/// Only populated for collections of type DefaultUserCollection.
/// Set to null when the OrganizationUser entry is deleted.
/// </remarks>
public Guid? DefaultCollectionOwnerId { get; set; }

public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
Expand Down
10 changes: 10 additions & 0 deletions src/Core/Enums/CollectionType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

public enum CollectionType
{
/// <summary>
/// A standard collection used to share items within an organization.
/// </summary>
SharedCollection = 0,

/// <summary>
/// The default collection for an OrganizationUser, called a "My Items" collection in the product.
/// This is used by an OrganizationUser to store their items within the organization and is not
/// accessible by other members.
/// It is only created if the Organization Data Ownership policy is enabled.
/// </summary>
DefaultUserCollection = 1,
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ private static DataTable BuildCollectionsTable(SqlBulkCopy bulkCopy, IEnumerable
collectionsTable.Columns.Add(typeColumn);
var defaultUserCollectionEmailColumn = new DataColumn(nameof(collection.DefaultUserCollectionEmail), typeof(string));
collectionsTable.Columns.Add(defaultUserCollectionEmailColumn);
var defaultCollectionOwnerIdColumn = new DataColumn(nameof(collection.DefaultCollectionOwnerId), typeof(Guid));
collectionsTable.Columns.Add(defaultCollectionOwnerIdColumn);

foreach (DataColumn col in collectionsTable.Columns)
{
Expand All @@ -178,6 +180,7 @@ private static DataTable BuildCollectionsTable(SqlBulkCopy bulkCopy, IEnumerable
row[externalIdColumn] = collectionRecord.ExternalId;
row[typeColumn] = collectionRecord.Type;
row[defaultUserCollectionEmailColumn] = collectionRecord.DefaultUserCollectionEmail;
row[defaultCollectionOwnerIdColumn] = collectionRecord.DefaultCollectionOwnerId;

collectionsTable.Rows.Add(row);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,8 @@ INNER JOIN
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null

DefaultUserCollectionEmail = null,
DefaultCollectionOwnerId = orgUserId
});

collectionUsers.Add(new CollectionUser
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
๏ปฟusing Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations;

public class CollectionEntityTypeConfiguration : IEntityTypeConfiguration<Collection>
{
public void Configure(EntityTypeBuilder<Collection> builder)
{
builder
.Property(c => c.Id)
.ValueGeneratedNever();

builder
.HasIndex(c => new { c.DefaultCollectionOwnerId, c.OrganizationId, c.Type })
.IsUnique()
.HasFilter("\"Type\" = 1")
.HasDatabaseName("IX_Collection_DefaultCollectionOwnerId_OrganizationId_Type");

// Configure FK with NO ACTION delete behavior to prevent cascade conflicts
// Cleanup is handled explicitly in OrganizationUserRepository.DeleteAsync
builder
.HasOne<OrganizationUser>()
.WithMany()
.HasForeignKey(c => c.DefaultCollectionOwnerId)
.OnDelete(DeleteBehavior.NoAction);

builder.ToTable(nameof(Collection));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ await dbContext.Ciphers.Where(c => c.UserId == null && c.OrganizationId == organ
await deleteCiphersTransaction.CommitAsync();

var organizationDeleteTransaction = await dbContext.Database.BeginTransactionAsync();
await dbContext.Collections.Where(c => c.OrganizationId == organization.Id)
.ExecuteDeleteAsync();
await dbContext.AuthRequests.Where(ar => ar.OrganizationId == organization.Id)
.ExecuteDeleteAsync();
await dbContext.SsoUsers.Where(su => su.OrganizationId == organization.Id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ await dbContext.Collections
.SetProperty(c => c.Type, CollectionType.SharedCollection)
.SetProperty(c => c.RevisionDate, utcNow)
.SetProperty(c => c.DefaultUserCollectionEmail,
c => c.DefaultUserCollectionEmail == null ? email : c.DefaultUserCollectionEmail));
c => c.DefaultUserCollectionEmail == null ? email : c.DefaultUserCollectionEmail)
.SetProperty(c => c.DefaultCollectionOwnerId, (Guid?)null));

await dbContext.CollectionUsers
.Where(cu => cu.OrganizationUserId == organizationUser.Id)
Expand Down Expand Up @@ -217,6 +218,7 @@ public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds)
}
}
collection.Type = CollectionType.SharedCollection;
collection.DefaultCollectionOwnerId = null;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,8 +861,8 @@ private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(Databa
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null

DefaultUserCollectionEmail = null,
DefaultCollectionOwnerId = orgUserId
});

collectionUsers.Add(new CollectionUser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ BEGIN
SET
[DefaultUserCollectionEmail] = CASE WHEN c.[DefaultUserCollectionEmail] IS NULL THEN u.[Email] ELSE c.[DefaultUserCollectionEmail] END,
[RevisionDate] = @UtcNow,
[Type] = 0
[Type] = 0,
[DefaultCollectionOwnerId] = NULL
FROM
[dbo].[Collection] c
INNER JOIN [dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId]
Expand Down
6 changes: 6 additions & 0 deletions src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ BEGIN
WHERE
[OrganizationId] = @Id

DELETE C
FROM
[dbo].[Collection] C
WHERE
[OrganizationId] = @Id

DELETE CU
FROM
[dbo].[CollectionUser] CU
Expand Down
9 changes: 8 additions & 1 deletion src/Sql/dbo/Tables/Collection.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
[RevisionDate] DATETIME2 (7) NOT NULL,
[DefaultUserCollectionEmail] NVARCHAR(256) NULL,
[Type] TINYINT NOT NULL DEFAULT(0),
[DefaultCollectionOwnerId] UNIQUEIDENTIFIER NULL,
CONSTRAINT [PK_Collection] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_Collection_OrganizationUser] FOREIGN KEY ([DefaultCollectionOwnerId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE NO ACTION
);
GO

Expand All @@ -17,3 +19,8 @@ CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll]
INCLUDE([CreationDate], [Name], [RevisionDate], [Type]);
GO

CREATE UNIQUE NONCLUSTERED INDEX [IX_Collection_DefaultCollectionOwnerId_OrganizationId_Type]
ON [dbo].[Collection]([DefaultCollectionOwnerId], [OrganizationId], [Type])
WHERE [Type] = 1;
GO

Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ await sutProvider.GetDependency<ICollectionRepository>()
Arg.Is<Collection>(c =>
c.OrganizationId == organization.Id &&
c.Name == defaultCollectionName &&
c.Type == CollectionType.DefaultUserCollection),
c.Type == CollectionType.DefaultUserCollection &&
c.DefaultCollectionOwnerId == request.OrganizationUserId),
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
Arg.Is<IEnumerable<CollectionAccessSelection>>(access =>
access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,8 @@ await sutProvider.GetDependency<ICollectionRepository>()
Arg.Is<Collection>(c =>
c.Name == collectionName &&
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection),
c.Type == CollectionType.DefaultUserCollection &&
c.DefaultCollectionOwnerId == orgUser.Id),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Is<IEnumerable<CollectionAccessSelection>>(cu =>
cu.Single().Id == orgUser.Id &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,13 @@ IOrganizationUserRepository organizationUserRepository
Assert.NotNull(updatedCollection1);
Assert.Equal(CollectionType.SharedCollection, updatedCollection1.Type);
Assert.Equal(user1.Email, updatedCollection1.DefaultUserCollectionEmail);
Assert.Null(updatedCollection1.DefaultCollectionOwnerId);

var updatedCollection2 = await collectionRepository.GetByIdAsync(defaultUserCollection2.Id);
Assert.NotNull(updatedCollection2);
Assert.Equal(CollectionType.SharedCollection, updatedCollection2.Type);
Assert.Equal(user2.Email, updatedCollection2.DefaultUserCollectionEmail);
Assert.Null(updatedCollection2.DefaultCollectionOwnerId);
}

[DatabaseTheory, DatabaseData]
Expand Down Expand Up @@ -214,6 +216,7 @@ IOrganizationUserRepository organizationUserRepository
Assert.NotNull(updatedCollection);
Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type);
Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail);
Assert.Null(updatedCollection.DefaultCollectionOwnerId);
}


Expand Down Expand Up @@ -1355,6 +1358,7 @@ IOrganizationUserRepository organizationUserRepository
Assert.NotNull(updatedCollection);
Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type);
Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail);
Assert.Null(updatedCollection.DefaultCollectionOwnerId);
}

[DatabaseTheory, DatabaseData]
Expand Down Expand Up @@ -1412,6 +1416,7 @@ IOrganizationUserRepository organizationUserRepository
Assert.NotNull(updatedCollection);
Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type);
Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail);
Assert.Null(updatedCollection.DefaultCollectionOwnerId);
}

[DatabaseTheory, DatabaseData]
Expand Down
Loading
Loading