1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-27 07:42:15 -05:00

Merge branch 'main' into ac/pm-14613/feature-flag-removal---step-1-remove-flagged-logic-from-clients/server-and-clients-feature-flag

This commit is contained in:
Thomas Rittson 2025-04-22 09:20:51 +10:00 committed by GitHub
commit 8a469f484e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 893 additions and 177 deletions

View File

@ -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"],

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.4.1</Version>
<Version>2025.4.2</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -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");

View File

@ -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)
{

View File

@ -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<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{
var user = await _userService.GetUserByPrincipalAsync(User);

View File

@ -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);
}
}

View File

@ -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
{

View File

@ -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; }
}

View File

@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> 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<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
await NotifyOwnersIfAutoscaleOccursAsync(validatedResult, organization);
await NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(validatedResult, organization);
}
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
private async Task NotifyOwnersIfAutoscaleOccursAsync(Valid<InviteOrganizationUsersValidationRequest> 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<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
{
@ -258,12 +274,11 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> 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.SeatsRequiredToAdd);
await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
@ -280,3 +295,4 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
});
}
}
}

View File

@ -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; }

View File

@ -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;

View File

@ -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<string, string> GetLocalOverrideFlagValues()
{
// place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>()
{
{ DuoRedirect, "true" },
};
return null;
}
}

View File

@ -14,24 +14,27 @@ BEGIN
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[Group]
WHERE
OrganizationId = @OrganizationId
)
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
-- 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],
@ -39,46 +42,41 @@ BEGIN
[HidePasswords],
[Manage]
)
VALUES
(
SELECT
@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
;
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
)
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
-- 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],
@ -86,26 +84,18 @@ BEGIN
[HidePasswords],
[Manage]
)
VALUES
(
SELECT
@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
;
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

View File

@ -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

View File

@ -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

View File

@ -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<IUserRepository>();
@ -74,7 +75,7 @@ public static class OrganizationTestHelpers
UserId = user.Id,
Key = null,
Type = type,
Status = OrganizationUserStatusType.Confirmed,
Status = userStatusType,
ExternalId = null,
AccessSecretsManager = accessSecretsManager,
};

View File

@ -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<ApiApplication
private readonly IUserRepository _userRepository;
private readonly IDeviceRepository _deviceRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IOrganizationRepository _organizationRepository;
private string _ownerEmail = null!;
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
@ -45,6 +48,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
_emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
_organizationRepository = _factory.GetService<IOrganizationRepository>();
}
public async Task InitializeAsync()
@ -174,7 +178,8 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeysAsync_NotLoggedIn_Unauthorized(RotateUserAccountKeysAndDataRequestModel request)
public async Task RotateUserAccountKeysAsync_NotLoggedIn_Unauthorized(
RotateUserAccountKeysAndDataRequestModel request)
{
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
@ -256,4 +261,97 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
}
[Theory]
[BitAutoData]
public async Task PostSetKeyConnectorKeyAsync_NotLoggedIn_Unauthorized(SetKeyConnectorKeyRequestModel request)
{
var response = await _client.PostAsJsonAsync("/accounts/set-key-connector-key", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[BitAutoData]
public async Task PostSetKeyConnectorKeyAsync_Success(string organizationSsoIdentifier,
SetKeyConnectorKeyRequestModel request)
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);
organization.UseKeyConnector = true;
organization.UseSso = true;
organization.Identifier = organizationSsoIdentifier;
await _organizationRepository.ReplaceAsync(organization);
var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ssoUserEmail);
await _loginHelper.LoginAsync(ssoUserEmail);
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
var ssoUser = await _userRepository.GetByEmailAsync(ssoUserEmail);
Assert.NotNull(ssoUser);
request.Keys = new KeysRequestModel
{
PublicKey = ssoUser.PublicKey,
EncryptedPrivateKey = ssoUser.PrivateKey
};
request.Key = _mockEncryptedString;
request.OrgIdentifier = organizationSsoIdentifier;
var response = await _client.PostAsJsonAsync("/accounts/set-key-connector-key", request);
response.EnsureSuccessStatusCode();
var user = await _userRepository.GetByEmailAsync(ssoUserEmail);
Assert.NotNull(user);
Assert.Equal(request.Key, user.Key);
Assert.True(user.UsesKeyConnector);
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
var ssoOrganizationUser =
await _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
Assert.NotNull(ssoOrganizationUser);
Assert.Equal(OrganizationUserStatusType.Accepted, ssoOrganizationUser.Status);
Assert.Equal(user.Id, ssoOrganizationUser.UserId);
Assert.Null(ssoOrganizationUser.Email);
}
[Fact]
public async Task PostConvertToKeyConnectorAsync_NotLoggedIn_Unauthorized()
{
var response = await _client.PostAsJsonAsync("/accounts/convert-to-key-connector", new { });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PostConvertToKeyConnectorAsync_Success()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);
organization.UseKeyConnector = true;
organization.UseSso = true;
await _organizationRepository.ReplaceAsync(organization);
var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ssoUserEmail);
await _loginHelper.LoginAsync(ssoUserEmail);
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail,
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Accepted);
var response = await _client.PostAsJsonAsync("/accounts/convert-to-key-connector", new { });
response.EnsureSuccessStatusCode();
var user = await _userRepository.GetByEmailAsync(ssoUserEmail);
Assert.NotNull(user);
Assert.Null(user.MasterPassword);
Assert.True(user.UsesKeyConnector);
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
}
}

