diff --git a/src/Core/AdminConsole/Entities/Group.cs b/src/Core/AdminConsole/Entities/Group.cs index 4355ea9705f7..90b11ce00a34 100644 --- a/src/Core/AdminConsole/Entities/Group.cs +++ b/src/Core/AdminConsole/Entities/Group.cs @@ -16,7 +16,7 @@ public class Group : ITableObject, IExternal [MaxLength(300)] public string? ExternalId { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; - public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public void SetNewId() { diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index 3c99974ca5a3..80086e49d9dc 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -65,7 +65,7 @@ public class OrganizationUser : ITableObject, IExternal, IOrganizationUser /// /// The last date the OrganizationUser entry was updated. /// - public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; /// /// A json blob representing the of the OrganizationUser if they /// are a Custom user role (i.e. the is Custom). MAY be NULL if they are not diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs index 929c236ef2e6..68f0ad75cdcf 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs @@ -15,17 +15,20 @@ public class BulkAddCollectionAccessCommand : IBulkAddCollectionAccessCommand private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; + private readonly TimeProvider _timeProvider; public BulkAddCollectionAccessCommand( ICollectionRepository collectionRepository, IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository, - IEventService eventService) + IEventService eventService, + TimeProvider timeProvider) { _collectionRepository = collectionRepository; _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; _eventService = eventService; + _timeProvider = timeProvider; } public async Task AddAccessAsync(ICollection collections, @@ -34,15 +37,18 @@ public async Task AddAccessAsync(ICollection collections, { await ValidateRequestAsync(collections, users, groups); + var revisionDate = _timeProvider.GetUtcNow().UtcDateTime; + await _collectionRepository.CreateOrUpdateAccessForManyAsync( collections.First().OrganizationId, collections.Select(c => c.Id), users, - groups + groups, + revisionDate ); await _eventService.LogCollectionEventsAsync(collections.Select(c => - (c, EventType.Collection_Updated, (DateTime?)DateTime.UtcNow))); + (c, EventType.Collection_Updated, (DateTime?)revisionDate))); } private async Task ValidateRequestAsync(ICollection collections, ICollection usersAccess, ICollection groupsAccess) diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs index b84e76a18cdb..6be1053c0e24 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs @@ -16,15 +16,18 @@ public class UpdateCollectionCommand : IUpdateCollectionCommand private readonly IEventService _eventService; private readonly IOrganizationRepository _organizationRepository; private readonly ICollectionRepository _collectionRepository; + private readonly TimeProvider _timeProvider; public UpdateCollectionCommand( IEventService eventService, IOrganizationRepository organizationRepository, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + TimeProvider timeProvider) { _eventService = eventService; _organizationRepository = organizationRepository; _collectionRepository = collectionRepository; + _timeProvider = timeProvider; } public async Task UpdateAsync(Collection collection, IEnumerable groups = null, @@ -75,6 +78,7 @@ public async Task UpdateAsync(Collection collection, IEnumerable Task UpdateUsersAsync(Guid id, IEnumerable users); Task> GetManyUsersByIdAsync(Guid id); Task DeleteManyAsync(IEnumerable collectionIds); + + /// + /// Creates or updates the access for many collections. + /// + /// The Organization ID. + /// The Collection IDs to create or update access for. + /// The users to grant access to. + /// The groups to grant access to. + /// The revision date to use for the collections. Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups); + IEnumerable users, IEnumerable groups, + DateTime revisionDate); /// /// Creates default user collections for the specified organization users. diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 7dbe4b18ff8e..b7de0568a62a 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -652,6 +652,10 @@ public async Task CreateManyAsync(IEnumerable organizati await using var connection = new SqlConnection(_marsConnectionString); var organizationUsersList = organizationUserCollection.ToList(); + if (organizationUsersList.Count == 0) + { + return; + } await connection.ExecuteAsync( $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", @@ -672,7 +676,9 @@ await connection.ExecuteAsync( { GroupId = group, OrganizationUserId = user.OrganizationUser.Id - })) + })), + // Use the same RevisionDate as the created OrganizationUsers + RevisionDate = organizationUsersList.First().OrganizationUser.RevisionDate }, commandType: CommandType.StoredProcedure); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 2c733956c03e..e0b2657258a8 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -301,7 +301,8 @@ await connection.ExecuteAsync("[dbo].[Collection_DeleteByIds]", } public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups) + IEnumerable users, IEnumerable groups, + DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { @@ -310,7 +311,14 @@ public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumera var results = await connection.ExecuteAsync( $"[{Schema}].[Collection_CreateOrUpdateAccessForMany]", - new { OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP(), Users = usersArray, Groups = groupsArray }, + new + { + OrganizationId = organizationId, + CollectionIds = collectionIds.ToGuidIdArrayTVP(), + Users = usersArray, + Groups = groupsArray, + RevisionDate = revisionDate + }, commandType: CommandType.StoredProcedure); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs index 3b6ea749fa8a..da256815d667 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs @@ -38,6 +38,12 @@ from c in dbContext.Collections Manage = y.Manage, }); await dbContext.CollectionGroups.AddRangeAsync(collectionGroups); + // Bump RevisionDate on all affected collections + var filteredCollectionIds = filteredCollections.Select(fc => fc.Id).ToHashSet(); + foreach (var c in availableCollections.Where(a => filteredCollectionIds.Contains(a.Id))) + { + c.RevisionDate = grp.RevisionDate; + } await dbContext.SaveChangesAsync(); } } @@ -227,6 +233,20 @@ public async Task ReplaceAsync(AdminConsoleEntities.Group group, IEnumerable !requestedCollectionIds.Contains(cg.CollectionId))); + // Bump the revision date on all affected collections + var allAffectedCollectionIds = existingCollectionGroups.Select(cg => cg.CollectionId) + .Union(requestedCollections.Select(rc => rc.Id)) + .Distinct() + .ToList(); + var affectedCollections = await dbContext.Collections + .Where(c => c.OrganizationId == group.OrganizationId + && allAffectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + foreach (var c in affectedCollections) + { + c.RevisionDate = group.RevisionDate; + } + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(group.OrganizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index afc3de9e487f..a441d519d064 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -46,6 +46,12 @@ from c in dbContext.Collections Manage = y.Manage }); await dbContext.CollectionUsers.AddRangeAsync(collectionUsers); + // Bump RevisionDate on all affected collections + var filteredCollectionIds = filteredCollections.Select(fc => fc.Id).ToHashSet(); + foreach (var c in availableCollections.Where(a => filteredCollectionIds.Contains(a.Id))) + { + c.RevisionDate = organizationUser.RevisionDate; + } await dbContext.SaveChangesAsync(); } @@ -651,6 +657,21 @@ join c in dbContext.Collections on cu.CollectionId equals c.Id // Remove all existing ones that are no longer requested var requestedCollectionIds = requestedCollections.Select(c => c.Id).ToList(); dbContext.CollectionUsers.RemoveRange(existingCollectionUsers.Where(cu => !requestedCollectionIds.Contains(cu.CollectionId))); + + // Bump the revision date on all affected collections + var allAffectedCollectionIds = existingCollectionUsers.Select(cu => cu.CollectionId) + .Union(requestedCollections.Select(rc => rc.Id)) + .Distinct() + .ToList(); + var affectedCollections = await dbContext.Collections + .Where(c => c.OrganizationId == obj.OrganizationId + && allAffectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + foreach (var c in affectedCollections) + { + c.RevisionDate = obj.RevisionDate; + } + await dbContext.SaveChangesAsync(); } } @@ -923,6 +944,11 @@ on ou.UserId equals u.Id public async Task CreateManyAsync(IEnumerable organizationUserCollection) { + if (!organizationUserCollection.Any()) + { + return; + } + using var scope = ServiceScopeFactory.CreateScope(); await using var dbContext = GetDatabaseContext(scope); @@ -942,6 +968,27 @@ public async Task CreateManyAsync(IEnumerable organizati OrganizationUserId = user.OrganizationUser.Id })); + // Bump RevisionDate on all affected collections + var affectedCollectionIds = organizationUserCollection + .SelectMany(x => x.Collections) + .Select(c => c.Id) + .Distinct() + .ToList(); + if (affectedCollectionIds.Count > 0) + { + var organizationId = organizationUserCollection.First().OrganizationUser.OrganizationId; + var affectedCollections = await dbContext.Collections + .Where(c => c.OrganizationId == organizationId + && affectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + // Use the same RevisionDate as the created OrganizationUsers + var revisionDate = organizationUserCollection.First().OrganizationUser.RevisionDate; + foreach (var c in affectedCollections) + { + c.RevisionDate = revisionDate; + } + } + await dbContext.SaveChangesAsync(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 67441fb3f53c..277d9edc3379 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -630,7 +630,8 @@ public async Task DeleteManyAsync(IEnumerable collectionIds) } public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups) + IEnumerable users, IEnumerable groups, + DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -715,6 +716,16 @@ public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumera } // Need to save the new collection users/groups before running the bump revision code await dbContext.SaveChangesAsync(); + + // Bump the revision date on all affected collections + var collections = await dbContext.Collections + .Where(c => collectionIdsList.Contains(c.Id)) + .ToListAsync(); + foreach (var c in collections) + { + c.RevisionDate = revisionDate; + } + await dbContext.UserBumpAccountRevisionDateByCollectionIdsAsync(collectionIdsList, organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql index 6a41971aa00a..6ca78fc22f0f 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql @@ -2,7 +2,8 @@ CREATE PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany] @OrganizationId UNIQUEIDENTIFIER, @CollectionIds AS [dbo].[GuidIdArray] READONLY, @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, - @Users AS [dbo].[CollectionAccessSelectionType] READONLY + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -109,5 +110,18 @@ BEGIN [Source].[Manage] ); + IF @RevisionDate IS NOT NULL + BEGIN + -- Bump the revision date on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @CollectionIds CI ON C.[Id] = CI.[Id] + END + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId END diff --git a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql index 0d1db68a8730..b4766b406583 100644 --- a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql @@ -39,5 +39,16 @@ BEGIN WHERE [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + -- Bump RevisionDate on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId END diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql index cabc2dd3b229..70de3efa5e99 100644 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql @@ -12,6 +12,32 @@ BEGIN EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT + [Id] + FROM + @Collections + + UNION + + SELECT + CG.[CollectionId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[GroupId] = @Id + ) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + ;WITH [AvailableCollectionsCTE] AS( SELECT Id diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index 78ff2933f6a5..6603538f1850 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -1,7 +1,8 @@ CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] @organizationUserData NVARCHAR(MAX), @collectionData NVARCHAR(MAX), - @groupData NVARCHAR(MAX) + @groupData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -69,20 +70,13 @@ BEGIN [GroupId] UNIQUEIDENTIFIER '$.GroupId' ) OUG - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords], - [Manage] - ) SELECT OUC.[CollectionId], OUC.[OrganizationUserId], OUC.[ReadOnly], OUC.[HidePasswords], OUC.[Manage] + INTO #CollectionUserData FROM OPENJSON(@collectionData) WITH( @@ -92,6 +86,35 @@ BEGIN [HidePasswords] BIT '$.HidePasswords', [Manage] BIT '$.Manage' ) OUC + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + FROM #CollectionUserData + + -- Bump RevisionDate on all affected collections + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + #CollectionUserData CUD ON CUD.[CollectionId] = C.[Id] + END END go diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql index a2ec1e9d3385..0555db7cf8e7 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql @@ -45,4 +45,15 @@ BEGIN @Collections WHERE [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index e030958c3e80..f0a77b7a2680 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -18,6 +18,33 @@ BEGIN SET NOCOUNT ON EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT + [Id] + FROM + @Collections + + UNION + + SELECT + CU.[CollectionId] + FROM + [dbo].[CollectionUser] CU + WHERE + CU.[OrganizationUserId] = @Id + ) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + -- Update UPDATE [Target] diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs index 713edeefbfa5..59b214d20e4a 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Test.Vault.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -18,8 +19,10 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections; [SutProviderCustomize] public class BulkAddCollectionAccessCommandTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory, BitAutoData, CollectionCustomization] - public async Task AddAccessAsync_Success(SutProvider sutProvider, + public async Task AddAccessAsync_Success( Organization org, ICollection collections, ICollection organizationUsers, @@ -27,6 +30,7 @@ public async Task AddAccessAsync_Success(SutProvider collectionUsers, IEnumerable collectionGroups) { + var sutProvider = SetupSutProvider(); SetCollectionsToSharedType(collections); sutProvider.GetDependency() @@ -59,7 +63,8 @@ await sutProvider.GetDependency().Received().CreateOrUpda org.Id, Arg.Is>(ids => ids.SequenceEqual(collections.Select(c => c.Id))), userAccessSelections, - groupAccessSelections); + groupAccessSelections, + _expectedRevisionDate); await sutProvider.GetDependency().Received().LogCollectionEventsAsync( Arg.Is>( @@ -279,7 +284,7 @@ public async Task AddAccessAsync_WithDefaultUserCollectionType_ThrowsBadRequest( Assert.Contains("You cannot add access to collections with the type as DefaultUserCollection.", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateOrUpdateAccessForManyAsync(default, default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateOrUpdateAccessForManyAsync(default, default, default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCollectionEventsAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByManyIds(default); @@ -313,4 +318,13 @@ private static ICollection ToAccessSelection(IEnumera ReadOnly = cg.ReadOnly }).ToList(); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + return sutProvider; + } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommandTests.cs index 9e414d20207a..b5ec82ebb248 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -19,12 +20,16 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections; [OrganizationCustomize] public class UpdateCollectionCommandTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory, BitAutoData] public async Task UpdateAsync_WithoutGroupsAndUsers_ReplacesCollection( Organization organization, Collection collection, - [CollectionAccessSelectionCustomize(true)] IEnumerable existingUsers, - SutProvider sutProvider) + [CollectionAccessSelectionCustomize(true)] IEnumerable existingUsers) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + organization.AllowAdminAccessToAllCollectionItems = false; var creationDate = collection.CreationDate; sutProvider.GetDependency() @@ -35,7 +40,6 @@ public async Task UpdateAsync_WithoutGroupsAndUsers_ReplacesCollection( .Returns(new Tuple( collection, new CollectionAccessDetails { Groups = [], Users = existingUsers })); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, null, null); @@ -49,22 +53,23 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] public async Task UpdateAsync_WithGroupsAndUsers_ReplacesCollectionWithGroupsAndUsers( Organization organization, Collection collection, [CollectionAccessSelectionCustomize(true)] IEnumerable groups, - IEnumerable users, - SutProvider sutProvider) + IEnumerable users) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + var creationDate = collection.CreationDate; organization.UseGroups = true; sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, groups, users); @@ -78,22 +83,23 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] public async Task UpdateAsync_WithOrganizationUseGroupDisabled_ReplacesCollectionWithoutGroups( Organization organization, Collection collection, [CollectionAccessSelectionCustomize] IEnumerable groups, - [CollectionAccessSelectionCustomize(true)] IEnumerable users, - SutProvider sutProvider) + [CollectionAccessSelectionCustomize(true)] IEnumerable users) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + var creationDate = collection.CreationDate; organization.UseGroups = false; sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, groups, users); @@ -107,7 +113,7 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] @@ -316,4 +322,13 @@ await sutProvider.GetDependency() .Received(1) .ReplaceAsync(collection, null, null); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + return sutProvider; + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs index de4fd53a6895..2f9931077ffb 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs @@ -51,6 +51,7 @@ await collectionRepository.CreateAsync(collection, // Act collection.Name = "Updated Collection Name"; + collection.RevisionDate = DateTime.UtcNow; await collectionRepository.ReplaceAsync(collection, [ @@ -74,6 +75,7 @@ await collectionRepository.ReplaceAsync(collection, Assert.NotNull(actualCollection); Assert.Equal("Updated Collection Name", actualCollection.Name); + Assert.Equal(collection.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); var groups = actualAccess.Groups.ToArray(); Assert.Equal(2, groups.Length); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs index 0f8feb4a6ab6..da5d7e3361ba 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs @@ -608,4 +608,42 @@ public async Task GetManySharedCollectionsByOrganizationIdAsync_Success( Assert.Contains(collections, c => c.Name == "Collection 3"); Assert.DoesNotContain(collections, c => c.Name == "My Items"); } + + [DatabaseTheory, DatabaseData] + public async Task CreateOrUpdateAccessForManyAsync_CreatesAccessAndBumpsRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository, + IUserRepository userRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + var group = await groupRepository.CreateTestGroupAsync(organization); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var revisionDate = DateTime.UtcNow.AddMinutes(10); + + await collectionRepository.CreateOrUpdateAccessForManyAsync( + organization.Id, + [collection.Id], + [new CollectionAccessSelection { Id = orgUser.Id, Manage = true, HidePasswords = false, ReadOnly = false }], + [new CollectionAccessSelection { Id = group.Id, Manage = false, HidePasswords = true, ReadOnly = true }], + revisionDate + ); + + var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.Equal(revisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); + + var userAccess = Assert.Single(actualAccess.Users); + Assert.Equal(orgUser.Id, userAccess.Id); + Assert.True(userAccess.Manage); + + var groupAccess = Assert.Single(actualAccess.Groups); + Assert.Equal(group.Id, groupAccess.Id); + Assert.True(groupAccess.ReadOnly); + Assert.True(groupAccess.HidePasswords); + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs index e2c2cbfa0269..5d936565c7ef 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Xunit; @@ -6,6 +8,74 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories; public class GroupRepositoryTests { + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_WithCollections_CreatesGroupAccessAndBumpsCollectionRevisionDate( + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + var org = await organizationRepository.CreateTestOrganizationAsync(); + var collection1 = await collectionRepository.CreateTestCollectionAsync(org); + var collection2 = await collectionRepository.CreateTestCollectionAsync(org); + + var group = new Group { OrganizationId = org.Id, Name = "New Group", RevisionDate = DateTime.UtcNow.AddMinutes(10) }; + await groupRepository.CreateAsync(group, [ + new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, + new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, + ]); + + var (actualGroup, actualCollections) = await groupRepository.GetByIdWithCollectionsAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal("New Group", actualGroup.Name); + Assert.Equal(2, actualCollections.Count); + Assert.Single(actualCollections, c => c.Id == collection1.Id && c.Manage && !c.HidePasswords && !c.ReadOnly); + Assert.Single(actualCollections, c => c.Id == collection2.Id && !c.Manage && c.HidePasswords && c.ReadOnly); + + var (actualCollection1, _) = await collectionRepository.GetByIdWithAccessAsync(collection1.Id); + var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); + Assert.NotNull(actualCollection1); + Assert.NotNull(actualCollection2); + Assert.Equal(group.RevisionDate, actualCollection1.RevisionDate, TimeSpan.FromMilliseconds(10)); + Assert.Equal(group.RevisionDate, actualCollection2.RevisionDate, TimeSpan.FromMilliseconds(10)); + } + + [DatabaseTheory, DatabaseData] + public async Task ReplaceAsync_WithCollections_UpdatesGroupAndBumpsCollectionRevisionDate( + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var org = await organizationRepository.CreateTestOrganizationAsync(); + var group = await groupRepository.CreateTestGroupAsync(org); + var collection1 = await collectionRepository.CreateTestCollectionAsync(org); + var collection2 = await collectionRepository.CreateTestCollectionAsync(org); + + // Act + group.Name = "Updated Group Name"; + group.RevisionDate = DateTime.UtcNow.AddMinutes(10); + await groupRepository.ReplaceAsync(group, [ + new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, + new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, + ]); + + // Assert + var (actualGroup, actualCollections) = await groupRepository.GetByIdWithCollectionsAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal("Updated Group Name", actualGroup.Name); + + Assert.Equal(2, actualCollections.Count); + Assert.Single(actualCollections, c => c.Id == collection1.Id && c.Manage && !c.HidePasswords && !c.ReadOnly); + Assert.Single(actualCollections, c => c.Id == collection2.Id && !c.Manage && c.HidePasswords && c.ReadOnly); + + var (actualCollection1, _) = await collectionRepository.GetByIdWithAccessAsync(collection1.Id); + var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); + Assert.NotNull(actualCollection1); + Assert.NotNull(actualCollection2); + Assert.Equal(group.RevisionDate, actualCollection1.RevisionDate, TimeSpan.FromMilliseconds(10)); + Assert.Equal(group.RevisionDate, actualCollection2.RevisionDate, TimeSpan.FromMilliseconds(10)); + } + [DatabaseTheory, DatabaseData] public async Task AddGroupUsersByIdAsync_CreatesGroupUsers( IGroupRepository groupRepository, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs new file mode 100644 index 000000000000..6408dc65aaa8 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs @@ -0,0 +1,97 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository; + +public class OrganizationUserCreateTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_WithCollections_CreatesAccessAndBumpsCollectionRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = null, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + RevisionDate = DateTime.UtcNow.AddMinutes(10), + }; + + await organizationUserRepository.CreateAsync(orgUser, [ + new CollectionAccessSelection { Id = collection.Id, Manage = true, HidePasswords = false, ReadOnly = false } + ]); + + await AssertOrgUserAndCollectionRevisionDate( + organizationUserRepository, collectionRepository, + orgUser, collection.Id, orgUser.RevisionDate); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateManyAsync_WithCollections_CreatesAccessAndBumpsCollectionRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var orgUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = null, + Email = $"invite-{Guid.NewGuid()}@example.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow.AddMinutes(10), + }; + + await organizationUserRepository.CreateManyAsync([ + new CreateOrganizationUser + { + OrganizationUser = orgUser, + Collections = [new CollectionAccessSelection { Id = collection.Id, Manage = true }], + Groups = [], + } + ]); + + await AssertOrgUserAndCollectionRevisionDate( + organizationUserRepository, collectionRepository, + orgUser, collection.Id, orgUser.RevisionDate); + } + + private static async Task AssertOrgUserAndCollectionRevisionDate( + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + OrganizationUser expectedOrgUser, + Guid collectionId, + DateTime expectedCollectionRevisionDate) + { + var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(expectedOrgUser.Id); + Assert.NotNull(actualOrgUser); + Assert.Equal(expectedOrgUser.OrganizationId, actualOrgUser.OrganizationId); + Assert.Equal(expectedOrgUser.Email, actualOrgUser.Email); + Assert.Equal(expectedOrgUser.Status, actualOrgUser.Status); + Assert.Equal(expectedOrgUser.Type, actualOrgUser.Type); + + var collectionAccess = Assert.Single(actualCollections); + Assert.Equal(collectionId, collectionAccess.Id); + Assert.True(collectionAccess.Manage); + + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collectionId); + Assert.NotNull(actualCollection); + Assert.Equal(expectedCollectionRevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs index 0f6393fec0f6..9bfcb2f60f6d 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs @@ -18,13 +18,14 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsInvited_Success( ICollectionRepository collectionRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; - var collection = await collectionRepository.CreateTestCollectionAsync(organization); + orgUser.RevisionDate = DateTime.UtcNow.AddMinutes(10); await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -39,6 +40,11 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ var collectionAccess = Assert.Single(actualCollections); Assert.Equal(collection.Id, collectionAccess.Id); Assert.True(collectionAccess.Manage); + + // Collection revision date should match the orgUser's RevisionDate + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.Equal(orgUser.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); } /// @@ -53,6 +59,7 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( ICollectionRepository collectionRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); var user = await userRepository.CreateTestUserAsync(); // OrganizationUser is linked with the User in the Confirmed status @@ -61,7 +68,7 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; - var collection = await collectionRepository.CreateTestCollectionAsync(organization); + orgUser.RevisionDate = DateTime.UtcNow.AddMinutes(10); await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -81,5 +88,10 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ var actualUser = await userRepository.GetByIdAsync(user.Id); Assert.NotNull(actualUser); Assert.True(actualUser.AccountRevisionDate.CompareTo(user.AccountRevisionDate) > 0); + + // Collection revision date should match the orgUser's RevisionDate + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.Equal(orgUser.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); } } diff --git a/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql new file mode 100644 index 000000000000..c5b996d72a67 --- /dev/null +++ b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -0,0 +1,580 @@ +-- Bump Collection.RevisionDate when collection access is modified via: +-- 1. Bulk collection access (Collection_CreateOrUpdateAccessForMany) +-- 2. Organization user create (OrganizationUser_CreateWithCollections) +-- 3. Organization user update (OrganizationUser_UpdateWithCollections) +-- 4. Bulk organization user create (OrganizationUser_CreateManyWithCollectionsAndGroups) +-- 5. Group create (Group_CreateWithCollections) +-- 6. Group update (Group_UpdateWithCollections) + +CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany] + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Groups + ;WITH [NewCollectionGroups] AS ( + SELECT + cId.[Id] AS [CollectionId], + cg.[Id] AS [GroupId], + cg.[ReadOnly], + cg.[HidePasswords], + cg.[Manage] + FROM + @Groups AS cg + CROSS JOIN -- Create a CollectionGroup record for every CollectionId + @CollectionIds cId + INNER JOIN + [dbo].[Group] g ON cg.[Id] = g.[Id] + WHERE + g.[OrganizationId] = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] as [Target] + USING + [NewCollectionGroups] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[GroupId] = [Source].[GroupId] + -- Update the target if any values are different from the source + WHEN MATCHED AND EXISTS( + SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage] + EXCEPT + SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage] + ) THEN UPDATE SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY TARGET + THEN INSERT + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[CollectionId], + [Source].[GroupId], + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ); + + -- Users + ;WITH [NewCollectionUsers] AS ( + SELECT + cId.[Id] AS [CollectionId], + cu.[Id] AS [OrganizationUserId], + cu.[ReadOnly], + cu.[HidePasswords], + cu.[Manage] + FROM + @Users AS cu + CROSS JOIN -- Create a CollectionUser record for every CollectionId + @CollectionIds cId + INNER JOIN + [dbo].[OrganizationUser] u ON cu.[Id] = u.[Id] + WHERE + u.[OrganizationId] = @OrganizationId + ) + MERGE + [dbo].[CollectionUser] as [Target] + USING + [NewCollectionUsers] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[OrganizationUserId] = [Source].[OrganizationUserId] + -- Update the target if any values are different from the source + WHEN MATCHED AND EXISTS( + SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage] + EXCEPT + SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage] + ) THEN UPDATE SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY TARGET + THEN INSERT + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[CollectionId], + [Source].[OrganizationUserId], + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ); + + IF @RevisionDate IS NOT NULL + BEGIN + -- Bump the revision date on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @CollectionIds CI ON C.[Id] = CI.[Id] + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Email NVARCHAR(256), + @Key VARCHAR(MAX), + @Status SMALLINT, + @Type TINYINT, + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Permissions NVARCHAR(MAX), + @ResetPasswordKey VARCHAR(MAX), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY, + @AccessSecretsManager BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT + [Id] + FROM + @Collections + + UNION + + SELECT + CU.[CollectionId] + FROM + [dbo].[CollectionUser] CU + WHERE + CU.[OrganizationUserId] = @Id + ) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + + -- Update + UPDATE + [Target] + SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + FROM + [dbo].[CollectionUser] AS [Target] + INNER JOIN + @Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId] + WHERE + [Target].[OrganizationUserId] = @Id + AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + OR [Target].[Manage] != [Source].[Manage] + ) + + -- Insert + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Source].[Id], + @Id, + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + FROM + @Collections AS [Source] + INNER JOIN + [dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[CollectionUser] + WHERE + [CollectionId] = [Source].[Id] + AND [OrganizationUserId] = @Id + ) + + -- Delete + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CU.[CollectionId] + WHERE + CU.[OrganizationUserId] = @Id + AND C.[Type] != 1 -- Don't delete default collections + AND NOT EXISTS ( + SELECT + 1 + FROM + @Collections + WHERE + [Id] = CU.[CollectionId] + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Group_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(100), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT + [Id] + FROM + @Collections + + UNION + + SELECT + CG.[CollectionId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[GroupId] = @Id + ) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + Id + FROM + [dbo].[Collection] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] AS [Target] + USING + @Collections AS [Source] + ON + [Target].[CollectionId] = [Source].[Id] + AND [Target].[GroupId] = @Id + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN + INSERT + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[Id], + @Id, + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + OR [Target].[Manage] != [Source].[Manage] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY SOURCE + AND [Target].[GroupId] = @Id THEN + DELETE + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Email NVARCHAR(256), + @Key VARCHAR(MAX), + @Status SMALLINT, + @Type TINYINT, + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Permissions NVARCHAR(MAX), + @ResetPasswordKey VARCHAR(MAX), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY, + @AccessSecretsManager BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[OrganizationUser_Create] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Collection] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Id], + @Id, + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Collections + WHERE + [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Group_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(100), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Collection] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Id], + @Id, + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Collections + WHERE + [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + INTO #CollectionUserData + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + FROM #CollectionUserData + + -- Bump RevisionDate on all affected collections + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + #CollectionUserData CUD ON CUD.[CollectionId] = C.[Id] + END +END +GO