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:
commit
8a469f484e
13
.github/renovate.json5
vendored
13
.github/renovate.json5
vendored
@ -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"],
|
||||
|
@ -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>
|
||||
|
@ -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");
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
{
|
@ -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; }
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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>>());
|
||||
}
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user