View File

@ -63,7 +63,9 @@ public class DevicesControllerTest
UserId = userId,
Name = "chrome",
Type = DeviceType.ChromeBrowser,
Identifier = Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString()
Identifier = Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString(),
EncryptedPublicKey = "PublicKey",
EncryptedUserKey = "UserKey",
},
Guid.Parse("E09D6943-D574-49E5-AC85-C3F12B4E019E"),
authDateTimeResponse)
@ -78,6 +80,13 @@ public class DevicesControllerTest
// Assert
Assert.NotNull(result);
Assert.IsType<ListResponseModel<DeviceAuthRequestResponseModel>>(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]

View File

@ -178,4 +178,133 @@ public class AccountsKeyManagementControllerTests
Assert.NotEmpty(ex.ModelState.Values);
}
}
[Theory]
[BitAutoData]
public async Task PostSetKeyConnectorKeyAsync_UserNull_Throws(
SutProvider<AccountsKeyManagementController> sutProvider,
SetKeyConnectorKeyRequestModel data)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data));
await sutProvider.GetDependency<IUserService>().ReceivedWithAnyArgs(0)
.SetKeyConnectorKeyAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse(
SutProvider<AccountsKeyManagementController> sutProvider,
SetKeyConnectorKeyRequestModel data, User expectedUser)
{
expectedUser.PublicKey = null;
expectedUser.PrivateKey = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.SetKeyConnectorKeyAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "set key connector key error" }));
var badRequestException =
await Assert.ThrowsAsync<BadRequestException>(() => 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<IUserService>().Received(1)
.SetKeyConnectorKeyAsync(Arg.Do<User>(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<AccountsKeyManagementController> sutProvider,
SetKeyConnectorKeyRequestModel data, User expectedUser)
{
expectedUser.PublicKey = null;
expectedUser.PrivateKey = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.SetKeyConnectorKeyAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(IdentityResult.Success);
await sutProvider.Sut.PostSetKeyConnectorKeyAsync(data);
await sutProvider.GetDependency<IUserService>().Received(1)
.SetKeyConnectorKeyAsync(Arg.Do<User>(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<AccountsKeyManagementController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostConvertToKeyConnectorAsync());
await sutProvider.GetDependency<IUserService>().ReceivedWithAnyArgs(0)
.ConvertToKeyConnectorAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse(
SutProvider<AccountsKeyManagementController> sutProvider,
User expectedUser)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" }));
var badRequestException =
await Assert.ThrowsAsync<BadRequestException>(() => 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<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser));
}
[Theory]
[BitAutoData]
public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse(
SutProvider<AccountsKeyManagementController> sutProvider,
User expectedUser)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(expectedUser);
sutProvider.GetDependency<IUserService>()
.ConvertToKeyConnectorAsync(Arg.Any<User>())
.Returns(IdentityResult.Success);
await sutProvider.Sut.PostConvertToKeyConnectorAsync();
await sutProvider.GetDependency<IUserService>().Received(1)
.ConvertToKeyConnectorAsync(Arg.Is(expectedUser));
}
}

View File

@ -287,6 +287,77 @@ public class InviteOrganizationUserCommandTests
Arg.Is<IEnumerable<string>>(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<InviteOrganizationUsersCommand> 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<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(
GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
Assert.NotNull(inviteOrganization.MaxAutoScaleSeats);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationAutoscaledEmailAsync(organization,
inviteOrganization.Seats.Value,
Arg.Is<IEnumerable<string>>(emails => emails.Any(email => email == ownerDetails.Email)));
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated(
@ -349,7 +420,7 @@ public class InviteOrganizationUserCommandTests
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IPaymentService>()
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd);
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.UpdatedSeatTotal!.Value);
await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
@ -610,4 +681,237 @@ public class InviteOrganizationUserCommandTests
.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2,
Arg.Is<IEnumerable<string>>(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<InviteOrganizationUsersCommand> 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<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate)
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
sutProvider.GetDependency<IProviderOrganizationRepository>()
.GetByOrganizationId(organization.Id)
.Returns(providerOrganization);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed)
.Returns(new List<ProviderUserUserDetails>
{
new()
{
Email = "provider@email.com"
}
});
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
sutProvider.GetDependency<IMailService>().Received(1)
.SendOrganizationAutoscaledEmailAsync(organization, 1,
Arg.Is<IEnumerable<string>>(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<InviteOrganizationUsersCommand> 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<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(
GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
Assert.NotNull(inviteOrganization.MaxAutoScaleSeats);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationAutoscaledEmailAsync(Arg.Any<Organization>(),
Arg.Any<int>(),
Arg.Any<IEnumerable<string>>());
}
[Theory]
[BitAutoData]
public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationDoesNotAutoScale_ThenAnEmailShouldNotBeSent(
MailAddress address,
Organization organization,
OrganizationUser user,
FakeTimeProvider timeProvider,
string externalId,
OrganizationUserUserDetails ownerDetails,
SutProvider<InviteOrganizationUsersCommand> 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<IOrganizationUserRepository>();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
.Returns([]);
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IInviteUsersValidator>()
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(
GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
// Assert
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
Assert.NotNull(inviteOrganization.MaxAutoScaleSeats);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationAutoscaledEmailAsync(Arg.Any<Organization>(),
Arg.Any<int>(),
Arg.Any<IEnumerable<string>>());
}
}

View File

@ -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]);
}
}

View File

@ -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