diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 4722307d10..344a326519 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -9,6 +9,19 @@ "nuget", ], packageRules: [ + { + // Group all release-related workflows for GitHub Actions together for BRE. + groupName: "github-action", + matchManagers: ["github-actions"], + matchFileNames: [ + ".github/workflows/publish.yml", + ".github/workflows/release.yml", + ".github/workflows/repository-management.yml" + ], + commitMessagePrefix: "[deps] BRE:", + reviewers: ["team:dept-bre"], + addLabels: ["hold"] + }, { groupName: "dockerfile minor", matchManagers: ["dockerfile"], diff --git a/Directory.Build.props b/Directory.Build.props index e4712937ca..796fb26364 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.4.1 + 2025.4.2 Bit.$(MSBuildProjectName) enable diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 00d0d3de03..ab888a52b8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -494,7 +494,7 @@ public class OrganizationUsersController : Controller } var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); - var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; + var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) { throw new BadRequestException("Incorrect password"); diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 10212b3e38..b03642e372 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -284,52 +284,6 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } - [HttpPost("set-key-connector-key")] - public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - - [HttpPost("convert-to-key-connector")] - public async Task PostConvertToKeyConnector() - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.ConvertToKeyConnectorAsync(user); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - [HttpPost("kdf")] public async Task PostKdf([FromBody] KdfRequestModel model) { diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 6851aed5de..0ff4e93abe 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -128,6 +128,7 @@ public class DevicesController : Controller } [HttpPost("{identifier}/retrieve-keys")] + [Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")] public async Task GetDeviceKeys(string identifier) { var user = await _userService.GetUserByPrincipalAsync(User); diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 0764e2ee28..9fc0e9a75a 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -24,7 +24,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.KeyManagement.Controllers; -[Route("accounts/key-management")] +[Route("accounts")] [Authorize("Application")] public class AccountsKeyManagementController : Controller { @@ -77,7 +77,7 @@ public class AccountsKeyManagementController : Controller _deviceValidator = deviceValidator; } - [HttpPost("regenerate-keys")] + [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) @@ -93,7 +93,7 @@ public class AccountsKeyManagementController : Controller } - [HttpPost("rotate-user-account-keys")] + [HttpPost("key-management/rotate-user-account-keys")] public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -133,4 +133,50 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpPost("set-key-connector-key")] + public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("convert-to-key-connector")] + public async Task PostConvertToKeyConnectorAsync() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } diff --git a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs similarity index 94% rename from src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 25d543b916..bac42bc302 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Api.Auth.Models.Request.Accounts; +namespace Bit.Api.KeyManagement.Models.Requests; public class SetKeyConnectorKeyRequestModel { diff --git a/src/Api/Models/Response/DeviceResponseModel.cs b/src/Api/Models/Response/DeviceResponseModel.cs index 7d36f0aa21..44f8a16db2 100644 --- a/src/Api/Models/Response/DeviceResponseModel.cs +++ b/src/Api/Models/Response/DeviceResponseModel.cs @@ -2,6 +2,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; +using Bit.Core.Utilities; namespace Bit.Api.Models.Response; @@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel Identifier = device.Identifier; CreationDate = device.CreationDate; IsTrusted = device.IsTrusted(); + EncryptedUserKey = device.EncryptedUserKey; + EncryptedPublicKey = device.EncryptedPublicKey; } public Guid Id { get; set; } @@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel public string Identifier { get; set; } public DateTime CreationDate { get; set; } public bool IsTrusted { get; set; } + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedUserKey { get; set; } + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPublicKey { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 4eacb9386a..662ed314ce 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task RevertPasswordManagerChangesAsync(Valid validatedResult, Organization organization) { - if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) + if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 }) { - // When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add. - // However, this might lead to a problem if we don't actually update stripe but throw any ways. - // stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. - var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; - await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); + + + await paymentService.AdjustSeatsAsync(organization, + validatedResult.Value.InviteOrganization.Plan, + validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value); organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; @@ -206,10 +206,26 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task SendAdditionalEmailsAsync(Valid validatedResult, Organization organization) { - await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); + await NotifyOwnersIfAutoscaleOccursAsync(validatedResult, organization); + await NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(validatedResult, organization); } - private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid validatedResult, Organization organization) + private async Task NotifyOwnersIfAutoscaleOccursAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 + && !organization.OwnersNotifiedOfAutoscaling.HasValue) + { + await mailService.SendOrganizationAutoscaledEmailAsync( + organization, + validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats!.Value, + await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization)); + + organization.OwnersNotifiedOfAutoscaling = validatedResult.Value.PerformedAt.UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(Valid validatedResult, Organization organization) { if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) { @@ -258,25 +274,25 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task AdjustPasswordManagerSeatsAsync(Valid validatedResult, Organization organization) { - if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) + if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 }) { - return; + await paymentService.AdjustSeatsAsync(organization, + validatedResult.Value.InviteOrganization.Plan, + validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + + await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) + { + PlanName = validatedResult.Value.InviteOrganization.Plan.Name, + PlanType = validatedResult.Value.InviteOrganization.Plan.Type, + Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, + PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats + }); } - - await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd); - - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; - - await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update - await applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) - { - PlanName = validatedResult.Value.InviteOrganization.Plan.Name, - PlanType = validatedResult.Value.InviteOrganization.Plan.Type, - Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, - PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats - }); } } diff --git a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs index 59630a6d2c..3e3076e84e 100644 --- a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; using Bit.Core.Models.Api; +using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Response; @@ -19,6 +20,8 @@ public class DeviceAuthRequestResponseModel : ResponseModel Identifier = deviceAuthDetails.Identifier, CreationDate = deviceAuthDetails.CreationDate, IsTrusted = deviceAuthDetails.IsTrusted, + EncryptedPublicKey = deviceAuthDetails.EncryptedPublicKey, + EncryptedUserKey = deviceAuthDetails.EncryptedUserKey }; if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null) @@ -39,6 +42,12 @@ public class DeviceAuthRequestResponseModel : ResponseModel public string Identifier { get; set; } public DateTime CreationDate { get; set; } public bool IsTrusted { get; set; } + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedUserKey { get; set; } + [EncryptedString] + [EncryptedStringLength(2000)] + public string EncryptedPublicKey { get; set; } public PendingAuthRequest DevicePendingAuthRequest { get; set; } diff --git a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs index ef242705f4..f9f799c308 100644 --- a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs +++ b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs @@ -29,6 +29,8 @@ public class DeviceAuthDetails : Device Identifier = device.Identifier; CreationDate = device.CreationDate; IsTrusted = device.IsTrusted(); + EncryptedPublicKey = device.EncryptedPublicKey; + EncryptedUserKey = device.EncryptedUserKey; AuthRequestId = authRequestId; AuthRequestCreatedAt = authRequestCreationDate; } @@ -74,6 +76,8 @@ public class DeviceAuthDetails : Device EncryptedPrivateKey = encryptedPrivateKey, Active = active }.IsTrusted(); + EncryptedPublicKey = encryptedPublicKey; + EncryptedUserKey = encryptedUserKey; AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null; AuthRequestCreatedAt = authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8071a933f6..2c47e92c76 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -112,7 +112,6 @@ public static class FeatureFlagKeys /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; - public const string DuoRedirect = "duo-redirect"; public const string EmailVerification = "email-verification"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; @@ -176,6 +175,7 @@ public static class FeatureFlagKeys public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string MobileErrorReporting = "mobile-error-reporting"; + public const string AndroidChromeAutofill = "android-chrome-autofill"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; @@ -215,9 +215,6 @@ public static class FeatureFlagKeys public static Dictionary GetLocalOverrideFlagValues() { // place overriding values when needed locally (offline), or return null - return new Dictionary() - { - { DuoRedirect, "true" }, - }; + return null; } } diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql index da7e77cc14..4a66b20d86 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -14,98 +14,88 @@ BEGIN EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate -- Groups - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[Group] - WHERE - OrganizationId = @OrganizationId + -- 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] ) - MERGE - [dbo].[CollectionGroup] AS [Target] - USING - @Groups AS [Source] - ON - [Target].[CollectionId] = @Id - AND [Target].[GroupId] = [Source].[Id] - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN - INSERT -- Add explicit column list - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords], - [Manage] - ) - VALUES - ( - @Id, - [Source].[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].[CollectionId] = @Id THEN - DELETE - ; + 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 - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[OrganizationUser] - WHERE - OrganizationId = @OrganizationId + -- 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] ) - MERGE - [dbo].[CollectionUser] AS [Target] - USING - @Users AS [Source] - ON - [Target].[CollectionId] = @Id - AND [Target].[OrganizationUserId] = [Source].[Id] - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN - INSERT - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords], - [Manage] - ) - VALUES - ( - @Id, - [Source].[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].[CollectionId] = @Id THEN - DELETE - ; + 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 diff --git a/src/Sql/dbo/Tables/CollectionGroup.sql b/src/Sql/dbo/Tables/CollectionGroup.sql index 756cd79ece..72a6710e2a 100644 --- a/src/Sql/dbo/Tables/CollectionGroup.sql +++ b/src/Sql/dbo/Tables/CollectionGroup.sql @@ -9,3 +9,9 @@ CONSTRAINT [FK_CollectionGroup_Group] FOREIGN KEY ([GroupId]) REFERENCES [dbo].[Group] ([Id]) ON DELETE CASCADE ); +GO +CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId + ON [dbo].[CollectionGroup] (GroupId) + INCLUDE (ReadOnly, HidePasswords, Manage) + +GO diff --git a/src/Sql/dbo/Tables/CollectionUser.sql b/src/Sql/dbo/Tables/CollectionUser.sql index 8e0c0ef035..afdb0f84a0 100644 --- a/src/Sql/dbo/Tables/CollectionUser.sql +++ b/src/Sql/dbo/Tables/CollectionUser.sql @@ -9,3 +9,9 @@ CONSTRAINT [FK_CollectionUser_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ); +GO +CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId + ON [dbo].[CollectionUser] (OrganizationUserId) + INCLUDE (ReadOnly, HidePasswords, Manage) + +GO diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 9370948a85..f2bc9f4bac 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -59,7 +59,8 @@ public static class OrganizationTestHelpers string userEmail, OrganizationUserType type, bool accessSecretsManager = false, - Permissions? permissions = null + Permissions? permissions = null, + OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed ) where T : class { var userRepository = factory.GetService(); @@ -74,7 +75,7 @@ public static class OrganizationTestHelpers UserId = user.Id, Key = null, Type = type, - Status = OrganizationUserStatusType.Confirmed, + Status = userStatusType, ExternalId = null, AccessSecretsManager = accessSecretsManager, }; diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1b065adbd6..bf27d7f0d1 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -1,4 +1,5 @@ -using System.Net; +#nullable enable +using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; @@ -7,6 +8,7 @@ using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -31,6 +33,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher; + private readonly IOrganizationRepository _organizationRepository; private string _ownerEmail = null!; public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) @@ -45,6 +48,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); _organizationUserRepository = _factory.GetService(); _passwordHasher = _factory.GetService>(); + _organizationRepository = _factory.GetService(); } public async Task InitializeAsync() @@ -174,7 +178,8 @@ public class AccountsKeyManagementControllerTests : IClassFixture>(result); + var resultDevice = result.Data.First(); + Assert.Equal("chrome", resultDevice.Name); + Assert.Equal(DeviceType.ChromeBrowser, resultDevice.Type); + Assert.Equal(Guid.Parse("B3136B10-7818-444F-B05B-4D7A9B8C48BF"), resultDevice.Id); + Assert.Equal(Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString(), resultDevice.Identifier); + Assert.Equal("PublicKey", resultDevice.EncryptedPublicKey); + Assert.Equal("UserKey", resultDevice.EncryptedUserKey); } [Fact] diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 49c4f88cb4..d2775762e8 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -178,4 +178,133 @@ public class AccountsKeyManagementControllerTests Assert.NotEmpty(ex.ModelState.Values); } } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_UserNull_Throws( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "set key connector key error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("set key connector key error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(data); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostConvertToKeyConnectorAsync(); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index ba7605d682..0592b481d3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -287,6 +287,77 @@ public class InviteOrganizationUserCommandTests Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); } + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrgToAutoscale_ThenOrganizationOwnersShouldBeNotified( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, + inviteOrganization.Seats.Value, + Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); + } + [Theory] [BitAutoData] public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated( @@ -349,7 +420,7 @@ public class InviteOrganizationUserCommandTests Assert.IsType>(result); await sutProvider.GetDependency() - .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd); + .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.UpdatedSeatTotal!.Value); await orgRepository.Received(1).ReplaceAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); @@ -610,4 +681,237 @@ public class InviteOrganizationUserCommandTests .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProviderAndAutoscaleOccurs_ThenAnEmailShouldBeSentToTheProvider( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + providerOrganization.OrganizationId = organization.Id; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + sutProvider.GetDependency() + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed) + .Returns(new List + { + new() + { + Email = "provider@email.com" + } + }); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + sutProvider.GetDependency().Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, 1, + Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationAutoscalesButOwnersHaveAlreadyBeenNotified_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationDoesNotAutoScale_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 2; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs index fa7197ff61..268d46ef6b 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs @@ -599,5 +599,11 @@ public class CollectionRepositoryTests Assert.True(actualOrgUser3.Manage); Assert.False(actualOrgUser3.HidePasswords); Assert.True(actualOrgUser3.ReadOnly); + + // Clean up data + await userRepository.DeleteAsync(user); + await organizationRepository.DeleteAsync(organization); + await groupRepository.DeleteManyAsync([group1.Id, group2.Id, group3.Id]); + await organizationUserRepository.DeleteManyAsync([orgUser1.Id, orgUser2.Id, orgUser3.Id]); } } diff --git a/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql b/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql new file mode 100644 index 0000000000..5bdeb5b254 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql @@ -0,0 +1,118 @@ +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 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- 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 + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionGroup_GroupId') + BEGIN + CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId + ON [dbo].[CollectionGroup] (GroupId) + INCLUDE (ReadOnly, HidePasswords, Manage) + END +GO + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionUser_OrganizationUserId') + BEGIN + CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId + ON [dbo].[CollectionUser] (OrganizationUserId) + INCLUDE (ReadOnly, HidePasswords, Manage) + END +GO