diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs index c83b2c04937f..4e6ad6a1499c 100644 --- a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs @@ -22,19 +22,22 @@ public class PatchGroupCommand : IPatchGroupCommand private readonly IUpdateGroupCommand _updateGroupCommand; private readonly ILogger _logger; private readonly IOrganizationRepository _organizationRepository; + private readonly TimeProvider _timeProvider; public PatchGroupCommand( IGroupRepository groupRepository, IGroupService groupService, IUpdateGroupCommand updateGroupCommand, ILogger logger, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + TimeProvider timeProvider) { _groupRepository = groupRepository; _groupService = groupService; _updateGroupCommand = updateGroupCommand; _logger = logger; _organizationRepository = organizationRepository; + _timeProvider = timeProvider; } public async Task PatchGroupAsync(Group group, ScimPatchModel model) @@ -53,7 +56,7 @@ private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationMod case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members: { var ids = GetOperationValueIds(operation.Value); - await _groupRepository.UpdateUsersAsync(group.Id, ids); + await _groupRepository.UpdateUsersAsync(group.Id, ids, _timeProvider.GetUtcNow().UtcDateTime); break; } @@ -122,7 +125,7 @@ private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationMod { orgUserIds.Remove(v); } - await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); + await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds, _timeProvider.GetUtcNow().UtcDateTime); break; } @@ -146,7 +149,7 @@ private async Task AddMembersAsync(Group group, HashSet usersToAdd) return; } - await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd); + await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd, _timeProvider.GetUtcNow().UtcDateTime); } private static HashSet GetOperationValueIds(JsonElement objArray) diff --git a/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs index 5a7a03f1f483..bf30e6a3a7f5 100644 --- a/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PostGroupCommand.cs @@ -62,6 +62,6 @@ private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel mo return; } - await _groupRepository.UpdateUsersAsync(group.Id, memberIds); + await _groupRepository.UpdateUsersAsync(group.Id, memberIds, group.RevisionDate); } } diff --git a/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs index c2cef246a959..1f6d4f96c1e8 100644 --- a/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PutGroupCommand.cs @@ -52,6 +52,6 @@ private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel mo } } - await _groupRepository.UpdateUsersAsync(group.Id, memberIds); + await _groupRepository.UpdateUsersAsync(group.Id, memberIds, group.RevisionDate); } } diff --git a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs index 8816885ea7c8..f1f094b61f3b 100644 --- a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs @@ -13,6 +13,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -21,11 +22,14 @@ namespace Bit.Scim.Test.Groups; [SutProviderCustomize] public class PatchGroupCommandTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory] [BitAutoData] - public async Task PatchGroup_ReplaceListMembers_Success(SutProvider sutProvider, + public async Task PatchGroup_ReplaceListMembers_Success( Organization organization, Group group, IEnumerable userIds) { + var sutProvider = SetupSutProvider(); group.OrganizationId = organization.Id; var scimPatchModel = new ScimPatchModel @@ -48,7 +52,8 @@ await sutProvider.GetDependency().Received(1).UpdateUsersAsync group.Id, Arg.Is>(arg => arg.Count() == userIds.Count() && - arg.ToHashSet().SetEquals(userIds))); + arg.ToHashSet().SetEquals(userIds)), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] @@ -168,8 +173,9 @@ public async Task PatchGroup_ReplaceDisplayNameFromValueObject_MissingOrganizati [Theory] [BitAutoData] - public async Task PatchGroup_AddSingleMember_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, Guid userId) + public async Task PatchGroup_AddSingleMember_Success(Organization organization, Group group, ICollection existingMembers, Guid userId) { + var sutProvider = SetupSutProvider(); group.OrganizationId = organization.Id; sutProvider.GetDependency() @@ -193,7 +199,8 @@ public async Task PatchGroup_AddSingleMember_Success(SutProvider().Received(1).AddGroupUsersByIdAsync( group.Id, - Arg.Is>(arg => arg.Single() == userId)); + Arg.Is>(arg => arg.Single() == userId), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] @@ -229,13 +236,14 @@ public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup( await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .AddGroupUsersByIdAsync(default, default); + .AddGroupUsersByIdAsync(default, default, default); } [Theory] [BitAutoData] - public async Task PatchGroup_AddListMembers_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, ICollection userIds) + public async Task PatchGroup_AddListMembers_Success(Organization organization, Group group, ICollection existingMembers, ICollection userIds) { + var sutProvider = SetupSutProvider(); group.OrganizationId = organization.Id; sutProvider.GetDependency() @@ -262,15 +270,18 @@ await sutProvider.GetDependency().Received(1).AddGroupUsersByI group.Id, Arg.Is>(arg => arg.Count() == userIds.Count && - arg.ToHashSet().SetEquals(userIds))); + arg.ToHashSet().SetEquals(userIds)), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] [BitAutoData] public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest( - SutProvider sutProvider, Organization organization, Group group, + Organization organization, Group group, ICollection existingMembers) { + var sutProvider = SetupSutProvider(); + // Create 3 userIds var fixture = new Fixture { RepeatCount = 3 }; var userIds = fixture.CreateMany().ToList(); @@ -308,17 +319,19 @@ await sutProvider.GetDependency().Received(1).AddGroupUsersByI group.Id, Arg.Is>(arg => arg.Count() == 3 && - arg.ToHashSet().SetEquals(userIds))); + arg.ToHashSet().SetEquals(userIds)), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] [BitAutoData] public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup( - SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, ICollection userIds) { + var sutProvider = SetupSutProvider(); + // A user is already in the group, but some still need to be added userIds.Add(existingMembers.First()); @@ -350,7 +363,8 @@ await sutProvider.GetDependency() group.Id, Arg.Is>(arg => arg.Count() == userIds.Count && - arg.ToHashSet().SetEquals(userIds))); + arg.ToHashSet().SetEquals(userIds)), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] @@ -379,9 +393,10 @@ public async Task PatchGroup_RemoveSingleMember_Success(SutProvider sutProvider, + public async Task PatchGroup_RemoveListMembers_Success( Organization organization, Group group, ICollection existingMembers) { + var sutProvider = SetupSutProvider(); List usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()]; group.OrganizationId = organization.Id; @@ -412,7 +427,8 @@ await sutProvider.GetDependency() group.Id, Arg.Is>(arg => arg.Count() == expectedRemainingUsers.Count && - arg.ToHashSet().SetEquals(expectedRemainingUsers))); + arg.ToHashSet().SetEquals(expectedRemainingUsers)), + Arg.Is(d => d == _expectedRevisionDate)); } [Theory] @@ -430,7 +446,7 @@ public async Task PatchGroup_InvalidOperation_Success(SutProvider().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); @@ -454,9 +470,18 @@ public async Task PatchGroup_NoOperation_Success( await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + return sutProvider; + } } diff --git a/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs index b44295192b4c..a49169da0528 100644 --- a/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/PostGroupCommandTests.cs @@ -43,7 +43,7 @@ public async Task PostGroup_Success(SutProvider sutProvider, s var group = await sutProvider.Sut.PostGroupAsync(organization, scimGroupRequestModel); await sutProvider.GetDependency().Received(1).CreateGroupAsync(group, organization, EventSystemUser.SCIM, null); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default, default); AssertHelper.AssertPropertyEqual(expectedResult, group, "Id", "CreationDate", "RevisionDate"); } @@ -74,7 +74,7 @@ public async Task PostGroup_WithMembers_Success(SutProvider su var group = await sutProvider.Sut.PostGroupAsync(organization, scimGroupRequestModel); await sutProvider.GetDependency().Received(1).CreateGroupAsync(group, organization, EventSystemUser.SCIM, null); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(Arg.Any(), Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id)))); + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id))), group.RevisionDate); AssertHelper.AssertPropertyEqual(expectedResult, group, "Id", "CreationDate", "RevisionDate"); } diff --git a/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs index fb67cd684bbc..8f29d23e83ed 100644 --- a/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/PutGroupCommandTests.cs @@ -47,7 +47,7 @@ public async Task PutGroup_Success(SutProvider sutProvider, Org Assert.Equal(displayName, group.Name); await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default, default); } [Theory] @@ -81,7 +81,7 @@ public async Task PutGroup_ChangeMembers_Success(SutProvider su Assert.Equal(displayName, group.Name); await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id)))); + await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => membersUserIds.Contains(id))), group.RevisionDate); } [Theory] diff --git a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs index 9644d2d79973..e2054dd43093 100644 --- a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs @@ -23,19 +23,22 @@ public class GroupsController : Controller private readonly ICurrentContext _currentContext; private readonly ICreateGroupCommand _createGroupCommand; private readonly IUpdateGroupCommand _updateGroupCommand; + private readonly TimeProvider _timeProvider; public GroupsController( IGroupRepository groupRepository, IOrganizationRepository organizationRepository, ICurrentContext currentContext, ICreateGroupCommand createGroupCommand, - IUpdateGroupCommand updateGroupCommand) + IUpdateGroupCommand updateGroupCommand, + TimeProvider timeProvider) { _groupRepository = groupRepository; _organizationRepository = organizationRepository; _currentContext = currentContext; _createGroupCommand = createGroupCommand; _updateGroupCommand = updateGroupCommand; + _timeProvider = timeProvider; } /// @@ -168,7 +171,7 @@ public async Task PutMemberIds(Guid id, [FromBody] UpdateMemberId { return new NotFoundResult(); } - await _groupRepository.UpdateUsersAsync(existingGroup.Id, model.MemberIds); + await _groupRepository.UpdateUsersAsync(existingGroup.Id, model.MemberIds, _timeProvider.GetUtcNow().UtcDateTime); return new OkResult(); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index 86a222439eed..969df27ea6ac 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -17,16 +17,19 @@ public class CreateGroupCommand : ICreateGroupCommand private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly TimeProvider _timeProvider; public CreateGroupCommand( IEventService eventService, IGroupRepository groupRepository, - IOrganizationUserRepository organizationUserRepository + IOrganizationUserRepository organizationUserRepository, + TimeProvider timeProvider ) { _eventService = eventService; _groupRepository = groupRepository; _organizationUserRepository = organizationUserRepository; + _timeProvider = timeProvider; } public async Task CreateGroupAsync(Group group, Organization organization, @@ -61,7 +64,8 @@ public async Task CreateGroupAsync(Group group, Organization organization, Event private async Task GroupRepositoryCreateGroupAsync(Group group, Organization organization, IEnumerable collections = null) { - group.CreationDate = group.RevisionDate = DateTime.UtcNow; + var now = _timeProvider.GetUtcNow().UtcDateTime; + group.CreationDate = group.RevisionDate = now; if (collections == null) { @@ -78,10 +82,10 @@ private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable? collections = null) { - group.RevisionDate = DateTime.UtcNow; + var now = _timeProvider.GetUtcNow().UtcDateTime; + group.RevisionDate = now; if (collections == null) { @@ -81,7 +85,7 @@ private async Task SaveGroupUsersAsync(Group group, IEnumerable userIds, E var newUserIds = userIds as Guid[] ?? userIds.ToArray(); var originalUserIds = await _groupRepository.GetManyUserIdsByIdAsync(group.Id); - await _groupRepository.UpdateUsersAsync(group.Id, newUserIds); + await _groupRepository.UpdateUsersAsync(group.Id, newUserIds, group.RevisionDate); // We only want to create events OrganizationUserEvents for those that were actually modified. // HashSet.SymmetricExceptWith is a convenient method of finding the difference between lists @@ -90,7 +94,7 @@ private async Task SaveGroupUsersAsync(Group group, IEnumerable userIds, E // Fetch all changed users for logging the event var users = await _organizationUserRepository.GetManyAsync(changedUserIds); - var eventDate = DateTime.UtcNow; + var eventDate = group.RevisionDate; if (systemUser.HasValue) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index b9bad6a3466c..2fe1fef05b1a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -379,7 +379,7 @@ private async Task UpdateUsersAsync(Group group, HashSet groupUsers, return; } - await _groupRepository.UpdateUsersAsync(group.Id, users); + await _groupRepository.UpdateUsersAsync(group.Id, users, group.RevisionDate); } private async Task GetOrgById(Guid id) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs index 2623242ad6ce..7d77518f7d56 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs @@ -26,6 +26,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand private readonly IGroupRepository _groupRepository; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; + private readonly TimeProvider _timeProvider; public UpdateOrganizationUserCommand( IEventService eventService, @@ -37,7 +38,8 @@ public UpdateOrganizationUserCommand( ICollectionRepository collectionRepository, IGroupRepository groupRepository, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + TimeProvider timeProvider) { _eventService = eventService; _organizationService = organizationService; @@ -49,6 +51,7 @@ public UpdateOrganizationUserCommand( _groupRepository = groupRepository; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; + _timeProvider = timeProvider; } /// @@ -139,7 +142,7 @@ public async Task UpdateUserAsync(OrganizationUser organizationUser, Organizatio if (groupAccess != null) { - await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupAccess); + await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupAccess, _timeProvider.GetUtcNow().UtcDateTime); } await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommand.cs index 615a33dbf422..8edc0960beb8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommand.cs @@ -10,18 +10,21 @@ public class UpdateOrganizationUserGroupsCommand : IUpdateOrganizationUserGroups { private readonly IEventService _eventService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly TimeProvider _timeProvider; public UpdateOrganizationUserGroupsCommand( IEventService eventService, - IOrganizationUserRepository organizationUserRepository) + IOrganizationUserRepository organizationUserRepository, + TimeProvider timeProvider) { _eventService = eventService; _organizationUserRepository = organizationUserRepository; + _timeProvider = timeProvider; } public async Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable groupIds) { - await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds); + await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds, _timeProvider.GetUtcNow().UtcDateTime); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups); } } diff --git a/src/Core/AdminConsole/Repositories/IGroupRepository.cs b/src/Core/AdminConsole/Repositories/IGroupRepository.cs index b70331a3f5a0..3fab5b46acda 100644 --- a/src/Core/AdminConsole/Repositories/IGroupRepository.cs +++ b/src/Core/AdminConsole/Repositories/IGroupRepository.cs @@ -27,16 +27,28 @@ Task>>> GetManyW Task> GetManyGroupUsersByOrganizationIdAsync(Guid organizationId); Task CreateAsync(Group obj, IEnumerable collections); Task ReplaceAsync(Group obj, IEnumerable collections); - Task DeleteUserAsync(Guid groupId, Guid organizationUserId); + /// + /// Remove a user from a group. + /// + /// The group to remove the user from. + /// The organization user to remove. + /// The timestamp to set as the group's new . + Task DeleteUserAsync(Guid groupId, Guid organizationUserId, DateTime revisionDate); /// /// Update a group's members. Replaces all members currently in the group. /// Ignores members that do not belong to the same organization as the group. /// - Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds); + /// The group whose members will be replaced. + /// The full set of organization user ids that should be in the group. + /// The timestamp to set as the group's new . + Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate); /// /// Add members to a group. Gracefully ignores members that are already in the group, /// duplicate organizationUserIds, and organizationUsers who are not part of the organization. /// - Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds); + /// The group to add members to. + /// The organization user ids to add. + /// The timestamp to set as the group's new . + Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate); Task DeleteManyAsync(IEnumerable groupIds); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 34fa8c968b0a..1e3fcfce3de9 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -49,7 +49,13 @@ Task> GetManyDetailsByUserAsync Task> GetManyConfirmedAcceptedDetailsByUserAsync(Guid userId); Task GetDetailsByUserAsync(Guid userId, Guid organizationId, OrganizationUserStatusType? status = null); - Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds); + /// + /// Replace the group memberships for an organization user. + /// + /// The organization user whose group memberships will be replaced. + /// The full set of group ids the user should belong to. + /// The timestamp to set as each affected group's new revision date. + Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds, DateTime revisionDate); Task UpsertManyAsync(IEnumerable organizationUsers); Task CreateAsync(OrganizationUser obj, IEnumerable collections); Task?> CreateManyAsync(IEnumerable organizationIdUsers); diff --git a/src/Core/AdminConsole/Services/Implementations/GroupService.cs b/src/Core/AdminConsole/Services/Implementations/GroupService.cs index 62ab4ed487c4..272dbbe0af24 100644 --- a/src/Core/AdminConsole/Services/Implementations/GroupService.cs +++ b/src/Core/AdminConsole/Services/Implementations/GroupService.cs @@ -13,15 +13,18 @@ public class GroupService : IGroupService private readonly IEventService _eventService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IGroupRepository _groupRepository; + private readonly TimeProvider _timeProvider; public GroupService( IEventService eventService, IOrganizationUserRepository organizationUserRepository, - IGroupRepository groupRepository) + IGroupRepository groupRepository, + TimeProvider timeProvider) { _eventService = eventService; _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; + _timeProvider = timeProvider; } [Obsolete("IDeleteGroupCommand should be used instead. To be removed by EC-608.")] @@ -58,7 +61,7 @@ private async Task GroupRepositoryDeleteUserAsync(Group group, throw new NotFoundException(); } - await _groupRepository.DeleteUserAsync(group.Id, organizationUserId); + await _groupRepository.DeleteUserAsync(group.Id, organizationUserId, _timeProvider.GetUtcNow().UtcDateTime); return orgUser; } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 0201a186a94e..6929daa78087 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -63,6 +63,7 @@ public class OrganizationService : IOrganizationService private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; private readonly IStripeAdapter _stripeAdapter; private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; + private readonly TimeProvider _timeProvider; public OrganizationService( IOrganizationRepository organizationRepository, @@ -86,7 +87,9 @@ public OrganizationService( IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IStripeAdapter stripeAdapter, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) + IStripeAdapter stripeAdapter, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, + TimeProvider timeProvider) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -111,6 +114,7 @@ public OrganizationService( _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; _stripeAdapter = stripeAdapter; _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; + _timeProvider = timeProvider; } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -675,9 +679,10 @@ private async await _organizationUserRepository.CreateAsync(orgUser, collections); } + var revisionDate = _timeProvider.GetUtcNow().UtcDateTime; foreach (var (orgUser, groups) in orgUserGroups) { - await _organizationUserRepository.UpdateGroupsAsync(orgUser.Id, groups); + await _organizationUserRepository.UpdateGroupsAsync(orgUser.Id, groups, revisionDate); } if (!await _currentContext.ManageUsers(organization.Id)) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/GroupRepository.cs index 2b4db3940cb4..4418b0bc7373 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/GroupRepository.cs @@ -168,35 +168,35 @@ public async Task ReplaceAsync(Group obj, IEnumerable } } - public async Task DeleteUserAsync(Guid groupId, Guid organizationUserId) + public async Task DeleteUserAsync(Guid groupId, Guid organizationUserId, DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( $"[{Schema}].[GroupUser_Delete]", - new { GroupId = groupId, OrganizationUserId = organizationUserId }, + new { GroupId = groupId, OrganizationUserId = organizationUserId, RevisionDate = revisionDate }, commandType: CommandType.StoredProcedure); } } - public async Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds) + public async Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( "[dbo].[GroupUser_UpdateUsers]", - new { GroupId = groupId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() }, + new { GroupId = groupId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), RevisionDate = revisionDate }, commandType: CommandType.StoredProcedure); } } - public async Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds) + public async Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( "[dbo].[GroupUser_AddUsers]", - new { GroupId = groupId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() }, + new { GroupId = groupId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), RevisionDate = revisionDate }, commandType: CommandType.StoredProcedure); } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 8d1fe3565f9d..e737dc9d5896 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -371,13 +371,13 @@ public async Task> GetManyConfi } } - public async Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds) + public async Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds, DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.ExecuteAsync( "[dbo].[GroupUser_UpdateGroups]", - new { OrganizationUserId = orgUserId, GroupIds = groupIds.ToGuidIdArrayTVP() }, + new { OrganizationUserId = orgUserId, GroupIds = groupIds.ToGuidIdArrayTVP(), RevisionDate = revisionDate }, commandType: CommandType.StoredProcedure); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs index d00fb6648720..0df120fedb83 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/CollectionRepository.cs @@ -545,9 +545,28 @@ public async Task ReplaceAsync(Core.Entities.Collection collection, IEnumerable< using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); + if (groups != null) { + var existingGroupIds = await dbContext.CollectionGroups + .Where(cg => cg.CollectionId == collection.Id) + .Select(cg => cg.GroupId) + .ToListAsync(); + await ReplaceCollectionGroupsAsync(dbContext, collection, groups); + + var allAffectedGroupIds = existingGroupIds + .Union(groups.Select(g => g.Id)) + .Distinct() + .ToList(); + var affectedGroups = await dbContext.Groups + .Where(g => g.OrganizationId == collection.OrganizationId + && allAffectedGroupIds.Contains(g.Id)) + .ToListAsync(); + foreach (var g in affectedGroups) + { + g.RevisionDate = collection.RevisionDate; + } } if (users != null) { @@ -727,6 +746,20 @@ public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumera c.RevisionDate = revisionDate; } + // Bump the revision date on all affected groups + if (groups != null) + { + var groupIdsList = groups.Select(g => g.Id).ToList(); + var affectedGroups = await dbContext.Groups + .Where(g => g.OrganizationId == organizationId + && groupIdsList.Contains(g.Id)) + .ToListAsync(); + foreach (var g in affectedGroups) + { + g.RevisionDate = revisionDate; + } + } + await dbContext.UserBumpAccountRevisionDateByCollectionIdsAsync(collectionIdsList, organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs index f45a664e7eb2..ed9491dfebad 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs @@ -49,11 +49,18 @@ from c in dbContext.Collections } } - public async Task DeleteUserAsync(Guid groupId, Guid organizationUserId) + public async Task DeleteUserAsync(Guid groupId, Guid organizationUserId, DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); + + var group = await dbContext.Groups.FindAsync(groupId); + if (group != null) + { + group.RevisionDate = revisionDate; + } + var query = from gu in dbContext.GroupUsers where gu.GroupId == groupId && gu.OrganizationUserId == organizationUserId @@ -253,12 +260,16 @@ public async Task ReplaceAsync(AdminConsoleEntities.Group group, IEnumerable organizationUserIds) + public async Task UpdateUsersAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var orgId = (await dbContext.Groups.FindAsync(groupId)).OrganizationId; + var group = await dbContext.Groups.FindAsync(groupId); + var orgId = group.OrganizationId; + + group.RevisionDate = revisionDate; + var insert = from ou in dbContext.OrganizationUsers where organizationUserIds.Contains(ou.Id) && ou.OrganizationId == orgId && @@ -281,12 +292,16 @@ where organizationUserIds.Contains(ou.Id) && } } - public async Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds) + public async Task AddGroupUsersByIdAsync(Guid groupId, IEnumerable organizationUserIds, DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var orgId = (await dbContext.Groups.FindAsync(groupId)).OrganizationId; + var group = await dbContext.Groups.FindAsync(groupId); + var orgId = group.OrganizationId; + + group.RevisionDate = revisionDate; + var insert = from ou in dbContext.OrganizationUsers where organizationUserIds.Contains(ou.Id) && ou.OrganizationId == orgId && diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 27e71a485e48..f35bd828391a 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -729,12 +729,36 @@ from u in u_g } } - public async Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds) + public async Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds, DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); + var orgUser = await dbContext.OrganizationUsers.FindAsync(orgUserId); + if (orgUser != null) + { + var existingGroupIds = await dbContext.GroupUsers + .Where(gu => gu.OrganizationUserId == orgUserId) + .Select(gu => gu.GroupId) + .ToListAsync(); + + var allAffectedGroupIds = existingGroupIds + .Union(groupIds) + .Distinct() + .ToList(); + + var affectedGroups = await dbContext.Groups + .Where(g => g.OrganizationId == orgUser.OrganizationId + && allAffectedGroupIds.Contains(g.Id)) + .ToListAsync(); + + foreach (var g in affectedGroups) + { + g.RevisionDate = revisionDate; + } + } + var procedure = new GroupUserUpdateGroupsQuery(orgUserId, groupIds); var insert = procedure.Insert.Run(dbContext); diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql index 6ca78fc22f0f..32c723674fcc 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql @@ -121,6 +121,18 @@ BEGIN [dbo].[Collection] C INNER JOIN @CollectionIds CI ON C.[Id] = CI.[Id] + + -- Bump the revision date on all affected groups + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + INNER JOIN + @Groups GR ON G.[Id] = GR.[Id] + WHERE + G.[OrganizationId] = @OrganizationId END EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql index 7f7fc2e0d78d..13b03e8d98a4 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroups.sql @@ -14,6 +14,32 @@ BEGIN EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + -- Groups -- Delete groups that are no longer in source DELETE diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql index 29894f984b01..80e980019da4 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -15,6 +15,32 @@ BEGIN EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + -- Groups -- Delete groups that are no longer in source DELETE cg diff --git a/src/Sql/dbo/Stored Procedures/GroupUser_AddUsers.sql b/src/Sql/dbo/Stored Procedures/GroupUser_AddUsers.sql index 362cdce785bd..7df4b7a74eba 100644 --- a/src/Sql/dbo/Stored Procedures/GroupUser_AddUsers.sql +++ b/src/Sql/dbo/Stored Procedures/GroupUser_AddUsers.sql @@ -1,6 +1,7 @@ CREATE PROCEDURE [dbo].[GroupUser_AddUsers] @GroupId UNIQUEIDENTIFIER, - @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -14,6 +15,19 @@ BEGIN [Id] = @GroupId ) + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + -- Insert INSERT INTO [dbo].[GroupUser] (GroupId, OrganizationUserId) diff --git a/src/Sql/dbo/Stored Procedures/GroupUser_Delete.sql b/src/Sql/dbo/Stored Procedures/GroupUser_Delete.sql index bb8ae1da7da5..b99c29bab62f 100644 --- a/src/Sql/dbo/Stored Procedures/GroupUser_Delete.sql +++ b/src/Sql/dbo/Stored Procedures/GroupUser_Delete.sql @@ -1,10 +1,24 @@ CREATE PROCEDURE [dbo].[GroupUser_Delete] @GroupId UNIQUEIDENTIFIER, - @OrganizationUserId UNIQUEIDENTIFIER + @OrganizationUserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + DELETE FROM [dbo].[GroupUser] diff --git a/src/Sql/dbo/Stored Procedures/GroupUser_UpdateGroups.sql b/src/Sql/dbo/Stored Procedures/GroupUser_UpdateGroups.sql index c417d0c4d8dd..68b1fe113689 100644 --- a/src/Sql/dbo/Stored Procedures/GroupUser_UpdateGroups.sql +++ b/src/Sql/dbo/Stored Procedures/GroupUser_UpdateGroups.sql @@ -1,6 +1,7 @@ CREATE PROCEDURE [dbo].[GroupUser_UpdateGroups] @OrganizationUserId UNIQUEIDENTIFIER, - @GroupIds AS [dbo].[GuidIdArray] READONLY + @GroupIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -14,6 +15,35 @@ BEGIN [Id] = @OrganizationUserId ) + -- Bump RevisionDate on all affected groups (old + new) + IF @RevisionDate IS NOT NULL + BEGIN + ;WITH [AffectedGroupsCTE] AS ( + SELECT + [Id] + FROM + @GroupIds + + UNION + + SELECT + GU.[GroupId] + FROM + [dbo].[GroupUser] GU + WHERE + GU.[OrganizationUserId] = @OrganizationUserId + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrgId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + END + -- Insert INSERT INTO [dbo].[GroupUser] diff --git a/src/Sql/dbo/Stored Procedures/GroupUser_UpdateUsers.sql b/src/Sql/dbo/Stored Procedures/GroupUser_UpdateUsers.sql index 750d3fe9ceb5..23fd0a869362 100644 --- a/src/Sql/dbo/Stored Procedures/GroupUser_UpdateUsers.sql +++ b/src/Sql/dbo/Stored Procedures/GroupUser_UpdateUsers.sql @@ -1,6 +1,7 @@ CREATE PROCEDURE [dbo].[GroupUser_UpdateUsers] @GroupId UNIQUEIDENTIFIER, - @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -14,6 +15,19 @@ BEGIN [Id] = @GroupId ) + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + -- Insert INSERT INTO [dbo].[GroupUser] diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/CountsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/CountsControllerTests.cs index eb4b4de8f418..80abd2f0cecd 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/CountsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/CountsControllerTests.cs @@ -413,7 +413,7 @@ private async Task> CreateGroupsAsync(Guid organizationId, Organizat if (user != null) { - await _organizationUserRepository.UpdateGroupsAsync(user.Id, [group.Id]); + await _organizationUserRepository.UpdateGroupsAsync(user.Id, [group.Id], DateTime.UtcNow); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs index 1481ec6a91d7..097a443ba801 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs @@ -8,7 +8,7 @@ using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Bit.Test.Common.Helpers; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -17,20 +17,26 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Groups; [SutProviderCustomize] public class CreateGroupCommandTests { + private static readonly DateTime _expectedTimestamp = DateTime.UtcNow.AddYears(1); + [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task CreateGroup_Success(SutProvider sutProvider, Organization organization, Group group) + public async Task CreateGroup_Success(Organization organization, Group group) { + var sutProvider = SetupSutProvider(); + await sutProvider.Sut.CreateGroupAsync(group, organization); await sutProvider.GetDependency().Received(1).CreateAsync(group); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Created); - AssertHelper.AssertRecent(group.CreationDate); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.CreationDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task CreateGroup_WithCollections_Success(SutProvider sutProvider, Organization organization, Group group, List collections) + public async Task CreateGroup_WithCollections_Success(Organization organization, Group group, List collections) { + var sutProvider = SetupSutProvider(); + // Arrange list of collections to make sure Manage is mutually exclusive for (var i = 0; i < collections.Count; i++) { @@ -44,24 +50,28 @@ public async Task CreateGroup_WithCollections_Success(SutProvider().Received(1).CreateAsync(group, collections); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Created); - AssertHelper.AssertRecent(group.CreationDate); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.CreationDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task CreateGroup_WithEventSystemUser_Success(SutProvider sutProvider, Organization organization, Group group, EventSystemUser eventSystemUser) + public async Task CreateGroup_WithEventSystemUser_Success(Organization organization, Group group, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); + await sutProvider.Sut.CreateGroupAsync(group, organization, eventSystemUser); await sutProvider.GetDependency().Received(1).CreateAsync(group); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Created, eventSystemUser); - AssertHelper.AssertRecent(group.CreationDate); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.CreationDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task CreateGroup_WithNullOrganization_Throws(SutProvider sutProvider, Group group, EventSystemUser eventSystemUser) + public async Task CreateGroup_WithNullOrganization_Throws(Group group, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateGroupAsync(group, null, eventSystemUser)); Assert.Contains("Organization not found", exception.Message); @@ -71,8 +81,10 @@ public async Task CreateGroup_WithNullOrganization_Throws(SutProvider sutProvider, Organization organization, Group group, EventSystemUser eventSystemUser) + public async Task CreateGroup_WithUseGroupsAsFalse_Throws(Organization organization, Group group, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateGroupAsync(group, organization, eventSystemUser)); Assert.Contains("This organization cannot use groups", exception.Message); @@ -80,4 +92,13 @@ public async Task CreateGroup_WithUseGroupsAsFalse_Throws(SutProvider().DidNotReceiveWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedTimestamp); + return sutProvider; + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs index b9f69641237b..413e727d0a5c 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs @@ -11,7 +11,7 @@ using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Bit.Test.Common.Helpers; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -20,10 +20,13 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Groups; [SutProviderCustomize] public class UpdateGroupCommandTests { + private static readonly DateTime _expectedTimestamp = DateTime.UtcNow.AddYears(1); + [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_Success(SutProvider sutProvider, Group group, Group oldGroup, + public async Task UpdateGroup_Success(Group group, Group oldGroup, Organization organization) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -32,13 +35,14 @@ public async Task UpdateGroup_Success(SutProvider sutProvide await sutProvider.GetDependency().Received(1).ReplaceAsync(group); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Updated); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_WithCollections_Success(SutProvider sutProvider, Group group, + public async Task UpdateGroup_WithCollections_Success(Group group, Group oldGroup, Organization organization, List collections) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -56,13 +60,14 @@ public async Task UpdateGroup_WithCollections_Success(SutProvider().Received(1).ReplaceAsync(group, collections); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Updated); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_WithEventSystemUser_Success(SutProvider sutProvider, Group group, + public async Task UpdateGroup_WithEventSystemUser_Success(Group group, Group oldGroup, Organization organization, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -71,13 +76,14 @@ public async Task UpdateGroup_WithEventSystemUser_Success(SutProvider().Received(1).ReplaceAsync(group); await sutProvider.GetDependency().Received(1).LogGroupEventAsync(group, Enums.EventType.Group_Updated, eventSystemUser); - AssertHelper.AssertRecent(group.RevisionDate); + Assert.Equal(_expectedTimestamp, group.RevisionDate); } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_WithNullOrganization_Throws(SutProvider sutProvider, Group group, + public async Task UpdateGroup_WithNullOrganization_Throws(Group group, Group oldGroup, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -89,9 +95,10 @@ public async Task UpdateGroup_WithNullOrganization_Throws(SutProvider sutProvider, + public async Task UpdateGroup_WithUseGroupsAsFalse_Throws( Organization organization, Group group, Group oldGroup, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -105,9 +112,10 @@ public async Task UpdateGroup_WithUseGroupsAsFalse_Throws(SutProvider sutProvider, + public async Task UpdateGroup_GroupBelongsToDifferentOrganization_Throws( Group group, Group oldGroup, Organization organization) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); ArrangeCollections(sutProvider, group); @@ -119,9 +127,10 @@ public async Task UpdateGroup_GroupBelongsToDifferentOrganization_Throws(SutProv } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_CollectionsBelongsToDifferentOrganization_Throws(SutProvider sutProvider, + public async Task UpdateGroup_CollectionsBelongsToDifferentOrganization_Throws( Group group, Group oldGroup, Organization organization, List collectionAccess) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); @@ -135,9 +144,10 @@ await Assert.ThrowsAsync( } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_CollectionsDoNotExist_Throws(SutProvider sutProvider, + public async Task UpdateGroup_CollectionsDoNotExist_Throws( Group group, Group oldGroup, Organization organization, List collectionAccess) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); @@ -157,9 +167,10 @@ await Assert.ThrowsAsync( } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_WithDefaultUserCollectionType_Throws(SutProvider sutProvider, + public async Task UpdateGroup_WithDefaultUserCollectionType_Throws( Group group, Group oldGroup, Organization organization, List collectionAccess) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeUsers(sutProvider, group); @@ -175,9 +186,10 @@ public async Task UpdateGroup_WithDefaultUserCollectionType_Throws(SutProvider sutProvider, + public async Task UpdateGroup_MemberBelongsToDifferentOrganization_Throws( Group group, Group oldGroup, Organization organization, IEnumerable userAccess) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeCollections(sutProvider, group); @@ -191,9 +203,10 @@ await Assert.ThrowsAsync( } [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] - public async Task UpdateGroup_MemberDoesNotExist_Throws(SutProvider sutProvider, + public async Task UpdateGroup_MemberDoesNotExist_Throws( Group group, Group oldGroup, Organization organization, IEnumerable userAccess) { + var sutProvider = SetupSutProvider(); ArrangeGroup(sutProvider, group, oldGroup); ArrangeCollections(sutProvider, group); @@ -212,6 +225,15 @@ await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateGroupAsync(group, organization, null, userAccess)); } + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedTimestamp); + return sutProvider; + } + private void ArrangeGroup(SutProvider sutProvider, Group group, Group oldGroup) { oldGroup.OrganizationId = group.OrganizationId; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommandTests.cs index ad598649a42b..3e0b8b5023d6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserGroupsCommandTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -13,16 +14,22 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; [SutProviderCustomize] public class UpdateOrganizationUserGroupsCommandTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory, BitAutoData] public async Task UpdateUserGroups_ShouldUpdateUserGroupsAndLogUserEvent( OrganizationUser organizationUser, - IEnumerable groupIds, - SutProvider sutProvider) + IEnumerable groupIds) { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + await sutProvider.Sut.UpdateUserGroupsAsync(organizationUser, groupIds); await sutProvider.GetDependency().Received(1) - .UpdateGroupsAsync(organizationUser.Id, groupIds); + .UpdateGroupsAsync(organizationUser.Id, groupIds, Arg.Is(d => d == _expectedRevisionDate)); await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups); } diff --git a/test/Core.Test/AdminConsole/Services/GroupServiceTests.cs b/test/Core.Test/AdminConsole/Services/GroupServiceTests.cs index 4d1db2ab01da..563761146c3e 100644 --- a/test/Core.Test/AdminConsole/Services/GroupServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/GroupServiceTests.cs @@ -9,6 +9,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; @@ -18,6 +19,8 @@ namespace Bit.Core.Test.AdminConsole.Services; [OrganizationCustomize(UseGroups = true)] public class GroupServiceTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory, BitAutoData] public async Task DeleteAsync_ValidData_DeletesGroup(Group group, SutProvider sutProvider) { @@ -37,8 +40,9 @@ public async Task DeleteAsync_ValidData_WithEventSystemUser_DeletesGroup(Group g } [Theory, BitAutoData] - public async Task DeleteUserAsync_ValidData_DeletesUserInGroupRepository(Group group, Organization organization, OrganizationUser organizationUser, SutProvider sutProvider) + public async Task DeleteUserAsync_ValidData_DeletesUserInGroupRepository(Group group, Organization organization, OrganizationUser organizationUser) { + var sutProvider = SetupSutProvider(); group.OrganizationId = organization.Id; organization.UseGroups = true; organizationUser.OrganizationId = organization.Id; @@ -47,14 +51,15 @@ public async Task DeleteUserAsync_ValidData_DeletesUserInGroupRepository(Group g await sutProvider.Sut.DeleteUserAsync(group, organizationUser.Id); - await sutProvider.GetDependency().Received().DeleteUserAsync(group.Id, organizationUser.Id); + await sutProvider.GetDependency().Received().DeleteUserAsync(group.Id, organizationUser.Id, Arg.Is(d => d == _expectedRevisionDate)); await sutProvider.GetDependency().Received() .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups); } [Theory, BitAutoData] - public async Task DeleteUserAsync_ValidData_WithEventSystemUser_DeletesUserInGroupRepository(Group group, Organization organization, OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) + public async Task DeleteUserAsync_ValidData_WithEventSystemUser_DeletesUserInGroupRepository(Group group, Organization organization, OrganizationUser organizationUser, EventSystemUser eventSystemUser) { + var sutProvider = SetupSutProvider(); group.OrganizationId = organization.Id; organization.UseGroups = true; organizationUser.OrganizationId = organization.Id; @@ -63,7 +68,7 @@ public async Task DeleteUserAsync_ValidData_WithEventSystemUser_DeletesUserInGro await sutProvider.Sut.DeleteUserAsync(group, organizationUser.Id, eventSystemUser); - await sutProvider.GetDependency().Received().DeleteUserAsync(group.Id, organizationUser.Id); + await sutProvider.GetDependency().Received().DeleteUserAsync(group.Id, organizationUser.Id, Arg.Is(d => d == _expectedRevisionDate)); await sutProvider.GetDependency().Received() .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups, eventSystemUser); } @@ -82,8 +87,17 @@ public async Task DeleteUserAsync_InvalidUser_ThrowsNotFound(Group group, Organi // invalid user await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteUserAsync(group, Guid.NewGuid())); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .DeleteUserAsync(default, default); + .DeleteUserAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .LogOrganizationUserEventAsync(default, default); } + + 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/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs index da5d7e3361ba..f8b6ee257d0f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs @@ -116,7 +116,7 @@ public async Task GetByIdWithPermissionsAsync_UserOverrideGroup_Success(IUserRep }); // Assign the test user to the test group - await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }, DateTime.UtcNow); var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, }; @@ -191,8 +191,8 @@ public async Task GetByIdWithPermissionsAsync_CombineGroupPermissions_Success(IU }); // Assign the test user to the test groups - await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); - await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }, DateTime.UtcNow); + await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }, DateTime.UtcNow); var collection = new Collection { Name = "Test Collection", OrganizationId = organization.Id, }; @@ -399,8 +399,8 @@ public async Task GetManyByOrganizationIdWithPermissionsAsync_GroupBy_Success(IU }); // Assign the test user to the test groups - await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); - await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }, DateTime.UtcNow); + await groupRepository.UpdateUsersAsync(group2.Id, new[] { orgUser.Id }, DateTime.UtcNow); var collection1 = new Collection { Name = "Collection 1", OrganizationId = organization.Id, }; diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs index 5d936565c7ef..f322634f2976 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs @@ -95,12 +95,18 @@ public async Task AddGroupUsersByIdAsync_CreatesGroupUsers( var orgUserIds = new List([orgUser1.Id, orgUser2.Id, orgUser3.Id]); var group = await groupRepository.CreateTestGroupAsync(org); + var expectedRevisionDate = DateTime.UtcNow.AddMinutes(10); + // Act - await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds); + await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds, expectedRevisionDate); // Assert var actual = await groupRepository.GetManyUserIdsByIdAsync(group.Id); Assert.Equal(orgUserIds!.Order(), actual.Order()); + + var actualGroup = await groupRepository.GetByIdAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal(expectedRevisionDate, actualGroup.RevisionDate, TimeSpan.FromMilliseconds(10)); } [DatabaseTheory, DatabaseData] @@ -123,12 +129,12 @@ public async Task AddGroupUsersByIdAsync_IgnoresExistingGroupUsers( var group = await groupRepository.CreateTestGroupAsync(org); // Add user 2 to the group already, make sure this is executed correctly before proceeding - await groupRepository.UpdateUsersAsync(group.Id, [orgUser2.Id]); + await groupRepository.UpdateUsersAsync(group.Id, [orgUser2.Id], DateTime.UtcNow); var existingUsers = await groupRepository.GetManyUserIdsByIdAsync(group.Id); Assert.Equal([orgUser2.Id], existingUsers); // Act - await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds); + await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds, DateTime.UtcNow); // Assert - group should contain all users var actual = await groupRepository.GetManyUserIdsByIdAsync(group.Id); @@ -159,7 +165,7 @@ public async Task AddGroupUsersByIdAsync_IgnoresUsersNotInOrganization( var group = await groupRepository.CreateTestGroupAsync(org); // Act - await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds); + await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds, DateTime.UtcNow); // Assert var actual = await groupRepository.GetManyUserIdsByIdAsync(group.Id); @@ -188,7 +194,7 @@ public async Task AddGroupUsersByIdAsync_IgnoresDuplicateUsers( var group = await groupRepository.CreateTestGroupAsync(org); // Act - await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds); + await groupRepository.AddGroupUsersByIdAsync(group.Id, orgUserIds, DateTime.UtcNow); // Assert var actual = await groupRepository.GetManyUserIdsByIdAsync(group.Id); @@ -196,4 +202,60 @@ public async Task AddGroupUsersByIdAsync_IgnoresDuplicateUsers( Assert.Contains(orgUser1.Id, actual); Assert.Contains(orgUser2.Id, actual); } + + [DatabaseTheory, DatabaseData] + public async Task UpdateUsersAsync_BumpsGroupRevisionDate( + IGroupRepository groupRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + // Arrange + var user1 = await userRepository.CreateTestUserAsync("user1"); + var user2 = await userRepository.CreateTestUserAsync("user2"); + + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user1); + var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user2); + var group = await groupRepository.CreateTestGroupAsync(org); + + var expectedRevisionDate = DateTime.UtcNow.AddMinutes(10); + + // Act + await groupRepository.UpdateUsersAsync(group.Id, [orgUser1.Id, orgUser2.Id], expectedRevisionDate); + + // Assert + var actualGroup = await groupRepository.GetByIdAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal(expectedRevisionDate, actualGroup.RevisionDate, TimeSpan.FromMilliseconds(10)); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteUserAsync_BumpsGroupRevisionDate( + IGroupRepository groupRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + var group = await groupRepository.CreateTestGroupAsync(org); + + await groupRepository.UpdateUsersAsync(group.Id, [orgUser.Id], DateTime.UtcNow); + var existingUsers = await groupRepository.GetManyUserIdsByIdAsync(group.Id); + Assert.Equal([orgUser.Id], existingUsers); + + var expectedRevisionDate = DateTime.UtcNow.AddMinutes(10); + + // Act + await groupRepository.DeleteUserAsync(group.Id, orgUser.Id, expectedRevisionDate); + + // Assert + var actualGroup = await groupRepository.GetByIdAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal(expectedRevisionDate, actualGroup.RevisionDate, TimeSpan.FromMilliseconds(10)); + } + } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 2b494ef7874a..bbb4383639f5 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1476,4 +1476,32 @@ public async Task ConfirmOrganizationUserAsync_WhenUserDoesNotExist_ReturnsFalse // Assert Assert.False(result); } + + [DatabaseTheory, DatabaseData] + public async Task UpdateGroupsAsync_BumpsGroupRevisionDate( + IGroupRepository groupRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + var group1 = await groupRepository.CreateTestGroupAsync(org, "group1"); + var group2 = await groupRepository.CreateTestGroupAsync(org, "group2"); + + var expectedRevisionDate = DateTime.UtcNow.AddMinutes(10); + + // Act + await organizationUserRepository.UpdateGroupsAsync(orgUser.Id, [group1.Id, group2.Id], expectedRevisionDate); + + // Assert + var actualGroup1 = await groupRepository.GetByIdAsync(group1.Id); + var actualGroup2 = await groupRepository.GetByIdAsync(group2.Id); + Assert.NotNull(actualGroup1); + Assert.NotNull(actualGroup2); + Assert.Equal(expectedRevisionDate, actualGroup1.RevisionDate, TimeSpan.FromMilliseconds(10)); + Assert.Equal(expectedRevisionDate, actualGroup2.RevisionDate, TimeSpan.FromMilliseconds(10)); + } } diff --git a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index 713e6bfcacba..4d7dca875957 100644 --- a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -45,8 +45,8 @@ public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganiz var group1 = await groupRepository.CreateTestGroupAsync(organization, "test-group-1"); var group2 = await groupRepository.CreateTestGroupAsync(organization, "test-group-2"); - await groupRepository.UpdateUsersAsync(group1.Id, [orgUser1.Id]); - await groupRepository.UpdateUsersAsync(group2.Id, [orgUser3.Id]); + await groupRepository.UpdateUsersAsync(group1.Id, [orgUser1.Id], DateTime.UtcNow); + await groupRepository.UpdateUsersAsync(group2.Id, [orgUser3.Id], DateTime.UtcNow); var collection1 = new Collection { diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index a33caa6ea3f1..6fdbe05ee1e8 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -246,7 +246,7 @@ IGroupRepository groupRepository OrganizationId = organization.Id, Name = "Edit Group", }); - await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser.Id }, DateTime.UtcNow); // MANAGE @@ -487,7 +487,7 @@ public async Task GetCipherPermissionsForOrganizationAsync_ManageProperty_Respec OrganizationId = organization.Id, Name = "Test Group", }); - await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }); + await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id }, DateTime.UtcNow); var (manageCipher, nonManageCipher) = await CreateCipherInOrganizationCollectionWithGroup( organization, group, cipherRepository, collectionRepository, collectionCipherRepository, groupRepository); @@ -822,7 +822,7 @@ IGroupRepository groupRepository OrganizationId = organization.Id, Name = "Edit Group", }); - await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser1.Id }); + await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser1.Id }, DateTime.UtcNow); // Add collections to Org var manageCollection = await collectionRepository.CreateAsync(new Collection diff --git a/util/Migrator/DbScripts/2026-04-28_00_GroupBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-28_00_GroupBumpRevisionDateOnAccessChange.sql new file mode 100644 index 000000000000..864de9e9712e --- /dev/null +++ b/util/Migrator/DbScripts/2026-04-28_00_GroupBumpRevisionDateOnAccessChange.sql @@ -0,0 +1,627 @@ +-- Bump Group.RevisionDate when group membership or collection-group access is modified via: +-- 1. Group member updates (GroupUser_UpdateUsers) +-- 2. User group updates (GroupUser_UpdateGroups) +-- 3. Group member removal (GroupUser_Delete) +-- 4. Group member additions (GroupUser_AddUsers) +-- 5. Collection update with groups and users (Collection_UpdateWithGroupsAndUsers) +-- 6. Collection update with groups (Collection_UpdateWithGroups) +-- 7. Bulk collection access (Collection_CreateOrUpdateAccessForMany) + +CREATE OR ALTER PROCEDURE [dbo].[GroupUser_UpdateUsers] + @GroupId UNIQUEIDENTIFIER, + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgId UNIQUEIDENTIFIER = ( + SELECT TOP 1 + [OrganizationId] + FROM + [dbo].[Group] + WHERE + [Id] = @GroupId + ) + + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + + -- Insert + INSERT INTO + [dbo].[GroupUser] + SELECT + @GroupId, + [Source].[Id] + FROM + @OrganizationUserIds AS [Source] + INNER JOIN + [dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[GroupUser] + WHERE + [GroupId] = @GroupId + AND [OrganizationUserId] = [Source].[Id] + ) + + -- Delete + DELETE + GU + FROM + [dbo].[GroupUser] GU + WHERE + GU.[GroupId] = @GroupId + AND NOT EXISTS ( + SELECT + 1 + FROM + @OrganizationUserIds + WHERE + [Id] = GU.[OrganizationUserId] + ) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrgId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[GroupUser_UpdateGroups] + @OrganizationUserId UNIQUEIDENTIFIER, + @GroupIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgId UNIQUEIDENTIFIER = ( + SELECT TOP 1 + [OrganizationId] + FROM + [dbo].[OrganizationUser] + WHERE + [Id] = @OrganizationUserId + ) + + -- Bump RevisionDate on all affected groups (old + new) + IF @RevisionDate IS NOT NULL + BEGIN + ;WITH [AffectedGroupsCTE] AS ( + SELECT + [Id] + FROM + @GroupIds + + UNION + + SELECT + GU.[GroupId] + FROM + [dbo].[GroupUser] GU + WHERE + GU.[OrganizationUserId] = @OrganizationUserId + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrgId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + END + + -- Insert + INSERT INTO + [dbo].[GroupUser] + SELECT + [Source].[Id], + @OrganizationUserId + FROM + @GroupIds [Source] + INNER JOIN + [dbo].[Group] G ON G.[Id] = [Source].[Id] AND G.[OrganizationId] = @OrgId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[GroupUser] + WHERE + [OrganizationUserId] = @OrganizationUserId + AND [GroupId] = [Source].[Id] + ) + + -- Delete + DELETE + GU + FROM + [dbo].[GroupUser] GU + WHERE + GU.[OrganizationUserId] = @OrganizationUserId + AND NOT EXISTS ( + SELECT + 1 + FROM + @GroupIds + WHERE + [Id] = GU.[GroupId] + ) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserId] @OrganizationUserId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[GroupUser_Delete] + @GroupId UNIQUEIDENTIFIER, + @OrganizationUserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + + DELETE + FROM + [dbo].[GroupUser] + WHERE + [GroupId] = @GroupId + AND [OrganizationUserId] = @OrganizationUserId + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserId] @OrganizationUserId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[GroupUser_AddUsers] + @GroupId UNIQUEIDENTIFIER, + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @OrgId UNIQUEIDENTIFIER = ( + SELECT TOP 1 + [OrganizationId] + FROM + [dbo].[Group] + WHERE + [Id] = @GroupId + ) + + -- Bump RevisionDate on the affected group + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[Id] = @GroupId + END + + -- Insert + INSERT INTO + [dbo].[GroupUser] (GroupId, OrganizationUserId) + SELECT DISTINCT + @GroupId, + [Source].[Id] + FROM + @OrganizationUserIds AS [Source] + INNER JOIN + [dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[GroupUser] + WHERE + [GroupId] = @GroupId + AND [OrganizationUserId] = [Source].[Id] + ) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrgId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + + -- Groups + -- Delete groups that are no longer in source + DELETE cg + FROM [dbo].[CollectionGroup] cg + LEFT JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE cg + SET cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM [dbo].[CollectionGroup] cg + INNER JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND (cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM @Groups g + INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN [dbo].[CollectionGroup] cg + ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + -- Users + -- Delete users that are no longer in source + DELETE cu + FROM [dbo].[CollectionUser] cu + LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE cu + SET cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM [dbo].[CollectionUser] cu + INNER JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND (cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM @Users u + INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN [dbo].[CollectionUser] cu + ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Bump RevisionDate on all affected groups (old + new) before modifying CollectionGroup + ;WITH [AffectedGroupsCTE] AS ( + SELECT + g.[Id] + FROM + @Groups g + + UNION + + SELECT + CG.[GroupId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[CollectionId] = @Id + ) + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + WHERE + G.[OrganizationId] = @OrganizationId + AND G.[Id] IN (SELECT [Id] FROM [AffectedGroupsCTE]) + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +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] + + -- Bump the revision date on all affected groups + UPDATE + G + SET + G.[RevisionDate] = @RevisionDate + FROM + [dbo].[Group] G + INNER JOIN + @Groups GR ON G.[Id] = GR.[Id] + WHERE + G.[OrganizationId] = @OrganizationId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId +END +GO