mirror of
https://github.com/bitwarden/server.git
synced 2025-04-18 19:48:12 -05:00
Merge remote-tracking branch 'origin/main' into experiment/authorize-attribute
This commit is contained in:
commit
8345ce01cc
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.4.0</Version>
|
<Version>2025.4.1</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Scim.Models;
|
namespace Bit.Scim.Models;
|
||||||
|
|
||||||
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
{
|
{
|
||||||
public ScimUserRequestModel()
|
public ScimUserRequestModel()
|
||||||
: base(false)
|
: base(false)
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest ToRequest(
|
||||||
|
ScimProviderType scimProvider,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
var email = EmailForInvite(scimProvider);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||||
|
{
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InviteOrganizationUsersRequest(
|
||||||
|
invites:
|
||||||
|
[
|
||||||
|
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||||
|
email: email,
|
||||||
|
externalId: ExternalIdForInvite()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||||
|
performedAt: performedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private string EmailForInvite(ScimProviderType scimProvider)
|
private string EmailForInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var email = PrimaryEmail?.ToLowerInvariant();
|
var email = PrimaryEmail?.ToLowerInvariant();
|
||||||
|
@ -1,39 +1,99 @@
|
|||||||
using Bit.Core.Enums;
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users.Interfaces;
|
using Bit.Scim.Users.Interfaces;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||||
|
|
||||||
namespace Bit.Scim.Users;
|
namespace Bit.Scim.Users;
|
||||||
|
|
||||||
public class PostUserCommand : IPostUserCommand
|
public class PostUserCommand(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IScimContext scimContext,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IPricingClient pricingClient)
|
||||||
|
: IPostUserCommand
|
||||||
{
|
{
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
|
||||||
private readonly IOrganizationService _organizationService;
|
|
||||||
private readonly IPaymentService _paymentService;
|
|
||||||
private readonly IScimContext _scimContext;
|
|
||||||
|
|
||||||
public PostUserCommand(
|
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
|
||||||
IOrganizationService organizationService,
|
|
||||||
IPaymentService paymentService,
|
|
||||||
IScimContext scimContext)
|
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||||
_organizationUserRepository = organizationUserRepository;
|
{
|
||||||
_organizationService = organizationService;
|
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||||
_paymentService = paymentService;
|
}
|
||||||
_scimContext = scimContext;
|
|
||||||
|
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var request = model.ToRequest(
|
||||||
|
scimProvider: scimProvider,
|
||||||
|
inviteOrganization: new InviteOrganization(organization, plan),
|
||||||
|
performedAt: timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var orgUsers = await organizationUserRepository
|
||||||
|
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
if (orgUsers.Any(existingUser =>
|
||||||
|
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new ConflictException("User already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
var invitedOrganizationUserId = result switch
|
||||||
|
{
|
||||||
|
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
|
||||||
|
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
|
||||||
|
var organizationUser = invitedOrganizationUserId.HasValue
|
||||||
|
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return organizationUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var scimProvider = _scimContext.RequestScimProvider;
|
|
||||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||||
|
|
||||||
var email = invite.Emails.Single();
|
var email = invite.Emails.Single();
|
||||||
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||||
if (orgUserByEmail != null)
|
if (orgUserByEmail != null)
|
||||||
{
|
{
|
||||||
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new ConflictException();
|
throw new ConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||||
|
|
||||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||||
invite, externalId);
|
EventSystemUser.SCIM,
|
||||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
invite,
|
||||||
|
externalId);
|
||||||
|
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.IntegrationTest.Factories;
|
using Bit.Scim.IntegrationTest.Factories;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Utilities;
|
using Bit.Scim.Utilities;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
||||||
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Post_Success()
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
|
||||||
{
|
{
|
||||||
|
var localFactory = new ScimApplicationFactory();
|
||||||
|
localFactory.SubstituteService((IFeatureService featureService)
|
||||||
|
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
|
||||||
|
.Returns(isScimInviteUserOptimizationEnabled));
|
||||||
|
|
||||||
|
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
|
||||||
|
|
||||||
var email = "user5@example.com";
|
var email = "user5@example.com";
|
||||||
var displayName = "Test User 5";
|
var displayName = "Test User 5";
|
||||||
var externalId = "UE";
|
var externalId = "UE";
|
||||||
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||||
};
|
};
|
||||||
|
|
||||||
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||||
|
|
||||||
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
||||||
|
|
||||||
var databaseContext = _factory.GetDatabaseContext();
|
var databaseContext = localFactory.GetDatabaseContext();
|
||||||
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ public class PostUserCommandTests
|
|||||||
ExternalId = externalId,
|
ExternalId = externalId,
|
||||||
Emails = emails,
|
Emails = emails,
|
||||||
Active = true,
|
Active = true,
|
||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = [ScimConstants.Scim2SchemaUser]
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
@ -39,13 +39,16 @@ public class PostUserCommandTests
|
|||||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationService>()
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
.InviteUserAsync(organizationId,
|
||||||
|
invitingUserId: null,
|
||||||
|
EventSystemUser.SCIM,
|
||||||
Arg.Is<OrganizationUserInvite>(i =>
|
Arg.Is<OrganizationUserInvite>(i =>
|
||||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||||
i.Type == OrganizationUserType.User &&
|
i.Type == OrganizationUserType.User &&
|
||||||
!i.Collections.Any() &&
|
!i.Collections.Any() &&
|
||||||
!i.Groups.Any() &&
|
!i.Groups.Any() &&
|
||||||
i.AccessSecretsManager), externalId)
|
i.AccessSecretsManager),
|
||||||
|
externalId)
|
||||||
.Returns(newUser);
|
.Returns(newUser);
|
||||||
|
|
||||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||||
|
@ -184,7 +184,7 @@ public class UsersController : Controller
|
|||||||
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
|
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
|
||||||
{
|
{
|
||||||
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
? await _userService.IsManagedByAnyOrganizationAsync(userId)
|
? await _userService.IsClaimedByAnyOrganizationAsync(userId)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,8 +56,8 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
|
||||||
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
|
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
@ -83,8 +83,8 @@ public class OrganizationUsersController : Controller
|
|||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
|
IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
|
||||||
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
|
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
@ -109,8 +109,8 @@ public class OrganizationUsersController : Controller
|
|||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
_deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
|
||||||
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
|
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
@ -127,11 +127,11 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedByOrganization = await GetManagedByOrganizationStatusAsync(
|
var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync(
|
||||||
organizationUser.OrganizationId,
|
organizationUser.OrganizationId,
|
||||||
[organizationUser.Id]);
|
[organizationUser.Id]);
|
||||||
|
|
||||||
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
|
var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections);
|
||||||
|
|
||||||
if (includeGroups)
|
if (includeGroups)
|
||||||
{
|
{
|
||||||
@ -175,13 +175,13 @@ public class OrganizationUsersController : Controller
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||||
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
|
var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
|
||||||
var responses = organizationUsers
|
var responses = organizationUsers
|
||||||
.Select(o =>
|
.Select(o =>
|
||||||
{
|
{
|
||||||
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
||||||
var managedByOrganization = organizationUsersManagementStatus[o.Id];
|
var claimedByOrganization = organizationUsersClaimedStatus[o.Id];
|
||||||
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
|
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
});
|
});
|
||||||
@ -591,7 +591,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
@ -610,7 +610,7 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
||||||
|
|
||||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
||||||
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
|
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
|
||||||
@ -717,14 +717,14 @@ public class OrganizationUsersController : Controller
|
|||||||
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
|
private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
{
|
{
|
||||||
return userIds.ToDictionary(kvp => kvp, kvp => false);
|
return userIds.ToDictionary(kvp => kvp, kvp => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
|
var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
|
||||||
return usersOrganizationManagementStatus;
|
return usersOrganizationClaimedStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -140,10 +140,10 @@ public class OrganizationsController : Controller
|
|||||||
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
|
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
|
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser));
|
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser));
|
||||||
return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
|
return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,9 +277,9 @@ public class OrganizationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
&& (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
|
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
|
||||||
|
@ -66,24 +66,30 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
|
|||||||
{
|
{
|
||||||
public OrganizationUserDetailsResponseModel(
|
public OrganizationUserDetailsResponseModel(
|
||||||
OrganizationUser organizationUser,
|
OrganizationUser organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool managedByOrganization,
|
bool claimedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ManagedByOrganization { get; set; }
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
|
public bool ClaimedByOrganization { get; set; }
|
||||||
|
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
@ -117,7 +123,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
|
|||||||
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
|
bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
|
||||||
: base(organizationUser, obj)
|
: base(organizationUser, obj)
|
||||||
{
|
{
|
||||||
if (organizationUser == null)
|
if (organizationUser == null)
|
||||||
@ -134,7 +140,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
Groups = organizationUser.Groups;
|
Groups = organizationUser.Groups;
|
||||||
// Prevent reset password when using key connector.
|
// Prevent reset password when using key connector.
|
||||||
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
||||||
ManagedByOrganization = managedByOrganization;
|
ClaimedByOrganization = claimedByOrganization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@ -142,11 +148,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
public string AvatarColor { get; set; }
|
public string AvatarColor { get; set; }
|
||||||
public bool TwoFactorEnabled { get; set; }
|
public bool TwoFactorEnabled { get; set; }
|
||||||
public bool SsoBound { get; set; }
|
public bool SsoBound { get; set; }
|
||||||
|
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool ManagedByOrganization
|
||||||
|
{
|
||||||
|
get => ClaimedByOrganization;
|
||||||
|
set => ClaimedByOrganization = value;
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
|
/// Indicates if the organization claimed the user. If a user is "claimed" by an organization,
|
||||||
/// the organization has greater control over their account, and some user actions are restricted.
|
/// the organization has greater control over their account, and some user actions are restricted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ManagedByOrganization { get; set; }
|
public bool ClaimedByOrganization { get; set; }
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
public IEnumerable<Guid> Groups { get; set; }
|
public IEnumerable<Guid> Groups { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
|
|
||||||
public ProfileOrganizationResponseModel(
|
public ProfileOrganizationResponseModel(
|
||||||
OrganizationUserOrganizationDetails organization,
|
OrganizationUserOrganizationDetails organization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser)
|
IEnumerable<Guid> organizationIdsClaimingUser)
|
||||||
: this("profileOrganization")
|
: this("profileOrganization")
|
||||||
{
|
{
|
||||||
Id = organization.OrganizationId;
|
Id = organization.OrganizationId;
|
||||||
@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
|
||||||
Identifier = organization.Identifier;
|
Identifier = organization.Identifier;
|
||||||
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
|
||||||
ResetPasswordEnrolled = organization.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
|
||||||
UserId = organization.UserId;
|
UserId = organization.UserId;
|
||||||
OrganizationUserId = organization.OrganizationUserId;
|
OrganizationUserId = organization.OrganizationUserId;
|
||||||
ProviderId = organization.ProviderId;
|
ProviderId = organization.ProviderId;
|
||||||
@ -70,7 +70,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
LimitCollectionDeletion = organization.LimitCollectionDeletion;
|
LimitCollectionDeletion = organization.LimitCollectionDeletion;
|
||||||
LimitItemDeletion = organization.LimitItemDeletion;
|
LimitItemDeletion = organization.LimitItemDeletion;
|
||||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||||
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId);
|
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
|
||||||
UseRiskInsights = organization.UseRiskInsights;
|
UseRiskInsights = organization.UseRiskInsights;
|
||||||
|
|
||||||
if (organization.SsoConfig != null)
|
if (organization.SsoConfig != null)
|
||||||
@ -133,15 +133,26 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
public bool LimitItemDeletion { get; set; }
|
public bool LimitItemDeletion { get; set; }
|
||||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates if the organization manages the user.
|
/// Obsolete.
|
||||||
|
///
|
||||||
|
/// See <see cref="UserIsClaimedByOrganization"/>
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
|
||||||
|
public bool UserIsManagedByOrganization
|
||||||
|
{
|
||||||
|
get => UserIsClaimedByOrganization;
|
||||||
|
set => UserIsClaimedByOrganization = value;
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the organization claims the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it.
|
/// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it.
|
||||||
/// The organization must be enabled and able to have verified domains.
|
/// The organization must be enabled and able to have verified domains.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// False if the Account Deprovisioning feature flag is disabled.
|
/// False if the Account Deprovisioning feature flag is disabled.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public bool UserIsManagedByOrganization { get; set; }
|
public bool UserIsClaimedByOrganization { get; set; }
|
||||||
public bool UseRiskInsights { get; set; }
|
public bool UseRiskInsights { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
SsoExternalId = user.SsoExternalId;
|
SsoExternalId = user.SsoExternalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,11 +124,11 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
|
var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(managedUserValidationResult.Errors);
|
throw new BadRequestException(claimedUserValidationResult.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
||||||
@ -437,11 +437,11 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, twoFactorEnabled,
|
providerUserOrganizationDetails, twoFactorEnabled,
|
||||||
hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,9 +451,9 @@ public class AccountsController : Controller
|
|||||||
var userId = _userService.GetProperUserId(User);
|
var userId = _userService.GetProperUserId(User);
|
||||||
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value);
|
var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value);
|
||||||
|
|
||||||
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser));
|
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,9 +471,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,9 +490,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,9 +560,9 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
@ -763,9 +763,9 @@ public class AccountsController : Controller
|
|||||||
await _userService.SaveUserAsync(user);
|
await _userService.SaveUserAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,10 +58,10 @@ public class AccountsController(
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
||||||
userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return new PaymentResponseModel
|
return new PaymentResponseModel
|
||||||
{
|
{
|
||||||
UserProfile = profile,
|
UserProfile = profile,
|
||||||
@ -229,9 +229,9 @@ public class AccountsController(
|
|||||||
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
await paymentService.SaveTaxInfoAsync(user, taxInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -409,9 +409,9 @@ public class OrganizationsController(
|
|||||||
organizationId,
|
organizationId,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
|
|
||||||
var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId))
|
var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId))
|
||||||
.Select(o => o.Id);
|
.Select(o => o.Id);
|
||||||
|
|
||||||
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser);
|
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
bool premiumFromOrganization,
|
bool premiumFromOrganization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser) : base("profile")
|
IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
|
||||||
{
|
{
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
AvatarColor = user.AvatarColor;
|
AvatarColor = user.AvatarColor;
|
||||||
CreationDate = user.CreationDate;
|
CreationDate = user.CreationDate;
|
||||||
VerifyDevices = user.VerifyDevices;
|
VerifyDevices = user.VerifyDevices;
|
||||||
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser));
|
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||||
ProviderOrganizations =
|
ProviderOrganizations =
|
||||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||||
|
@ -1091,9 +1091,9 @@ public class CiphersController : Controller
|
|||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
|
@ -104,13 +104,13 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id);
|
var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||||
|
|
||||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||||
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
bool userTwoFactorEnabled,
|
bool userTwoFactorEnabled,
|
||||||
bool userHasPremiumFromOrganization,
|
bool userHasPremiumFromOrganization,
|
||||||
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser,
|
IEnumerable<Guid> organizationIdsClaimingingUser,
|
||||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
: base("sync")
|
: base("sync")
|
||||||
{
|
{
|
||||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser);
|
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
|
||||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||||
Ciphers = ciphers.Select(cipher =>
|
Ciphers = ciphers.Select(cipher =>
|
||||||
new CipherDetailsResponseModel(
|
new CipherDetailsResponseModel(
|
||||||
|
@ -12,7 +12,7 @@ public class OrganizationIntegration : ITableObject<Guid>
|
|||||||
public Guid OrganizationId { get; set; }
|
public Guid OrganizationId { get; set; }
|
||||||
public IntegrationType Type { get; set; }
|
public IntegrationType Type { get; set; }
|
||||||
public string? Configuration { get; set; }
|
public string? Configuration { get; set; }
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
|||||||
public EventType EventType { get; set; }
|
public EventType EventType { get; set; }
|
||||||
public string? Configuration { get; set; }
|
public string? Configuration { get; set; }
|
||||||
public string? Template { get; set; }
|
public string? Template { get; set; }
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
namespace Bit.Core.AdminConsole.Errors;
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
public record Error<T>(string Message, T ErroredValue);
|
public record Error<T>(string Message, T ErroredValue);
|
||||||
|
|
||||||
|
public static class ErrorMappers
|
||||||
|
{
|
||||||
|
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
|
||||||
|
}
|
||||||
|
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
|
||||||
|
{
|
||||||
|
public const string Code = "Invalid result type.";
|
||||||
|
};
|
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
public record InviteOrganization
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; init; }
|
||||||
|
public int? Seats { get; init; }
|
||||||
|
public int? MaxAutoScaleSeats { get; init; }
|
||||||
|
public int? SmSeats { get; init; }
|
||||||
|
public int? SmMaxAutoScaleSeats { get; init; }
|
||||||
|
public Plan Plan { get; init; }
|
||||||
|
public string GatewayCustomerId { get; init; }
|
||||||
|
public string GatewaySubscriptionId { get; init; }
|
||||||
|
public bool UseSecretsManager { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganization()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganization(Organization organization, Plan plan)
|
||||||
|
{
|
||||||
|
OrganizationId = organization.Id;
|
||||||
|
Seats = organization.Seats;
|
||||||
|
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
|
||||||
|
SmSeats = organization.SmSeats;
|
||||||
|
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
|
||||||
|
Plan = plan;
|
||||||
|
GatewayCustomerId = organization.GatewayCustomerId;
|
||||||
|
GatewaySubscriptionId = organization.GatewaySubscriptionId;
|
||||||
|
UseSecretsManager = organization.UseSecretsManager;
|
||||||
|
}
|
||||||
|
}
|
@ -154,6 +154,6 @@ public class VerifyOrganizationDomainCommand(
|
|||||||
|
|
||||||
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
|
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
|
||||||
|
|
||||||
await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization));
|
await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,11 +15,11 @@ using Bit.Core.Tools.Services;
|
|||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
|
||||||
public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand
|
public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand
|
||||||
{
|
{
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IEventService _eventService;
|
private readonly IEventService _eventService;
|
||||||
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
|
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
@ -28,10 +28,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
private readonly IPushNotificationService _pushService;
|
private readonly IPushNotificationService _pushService;
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly IProviderUserRepository _providerUserRepository;
|
private readonly IProviderUserRepository _providerUserRepository;
|
||||||
public DeleteManagedOrganizationUserAccountCommand(
|
public DeleteClaimedOrganizationUserAccountCommand(
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
|
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
@ -43,7 +43,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
|
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
@ -62,10 +62,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
throw new NotFoundException("Member not found.");
|
throw new NotFoundException("Member not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId });
|
var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId });
|
||||||
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true);
|
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true);
|
||||||
|
|
||||||
await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners);
|
await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value);
|
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@ -83,7 +83,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList();
|
var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList();
|
||||||
var users = await _userRepository.GetManyAsync(userIds);
|
var users = await _userRepository.GetManyAsync(userIds);
|
||||||
|
|
||||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds);
|
var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds);
|
||||||
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true);
|
var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true);
|
||||||
|
|
||||||
var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>();
|
var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>();
|
||||||
@ -97,7 +97,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
throw new NotFoundException("Member not found.");
|
throw new NotFoundException("Member not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners);
|
await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners);
|
||||||
|
|
||||||
var user = users.FirstOrDefault(u => u.Id == orgUser.UserId);
|
var user = users.FirstOrDefault(u => u.Id == orgUser.UserId);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@ -129,7 +129,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> managementStatus, bool hasOtherConfirmedOwners)
|
private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> claimedStatus, bool hasOtherConfirmedOwners)
|
||||||
{
|
{
|
||||||
if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited)
|
if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited)
|
||||||
{
|
{
|
||||||
@ -159,10 +159,9 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
|||||||
throw new BadRequestException("Custom users can not delete admins.");
|
throw new BadRequestException("Custom users can not delete admins.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed)
|
||||||
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Member is not managed by the organization.");
|
throw new BadRequestException("Member is not claimed by the organization.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,12 +4,12 @@ using Bit.Core.Services;
|
|||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
|
|
||||||
public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery
|
public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaimedStatusQuery
|
||||||
{
|
{
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
|
||||||
public GetOrganizationUsersManagementStatusQuery(
|
public GetOrganizationUsersClaimedStatusQuery(
|
||||||
IApplicationCacheService applicationCacheService,
|
IApplicationCacheService applicationCacheService,
|
||||||
IOrganizationUserRepository organizationUserRepository)
|
IOrganizationUserRepository organizationUserRepository)
|
||||||
{
|
{
|
||||||
@ -17,11 +17,11 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
|
|||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
|
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
|
||||||
{
|
{
|
||||||
if (organizationUserIds.Any())
|
if (organizationUserIds.Any())
|
||||||
{
|
{
|
||||||
// Users can only be managed by an Organization that is enabled and can have organization domains
|
// Users can only be claimed by an Organization that is enabled and can have organization domains
|
||||||
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||||
|
|
||||||
// TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622).
|
// TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622).
|
||||||
@ -31,7 +31,7 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
|
|||||||
// Get all organization users with claimed domains by the organization
|
// Get all organization users with claimed domains by the organization
|
||||||
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
|
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
|
||||||
|
|
||||||
// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization
|
// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization
|
||||||
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
|
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
|
||||||
public interface IDeleteManagedOrganizationUserAccountCommand
|
public interface IDeleteClaimedOrganizationUserAccountCommand
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a user from an organization and deletes all of their associated user data.
|
/// Removes a user from an organization and deletes all of their associated user data.
|
@ -1,19 +1,19 @@
|
|||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
|
||||||
public interface IGetOrganizationUsersManagementStatusQuery
|
public interface IGetOrganizationUsersClaimedStatusQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether each user in the provided list of organization user IDs is managed by the specified organization.
|
/// Checks whether each user in the provided list of organization user IDs is claimed by the specified organization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="organizationId">The unique identifier of the organization to check against.</param>
|
/// <param name="organizationId">The unique identifier of the organization to check against.</param>
|
||||||
/// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param>
|
/// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A managed user is a user whose email domain matches one of the Organization's verified domains.
|
/// A claimed user is a user whose email domain matches one of the Organization's verified domains.
|
||||||
/// The organization must be enabled and be on an Enterprise plan.
|
/// The organization must be enabled and be on an Enterprise plan.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization.
|
/// A dictionary containing the OrganizationUserId and a boolean indicating if the user is claimed by the organization.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId,
|
Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId,
|
||||||
IEnumerable<Guid> organizationUserIds);
|
IEnumerable<Guid> organizationUserIds);
|
||||||
}
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public static class ErrorMapper
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the ErrorT to a Bit.Exception class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(Error<T> error) =>
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
|
||||||
|
_ => new BadRequestException(error.Message)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This maps the ErrorT object to the Bit.Exception class.
|
||||||
|
///
|
||||||
|
/// This should be replaced by an IActionResult mapper when possible.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="errors"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
|
||||||
|
errors switch
|
||||||
|
{
|
||||||
|
not null when errors.Count == 1 => MapToBitException(errors.First()),
|
||||||
|
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
|
||||||
|
_ => new BadRequestException()
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "Failed to invite users";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "No users to invite";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "User already exists";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
|
||||||
|
/// Provides functionality for handling single email invitation requests within an organization context.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
|
||||||
|
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
|
||||||
|
/// Success will be the successful return object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">
|
||||||
|
/// Contains the details for inviting a single organization user via email.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
|
||||||
|
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for sending the invite to an organization user.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This sends emails out to organization users for a given organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"><see cref="SendInvitesRequest"/></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task SendInvitesAsync(SendInvitesRequest request);
|
||||||
|
}
|
@ -0,0 +1,282 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Enums;
|
||||||
|
using Bit.Core.Tools.Models.Business;
|
||||||
|
using Bit.Core.Tools.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersCommand(IEventService eventService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IInviteUsersValidator inviteUsersValidator,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IReferenceEventService referenceEventService,
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
|
IMailService mailService,
|
||||||
|
ILogger<InviteOrganizationUsersCommand> logger,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||||
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
|
IProviderUserRepository providerUserRepository
|
||||||
|
) : IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
|
||||||
|
|
||||||
|
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var result = await InviteOrganizationUsersAsync(request);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case Failure<InviteOrganizationUsersResponse> failure:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
|
||||||
|
new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
|
||||||
|
})));
|
||||||
|
|
||||||
|
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
|
||||||
|
var user = success.Value.InvitedUsers.First();
|
||||||
|
|
||||||
|
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
|
||||||
|
organizationUser: user,
|
||||||
|
type: EventType.OrganizationUser_Invited,
|
||||||
|
systemUser: EventSystemUser.SCIM,
|
||||||
|
date: request.PerformedAt.UtcDateTime);
|
||||||
|
|
||||||
|
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = user
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new ScimInviteOrganizationUsersResponse()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
|
||||||
|
|
||||||
|
if (invitesToSend.Length == 0)
|
||||||
|
{
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
|
||||||
|
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites = invitesToSend.ToArray(),
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
|
||||||
|
{
|
||||||
|
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
var organizationUserToInviteEntities = invitesToSend
|
||||||
|
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
|
||||||
|
|
||||||
|
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await AdjustSecretsManagerSeatsAsync(validatedRequest);
|
||||||
|
|
||||||
|
await SendAdditionalEmailsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await SendInvitesAsync(organizationUserToInviteEntities, organization);
|
||||||
|
|
||||||
|
await PublishReferenceEventAsync(validatedRequest, organization);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, FailedToInviteUsersError.Code);
|
||||||
|
|
||||||
|
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
|
||||||
|
|
||||||
|
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||||
|
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
|
||||||
|
|
||||||
|
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(
|
||||||
|
new FailedToInviteUsersError(
|
||||||
|
new InviteOrganizationUsersResponse(validatedRequest.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success<InviteOrganizationUsersResponse>(
|
||||||
|
new InviteOrganizationUsersResponse(
|
||||||
|
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
|
||||||
|
organizationId: organization!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
|
||||||
|
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return request.Invites
|
||||||
|
.Where(invite => !existingEmails.Contains(invite.Email))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.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);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: organization,
|
||||||
|
plan: validatedResult.Value.InviteOrganization.Plan,
|
||||||
|
autoscaling: false)
|
||||||
|
{
|
||||||
|
SmSeats = initialSmSeats
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
|
||||||
|
Organization organization) =>
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
|
||||||
|
{
|
||||||
|
Users = validatedResult.Value.Invites.Length
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
|
||||||
|
await sendOrganizationInvitesCommand.SendInvitesAsync(
|
||||||
|
new SendInvitesRequest(
|
||||||
|
users.Select(x => x.OrganizationUser),
|
||||||
|
organization));
|
||||||
|
|
||||||
|
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
|
||||||
|
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
|
||||||
|
{
|
||||||
|
var providerOrganization = await providerOrganizationRepository
|
||||||
|
.GetByOrganizationId(organization.OrganizationId);
|
||||||
|
|
||||||
|
if (providerOrganization == null)
|
||||||
|
{
|
||||||
|
return (await organizationUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await providerUserRepository
|
||||||
|
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
|
||||||
|
.Select(u => u.Email).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
|
||||||
|
{
|
||||||
|
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
|
||||||
|
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
|
||||||
|
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
|
||||||
|
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
|
||||||
|
/// <see cref="CollectionAccessSelection"/> and Group Ids.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateOrganizationUser
|
||||||
|
{
|
||||||
|
public OrganizationUser OrganizationUser { get; set; }
|
||||||
|
public CollectionAccessSelection[] Collections { get; set; } = [];
|
||||||
|
public Guid[] Groups { get; set; } = [];
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class CreateOrganizationUserExtensions
|
||||||
|
{
|
||||||
|
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
|
||||||
|
DateTimeOffset performedAt,
|
||||||
|
InviteOrganization organization) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.OrganizationId,
|
||||||
|
Email = organizationUserInvite.Email.ToLowerInvariant(),
|
||||||
|
Type = organizationUserInvite.Type,
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
|
||||||
|
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
|
||||||
|
CreationDate = performedAt.UtcDateTime,
|
||||||
|
RevisionDate = performedAt.UtcDateTime
|
||||||
|
},
|
||||||
|
Collections = organizationUserInvite.AssignedCollections,
|
||||||
|
Groups = organizationUserInvite.Groups
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class InviteOrganizationUserErrorMessages
|
||||||
|
{
|
||||||
|
public const string InvalidEmailErrorMessage = "The email address is not valid.";
|
||||||
|
public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersRequest
|
||||||
|
{
|
||||||
|
public OrganizationUserInvite[] Invites { get; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
public Guid PerformedBy { get; }
|
||||||
|
public DateTimeOffset PerformedAt { get; }
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
Guid performedBy,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
Invites = invites;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
PerformedBy = performedBy;
|
||||||
|
PerformedAt = performedAt;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersResponse(Guid organizationId)
|
||||||
|
{
|
||||||
|
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
|
||||||
|
public Guid OrganizationId { get; } = organizationId;
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
|
||||||
|
: this(usersValidationRequest.InviteOrganization.OrganizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
|
||||||
|
: this(organizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = invitedOrganizationUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
public OrganizationUser InvitedUser { get; init; }
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var userToInvite = request.Invites.First();
|
||||||
|
|
||||||
|
InvitedUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Email = userToInvite.Email,
|
||||||
|
ExternalId = userToInvite.ExternalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
public InviteOrganizationUsersValidationRequest()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites;
|
||||||
|
InviteOrganization = request.InviteOrganization;
|
||||||
|
PerformedBy = request.PerformedBy;
|
||||||
|
PerformedAt = request.PerformedAt;
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats;
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
|
||||||
|
: this(request)
|
||||||
|
{
|
||||||
|
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
|
||||||
|
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite[] Invites { get; init; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; init; }
|
||||||
|
public Guid PerformedBy { get; init; }
|
||||||
|
public DateTimeOffset PerformedAt { get; init; }
|
||||||
|
public int OccupiedPmSeats { get; init; }
|
||||||
|
public int OccupiedSmSeats { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
|
||||||
|
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class OrganizationUserInvite
|
||||||
|
{
|
||||||
|
public string Email { get; private init; }
|
||||||
|
public CollectionAccessSelection[] AssignedCollections { get; private init; }
|
||||||
|
public OrganizationUserType Type { get; private init; }
|
||||||
|
public Permissions Permissions { get; private init; }
|
||||||
|
public string ExternalId { get; private init; }
|
||||||
|
public bool AccessSecretsManager { get; private init; }
|
||||||
|
public Guid[] Groups { get; private init; }
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email, string externalId) :
|
||||||
|
this(
|
||||||
|
email: email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) :
|
||||||
|
this(invite.Email,
|
||||||
|
invite.AssignedCollections,
|
||||||
|
invite.Groups,
|
||||||
|
invite.Type,
|
||||||
|
invite.Permissions,
|
||||||
|
invite.ExternalId,
|
||||||
|
accessSecretsManager)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email,
|
||||||
|
IEnumerable<CollectionAccessSelection> assignedCollections,
|
||||||
|
IEnumerable<Guid> groups,
|
||||||
|
OrganizationUserType type,
|
||||||
|
Permissions permissions,
|
||||||
|
string externalId,
|
||||||
|
bool accessSecretsManager)
|
||||||
|
{
|
||||||
|
ValidateEmailAddress(email);
|
||||||
|
|
||||||
|
var collections = assignedCollections?.ToArray() ?? [];
|
||||||
|
|
||||||
|
if (collections.Any(x => x.IsValidCollectionAccessConfiguration()))
|
||||||
|
{
|
||||||
|
throw new BadRequestException(InvalidCollectionConfigurationErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Email = email;
|
||||||
|
AssignedCollections = collections;
|
||||||
|
Groups = groups.ToArray();
|
||||||
|
Type = type;
|
||||||
|
Permissions = permissions ?? new Permissions();
|
||||||
|
ExternalId = externalId;
|
||||||
|
AccessSecretsManager = accessSecretsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEmailAddress(string email)
|
||||||
|
{
|
||||||
|
if (!email.IsValidEmail())
|
||||||
|
{
|
||||||
|
throw new BadRequestException($"{email} {InvalidEmailErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to send invitations to a group of organization users.
|
||||||
|
/// </summary>
|
||||||
|
public class SendInvitesRequest
|
||||||
|
{
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
|
||||||
|
(Users, Organization) = (users.ToArray(), organization);
|
||||||
|
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization, bool initOrganization) =>
|
||||||
|
(Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization Users to send emails to.
|
||||||
|
/// </summary>
|
||||||
|
public OrganizationUser[] Users { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The organization to invite the users to.
|
||||||
|
/// </summary>
|
||||||
|
public Organization Organization { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for when the organization is being created and this is the owners initial invite
|
||||||
|
/// </summary>
|
||||||
|
public bool InitOrganization { get; init; }
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Auth.Models.Business;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class SendOrganizationInvitesCommand(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISsoConfigRepository ssoConfigurationRepository,
|
||||||
|
IPolicyRepository policyRepository,
|
||||||
|
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||||
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
|
||||||
|
IMailService mailService) : ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
public async Task SendInvitesAsync(SendInvitesRequest request)
|
||||||
|
{
|
||||||
|
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
|
||||||
|
Organization organization, bool initOrganization = false)
|
||||||
|
{
|
||||||
|
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
||||||
|
var orgUsersList = orgUsers.ToList();
|
||||||
|
|
||||||
|
// Email links must include information about the org and user for us to make routing decisions client side
|
||||||
|
// Given an org user, determine if existing BW user exists
|
||||||
|
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
||||||
|
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
|
||||||
|
|
||||||
|
// hash existing users emails list for O(1) lookups
|
||||||
|
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
||||||
|
|
||||||
|
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
||||||
|
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
||||||
|
ou => ou.Id,
|
||||||
|
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if org has SSO enabled and if user is required to login with SSO
|
||||||
|
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
||||||
|
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
||||||
|
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
||||||
|
// need to check the policy if the org has SSO enabled.
|
||||||
|
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||||
|
organization.UsePolicies &&
|
||||||
|
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||||
|
|
||||||
|
// Generate the list of org users and expiring tokens
|
||||||
|
// create helper function to create expiring tokens
|
||||||
|
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||||
|
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
|
||||||
|
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
||||||
|
|
||||||
|
return new OrganizationInvitesInfo(
|
||||||
|
organization,
|
||||||
|
orgSsoEnabled,
|
||||||
|
orgSsoLoginRequiredPolicyEnabled,
|
||||||
|
orgUsersWithExpTokens,
|
||||||
|
orgUserHasExistingUserDict,
|
||||||
|
initOrganization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class CollectionAccessSelectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This validates the permissions on the given assigned collection
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) =>
|
||||||
|
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error<EnvironmentRequest>(Code, Invalid)
|
||||||
|
{
|
||||||
|
public const string Code = "Cannot auto scale self-host.";
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public class EnvironmentRequest
|
||||||
|
{
|
||||||
|
public bool IsSelfHosted { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; }
|
||||||
|
|
||||||
|
public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
IsSelfHosted = globalSettings.SelfHosted;
|
||||||
|
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public interface IInviteUsersEnvironmentValidator : IValidator<EnvironmentRequest>;
|
||||||
|
|
||||||
|
public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<EnvironmentRequest>> ValidateAsync(EnvironmentRequest value) =>
|
||||||
|
Task.FromResult<ValidationResult<EnvironmentRequest>>(
|
||||||
|
value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ?
|
||||||
|
new Invalid<EnvironmentRequest>(new CannotAutoScaleOnSelfHostError(value)) :
|
||||||
|
new Valid<EnvironmentRequest>(value));
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public interface IInviteUsersValidator : IValidator<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidator(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand,
|
||||||
|
IPaymentService paymentService) : IInviteUsersValidator
|
||||||
|
{
|
||||||
|
public async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request);
|
||||||
|
|
||||||
|
var passwordManagerValidationResult =
|
||||||
|
await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
if (passwordManagerValidationResult is Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
return invalidSubscriptionUpdate.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager.
|
||||||
|
// This is an expensive call, so we're doing it now to delay the check as long as possible.
|
||||||
|
if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization))
|
||||||
|
{
|
||||||
|
request = new InviteOrganizationUsersValidationRequest(request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites
|
||||||
|
.Select(x => new OrganizationUserInvite(x, accessSecretsManager: true))
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager))
|
||||||
|
{
|
||||||
|
return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateSecretsManagerSubscriptionUpdateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
|
||||||
|
var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
plan: request.InviteOrganization.Plan,
|
||||||
|
autoscaling: true);
|
||||||
|
|
||||||
|
var seatsToAdd = GetSecretManagerSeatAdjustment(request);
|
||||||
|
|
||||||
|
if (seatsToAdd > 0)
|
||||||
|
{
|
||||||
|
smSubscriptionUpdate.AdjustSeats(seatsToAdd);
|
||||||
|
|
||||||
|
await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
smSubscriptionUpdate));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationUsersValidationRequest>(
|
||||||
|
new Error<InviteOrganizationUsersValidationRequest>(ex.Message, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This calculates the number of SM seats to add to the organization seat total.
|
||||||
|
///
|
||||||
|
/// If they have a current seat limit (it can be null), we want to figure out how many are available (seats -
|
||||||
|
/// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite.
|
||||||
|
///
|
||||||
|
/// If it's negative, we have available seats and do not need to increase, so we go with 0.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) =>
|
||||||
|
request.InviteOrganization.SmSeats.HasValue
|
||||||
|
? Math.Max(
|
||||||
|
request.Invites.Count(x => x.AccessSecretsManager) -
|
||||||
|
(request.InviteOrganization.SmSeats.Value -
|
||||||
|
request.OccupiedSmSeats),
|
||||||
|
0)
|
||||||
|
: 0;
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No payment method found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No subscription found.";
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public interface IInviteUsersOrganizationValidator : IValidator<InviteOrganization>;
|
||||||
|
|
||||||
|
public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<InviteOrganization>> ValidateAsync(InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
if (inviteOrganization.Seats is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoPaymentMethodFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoSubscriptionFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Plan does not allow additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(GetErrorMessage(InvalidRequest), InvalidRequest)
|
||||||
|
{
|
||||||
|
private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) =>
|
||||||
|
string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats);
|
||||||
|
|
||||||
|
public const string Code = "Organization plan allows a maximum of {0} additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have any Password Manager seats!";
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public interface IInviteUsersPasswordManagerValidator : IValidator<PasswordManagerSubscriptionUpdate>;
|
||||||
|
|
||||||
|
public class InviteUsersPasswordManagerValidator(
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator,
|
||||||
|
IInviteUsersOrganizationValidator inviteUsersOrganizationValidator,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository
|
||||||
|
) : IInviteUsersPasswordManagerValidator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is for validating if the organization can add additional users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscriptionUpdate"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static ValidationResult<PasswordManagerSubscriptionUpdate> ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
if (subscriptionUpdate.Seats is null)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.SeatsRequiredToAdd == 0)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apparently MaxAdditionalSeats is never set. Can probably be removed.
|
||||||
|
if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ValidationResult<PasswordManagerSubscriptionUpdate>> ValidateAsync(PasswordManagerSubscriptionUpdate request)
|
||||||
|
{
|
||||||
|
switch (ValidatePasswordManager(request))
|
||||||
|
{
|
||||||
|
case Valid<PasswordManagerSubscriptionUpdate> valid
|
||||||
|
when valid.Value.SeatsRequiredToAdd is 0:
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
|
||||||
|
case Invalid<PasswordManagerSubscriptionUpdate> invalid:
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid<EnvironmentRequest> invalidEnvironment)
|
||||||
|
{
|
||||||
|
return invalidEnvironment.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||||
|
|
||||||
|
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||||
|
{
|
||||||
|
return organizationValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
if (provider is not null)
|
||||||
|
{
|
||||||
|
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||||
|
|
||||||
|
if (providerValidationResult is Invalid<InviteOrganizationProvider> invalidProviderValidation)
|
||||||
|
{
|
||||||
|
return invalidProviderValidation.Map(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||||
|
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||||
|
|
||||||
|
var paymentValidationResult = InviteUserPaymentValidation.Validate(
|
||||||
|
new PaymentsSubscription(paymentSubscription, request.InviteOrganization));
|
||||||
|
|
||||||
|
if (paymentValidationResult is Invalid<PaymentsSubscription> invalidPaymentValidation)
|
||||||
|
{
|
||||||
|
return invalidPaymentValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public class PasswordManagerSubscriptionUpdate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seats the organization has
|
||||||
|
/// </summary>
|
||||||
|
public int? Seats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max number of seats that the organization can have
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAutoScaleSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seats currently occupied by current users
|
||||||
|
/// </summary>
|
||||||
|
public int OccupiedSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Users to add to the organization seats
|
||||||
|
/// </summary>
|
||||||
|
public int NewUsersToAdd { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats available for users
|
||||||
|
/// </summary>
|
||||||
|
public int? AvailableSeats => Seats - OccupiedSeats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats to scale the organization by.
|
||||||
|
///
|
||||||
|
/// If Organization has no seat limit (Seats is null), then there are no new seats to add.
|
||||||
|
/// </summary>
|
||||||
|
public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New total of seats for the organization
|
||||||
|
/// </summary>
|
||||||
|
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the new seat total is equal to the organization's auto-scale seat count
|
||||||
|
/// </summary>
|
||||||
|
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
|
||||||
|
|
||||||
|
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
|
||||||
|
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
|
||||||
|
private PasswordManagerSubscriptionUpdate(int? organizationSeats,
|
||||||
|
int? organizationAutoScaleSeatLimit,
|
||||||
|
int currentSeats,
|
||||||
|
int newUsersToAdd,
|
||||||
|
Plan.PasswordManagerPlanFeatures plan,
|
||||||
|
InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
Seats = organizationSeats;
|
||||||
|
MaxAutoScaleSeats = organizationAutoScaleSeatLimit;
|
||||||
|
OccupiedSeats = currentSeats;
|
||||||
|
NewUsersToAdd = newUsersToAdd;
|
||||||
|
PasswordManagerPlan = plan;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) :
|
||||||
|
this(
|
||||||
|
organizationSeats: inviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: occupiedSeats,
|
||||||
|
newUsersToAdd: newUsersToAdd,
|
||||||
|
plan: inviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: inviteOrganization)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) :
|
||||||
|
this(
|
||||||
|
organizationSeats: usersValidationRequest.InviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: usersValidationRequest.OccupiedPmSeats,
|
||||||
|
newUsersToAdd: usersValidationRequest.Invites.Length,
|
||||||
|
plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: usersValidationRequest.InviteOrganization)
|
||||||
|
{ }
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
|
||||||
|
public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest)
|
||||||
|
: Error<PaymentsSubscription>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes.";
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class InviteUserPaymentValidation
|
||||||
|
{
|
||||||
|
public static ValidationResult<PaymentsSubscription> Validate(PaymentsSubscription subscription)
|
||||||
|
{
|
||||||
|
if (subscription.ProductTierType is ProductTierType.Free)
|
||||||
|
{
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
|
||||||
|
{
|
||||||
|
return new Invalid<PaymentsSubscription>(new PaymentCancelledSubscriptionError(subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
public class PaymentsSubscription
|
||||||
|
{
|
||||||
|
public ProductTierType ProductTierType { get; init; }
|
||||||
|
public string SubscriptionStatus { get; init; }
|
||||||
|
|
||||||
|
public PaymentsSubscription() { }
|
||||||
|
|
||||||
|
public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty;
|
||||||
|
ProductTierType = inviteOrganization.Plan.ProductTier;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Please contact your provider to add more seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats.";
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public class InviteOrganizationProvider
|
||||||
|
{
|
||||||
|
public Guid ProviderId { get; init; }
|
||||||
|
public ProviderType Type { get; init; }
|
||||||
|
public ProviderStatusType Status { get; init; }
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganizationProvider(Entities.Provider.Provider provider)
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id;
|
||||||
|
Type = provider.Type;
|
||||||
|
Status = provider.Status;
|
||||||
|
Enabled = provider.Enabled;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public static class InvitingUserOrganizationProviderValidator
|
||||||
|
{
|
||||||
|
public static ValidationResult<InviteOrganizationProvider> Validate(InviteOrganizationProvider inviteOrganizationProvider)
|
||||||
|
{
|
||||||
|
if (inviteOrganizationProvider is not { Enabled: true })
|
||||||
|
{
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.IsBillable())
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderBillableSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.Type == ProviderType.Reseller)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderResellerSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
}
|
@ -18,7 +18,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
private readonly IPushRegistrationService _pushRegistrationService;
|
private readonly IPushRegistrationService _pushRegistrationService;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||||
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
|
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
IPushRegistrationService pushRegistrationService,
|
IPushRegistrationService pushRegistrationService,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
|
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
@ -49,7 +49,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
_pushRegistrationService = pushRegistrationService;
|
_pushRegistrationService = pushRegistrationService;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||||
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
|
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
}
|
}
|
||||||
@ -161,8 +161,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
||||||
{
|
{
|
||||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
||||||
if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged)
|
if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(RemoveClaimedAccountErrorMessage);
|
throw new BadRequestException(RemoveClaimedAccountErrorMessage);
|
||||||
}
|
}
|
||||||
@ -214,8 +214,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
|
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var managementStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null
|
var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null
|
||||||
? await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, filteredUsers.Select(u => u.Id))
|
? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id))
|
||||||
: filteredUsers.ToDictionary(u => u.Id, u => false);
|
: filteredUsers.ToDictionary(u => u.Id, u => false);
|
||||||
var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>();
|
var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>();
|
||||||
foreach (var orgUser in filteredUsers)
|
foreach (var orgUser in filteredUsers)
|
||||||
@ -232,7 +232,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
|||||||
throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage);
|
throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged)
|
if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(RemoveClaimedAccountErrorMessage);
|
throw new BadRequestException(RemoveClaimedAccountErrorMessage);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
|||||||
/// <param name="role">The role to search for</param>
|
/// <param name="role">The role to search for</param>
|
||||||
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
||||||
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
||||||
|
|
||||||
|
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
|||||||
using Bit.Core.AdminConsole.Models.Business;
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Business;
|
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -26,18 +26,17 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Models.Mail;
|
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Tools.Enums;
|
using Bit.Core.Tools.Enums;
|
||||||
using Bit.Core.Tools.Models.Business;
|
using Bit.Core.Tools.Models.Business;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
|
|
||||||
@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
|
||||||
private readonly ISsoUserRepository _ssoUserRepository;
|
private readonly ISsoUserRepository _ssoUserRepository;
|
||||||
private readonly IReferenceEventService _referenceEventService;
|
private readonly IReferenceEventService _referenceEventService;
|
||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||||
private readonly IProviderRepository _providerRepository;
|
private readonly IProviderRepository _providerRepository;
|
||||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
|
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
|
||||||
ISsoUserRepository ssoUserRepository,
|
ISsoUserRepository ssoUserRepository,
|
||||||
IReferenceEventService referenceEventService,
|
IReferenceEventService referenceEventService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IPolicyRequirementQuery policyRequirementQuery)
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
|
||||||
_ssoUserRepository = ssoUserRepository;
|
_ssoUserRepository = ssoUserRepository;
|
||||||
_referenceEventService = referenceEventService;
|
_referenceEventService = referenceEventService;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||||
_providerRepository = providerRepository;
|
_providerRepository = providerRepository;
|
||||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
|
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
await SendInviteAsync(orgUser, org, initOrganization);
|
await SendInviteAsync(orgUser, org, initOrganization);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
|
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||||
{
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
|
||||||
}
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||||
|
users: [orgUser],
|
||||||
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
|
organization: organization,
|
||||||
{
|
initOrganization: initOrganization));
|
||||||
// convert single org user into array of 1 org user
|
|
||||||
var orgUsers = new[] { orgUser };
|
|
||||||
|
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
|
|
||||||
IEnumerable<OrganizationUser> orgUsers,
|
|
||||||
Organization organization,
|
|
||||||
bool initOrganization = false)
|
|
||||||
{
|
|
||||||
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
|
||||||
var orgUsersList = orgUsers.ToList();
|
|
||||||
|
|
||||||
// Email links must include information about the org and user for us to make routing decisions client side
|
|
||||||
// Given an org user, determine if existing BW user exists
|
|
||||||
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
|
||||||
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
|
|
||||||
|
|
||||||
// hash existing users emails list for O(1) lookups
|
|
||||||
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
|
||||||
|
|
||||||
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
|
||||||
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
|
||||||
ou => ou.Id,
|
|
||||||
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine if org has SSO enabled and if user is required to login with SSO
|
|
||||||
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
|
||||||
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
|
||||||
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
|
||||||
// need to check the policy if the org has SSO enabled.
|
|
||||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
|
||||||
organization.UsePolicies &&
|
|
||||||
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
|
||||||
|
|
||||||
// Generate the list of org users and expiring tokens
|
|
||||||
// create helper function to create expiring tokens
|
|
||||||
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
|
||||||
{
|
|
||||||
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
|
||||||
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
|
||||||
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
|
||||||
|
|
||||||
return new OrganizationInvitesInfo(
|
|
||||||
organization,
|
|
||||||
orgSsoEnabled,
|
|
||||||
orgSsoLoginRequiredPolicyEnabled,
|
|
||||||
orgUsersWithExpTokens,
|
|
||||||
orgUserHasExistingUserDict,
|
|
||||||
initOrganization
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
|
@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
|
|||||||
|
|
||||||
public record Valid<T> : ValidationResult<T>
|
public record Valid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
|
public Valid() { }
|
||||||
|
|
||||||
|
public Valid(T Value)
|
||||||
|
{
|
||||||
|
this.Value = Value;
|
||||||
|
}
|
||||||
|
|
||||||
public T Value { get; init; }
|
public T Value { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Invalid<T> : ValidationResult<T>
|
public record Invalid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
public IEnumerable<Error<T>> Errors { get; init; }
|
public IEnumerable<Error<T>> Errors { get; init; } = [];
|
||||||
|
|
||||||
|
public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message));
|
||||||
|
|
||||||
|
public Invalid() { }
|
||||||
|
|
||||||
|
public Invalid(Error<T> error) : this([error]) { }
|
||||||
|
|
||||||
|
public Invalid(IEnumerable<Error<T>> errors)
|
||||||
|
{
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ValidationResultMappers
|
||||||
|
{
|
||||||
|
public static ValidationResult<B> Map<A, B>(this ValidationResult<A> validationResult, B invalidValue) =>
|
||||||
|
validationResult switch
|
||||||
|
{
|
||||||
|
Valid<A> => new Valid<B>(invalidValue),
|
||||||
|
Invalid<A> invalid => new Invalid<B>(invalid.Errors.Select(x => x.ToError(invalidValue))),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
namespace Bit.Core.Auth.Models.Mail;
|
namespace Bit.Core.Auth.Models.Mail;
|
||||||
|
|
||||||
public class CannotDeleteManagedAccountViewModel : BaseMailModel
|
public class CannotDeleteClaimedAccountViewModel : BaseMailModel
|
||||||
{
|
{
|
||||||
}
|
}
|
@ -53,23 +53,10 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
|||||||
var user = await _userRepository.GetByEmailAsync(email);
|
var user = await _userRepository.GetByEmailAsync(email);
|
||||||
var userExists = user != null;
|
var userExists = user != null;
|
||||||
|
|
||||||
// Delays enabled by default; flag must be enabled to remove the delays.
|
|
||||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
|
||||||
|
|
||||||
if (!_globalSettings.EnableEmailVerification)
|
if (!_globalSettings.EnableEmailVerification)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (userExists)
|
if (userExists)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (delaysEnabled)
|
|
||||||
{
|
|
||||||
// Add delay to prevent timing attacks
|
|
||||||
// Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms
|
|
||||||
// as it should be long enough to prevent timing attacks but not too long to be noticeable to the user.
|
|
||||||
await Task.Delay(Random.Shared.Next(100, 130));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException($"Email {email} is already taken");
|
throw new BadRequestException($"Email {email} is already taken");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,11 +74,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
|||||||
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
|
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delaysEnabled)
|
|
||||||
{
|
|
||||||
// Add random delay between 100ms-130ms to prevent timing attacks
|
|
||||||
await Task.Delay(Random.Shared.Next(100, 130));
|
|
||||||
}
|
|
||||||
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
|
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -28,6 +29,13 @@ public static class BillingExtensions
|
|||||||
Status: ProviderStatusType.Billable
|
Status: ProviderStatusType.Billable
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
|
||||||
|
inviteOrganizationProvider is
|
||||||
|
{
|
||||||
|
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||||
|
Status: ProviderStatusType.Billable
|
||||||
|
};
|
||||||
|
|
||||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ public class OrganizationBillingService(
|
|||||||
|
|
||||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||||
? await CreateCustomerAsync(organization, customerSetup)
|
? await CreateCustomerAsync(organization, customerSetup)
|
||||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
|
||||||
|
|
||||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||||
|
|
||||||
|
@ -622,47 +622,45 @@ public class SubscriberService(
|
|||||||
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(taxInformation.TaxId))
|
if (!string.IsNullOrWhiteSpace(taxInformation.TaxId))
|
||||||
{
|
{
|
||||||
return;
|
var taxIdType = taxInformation.TaxIdType;
|
||||||
}
|
if (string.IsNullOrWhiteSpace(taxIdType))
|
||||||
|
|
||||||
var taxIdType = taxInformation.TaxIdType;
|
|
||||||
if (string.IsNullOrWhiteSpace(taxIdType))
|
|
||||||
{
|
|
||||||
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
|
|
||||||
taxInformation.TaxId);
|
|
||||||
|
|
||||||
if (taxIdType == null)
|
|
||||||
{
|
{
|
||||||
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
|
||||||
taxInformation.Country,
|
|
||||||
taxInformation.TaxId);
|
taxInformation.TaxId);
|
||||||
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
if (taxIdType == null)
|
||||||
{
|
{
|
||||||
await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||||
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
|
|
||||||
}
|
|
||||||
catch (StripeException e)
|
|
||||||
{
|
|
||||||
switch (e.StripeError.Code)
|
|
||||||
{
|
|
||||||
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
|
||||||
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
|
||||||
taxInformation.TaxId,
|
|
||||||
taxInformation.Country);
|
|
||||||
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
|
|
||||||
default:
|
|
||||||
logger.LogError(e,
|
|
||||||
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
|
||||||
taxInformation.TaxId,
|
|
||||||
taxInformation.Country,
|
taxInformation.Country,
|
||||||
customer.Id);
|
taxInformation.TaxId);
|
||||||
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
|
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||||
|
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
|
||||||
|
}
|
||||||
|
catch (StripeException e)
|
||||||
|
{
|
||||||
|
switch (e.StripeError.Code)
|
||||||
|
{
|
||||||
|
case StripeConstants.ErrorCodes.TaxIdInvalid:
|
||||||
|
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
|
||||||
|
taxInformation.TaxId,
|
||||||
|
taxInformation.Country);
|
||||||
|
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
|
||||||
|
default:
|
||||||
|
logger.LogError(e,
|
||||||
|
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
|
||||||
|
taxInformation.TaxId,
|
||||||
|
taxInformation.Country,
|
||||||
|
customer.Id);
|
||||||
|
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,18 +104,16 @@ public static class FeatureFlagKeys
|
|||||||
/* Admin Console Team */
|
/* Admin Console Team */
|
||||||
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
|
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
|
||||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||||
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
|
|
||||||
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
||||||
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
|
||||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||||
|
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||||
|
|
||||||
/* Auth Team */
|
/* Auth Team */
|
||||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||||
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
|
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
|
||||||
public const string DuoRedirect = "duo-redirect";
|
public const string DuoRedirect = "duo-redirect";
|
||||||
public const string EmailVerification = "email-verification";
|
public const string EmailVerification = "email-verification";
|
||||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
|
||||||
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||||
@ -197,6 +195,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||||
public const string SecurityTasks = "security-tasks";
|
public const string SecurityTasks = "security-tasks";
|
||||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||||
|
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -4,9 +4,13 @@
|
|||||||
// EncryptedStringAttribute
|
// EncryptedStringAttribute
|
||||||
public enum EncryptionType : byte
|
public enum EncryptionType : byte
|
||||||
{
|
{
|
||||||
|
// symmetric
|
||||||
AesCbc256_B64 = 0,
|
AesCbc256_B64 = 0,
|
||||||
AesCbc128_HmacSha256_B64 = 1,
|
AesCbc128_HmacSha256_B64 = 1,
|
||||||
AesCbc256_HmacSha256_B64 = 2,
|
AesCbc256_HmacSha256_B64 = 2,
|
||||||
|
XChaCha20Poly1305_B64 = 7,
|
||||||
|
|
||||||
|
// asymmetric
|
||||||
Rsa2048_OaepSha256_B64 = 3,
|
Rsa2048_OaepSha256_B64 = 3,
|
||||||
Rsa2048_OaepSha1_B64 = 4,
|
Rsa2048_OaepSha1_B64 = 4,
|
||||||
Rsa2048_OaepSha256_HmacSha256_B64 = 5,
|
Rsa2048_OaepSha256_HmacSha256_B64 = 5,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
using Bit.Core.AdminConsole.Errors;
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
namespace Bit.Core.Models.Commands;
|
namespace Bit.Core.Models.Commands;
|
||||||
|
|
||||||
@ -40,10 +41,23 @@ public class Success<T>(T value) : CommandResult<T>
|
|||||||
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
||||||
{
|
{
|
||||||
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
||||||
|
public Error<T>[] Errors { get; set; } = [];
|
||||||
|
|
||||||
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
||||||
|
|
||||||
public Failure(string error) : this([error]) { }
|
public Failure(string error) : this([error])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(IEnumerable<Error<T>> errors) : this(errors.Select(e => e.Message))
|
||||||
|
{
|
||||||
|
Errors = errors.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(Error<T> error) : this([error.Message])
|
||||||
|
{
|
||||||
|
Errors = [error];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Partial<T> : CommandResult<T>
|
public class Partial<T> : CommandResult<T>
|
||||||
@ -57,3 +71,18 @@ public class Partial<T> : CommandResult<T>
|
|||||||
Failures = failedItems.ToArray();
|
Failures = failedItems.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class CommandResultExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="invalidResult">This is the invalid type from validating the object.</param>
|
||||||
|
/// <param name="mappingFunction">This function will map between the two types for the inner ErrorT</param>
|
||||||
|
/// <typeparam name="A">Invalid object's type</typeparam>
|
||||||
|
/// <typeparam name="B">Failure object's type</typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static CommandResult<B> MapToFailure<A, B>(this Invalid<A> invalidResult, Func<A, B> mappingFunction) =>
|
||||||
|
new Failure<B>(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue))));
|
||||||
|
}
|
||||||
|
@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
namespace Bit.Core.Models.Data.Organizations;
|
namespace Bit.Core.Models.Data.Organizations;
|
||||||
|
|
||||||
public record ManagedUserDomainClaimedEmails(IEnumerable<string> EmailList, Organization Organization);
|
public record ClaimedUserDomainClaimedEmails(IEnumerable<string> EmailList, Organization Organization);
|
@ -13,6 +13,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||||
using Bit.Core.Models.Business.Tokenables;
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
||||||
@ -116,7 +121,7 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
|
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
|
||||||
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
|
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
|
||||||
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
|
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
|
||||||
services.AddScoped<IDeleteManagedOrganizationUserAccountCommand, DeleteManagedOrganizationUserAccountCommand>();
|
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
|
||||||
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
|
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,13 +172,21 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
|
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
|
||||||
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
|
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
|
||||||
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
|
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
|
||||||
services.AddScoped<IGetOrganizationUsersManagementStatusQuery, GetOrganizationUsersManagementStatusQuery>();
|
services.AddScoped<IGetOrganizationUsersClaimedStatusQuery, GetOrganizationUsersClaimedStatusQuery>();
|
||||||
|
|
||||||
services.AddScoped<IRestoreOrganizationUserCommand, RestoreOrganizationUserCommand>();
|
services.AddScoped<IRestoreOrganizationUserCommand, RestoreOrganizationUserCommand>();
|
||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
|
||||||
|
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
|
||||||
|
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||||
|
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||||
|
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||||
|
@ -5,4 +5,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
|||||||
public interface IUpdateSecretsManagerSubscriptionCommand
|
public interface IUpdateSecretsManagerSubscriptionCommand
|
||||||
{
|
{
|
||||||
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
|
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
|
||||||
|
Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update);
|
||||||
}
|
}
|
||||||
|
@ -124,7 +124,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
public async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
||||||
{
|
{
|
||||||
if (_globalSettings.SelfHosted)
|
if (_globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,96 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
|
||||||
|
internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions<X509ChainOptions>
|
||||||
|
{
|
||||||
|
const string CertificateSearchPattern = "*.crt";
|
||||||
|
|
||||||
|
private readonly ILogger<PostConfigureX509ChainOptions> _logger;
|
||||||
|
private readonly IHostEnvironment _hostEnvironment;
|
||||||
|
private readonly GlobalSettings _globalSettings;
|
||||||
|
|
||||||
|
public PostConfigureX509ChainOptions(
|
||||||
|
ILogger<PostConfigureX509ChainOptions> logger,
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
GlobalSettings globalSettings)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_hostEnvironment = hostEnvironment;
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PostConfigure(string? name, X509ChainOptions options)
|
||||||
|
{
|
||||||
|
// We don't register or request a named instance of these options,
|
||||||
|
// so don't customize it.
|
||||||
|
if (name != Options.DefaultName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only allow this setting to be configured on self host.
|
||||||
|
if (!_globalSettings.SelfHosted)
|
||||||
|
{
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AdditionalCustomTrustCertificates != null)
|
||||||
|
{
|
||||||
|
// Additional certificates were added directly, this overwrites the need to
|
||||||
|
// read them from the directory.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Additional custom trust certificates were added directly, skipping loading them from '{Directory}'",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory))
|
||||||
|
{
|
||||||
|
// The default directory is volume mounted via the default Bitwarden setup process.
|
||||||
|
// If the directory doesn't exist it could indicate a error in configuration but this
|
||||||
|
// directory is never expected in a normal development environment so lower the log
|
||||||
|
// level in that case.
|
||||||
|
var logLevel = _hostEnvironment.IsDevelopment()
|
||||||
|
? LogLevel.Debug
|
||||||
|
: LogLevel.Warning;
|
||||||
|
_logger.Log(
|
||||||
|
logLevel,
|
||||||
|
"An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var certificates = new List<X509Certificate2>();
|
||||||
|
|
||||||
|
foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern))
|
||||||
|
{
|
||||||
|
certificates.Add(new X509Certificate2(certFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0)
|
||||||
|
{
|
||||||
|
// They have intentionally given us a non-default directory but there weren't certificates, that is odd.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No additional custom trust certificates were found in '{Directory}'",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AdditionalCustomTrustCertificates = certificates;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
using Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class X509ChainCustomizationServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures X509ChainPolicy customization through the root level <c>X509ChainOptions</c> configuration section
|
||||||
|
/// and configures the primary <see cref="HttpMessageHandler"/> to use custom certificate validation
|
||||||
|
/// when customized to do so.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
|
||||||
|
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
|
||||||
|
public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddOptions<X509ChainOptions>()
|
||||||
|
.BindConfiguration(nameof(X509ChainOptions));
|
||||||
|
|
||||||
|
// Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple
|
||||||
|
// times even if this method is called multiple times.
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<X509ChainOptions>, PostConfigureX509ChainOptions>());
|
||||||
|
|
||||||
|
services.AddHttpClient()
|
||||||
|
.ConfigureHttpClientDefaults(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||||
|
{
|
||||||
|
var x509ChainOptions = sp.GetRequiredService<IOptions<X509ChainOptions>>().Value;
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler();
|
||||||
|
|
||||||
|
if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback))
|
||||||
|
{
|
||||||
|
handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
return callback(certificate, chain, errors);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
81
src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs
Normal file
81
src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allows for customization of the <see cref="X509ChainPolicy"/> and access to a custom server certificate validator
|
||||||
|
/// if customization has been made.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class X509ChainOptions
|
||||||
|
{
|
||||||
|
// This is the directory that we historically used to allow certificates be added inside our container
|
||||||
|
// and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call
|
||||||
|
// `update-ca-certificates` but since that operation requires root we can't do it in a rootless container.
|
||||||
|
// Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41
|
||||||
|
public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A directory where additional certificates should be read from and included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only certificates suffixed with <c>*.crt</c> will be read. If <see cref="AdditionalCustomTrustCertificates"/> is
|
||||||
|
/// set, then this directory will not be read from.
|
||||||
|
/// </remarks>
|
||||||
|
public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of additional certificates that should be included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// If this value is set manually, then <see cref="AdditionalCustomTrustCertificatesDirectory"/> will be ignored.
|
||||||
|
/// </remarks>
|
||||||
|
public List<X509Certificate2>? AdditionalCustomTrustCertificates { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to retrieve a custom remote certificate validation callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="callback"></param>
|
||||||
|
/// <returns>Returns <see langword="true"/> when we have custom remote certification that should be added,
|
||||||
|
/// <see langword="false"/> when no custom validation is needed and the default validation callback should
|
||||||
|
/// be used instead.
|
||||||
|
/// </returns>
|
||||||
|
[MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))]
|
||||||
|
public bool TryGetCustomRemoteCertificateValidationCallback(
|
||||||
|
[MaybeNullWhen(false)] out Func<X509Certificate2?, X509Chain?, SslPolicyErrors, bool> callback)
|
||||||
|
{
|
||||||
|
callback = null;
|
||||||
|
if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do this outside of the callback so that we aren't opening the root store every request.
|
||||||
|
using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly);
|
||||||
|
var rootCertificates = store.Certificates;
|
||||||
|
|
||||||
|
// Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581
|
||||||
|
callback = (certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
if (chain == null || certificate == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
|
||||||
|
// We want our additional certificates to be in addition to the machines root store.
|
||||||
|
chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates);
|
||||||
|
|
||||||
|
foreach (var additionalCertificate in AdditionalCustomTrustCertificates)
|
||||||
|
{
|
||||||
|
chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate);
|
||||||
|
}
|
||||||
|
return chain.Build(certificate);
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,7 @@ public interface IMailService
|
|||||||
ProductTierType productTier,
|
ProductTierType productTier,
|
||||||
IEnumerable<ProductType> products);
|
IEnumerable<ProductType> products);
|
||||||
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
|
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
|
||||||
Task SendCannotDeleteManagedAccountEmailAsync(string email);
|
Task SendCannotDeleteClaimedAccountEmailAsync(string email);
|
||||||
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
||||||
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
||||||
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
|
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
|
||||||
@ -97,7 +97,7 @@ public interface IMailService
|
|||||||
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
|
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
|
||||||
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
|
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
|
||||||
string organizationName);
|
string organizationName);
|
||||||
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
|
Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList);
|
||||||
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
|
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
|
||||||
Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails);
|
Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||||
@ -38,9 +39,28 @@ public interface IPaymentService
|
|||||||
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
|
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
|
||||||
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
|
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
|
||||||
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
|
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
|
||||||
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
|
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount);
|
||||||
int additionalServiceAccount);
|
/// <summary>
|
||||||
|
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
|
||||||
|
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
|
||||||
|
/// Manager and Secrets Manger.
|
||||||
|
///
|
||||||
|
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">Organization Entity</param>
|
||||||
|
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
|
||||||
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
|
||||||
|
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
|
||||||
|
/// Manager and Secrets Manger.
|
||||||
|
///
|
||||||
|
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">Organization Representation used for Inviting Organization Users</param>
|
||||||
|
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
|
||||||
|
Task<bool> HasSecretsManagerStandalone(InviteOrganization organization);
|
||||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||||
|
|
||||||
|
@ -134,7 +134,7 @@ public interface IUserService
|
|||||||
/// <returns>
|
/// <returns>
|
||||||
/// False if the Account Deprovisioning feature flag is disabled.
|
/// False if the Account Deprovisioning feature flag is disabled.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
Task<bool> IsManagedByAnyOrganizationAsync(Guid userId);
|
Task<bool> IsClaimedByAnyOrganizationAsync(Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verify whether the new email domain meets the requirements for managed users.
|
/// Verify whether the new email domain meets the requirements for managed users.
|
||||||
@ -142,9 +142,9 @@ public interface IUserService
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// IdentityResult
|
/// IdentityResult
|
||||||
/// </returns>
|
/// </returns>
|
||||||
Task<IdentityResult> ValidateManagedUserDomainAsync(User user, string newEmail);
|
Task<IdentityResult> ValidateClaimedUserDomainAsync(User user, string newEmail);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the organizations that manage the user.
|
/// Gets the organizations that manage the user.
|
||||||
@ -152,6 +152,6 @@ public interface IUserService
|
|||||||
/// <returns>
|
/// <returns>
|
||||||
/// An empty collection if the Account Deprovisioning feature flag is disabled.
|
/// An empty collection if the Account Deprovisioning feature flag is disabled.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
/// <inheritdoc cref="IsManagedByAnyOrganizationAsync(Guid)"/>
|
/// <inheritdoc cref="IsClaimedByAnyOrganizationAsync"/>
|
||||||
Task<IEnumerable<Organization>> GetOrganizationsManagingUserAsync(Guid userId);
|
Task<IEnumerable<Organization>> GetOrganizationsClaimingUserAsync(Guid userId);
|
||||||
}
|
}
|
||||||
|
@ -77,6 +77,9 @@ public class DeviceService : IDeviceService
|
|||||||
|
|
||||||
device.Active = false;
|
device.Active = false;
|
||||||
device.RevisionDate = DateTime.UtcNow;
|
device.RevisionDate = DateTime.UtcNow;
|
||||||
|
device.EncryptedPrivateKey = null;
|
||||||
|
device.EncryptedPublicKey = null;
|
||||||
|
device.EncryptedUserKey = null;
|
||||||
await _deviceRepository.UpsertAsync(device);
|
await _deviceRepository.UpsertAsync(device);
|
||||||
|
|
||||||
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
|
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
|
||||||
|
@ -117,16 +117,16 @@ public class HandlebarsMailService : IMailService
|
|||||||
await _mailDeliveryService.SendEmailAsync(message);
|
await _mailDeliveryService.SendEmailAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendCannotDeleteManagedAccountEmailAsync(string email)
|
public async Task SendCannotDeleteClaimedAccountEmailAsync(string email)
|
||||||
{
|
{
|
||||||
var message = CreateDefaultMessage("Delete Your Account", email);
|
var message = CreateDefaultMessage("Delete Your Account", email);
|
||||||
var model = new CannotDeleteManagedAccountViewModel
|
var model = new CannotDeleteClaimedAccountViewModel
|
||||||
{
|
{
|
||||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||||
SiteName = _globalSettings.SiteName,
|
SiteName = _globalSettings.SiteName,
|
||||||
};
|
};
|
||||||
await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model);
|
await AddMessageContentAsync(message, "AdminConsole.CannotDeleteClaimedAccount", model);
|
||||||
message.Category = "CannotDeleteManagedAccount";
|
message.Category = "CannotDeleteClaimedAccount";
|
||||||
await _mailDeliveryService.SendEmailAsync(message);
|
await _mailDeliveryService.SendEmailAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,7 +474,7 @@ public class HandlebarsMailService : IMailService
|
|||||||
await _mailDeliveryService.SendEmailAsync(message);
|
await _mailDeliveryService.SendEmailAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList)
|
public async Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList)
|
||||||
{
|
{
|
||||||
await EnqueueMailAsync(emailList.EmailList.Select(email =>
|
await EnqueueMailAsync(emailList.EmailList.Select(email =>
|
||||||
CreateMessage(email, emailList.Organization)));
|
CreateMessage(email, emailList.Organization)));
|
||||||
@ -804,12 +804,10 @@ public class HandlebarsMailService : IMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var numeric = parameters[0];
|
if (int.TryParse(parameters[0].ToString(), out var number))
|
||||||
var singularText = parameters[1].ToString();
|
|
||||||
var pluralText = parameters[2].ToString();
|
|
||||||
|
|
||||||
if (numeric is int number)
|
|
||||||
{
|
{
|
||||||
|
var singularText = parameters[1].ToString();
|
||||||
|
var pluralText = parameters[2].ToString();
|
||||||
writer.WriteSafeString(number == 1 ? singularText : pluralText);
|
writer.WriteSafeString(number == 1 ? singularText : pluralText);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
using Bit.Core.Settings;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
@ -10,12 +13,14 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
|
|||||||
{
|
{
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly ILogger<MailKitSmtpMailDeliveryService> _logger;
|
private readonly ILogger<MailKitSmtpMailDeliveryService> _logger;
|
||||||
|
private readonly X509ChainOptions _x509ChainOptions;
|
||||||
private readonly string _replyDomain;
|
private readonly string _replyDomain;
|
||||||
private readonly string _replyEmail;
|
private readonly string _replyEmail;
|
||||||
|
|
||||||
public MailKitSmtpMailDeliveryService(
|
public MailKitSmtpMailDeliveryService(
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
ILogger<MailKitSmtpMailDeliveryService> logger)
|
ILogger<MailKitSmtpMailDeliveryService> logger,
|
||||||
|
IOptions<X509ChainOptions> x509ChainOptions)
|
||||||
{
|
{
|
||||||
if (globalSettings.Mail?.Smtp?.Host == null)
|
if (globalSettings.Mail?.Smtp?.Host == null)
|
||||||
{
|
{
|
||||||
@ -31,6 +36,7 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
|
|||||||
|
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_x509ChainOptions = x509ChainOptions.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendEmailAsync(Models.Mail.MailMessage message)
|
public async Task SendEmailAsync(Models.Mail.MailMessage message)
|
||||||
@ -75,6 +81,13 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
|
|||||||
{
|
{
|
||||||
client.ServerCertificateValidationCallback = (s, c, h, e) => true;
|
client.ServerCertificateValidationCallback = (s, c, h, e) => true;
|
||||||
}
|
}
|
||||||
|
else if (_x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback))
|
||||||
|
{
|
||||||
|
client.ServerCertificateValidationCallback = (sender, cert, chain, errors) =>
|
||||||
|
{
|
||||||
|
return callback(new X509Certificate2(cert), chain, errors);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
|
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
|
||||||
_globalSettings.Mail.Smtp.Port == 25)
|
_globalSettings.Mail.Smtp.Port == 25)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
@ -1110,14 +1111,27 @@ public class StripePaymentService : IPaymentService
|
|||||||
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
|
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
|
||||||
true);
|
true);
|
||||||
|
|
||||||
public async Task<bool> HasSecretsManagerStandalone(Organization organization)
|
public async Task<bool> HasSecretsManagerStandalone(Organization organization) =>
|
||||||
|
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
|
||||||
|
organizationHasSecretsManager: organization.UseSecretsManager);
|
||||||
|
|
||||||
|
public async Task<bool> HasSecretsManagerStandalone(InviteOrganization organization) =>
|
||||||
|
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
|
||||||
|
organizationHasSecretsManager: organization.UseSecretsManager);
|
||||||
|
|
||||||
|
private async Task<bool> HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
if (string.IsNullOrEmpty(gatewayCustomerId))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId);
|
if (organizationHasSecretsManager is false)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||||
|
|
||||||
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
|
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
|
||||||
}
|
}
|
||||||
|
@ -314,9 +314,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await IsManagedByAnyOrganizationAsync(user.Id))
|
if (await IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email);
|
await _mailService.SendCannotDeleteClaimedAccountEmailAsync(user.Email);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,11 +545,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail);
|
var claimedUserValidationResult = await ValidateClaimedUserDomainAsync(user, newEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
return managedUserValidationResult;
|
return claimedUserValidationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider,
|
if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider,
|
||||||
@ -617,18 +617,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IdentityResult> ValidateManagedUserDomainAsync(User user, string newEmail)
|
public async Task<IdentityResult> ValidateClaimedUserDomainAsync(User user, string newEmail)
|
||||||
{
|
{
|
||||||
var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id);
|
var claimingOrganization = await GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
if (!managingOrganizations.Any())
|
if (!claimingOrganization.Any())
|
||||||
{
|
{
|
||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newDomain = CoreHelpers.GetEmailDomain(newEmail);
|
var newDomain = CoreHelpers.GetEmailDomain(newEmail);
|
||||||
|
|
||||||
var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(managingOrganizations.Select(org => org.Id));
|
var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(claimingOrganization.Select(org => org.Id));
|
||||||
|
|
||||||
if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
|
if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
|
||||||
{
|
{
|
||||||
@ -1366,13 +1366,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IsLegacyUser(user);
|
return IsLegacyUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsManagedByAnyOrganizationAsync(Guid userId)
|
public async Task<bool> IsClaimedByAnyOrganizationAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var managingOrganizations = await GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await GetOrganizationsClaimingUserAsync(userId);
|
||||||
return managingOrganizations.Any();
|
return organizationsClaimingUser.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Organization>> GetOrganizationsManagingUserAsync(Guid userId)
|
public async Task<IEnumerable<Organization>> GetOrganizationsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
{
|
{
|
||||||
|
@ -103,7 +103,7 @@ public class NoopMailService : IMailService
|
|||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendCannotDeleteManagedAccountEmailAsync(string email)
|
public Task SendCannotDeleteClaimedAccountEmailAsync(string email)
|
||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
@ -317,7 +317,7 @@ public class NoopMailService : IMailService
|
|||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask;
|
public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask;
|
||||||
|
|
||||||
public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName)
|
public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName)
|
||||||
{
|
{
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using System.Collections.Concurrent;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
@ -59,22 +60,21 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
|||||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
|
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||||
|
|
||||||
var memberAccessCipherDetails = GenerateAccessData(
|
var memberAccessCipherDetails = GenerateAccessDataParallel(
|
||||||
orgGroups,
|
orgGroups,
|
||||||
orgCollectionsWithAccess,
|
orgCollectionsWithAccess,
|
||||||
orgItems,
|
orgItems,
|
||||||
organizationUsersTwoFactorEnabled,
|
organizationUsersTwoFactorEnabled,
|
||||||
orgAbility
|
orgAbility);
|
||||||
);
|
|
||||||
|
|
||||||
return memberAccessCipherDetails;
|
return memberAccessCipherDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a report for all members of an organization. Containing summary information
|
/// Generates a report for all members of an organization. Containing summary information
|
||||||
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
|
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
|
||||||
/// Child collection includes detailed information on the user and group collections along
|
/// Child collection includes detailed information on the user and group collections along
|
||||||
/// with their permissions.
|
/// with their permissions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="orgGroups">Organization groups collection</param>
|
/// <param name="orgGroups">Organization groups collection</param>
|
||||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
||||||
@ -82,72 +82,72 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
|||||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
||||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
||||||
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
|
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
|
||||||
private IEnumerable<MemberAccessCipherDetails> GenerateAccessData(
|
private IEnumerable<MemberAccessCipherDetails> GenerateAccessDataParallel(
|
||||||
ICollection<Group> orgGroups,
|
ICollection<Group> orgGroups,
|
||||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
||||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
||||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
||||||
OrganizationAbility orgAbility)
|
OrganizationAbility orgAbility)
|
||||||
{
|
{
|
||||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList();
|
||||||
// Create a dictionary to lookup the group names later.
|
|
||||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
||||||
|
|
||||||
// Get collections grouped and into a dictionary for counts
|
|
||||||
var collectionItems = orgItems
|
var collectionItems = orgItems
|
||||||
.SelectMany(x => x.CollectionIds,
|
.SelectMany(x => x.CollectionIds,
|
||||||
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
|
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
|
||||||
.GroupBy(y => y.CollectionId,
|
.GroupBy(y => y.CollectionId,
|
||||||
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
|
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
|
||||||
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()));
|
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList());
|
||||||
|
|
||||||
// Loop through the org users and populate report and access data
|
var memberAccessCipherDetails = new ConcurrentBag<MemberAccessCipherDetails>();
|
||||||
var memberAccessCipherDetails = new List<MemberAccessCipherDetails>();
|
|
||||||
foreach (var user in orgUsers)
|
Parallel.ForEach(orgUsers, user =>
|
||||||
{
|
{
|
||||||
var groupAccessDetails = new List<MemberAccessDetails>();
|
var groupAccessDetails = new List<MemberAccessDetails>();
|
||||||
var userCollectionAccessDetails = new List<MemberAccessDetails>();
|
var userCollectionAccessDetails = new List<MemberAccessDetails>();
|
||||||
|
|
||||||
foreach (var tCollect in orgCollectionsWithAccess)
|
foreach (var tCollect in orgCollectionsWithAccess)
|
||||||
{
|
{
|
||||||
var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items);
|
if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items))
|
||||||
var collectionCiphers = hasItems ? items.Select(x => x) : null;
|
|
||||||
|
|
||||||
var itemCounts = hasItems ? collectionCiphers.Count() : 0;
|
|
||||||
if (tCollect.Item2.Groups.Count() > 0)
|
|
||||||
{
|
{
|
||||||
|
var itemCounts = items.Count;
|
||||||
|
|
||||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
if (tCollect.Item2.Groups.Any())
|
||||||
new MemberAccessDetails
|
{
|
||||||
{
|
var groupDetails = tCollect.Item2.Groups
|
||||||
CollectionId = tCollect.Item1.Id,
|
.Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id))
|
||||||
CollectionName = tCollect.Item1.Name,
|
.Select(x => new MemberAccessDetails
|
||||||
GroupId = x.Id,
|
{
|
||||||
GroupName = groupNameDictionary[x.Id],
|
CollectionId = tCollect.Item1.Id,
|
||||||
ReadOnly = x.ReadOnly,
|
CollectionName = tCollect.Item1.Name,
|
||||||
HidePasswords = x.HidePasswords,
|
GroupId = x.Id,
|
||||||
Manage = x.Manage,
|
GroupName = groupNameDictionary[x.Id],
|
||||||
ItemCount = itemCounts,
|
ReadOnly = x.ReadOnly,
|
||||||
CollectionCipherIds = items
|
HidePasswords = x.HidePasswords,
|
||||||
});
|
Manage = x.Manage,
|
||||||
|
ItemCount = itemCounts,
|
||||||
|
CollectionCipherIds = items
|
||||||
|
});
|
||||||
|
|
||||||
groupAccessDetails.AddRange(groupDetails);
|
groupAccessDetails.AddRange(groupDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All collections assigned to users and their permissions
|
if (tCollect.Item2.Users.Any())
|
||||||
if (tCollect.Item2.Users.Count() > 0)
|
{
|
||||||
{
|
var userCollectionDetails = tCollect.Item2.Users
|
||||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
.Where(tCollectUser => tCollectUser.Id == user.Id)
|
||||||
new MemberAccessDetails
|
.Select(x => new MemberAccessDetails
|
||||||
{
|
{
|
||||||
CollectionId = tCollect.Item1.Id,
|
CollectionId = tCollect.Item1.Id,
|
||||||
CollectionName = tCollect.Item1.Name,
|
CollectionName = tCollect.Item1.Name,
|
||||||
ReadOnly = x.ReadOnly,
|
ReadOnly = x.ReadOnly,
|
||||||
HidePasswords = x.HidePasswords,
|
HidePasswords = x.HidePasswords,
|
||||||
Manage = x.Manage,
|
Manage = x.Manage,
|
||||||
ItemCount = itemCounts,
|
ItemCount = itemCounts,
|
||||||
CollectionCipherIds = items
|
CollectionCipherIds = items
|
||||||
});
|
});
|
||||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
|
||||||
|
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,7 +156,6 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
|||||||
UserName = user.Name,
|
UserName = user.Name,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
||||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
|
||||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||||
UserGuid = user.Id,
|
UserGuid = user.Id,
|
||||||
UsesKeyConnector = user.UsesKeyConnector
|
UsesKeyConnector = user.UsesKeyConnector
|
||||||
@ -169,9 +168,8 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
|||||||
userAccessDetails.AddRange(userGroups);
|
userAccessDetails.AddRange(userGroups);
|
||||||
}
|
}
|
||||||
|
|
||||||
// There can be edge cases where groups don't have a collection
|
|
||||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
||||||
if (groupsWithoutCollections.Count() > 0)
|
if (groupsWithoutCollections.Any())
|
||||||
{
|
{
|
||||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
|
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
|
||||||
{
|
{
|
||||||
@ -189,20 +187,20 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
|||||||
}
|
}
|
||||||
report.AccessDetails = userAccessDetails;
|
report.AccessDetails = userAccessDetails;
|
||||||
|
|
||||||
var userCiphers =
|
var userCiphers = report.AccessDetails
|
||||||
report.AccessDetails
|
.Where(x => x.ItemCount > 0)
|
||||||
.Where(x => x.ItemCount > 0)
|
.SelectMany(y => y.CollectionCipherIds)
|
||||||
.SelectMany(y => y.CollectionCipherIds)
|
.Distinct();
|
||||||
.Distinct();
|
|
||||||
report.CipherIds = userCiphers;
|
report.CipherIds = userCiphers;
|
||||||
report.TotalItemCount = userCiphers.Count();
|
report.TotalItemCount = userCiphers.Count();
|
||||||
|
|
||||||
// Distinct items only
|
|
||||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
||||||
report.CollectionsCount = distinctItems.Count();
|
report.CollectionsCount = distinctItems.Count();
|
||||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
||||||
|
|
||||||
memberAccessCipherDetails.Add(report);
|
memberAccessCipherDetails.Add(report);
|
||||||
}
|
});
|
||||||
|
|
||||||
return memberAccessCipherDetails;
|
return memberAccessCipherDetails;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
44
src/Core/Utilities/EmailValidation.cs
Normal file
44
src/Core/Utilities/EmailValidation.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Utilities;
|
||||||
|
|
||||||
|
public static class EmailValidation
|
||||||
|
{
|
||||||
|
public static bool IsValidEmail(this string emailAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(emailAddress))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address;
|
||||||
|
if (parsedEmailAddress != emailAddress)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ParseException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regex below is intended to catch edge cases that are not handled by the general parsing check above.
|
||||||
|
// This enforces the following rules:
|
||||||
|
// * Requires ASCII only in the local-part (code points 0-127)
|
||||||
|
// * Requires an @ symbol
|
||||||
|
// * Allows any char in second-level domain name, including unicode and symbols
|
||||||
|
// * Requires at least one period (.) separating SLD from TLD
|
||||||
|
// * Must end in a letter (including unicode)
|
||||||
|
// See the unit tests for examples of what is allowed.
|
||||||
|
var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$";
|
||||||
|
if (!Regex.IsMatch(emailAddress, emailFormat))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,7 @@ public class EncryptedStringAttribute : ValidationAttribute
|
|||||||
[EncryptionType.AesCbc256_B64] = 2, // iv|ct
|
[EncryptionType.AesCbc256_B64] = 2, // iv|ct
|
||||||
[EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac
|
[EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac
|
||||||
[EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac
|
[EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac
|
||||||
|
[EncryptionType.XChaCha20Poly1305_B64] = 1, // cose bytes
|
||||||
[EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt
|
[EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt
|
||||||
[EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt
|
[EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt
|
||||||
[EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac
|
[EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using MimeKit;
|
|
||||||
|
|
||||||
namespace Bit.Core.Utilities;
|
namespace Bit.Core.Utilities;
|
||||||
|
|
||||||
@ -12,39 +10,8 @@ public class StrictEmailAddressAttribute : ValidationAttribute
|
|||||||
|
|
||||||
public override bool IsValid(object value)
|
public override bool IsValid(object value)
|
||||||
{
|
{
|
||||||
var emailAddress = value?.ToString();
|
var emailAddress = value?.ToString() ?? string.Empty;
|
||||||
if (emailAddress == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
return emailAddress.IsValidEmail() && new EmailAddressAttribute().IsValid(emailAddress);
|
||||||
{
|
|
||||||
var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address;
|
|
||||||
if (parsedEmailAddress != emailAddress)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (ParseException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The regex below is intended to catch edge cases that are not handled by the general parsing check above.
|
|
||||||
// This enforces the following rules:
|
|
||||||
// * Requires ASCII only in the local-part (code points 0-127)
|
|
||||||
// * Requires an @ symbol
|
|
||||||
// * Allows any char in second-level domain name, including unicode and symbols
|
|
||||||
// * Requires at least one period (.) separating SLD from TLD
|
|
||||||
// * Must end in a letter (including unicode)
|
|
||||||
// See the unit tests for examples of what is allowed.
|
|
||||||
var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$";
|
|
||||||
if (!Regex.IsMatch(emailAddress, emailFormat))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new EmailAddressAttribute().IsValid(emailAddress);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,8 +121,7 @@ public class AccountsController : Controller
|
|||||||
var user = model.ToUser();
|
var user = model.ToUser();
|
||||||
var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
||||||
model.Token, model.OrganizationUserId);
|
model.Token, model.OrganizationUserId);
|
||||||
// delaysEnabled false is only for the new registration with email verification process
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register/send-verification-email")]
|
[HttpPost("register/send-verification-email")]
|
||||||
@ -188,7 +187,6 @@ public class AccountsController : Controller
|
|||||||
// Users will either have an emailed token or an email verification token - not both.
|
// Users will either have an emailed token or an email verification token - not both.
|
||||||
|
|
||||||
IdentityResult identityResult = null;
|
IdentityResult identityResult = null;
|
||||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
|
||||||
|
|
||||||
switch (model.GetTokenType())
|
switch (model.GetTokenType())
|
||||||
{
|
{
|
||||||
@ -197,32 +195,32 @@ public class AccountsController : Controller
|
|||||||
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
|
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
|
||||||
model.EmailVerificationToken);
|
model.EmailVerificationToken);
|
||||||
|
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
break;
|
break;
|
||||||
case RegisterFinishTokenType.OrganizationInvite:
|
case RegisterFinishTokenType.OrganizationInvite:
|
||||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
|
||||||
model.OrgInviteToken, model.OrganizationUserId);
|
model.OrgInviteToken, model.OrganizationUserId);
|
||||||
|
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
break;
|
break;
|
||||||
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
|
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
|
||||||
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
|
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
|
||||||
|
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
break;
|
break;
|
||||||
case RegisterFinishTokenType.EmergencyAccessInvite:
|
case RegisterFinishTokenType.EmergencyAccessInvite:
|
||||||
Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
|
Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
|
||||||
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
|
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
|
||||||
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
|
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
|
||||||
|
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
break;
|
break;
|
||||||
case RegisterFinishTokenType.ProviderInvite:
|
case RegisterFinishTokenType.ProviderInvite:
|
||||||
Debug.Assert(model.ProviderUserId.HasValue);
|
Debug.Assert(model.ProviderUserId.HasValue);
|
||||||
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
|
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
|
||||||
model.ProviderInviteToken, model.ProviderUserId.Value);
|
model.ProviderInviteToken, model.ProviderUserId.Value);
|
||||||
|
|
||||||
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
|
return ProcessRegistrationResult(identityResult, user);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -230,7 +228,7 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)
|
private RegisterResponseModel ProcessRegistrationResult(IdentityResult result, User user)
|
||||||
{
|
{
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
@ -243,10 +241,6 @@ public class AccountsController : Controller
|
|||||||
ModelState.AddModelError(string.Empty, error.Description);
|
ModelState.AddModelError(string.Empty, error.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delaysEnabled)
|
|
||||||
{
|
|
||||||
await Task.Delay(Random.Shared.Next(100, 130));
|
|
||||||
}
|
|
||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
@ -580,4 +581,32 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
|||||||
return results.ToList();
|
return results.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection)
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(_marsConnectionString);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
$"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)),
|
||||||
|
CollectionData = JsonSerializer.Serialize(organizationUserCollection
|
||||||
|
.SelectMany(x => x.Collections, (user, collection) => new CollectionUser
|
||||||
|
{
|
||||||
|
CollectionId = collection.Id,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id,
|
||||||
|
ReadOnly = collection.ReadOnly,
|
||||||
|
HidePasswords = collection.HidePasswords,
|
||||||
|
Manage = collection.Manage
|
||||||
|
})),
|
||||||
|
GroupData = JsonSerializer.Serialize(organizationUserCollection
|
||||||
|
.SelectMany(x => x.Groups, (user, group) => new GroupUser
|
||||||
|
{
|
||||||
|
GroupId = group,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ public class OrganizationIntegrationConfigurationEntityTypeConfiguration : IEnti
|
|||||||
public void Configure(EntityTypeBuilder<OrganizationIntegrationConfiguration> builder)
|
public void Configure(EntityTypeBuilder<OrganizationIntegrationConfiguration> builder)
|
||||||
{
|
{
|
||||||
builder
|
builder
|
||||||
.Property(p => p.Id)
|
.Property(oic => oic.Id)
|
||||||
.ValueGeneratedNever();
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
builder.ToTable(nameof(OrganizationIntegrationConfiguration));
|
builder.ToTable(nameof(OrganizationIntegrationConfiguration));
|
||||||
|
@ -9,15 +9,15 @@ public class OrganizationIntegrationEntityTypeConfiguration : IEntityTypeConfigu
|
|||||||
public void Configure(EntityTypeBuilder<OrganizationIntegration> builder)
|
public void Configure(EntityTypeBuilder<OrganizationIntegration> builder)
|
||||||
{
|
{
|
||||||
builder
|
builder
|
||||||
.Property(p => p.Id)
|
.Property(oi => oi.Id)
|
||||||
.ValueGeneratedNever();
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasIndex(p => p.OrganizationId)
|
.HasIndex(oi => oi.OrganizationId)
|
||||||
.IsClustered(false);
|
.IsClustered(false);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasIndex(p => new { p.OrganizationId, p.Type })
|
.HasIndex(oi => new { oi.OrganizationId, oi.Type })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.IsClustered(false);
|
.IsClustered(false);
|
||||||
|
|
||||||
|
@ -199,6 +199,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
|||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
await dbContext.ProviderOrganizations.Where(po => po.OrganizationId == organization.Id)
|
await dbContext.ProviderOrganizations.Where(po => po.OrganizationId == organization.Id)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
await dbContext.OrganizationIntegrations.Where(oi => oi.OrganizationId == organization.Id)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
await dbContext.GroupServiceAccountAccessPolicy.Where(ap => ap.GrantedServiceAccount.OrganizationId == organization.Id)
|
await dbContext.GroupServiceAccountAccessPolicy.Where(ap => ap.GrantedServiceAccount.OrganizationId == organization.Id)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
@ -757,4 +758,28 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
|||||||
return await query.ToListAsync();
|
return await query.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection)
|
||||||
|
{
|
||||||
|
using var scope = ServiceScopeFactory.CreateScope();
|
||||||
|
|
||||||
|
await using var dbContext = GetDatabaseContext(scope);
|
||||||
|
|
||||||
|
dbContext.OrganizationUsers.AddRange(Mapper.Map<List<OrganizationUser>>(organizationUserCollection.Select(x => x.OrganizationUser)));
|
||||||
|
dbContext.CollectionUsers.AddRange(organizationUserCollection.SelectMany(x => x.Collections, (user, collection) => new CollectionUser
|
||||||
|
{
|
||||||
|
CollectionId = collection.Id,
|
||||||
|
HidePasswords = collection.HidePasswords,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id,
|
||||||
|
Manage = collection.Manage,
|
||||||
|
ReadOnly = collection.ReadOnly
|
||||||
|
}));
|
||||||
|
dbContext.GroupUsers.AddRange(organizationUserCollection.SelectMany(x => x.Groups, (user, group) => new GroupUser
|
||||||
|
{
|
||||||
|
GroupId = group,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id
|
||||||
|
}));
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user