mirror of
https://github.com/bitwarden/server.git
synced 2025-07-03 00:52:49 -05:00
Merge branch 'main' into PM-17830
This commit is contained in:
@ -300,8 +300,7 @@ public class ProvidersController : Controller
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
provider,
|
||||
[
|
||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||
@ -314,15 +313,14 @@ public class ProvidersController : Controller
|
||||
|
||||
// 1. Change the plan and take over any old values.
|
||||
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||
provider,
|
||||
existingMoePlan.Id,
|
||||
model.Plan!.Value,
|
||||
provider.GatewaySubscriptionId);
|
||||
model.Plan!.Value);
|
||||
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||
|
||||
// 2. Update the seat minimums.
|
||||
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
provider,
|
||||
[
|
||||
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||
]);
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
@ -61,6 +62,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
|
||||
public OrganizationUsersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -86,7 +88,8 @@ public class OrganizationUsersController : Controller
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient,
|
||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand)
|
||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -112,6 +115,7 @@ public class OrganizationUsersController : Controller
|
||||
_featureService = featureService;
|
||||
_pricingClient = pricingClient;
|
||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -630,14 +634,14 @@ public class OrganizationUsersController : Controller
|
||||
[HttpPut("{id}/restore")]
|
||||
public async Task RestoreAsync(Guid orgId, Guid id)
|
||||
{
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _organizationService.RestoreUserAsync(orgUser, userId));
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
|
||||
}
|
||||
|
||||
[HttpPatch("restore")]
|
||||
[HttpPut("restore")]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _organizationService.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
|
||||
}
|
||||
|
||||
[HttpPatch("enable-secrets-manager")]
|
||||
|
@ -13,7 +13,17 @@ public static class PolicyDetailResponses
|
||||
{
|
||||
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||
}
|
||||
return new PolicyDetailResponseModel(policy, await CanToggleState());
|
||||
|
||||
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
|
||||
async Task<bool> CanToggleState()
|
||||
{
|
||||
if (!await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !policy.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -76,6 +76,13 @@ public class OrganizationSponsorshipsController : Controller
|
||||
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
|
||||
{
|
||||
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
|
||||
var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value;
|
||||
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
|
||||
@ -93,6 +100,14 @@ public class OrganizationSponsorshipsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId)
|
||||
{
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
|
||||
var sponsoringOrgUser = await _organizationUserRepository
|
||||
.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default);
|
||||
|
||||
@ -139,6 +154,14 @@ public class OrganizationSponsorshipsController : Controller
|
||||
throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
|
||||
}
|
||||
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
||||
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
|
||||
await _setUpSponsorshipCommand.SetUpSponsorshipAsync(
|
||||
sponsorship,
|
||||
await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId));
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
@ -125,7 +124,7 @@ public class DevicesController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("{identifier}/retrieve-keys")]
|
||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier, [FromBody] SecretVerificationRequestModel model)
|
||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
|
||||
@ -134,14 +133,7 @@ public class DevicesController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (!await _userService.VerifySecretAsync(user, model.Secret))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||
}
|
||||
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
||||
|
||||
if (device == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
|
@ -8,6 +8,7 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -43,6 +44,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_organizationUserValidator;
|
||||
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
_webauthnKeyValidator;
|
||||
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||
|
||||
public AccountsKeyManagementController(IUserService userService,
|
||||
IFeatureService featureService,
|
||||
@ -57,7 +59,8 @@ public class AccountsKeyManagementController : Controller
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator)
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||
{
|
||||
_userService = userService;
|
||||
_featureService = featureService;
|
||||
@ -71,6 +74,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_emergencyAccessValidator = emergencyAccessValidator;
|
||||
_organizationUserValidator = organizationUserValidator;
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
_deviceValidator = deviceValidator;
|
||||
}
|
||||
|
||||
[HttpPost("regenerate-keys")]
|
||||
@ -109,6 +113,7 @@ public class AccountsKeyManagementController : Controller
|
||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
||||
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
|
||||
|
||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
||||
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
||||
|
@ -3,6 +3,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
@ -13,4 +14,5 @@ public class UnlockDataRequestModel
|
||||
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
|
||||
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
|
||||
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
|
||||
public required IEnumerable<OtherDeviceKeysUpdateRequestModel> DeviceKeyUnlockData { get; set; }
|
||||
}
|
||||
|
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
53
src/Api/KeyManagement/Validators/DeviceRotationValidator.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Device implementation for <see cref="IRotationValidator{T,R}"/>
|
||||
/// </summary>
|
||||
public class DeviceRotationValidator : IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>
|
||||
{
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="DeviceRotationValidator"/>
|
||||
/// </summary>
|
||||
/// <param name="deviceRepository">Retrieves all user <see cref="Device"/>s</param>
|
||||
public DeviceRotationValidator(IDeviceRepository deviceRepository)
|
||||
{
|
||||
_deviceRepository = deviceRepository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Device>> ValidateAsync(User user, IEnumerable<OtherDeviceKeysUpdateRequestModel> devices)
|
||||
{
|
||||
var result = new List<Device>();
|
||||
|
||||
var existingTrustedDevices = (await _deviceRepository.GetManyByUserIdAsync(user.Id)).Where(d => d.IsTrusted()).ToList();
|
||||
if (existingTrustedDevices.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var existing in existingTrustedDevices)
|
||||
{
|
||||
var device = devices.FirstOrDefault(c => c.DeviceId == existing.Id);
|
||||
if (device == null)
|
||||
{
|
||||
throw new BadRequestException("All existing trusted devices must be included in the rotation.");
|
||||
}
|
||||
|
||||
if (device.EncryptedUserKey == null || device.EncryptedPublicKey == null)
|
||||
{
|
||||
throw new BadRequestException("Rotated encryption keys must be provided for all devices that are trusted.");
|
||||
}
|
||||
|
||||
result.Add(device.ToDevice(existing));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
@ -17,20 +17,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator<IEnumerable<
|
||||
|
||||
public async Task<IEnumerable<WebAuthnLoginRotateKeyData>> ValidateAsync(User user, IEnumerable<WebAuthnLoginRotateKeyRequestModel> keysToRotate)
|
||||
{
|
||||
// 2024-06: Remove after 3 releases, for backward compatibility
|
||||
if (keysToRotate == null)
|
||||
{
|
||||
return new List<WebAuthnLoginRotateKeyData>();
|
||||
}
|
||||
|
||||
var result = new List<WebAuthnLoginRotateKeyData>();
|
||||
var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
|
||||
if (existing == null || !existing.Any())
|
||||
if (existing == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var ea in existing)
|
||||
var validCredentials = existing.Where(credential => credential.SupportsPrf);
|
||||
if (!validCredentials.Any())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var ea in validCredentials)
|
||||
{
|
||||
var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id);
|
||||
if (keyToRotate == null)
|
||||
|
@ -22,6 +22,7 @@ public class NotificationResponseModel : ResponseModel
|
||||
Title = notificationStatusDetails.Title;
|
||||
Body = notificationStatusDetails.Body;
|
||||
Date = notificationStatusDetails.RevisionDate;
|
||||
TaskId = notificationStatusDetails.TaskId;
|
||||
ReadDate = notificationStatusDetails.ReadDate;
|
||||
DeletedDate = notificationStatusDetails.DeletedDate;
|
||||
}
|
||||
@ -40,6 +41,8 @@ public class NotificationResponseModel : ResponseModel
|
||||
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
public Guid? TaskId { get; set; }
|
||||
|
||||
public DateTime? ReadDate { get; set; }
|
||||
|
||||
public DateTime? DeletedDate { get; set; }
|
||||
|
@ -31,7 +31,7 @@ using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Tools.ImportFeatures;
|
||||
using Bit.Core.Tools.ReportFeatures;
|
||||
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
|
||||
#if !OSS
|
||||
using Bit.Commercial.Core.SecretsManager;
|
||||
@ -168,6 +168,9 @@ public class Startup
|
||||
services
|
||||
.AddScoped<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>,
|
||||
WebAuthnLoginKeyRotationValidator>();
|
||||
services
|
||||
.AddScoped<IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>>,
|
||||
DeviceRotationValidator>();
|
||||
|
||||
// Services
|
||||
services.AddBaseServices(globalSettings);
|
||||
|
@ -16,6 +16,7 @@ using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Authorization.Permissions;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Queries;
|
||||
@ -345,6 +346,77 @@ public class CiphersController : Controller
|
||||
return await CanEditCiphersAsync(organizationId, cipherIds);
|
||||
}
|
||||
|
||||
private async Task<bool> CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||
{
|
||||
return await CanEditCipherAsAdminAsync(organizationId, cipherIds);
|
||||
}
|
||||
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If we're not an "admin", we don't need to check the ciphers
|
||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }))
|
||||
{
|
||||
// Are we a provider user? If so, we need to be sure we're not restricted
|
||||
// Once the feature flag is removed, this check can be combined with the above
|
||||
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
|
||||
{
|
||||
// Provider is restricted from editing ciphers, so we're not an "admin"
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Provider is unrestricted, so we're an "admin", don't return early
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not a provider or admin
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the user can edit all ciphers for the organization, just check they all belong to the org
|
||||
if (await CanEditAllCiphersAsync(organizationId))
|
||||
{
|
||||
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
|
||||
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
|
||||
|
||||
// Ensure all requested ciphers are in orgCiphers
|
||||
return cipherIds.All(c => orgCiphers.ContainsKey(c));
|
||||
}
|
||||
|
||||
// The user cannot access any ciphers for the organization, we're done
|
||||
if (!await CanAccessOrganizationCiphersAsync(organizationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
// Select all deletable ciphers for this user belonging to the organization
|
||||
var deletableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(user.Id, true))
|
||||
.Where(c => c.OrganizationId == organizationId && c.UserId == null).ToList();
|
||||
|
||||
// Special case for unassigned ciphers
|
||||
if (await CanAccessUnassignedCiphersAsync(organizationId))
|
||||
{
|
||||
var unassignedCiphers =
|
||||
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
|
||||
organizationId));
|
||||
|
||||
// Users that can access unassigned ciphers can also delete them
|
||||
deletableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Manage = true }));
|
||||
}
|
||||
|
||||
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||
var deletableOrgCiphers = deletableOrgCipherList
|
||||
.Where(c => NormalCipherPermissions.CanDelete(user, c, organizationAbility))
|
||||
.ToDictionary(c => c.Id);
|
||||
|
||||
return cipherIds.All(c => deletableOrgCiphers.ContainsKey(c));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
@ -763,12 +835,12 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpDelete("{id}/admin")]
|
||||
[HttpPost("{id}/delete-admin")]
|
||||
public async Task DeleteAdmin(string id)
|
||||
public async Task DeleteAdmin(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
||||
var cipher = await GetByIdAsync(id, userId);
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -808,7 +880,7 @@ public class CiphersController : Controller
|
||||
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
||||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -830,12 +902,12 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{id}/delete-admin")]
|
||||
public async Task PutDeleteAdmin(string id)
|
||||
public async Task PutDeleteAdmin(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
||||
var cipher = await GetByIdAsync(id, userId);
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -871,7 +943,7 @@ public class CiphersController : Controller
|
||||
var cipherIds = model.Ids.Select(i => new Guid(i)).ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.OrganizationId) ||
|
||||
!await CanEditCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(new Guid(model.OrganizationId), cipherIds))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -899,12 +971,12 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{id}/restore-admin")]
|
||||
public async Task<CipherMiniResponseModel> PutRestoreAdmin(string id)
|
||||
public async Task<CipherMiniResponseModel> PutRestoreAdmin(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||
var cipher = await GetByIdAsync(id, userId);
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -944,7 +1016,7 @@ public class CiphersController : Controller
|
||||
|
||||
var cipherIdsToRestore = new HashSet<Guid>(model.Ids.Select(i => new Guid(i)));
|
||||
|
||||
if (model.OrganizationId == default || !await CanEditCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
|
||||
if (model.OrganizationId == default || !await CanDeleteOrRestoreCipherAsAdminAsync(model.OrganizationId, cipherIdsToRestore))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
|
||||
|
@ -1,8 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -12,6 +15,7 @@ using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class UpcomingInvoiceHandler(
|
||||
IFeatureService featureService,
|
||||
ILogger<StripeEventProcessor> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -21,7 +25,8 @@ public class UpcomingInvoiceHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IUserRepository userRepository,
|
||||
IValidateSponsorshipCommand validateSponsorshipCommand)
|
||||
IValidateSponsorshipCommand validateSponsorshipCommand,
|
||||
IAutomaticTaxFactory automaticTaxFactory)
|
||||
: IUpcomingInvoiceHandler
|
||||
{
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
@ -136,6 +141,21 @@ public class UpcomingInvoiceHandler(
|
||||
|
||||
private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
|
||||
{
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
{
|
||||
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id));
|
||||
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||
|
||||
if (updateOptions == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.AutomaticTax.Enabled ||
|
||||
!subscription.Customer.HasBillingLocation() ||
|
||||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
|
||||
|
@ -87,8 +87,7 @@ public class Startup
|
||||
// TODO: no longer be required - see PM-1880
|
||||
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
||||
|
||||
// Mvc
|
||||
services.AddMvc(config =>
|
||||
services.AddControllers(config =>
|
||||
{
|
||||
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
|
||||
});
|
||||
|
@ -1,6 +0,0 @@
|
||||
@{
|
||||
ViewData["Title"] = "Index";
|
||||
}
|
||||
|
||||
<h2>Index</h2>
|
||||
|
@ -1,21 +0,0 @@
|
||||
@model LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
}
|
||||
|
||||
<div class="row justify-content-md-center">
|
||||
<div class="col-4">
|
||||
<p>Please enter your email address below to log in.</p>
|
||||
<form asp-action="" method="post">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Email" class="sr-only">Email Address</label>
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="ex. john@example.com"
|
||||
required autofocus>
|
||||
<span asp-validation-for="Email" class="invalid-feedback"></span>
|
||||
<small class="form-text text-body-secondary">We'll email you a secure login link.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit">Continue</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
@ -1,14 +0,0 @@
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
|
||||
</p>
|
@ -1,41 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] | Bitwarden Billing Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
|
||||
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
|
||||
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="~/styles/billing.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#"><i class="fa fa-lg fa-fw fa-shield"></i> Billing</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
|
||||
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main role="main" class="container">
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
@ -1,3 +0,0 @@
|
||||
@using Bit.Billing
|
||||
@using Bit.Billing.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@ -1,3 +0,0 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.custom-select.input-validation-error ~ .invalid-feedback,
|
||||
.custom-select.input-validation-error ~ .invalid-tooltip,
|
||||
.form-control.input-validation-error ~ .invalid-feedback,
|
||||
.form-control.input-validation-error ~ .invalid-tooltip {
|
||||
display: block;
|
||||
}
|
@ -318,5 +318,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
UseSecretsManager = license.UseSecretsManager;
|
||||
SmSeats = license.SmSeats;
|
||||
SmServiceAccounts = license.SmServiceAccounts;
|
||||
UseRiskInsights = license.UseRiskInsights;
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ public class OrganizationIntegration : ITableObject<Guid>
|
||||
public Guid OrganizationId { get; set; }
|
||||
public IntegrationType Type { 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 void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
||||
public EventType EventType { get; set; }
|
||||
public string? Configuration { 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 void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
@ -1,3 +1,8 @@
|
||||
namespace Bit.Core.AdminConsole.Errors;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Models.Data.Organizations;
|
||||
|
||||
public class OrganizationIntegrationConfigurationDetails
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationIntegrationId { get; set; }
|
||||
public IntegrationType IntegrationType { get; set; }
|
||||
public EventType EventType { get; set; }
|
||||
public string? Configuration { get; set; }
|
||||
public string? IntegrationConfiguration { get; set; }
|
||||
public string? Template { get; set; }
|
||||
|
||||
public JsonObject MergedConfiguration
|
||||
{
|
||||
get
|
||||
{
|
||||
var integrationJson = IntegrationConfigurationJson;
|
||||
|
||||
foreach (var kvp in ConfigurationJson)
|
||||
{
|
||||
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
|
||||
}
|
||||
|
||||
return integrationJson;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject ConfigurationJson
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var configuration = Configuration ?? string.Empty;
|
||||
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject IntegrationConfigurationJson
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var integration = IntegrationConfiguration ?? string.Empty;
|
||||
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -148,7 +148,8 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
LimitCollectionDeletion = LimitCollectionDeletion,
|
||||
LimitItemDeletion = LimitItemDeletion,
|
||||
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
||||
Status = Status
|
||||
Status = Status,
|
||||
UseRiskInsights = UseRiskInsights,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -154,6 +154,12 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
|
||||
}
|
||||
}
|
||||
|
||||
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
|
||||
{
|
||||
throw new BadRequestException("Custom users can not delete admins.");
|
||||
}
|
||||
|
||||
|
||||
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
|
||||
{
|
||||
throw new BadRequestException("Member is not managed by the organization.");
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -25,7 +25,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
||||
public const string UserNotFoundErrorMessage = "User not found.";
|
||||
public const string UsersInvalidErrorMessage = "Users invalid.";
|
||||
public const string RemoveYourselfErrorMessage = "You cannot remove yourself.";
|
||||
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can delete other owners.";
|
||||
public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can remove other owners.";
|
||||
public const string RemoveAdminByCustomUserErrorMessage = "Custom users can not remove admins.";
|
||||
public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner.";
|
||||
public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account.";
|
||||
|
||||
@ -153,6 +154,11 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
|
||||
}
|
||||
}
|
||||
|
||||
if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(orgUser.OrganizationId))
|
||||
{
|
||||
throw new BadRequestException(RemoveAdminByCustomUserErrorMessage);
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
|
||||
{
|
||||
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
|
||||
|
@ -0,0 +1,54 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
|
||||
/// <summary>
|
||||
/// Restores a user back to their previous status.
|
||||
/// </summary>
|
||||
public interface IRestoreOrganizationUserCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
|
||||
/// can re-add this user based on their current occupied seats.
|
||||
///
|
||||
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
|
||||
/// other organizations the user may belong to.
|
||||
///
|
||||
/// Reference Events and Push Notifications are fired off for this as well.
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">Revoked user to be restored.</param>
|
||||
/// <param name="restoringUserId">UserId of the user performing the action.</param>
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
|
||||
/// can re-add this user based on their current occupied seats.
|
||||
///
|
||||
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
|
||||
/// other organizations the user may belong to.
|
||||
///
|
||||
/// Reference Events and Push Notifications are fired off for this as well.
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">Revoked user to be restored.</param>
|
||||
/// <param name="systemUser">System that is performing the action on behalf of the organization (Public API, SCIM, etc.)</param>
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
|
||||
/// can re-add this user based on their current occupied seats.
|
||||
///
|
||||
/// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as
|
||||
/// other organizations the user may belong to.
|
||||
///
|
||||
/// Reference Events and Push Notifications are fired off for this as well.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">Organization the users should be restored to.</param>
|
||||
/// <param name="organizationUserIds">List of organization user ids to restore to previous status.</param>
|
||||
/// <param name="restoringUserId">UserId of the user performing the action.</param>
|
||||
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
|
||||
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
|
||||
/// If an error occurs, the error message will be provided.</returns>
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
|
||||
}
|
@ -0,0 +1,302 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
|
||||
public class RestoreOrganizationUserCommand(
|
||||
ICurrentContext currentContext,
|
||||
IEventService eventService,
|
||||
IPushNotificationService pushNotificationService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IPolicyService policyService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationService organizationService) : IRestoreOrganizationUserCommand
|
||||
{
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
|
||||
{
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
|
||||
{
|
||||
throw new BadRequestException("You cannot restore yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
|
||||
!await currentContext.OrganizationOwner(organizationUser.OrganizationId))
|
||||
{
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
|
||||
{
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
|
||||
systemUser);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already active.");
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
|
||||
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray
|
||||
}
|
||||
|
||||
var userTwoFactorIsEnabled = false;
|
||||
// Only check 2FA status if the user is linked to a user account
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
userTwoFactorIsEnabled =
|
||||
(await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([organizationUser.UserId.Value]))
|
||||
.FirstOrDefault()
|
||||
.twoFactorIsEnabled;
|
||||
}
|
||||
|
||||
if (organization.PlanType == PlanType.Free)
|
||||
{
|
||||
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
||||
}
|
||||
|
||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
||||
|
||||
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
|
||||
organizationUser.Status = status;
|
||||
}
|
||||
|
||||
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
||||
{
|
||||
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value);
|
||||
var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value);
|
||||
|
||||
var orgOrgUserDict = relatedOrgUsersFromOtherOrgs
|
||||
.Where(x => x.Id != organizationUser.Id)
|
||||
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
|
||||
|
||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<OrganizationUser, Organization>> GetRelatedOrganizationUsersAndOrganizationsAsync(
|
||||
List<OrganizationUser> organizationUsers)
|
||||
{
|
||||
var allUserIds = organizationUsers
|
||||
.Where(x => x.UserId.HasValue)
|
||||
.Select(x => x.UserId.Value);
|
||||
|
||||
var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds))
|
||||
.Where(x => organizationUsers.Any(y => y.Id == x.Id) == false)
|
||||
.ToArray();
|
||||
|
||||
var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers
|
||||
.Select(x => x.OrganizationId)
|
||||
.Distinct());
|
||||
|
||||
return otherOrganizationUsers
|
||||
.ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId));
|
||||
}
|
||||
|
||||
private static void CheckForOtherFreeOrganizationOwnership(OrganizationUser organizationUser,
|
||||
Dictionary<OrganizationUser, Organization> otherOrgUsersAndOrgs)
|
||||
{
|
||||
var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin };
|
||||
|
||||
if (ownerOrAdminList.Any(x => organizationUser.Type == x) &&
|
||||
otherOrgUsersAndOrgs.Any(x =>
|
||||
x.Key.UserId == organizationUser.UserId &&
|
||||
ownerOrAdminList.Any(userType => userType == x.Key.Type) &&
|
||||
x.Key.Status == OrganizationUserStatusType.Confirmed &&
|
||||
x.Value.PlanType == PlanType.Free))
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
.ToList();
|
||||
|
||||
if (filteredUsers.Count == 0)
|
||||
{
|
||||
throw new BadRequestException("Users invalid.");
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
|
||||
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);
|
||||
|
||||
var deletingUserIsOwner = false;
|
||||
if (restoringUserId.HasValue)
|
||||
{
|
||||
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
|
||||
}
|
||||
|
||||
// Query Two Factor Authentication status for all users in the organization
|
||||
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
|
||||
var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
||||
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
||||
|
||||
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already active.");
|
||||
}
|
||||
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot restore yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
|
||||
!deletingUserIsOwner)
|
||||
{
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
var twoFactorIsEnabled = organizationUser.UserId.HasValue
|
||||
&& organizationUsersTwoFactorEnabled
|
||||
.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value)
|
||||
.twoFactorIsEnabled;
|
||||
|
||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
||||
|
||||
if (organization.PlanType == PlanType.Free)
|
||||
{
|
||||
CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs);
|
||||
}
|
||||
|
||||
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
organizationUser.Status = status;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(organizationUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
|
||||
{
|
||||
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
|
||||
// The user will be subject to the same checks when they try to accept the invite
|
||||
if (OrganizationService.GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = orgUser.UserId.Value;
|
||||
|
||||
// Enforce Single Organization Policy of organization user is being restored to
|
||||
var allOrgUsers = await organizationUserRepository.GetManyByUserAsync(userId);
|
||||
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
|
||||
var singleOrgPoliciesApplyingToRevokedUsers = await policyService.GetPoliciesApplicableToUserAsync(userId,
|
||||
PolicyType.SingleOrg, OrganizationUserStatusType.Revoked);
|
||||
var singleOrgPolicyApplies =
|
||||
singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId);
|
||||
|
||||
var singleOrgCompliant = true;
|
||||
var belongsToOtherOrgCompliant = true;
|
||||
var twoFactorCompliant = true;
|
||||
|
||||
if (hasOtherOrgs && singleOrgPolicyApplies)
|
||||
{
|
||||
singleOrgCompliant = false;
|
||||
}
|
||||
|
||||
// Enforce Single Organization Policy of other organizations user is a member of
|
||||
var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg);
|
||||
if (anySingleOrgPolicies)
|
||||
{
|
||||
belongsToOtherOrgCompliant = false;
|
||||
}
|
||||
|
||||
// Enforce 2FA Policy of organization user is trying to join
|
||||
if (!userHasTwoFactorEnabled)
|
||||
{
|
||||
var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId,
|
||||
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked);
|
||||
if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
|
||||
{
|
||||
twoFactorCompliant = false;
|
||||
}
|
||||
}
|
||||
|
||||
var user = await userRepository.GetByIdAsync(userId);
|
||||
|
||||
if (!singleOrgCompliant && !twoFactorCompliant)
|
||||
{
|
||||
throw new BadRequestException(user.Email +
|
||||
" is not compliant with the single organization and two-step login policy");
|
||||
}
|
||||
else if (!singleOrgCompliant)
|
||||
{
|
||||
throw new BadRequestException(user.Email + " is not compliant with the single organization policy");
|
||||
}
|
||||
else if (!belongsToOtherOrgCompliant)
|
||||
{
|
||||
throw new BadRequestException(user.Email +
|
||||
" belongs to an organization that doesn't allow them to join multiple organizations");
|
||||
}
|
||||
else if (!twoFactorCompliant)
|
||||
{
|
||||
throw new BadRequestException(user.Email + " is not compliant with the two-step login policy");
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,8 @@ public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactor
|
||||
|
||||
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
|
||||
|
||||
protected override IEnumerable<OrganizationUserStatusType> ExemptStatuses => [OrganizationUserStatusType.Revoked];
|
||||
|
||||
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||
{
|
||||
var result = policyDetails
|
||||
|
@ -0,0 +1,13 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
|
||||
{
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||
{
|
||||
}
|
@ -24,4 +24,5 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
|
||||
/// </summary>
|
||||
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
|
||||
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
|
||||
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
/// <param name="role">The role to search for</param>
|
||||
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
||||
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
||||
|
||||
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
|
||||
}
|
||||
|
@ -48,10 +48,6 @@ public interface IOrganizationService
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
|
||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||
/// <summary>
|
||||
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
||||
|
@ -6,20 +6,19 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
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.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
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.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -27,18 +26,17 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
@ -59,7 +57,6 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
@ -71,14 +68,12 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IOrganizationBillingService _organizationBillingService;
|
||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||
|
||||
public OrganizationService(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -96,7 +91,6 @@ public class OrganizationService : IOrganizationService
|
||||
IPaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyService policyService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IReferenceEventService referenceEventService,
|
||||
IGlobalSettings globalSettings,
|
||||
@ -106,16 +100,14 @@ public class OrganizationService : IOrganizationService
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IOrganizationBillingService organizationBillingService,
|
||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||
IPricingClient pricingClient,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -132,7 +124,6 @@ public class OrganizationService : IOrganizationService
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyService = policyService;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_ssoUserRepository = ssoUserRepository;
|
||||
_referenceEventService = referenceEventService;
|
||||
_globalSettings = globalSettings;
|
||||
@ -144,14 +135,12 @@ public class OrganizationService : IOrganizationService
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_providerRepository = providerRepository;
|
||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_organizationBillingService = organizationBillingService;
|
||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||
_pricingClient = pricingClient;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||
}
|
||||
|
||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||
@ -579,6 +568,7 @@ public class OrganizationService : IOrganizationService
|
||||
UseSecretsManager = license.UseSecretsManager,
|
||||
SmSeats = license.SmSeats,
|
||||
SmServiceAccounts = license.SmServiceAccounts,
|
||||
UseRiskInsights = license.UseRiskInsights,
|
||||
};
|
||||
|
||||
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
|
||||
@ -1058,74 +1048,14 @@ public class OrganizationService : IOrganizationService
|
||||
await SendInviteAsync(orgUser, org, initOrganization);
|
||||
}
|
||||
|
||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
|
||||
{
|
||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
|
||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||
|
||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
||||
}
|
||||
|
||||
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool 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
|
||||
);
|
||||
}
|
||||
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||
users: [orgUser],
|
||||
organization: organization,
|
||||
initOrganization: initOrganization));
|
||||
|
||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||
Organization organization,
|
||||
@ -1794,7 +1724,7 @@ public class OrganizationService : IOrganizationService
|
||||
await RepositoryRevokeUserAsync(organizationUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
@ -1806,7 +1736,7 @@ public class OrganizationService : IOrganizationService
|
||||
await RepositoryRevokeUserAsync(organizationUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
@ -1875,145 +1805,7 @@ public class OrganizationService : IOrganizationService
|
||||
await _organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(organizationUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
|
||||
{
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
|
||||
{
|
||||
throw new BadRequestException("You cannot restore yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue &&
|
||||
!await _currentContext.OrganizationOwner(organizationUser.OrganizationId))
|
||||
{
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
|
||||
{
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already active.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
await AutoAddSeatsAsync(organization, 1);
|
||||
}
|
||||
|
||||
var userTwoFactorIsEnabled = false;
|
||||
// Only check Two Factor Authentication status if the user is linked to a user account
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
userTwoFactorIsEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(new[] { organizationUser.UserId.Value })).FirstOrDefault().twoFactorIsEnabled;
|
||||
}
|
||||
|
||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled);
|
||||
|
||||
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||
|
||||
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
organizationUser.Status = status;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
|
||||
{
|
||||
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
.ToList();
|
||||
|
||||
if (!filteredUsers.Any())
|
||||
{
|
||||
throw new BadRequestException("Users invalid.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
|
||||
await AutoAddSeatsAsync(organization, newSeatsRequired);
|
||||
|
||||
var deletingUserIsOwner = false;
|
||||
if (restoringUserId.HasValue)
|
||||
{
|
||||
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
|
||||
}
|
||||
|
||||
// Query Two Factor Authentication status for all users in the organization
|
||||
// This is an optimization to avoid querying the Two Factor Authentication status for each user individually
|
||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(
|
||||
filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value));
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already active.");
|
||||
}
|
||||
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot restore yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && !deletingUserIsOwner)
|
||||
{
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
var twoFactorIsEnabled = organizationUser.UserId.HasValue
|
||||
&& organizationUsersTwoFactorEnabled.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value).twoFactorIsEnabled;
|
||||
await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);
|
||||
|
||||
var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
|
||||
|
||||
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
organizationUser.Status = status;
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
@ -2095,7 +1887,7 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
}
|
||||
|
||||
static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser)
|
||||
public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser)
|
||||
{
|
||||
// Determine status to revert back to
|
||||
var status = OrganizationUserStatusType.Invited;
|
||||
|
@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
|
||||
|
||||
public record Valid<T> : ValidationResult<T>
|
||||
{
|
||||
public Valid() { }
|
||||
|
||||
public Valid(T Value)
|
||||
{
|
||||
this.Value = Value;
|
||||
}
|
||||
|
||||
public T Value { get; init; }
|
||||
}
|
||||
|
||||
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")
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Api.Request;
|
||||
@ -7,6 +8,13 @@ public class OtherDeviceKeysUpdateRequestModel : DeviceKeysUpdateRequestModel
|
||||
{
|
||||
[Required]
|
||||
public Guid DeviceId { get; set; }
|
||||
|
||||
public Device ToDevice(Device existingDevice)
|
||||
{
|
||||
existingDevice.EncryptedPublicKey = EncryptedPublicKey;
|
||||
existingDevice.EncryptedUserKey = EncryptedUserKey;
|
||||
return existingDevice;
|
||||
}
|
||||
}
|
||||
|
||||
public class DeviceKeysUpdateRequestModel
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
@ -19,7 +18,7 @@ public class DeviceAuthRequestResponseModel : ResponseModel
|
||||
Type = deviceAuthDetails.Type,
|
||||
Identifier = deviceAuthDetails.Identifier,
|
||||
CreationDate = deviceAuthDetails.CreationDate,
|
||||
IsTrusted = deviceAuthDetails.IsTrusted()
|
||||
IsTrusted = deviceAuthDetails.IsTrusted,
|
||||
};
|
||||
|
||||
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
|
||||
|
@ -287,14 +287,14 @@ public class AuthRequestService : IAuthRequestService
|
||||
|
||||
private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications))
|
||||
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
||||
|
||||
if (adminEmails.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled");
|
||||
_logger.LogWarning("There are no admin emails to send to.");
|
||||
return;
|
||||
}
|
||||
|
||||
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
|
||||
|
||||
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
|
||||
adminEmails,
|
||||
organizationUser.OrganizationId,
|
||||
|
@ -47,6 +47,8 @@ public static class StripeConstants
|
||||
public static class MetadataKeys
|
||||
{
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string ProviderId = "providerId";
|
||||
public const string UserId = "userId";
|
||||
}
|
||||
|
||||
public static class PaymentBehavior
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -28,6 +29,13 @@ public static class BillingExtensions
|
||||
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)
|
||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||
|
||||
|
@ -21,7 +21,7 @@ public static class CustomerExtensions
|
||||
/// <param name="customer"></param>
|
||||
/// <returns></returns>
|
||||
public static bool HasTaxLocationVerified(this Customer customer) =>
|
||||
customer?.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
||||
customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation;
|
||||
|
||||
public static decimal GetBillingBalance(this Customer customer)
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
@ -18,6 +19,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
services.AddKeyedTransient<IAutomaticTaxStrategy, PersonalUseAutomaticTaxStrategy>(AutomaticTaxFactory.PersonalUse);
|
||||
services.AddKeyedTransient<IAutomaticTaxStrategy, BusinessUseAutomaticTaxStrategy>(AutomaticTaxFactory.BusinessUse);
|
||||
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
|
||||
services.AddLicenseServices();
|
||||
services.AddPricingClient();
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class SubscriptionCreateOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to enable automatic tax for given new subscription options.
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <param name="customer">The existing customer.</param>
|
||||
/// <returns>Returns true when successful, false when conditions are not met.</returns>
|
||||
public static bool EnableAutomaticTax(this SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
// We might only need to check the automatic tax status.
|
||||
if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
options.DefaultTaxRates = [];
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@ -36,6 +36,7 @@ public static class OrganizationLicenseConstants
|
||||
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
||||
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
||||
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
||||
public const string UseRiskInsights = nameof(UseRiskInsights);
|
||||
public const string Expires = nameof(Expires);
|
||||
public const string Refresh = nameof(Refresh);
|
||||
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
|
||||
|
@ -47,6 +47,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
||||
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion),
|
||||
(entity.LimitCollectionCreation || entity.LimitCollectionDeletion).ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.UseRiskInsights), entity.UseRiskInsights.ToString()),
|
||||
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
|
||||
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
|
||||
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
|
||||
|
@ -309,8 +309,7 @@ public class ProviderMigrator(
|
||||
.SeatMinimum ?? 0;
|
||||
|
||||
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
provider,
|
||||
[
|
||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
||||
|
@ -75,6 +75,7 @@ public abstract record Plan
|
||||
// Seats
|
||||
public string StripePlanId { get; init; }
|
||||
public string StripeSeatPlanId { get; init; }
|
||||
[Obsolete("No longer used to retrieve a provider's price ID. Use ProviderPriceAdapter instead.")]
|
||||
public string StripeProviderPortalSeatPlanId { get; init; }
|
||||
public decimal BasePrice { get; init; }
|
||||
public decimal SeatPrice { get; init; }
|
||||
|
@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Contracts;
|
||||
|
||||
public class AutomaticTaxFactoryParameters
|
||||
{
|
||||
public AutomaticTaxFactoryParameters(PlanType planType)
|
||||
{
|
||||
PlanType = planType;
|
||||
}
|
||||
|
||||
public AutomaticTaxFactoryParameters(ISubscriber subscriber, IEnumerable<string> prices)
|
||||
{
|
||||
Subscriber = subscriber;
|
||||
Prices = prices;
|
||||
}
|
||||
|
||||
public AutomaticTaxFactoryParameters(IEnumerable<string> prices)
|
||||
{
|
||||
Prices = prices;
|
||||
}
|
||||
|
||||
public ISubscriber? Subscriber { get; init; }
|
||||
|
||||
public PlanType? PlanType { get; init; }
|
||||
|
||||
public IEnumerable<string>? Prices { get; init; }
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Contracts;
|
||||
|
||||
public record ChangeProviderPlanCommand(
|
||||
Provider Provider,
|
||||
Guid ProviderPlanId,
|
||||
PlanType NewPlan,
|
||||
string GatewaySubscriptionId);
|
||||
PlanType NewPlan);
|
||||
|
@ -1,10 +1,10 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Contracts;
|
||||
|
||||
/// <param name="Id">The ID of the provider to update the seat minimums for.</param>
|
||||
/// <param name="Provider">The provider to update the seat minimums for.</param>
|
||||
/// <param name="Configuration">The new seat minimums for the provider.</param>
|
||||
public record UpdateProviderSeatMinimumsCommand(
|
||||
Guid Id,
|
||||
string GatewaySubscriptionId,
|
||||
Provider Provider,
|
||||
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);
|
||||
|
11
src/Core/Billing/Services/IAutomaticTaxFactory.cs
Normal file
11
src/Core/Billing/Services/IAutomaticTaxFactory.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.
|
||||
/// </summary>
|
||||
public interface IAutomaticTaxFactory
|
||||
{
|
||||
Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters);
|
||||
}
|
33
src/Core/Billing/Services/IAutomaticTaxStrategy.cs
Normal file
33
src/Core/Billing/Services/IAutomaticTaxStrategy.cs
Normal file
@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
public interface IAutomaticTaxStrategy
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="subscription"></param>
|
||||
/// <returns>
|
||||
/// Returns <see cref="SubscriptionUpdateOptions" /> if changes are to be applied to the subscription, returns null
|
||||
/// otherwise.
|
||||
/// </returns>
|
||||
SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Modifies an existing <see cref="SubscriptionCreateOptions" /> object with the automatic tax flag set correctly.
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <param name="customer"></param>
|
||||
void SetCreateOptions(SubscriptionCreateOptions options, Customer customer);
|
||||
|
||||
/// <summary>
|
||||
/// Modifies an existing <see cref="SubscriptionUpdateOptions" /> object with the automatic tax flag set correctly.
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <param name="subscription"></param>
|
||||
void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription);
|
||||
|
||||
void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options);
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||
|
||||
public class AutomaticTaxFactory(
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient) : IAutomaticTaxFactory
|
||||
{
|
||||
public const string BusinessUse = "business-use";
|
||||
public const string PersonalUse = "personal-use";
|
||||
|
||||
private readonly Lazy<Task<IEnumerable<string>>> _personalUsePlansTask = new(async () =>
|
||||
{
|
||||
var plans = await Task.WhenAll(
|
||||
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
|
||||
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually));
|
||||
|
||||
return plans.Select(plan => plan.PasswordManager.StripePlanId);
|
||||
});
|
||||
|
||||
public async Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters)
|
||||
{
|
||||
if (parameters.Subscriber is User)
|
||||
{
|
||||
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
if (parameters.PlanType.HasValue)
|
||||
{
|
||||
var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value);
|
||||
return plan.CanBeUsedByBusiness
|
||||
? new BusinessUseAutomaticTaxStrategy(featureService)
|
||||
: new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
var personalUsePlans = await _personalUsePlansTask.Value;
|
||||
|
||||
if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x)))
|
||||
{
|
||||
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
return new BusinessUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||
|
||||
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||
{
|
||||
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = shouldBeEnabled
|
||||
},
|
||||
DefaultTaxRates = []
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(customer)
|
||||
};
|
||||
}
|
||||
|
||||
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||
|
||||
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = shouldBeEnabled
|
||||
};
|
||||
options.DefaultTaxRates = [];
|
||||
}
|
||||
|
||||
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
options.AutomaticTax ??= new InvoiceAutomaticTaxOptions();
|
||||
|
||||
if (options.CustomerDetails.Address.Country == "US")
|
||||
{
|
||||
options.AutomaticTax.Enabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any();
|
||||
}
|
||||
|
||||
private bool ShouldBeEnabled(Customer customer)
|
||||
{
|
||||
if (!customer.HasTaxLocationVerified())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (customer.Address.Country == "US")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customer.TaxIds == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded.");
|
||||
}
|
||||
|
||||
return customer.TaxIds.Any();
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||
|
||||
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||
{
|
||||
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(customer)
|
||||
};
|
||||
}
|
||||
|
||||
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return;
|
||||
}
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(subscription.Customer)
|
||||
};
|
||||
options.DefaultTaxRates = [];
|
||||
}
|
||||
|
||||
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(subscription.Customer),
|
||||
},
|
||||
DefaultTaxRates = []
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
|
||||
private static bool ShouldBeEnabled(Customer customer)
|
||||
{
|
||||
return customer.HasTaxLocationVerified();
|
||||
}
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -23,6 +25,7 @@ namespace Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
public class OrganizationBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<OrganizationBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -30,7 +33,8 @@ public class OrganizationBillingService(
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
ITaxService taxService) : IOrganizationBillingService
|
||||
ITaxService taxService,
|
||||
IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService
|
||||
{
|
||||
public async Task Finalize(OrganizationSale sale)
|
||||
{
|
||||
@ -38,7 +42,7 @@ public class OrganizationBillingService(
|
||||
|
||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||
? 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);
|
||||
|
||||
@ -143,7 +147,7 @@ public class OrganizationBillingService(
|
||||
Coupon = customerSetup.Coupon,
|
||||
Description = organization.DisplayBusinessName(),
|
||||
Email = organization.BillingEmail,
|
||||
Expand = ["tax"],
|
||||
Expand = ["tax", "tax_ids"],
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
@ -369,21 +373,8 @@ public class OrganizationBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
var customerHasTaxInfo = customer is
|
||||
{
|
||||
Address:
|
||||
{
|
||||
Country: not null and not "",
|
||||
PostalCode: not null and not ""
|
||||
}
|
||||
};
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customerHasTaxInfo
|
||||
},
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
Customer = customer.Id,
|
||||
Items = subscriptionItemOptionsList,
|
||||
@ -395,6 +386,18 @@ public class OrganizationBillingService(
|
||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
{
|
||||
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType);
|
||||
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions();
|
||||
subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation();
|
||||
}
|
||||
|
||||
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -9,6 +10,7 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using Customer = Stripe.Customer;
|
||||
@ -20,19 +22,21 @@ using static Utilities;
|
||||
|
||||
public class PremiumUserBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<PremiumUserBillingService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository) : IPremiumUserBillingService
|
||||
IUserRepository userRepository,
|
||||
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService
|
||||
{
|
||||
public async Task Credit(User user, decimal amount)
|
||||
{
|
||||
var customer = await subscriberService.GetCustomer(user);
|
||||
|
||||
// Negative credit represents a balance and all Stripe denomination is in cents.
|
||||
var credit = (long)amount * -100;
|
||||
var credit = (long)(amount * -100);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
@ -318,10 +322,6 @@ public class PremiumUserBillingService(
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
||||
},
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
Customer = customer.Id,
|
||||
Items = subscriptionItemOptionsList,
|
||||
@ -335,6 +335,18 @@ public class PremiumUserBillingService(
|
||||
OffSession = true
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
{
|
||||
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
||||
};
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -20,11 +21,13 @@ namespace Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
public class SubscriberService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<SubscriberService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ITaxService taxService) : ISubscriberService
|
||||
ITaxService taxService,
|
||||
IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService
|
||||
{
|
||||
public async Task CancelSubscription(
|
||||
ISubscriber subscriber,
|
||||
@ -438,7 +441,8 @@ public class SubscriberService(
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber);
|
||||
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
|
||||
var customer = await GetCustomerOrThrow(subscriber, customerGetOptions);
|
||||
|
||||
var (type, token) = tokenizedPaymentSource;
|
||||
|
||||
@ -597,7 +601,7 @@ public class SubscriberService(
|
||||
Expand = ["subscriptions", "tax", "tax_ids"]
|
||||
});
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
@ -607,7 +611,8 @@ public class SubscriberService(
|
||||
Line2 = taxInformation.Line2,
|
||||
City = taxInformation.City,
|
||||
State = taxInformation.State
|
||||
}
|
||||
},
|
||||
Expand = ["subscriptions", "tax", "tax_ids"]
|
||||
});
|
||||
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
@ -661,21 +666,42 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
var subscriptionGetOptions = new SubscriptionGetOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
Expand = ["customer.tax", "customer.tax_ids"]
|
||||
};
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
|
||||
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id));
|
||||
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
|
||||
var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
|
||||
if (automaticTaxOptions?.AutomaticTax?.Enabled != null)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
return;
|
||||
|
||||
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
|
||||
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
|
||||
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
|
||||
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
||||
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
|
||||
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
|
||||
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
|
||||
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyBankAccount(
|
||||
|
@ -104,11 +104,82 @@ public static class FeatureFlagKeys
|
||||
/* Admin Console Team */
|
||||
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
|
||||
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 PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||
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 */
|
||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
public const string EmailVerification = "email-verification";
|
||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||
public const string NewDeviceVerification = "new-device-verification";
|
||||
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
|
||||
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
|
||||
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
|
||||
|
||||
/* Autofill Team */
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
|
||||
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
||||
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override";
|
||||
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
|
||||
public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2";
|
||||
public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements";
|
||||
public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain";
|
||||
public const string NotificationRefresh = "notification-refresh";
|
||||
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string InlineMenuTotp = "inline-menu-totp";
|
||||
|
||||
/* Billing Team */
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string TrialPayment = "PM-8163-trial-payment";
|
||||
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
|
||||
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
|
||||
public const string UsePricingService = "use-pricing-service";
|
||||
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
|
||||
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
||||
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
||||
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||
|
||||
/* Key Management Team */
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
|
||||
public const string Argon2Default = "argon2-default";
|
||||
public const string UserkeyRotationV2 = "userkey-rotation-v2";
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
|
||||
/* Mobile Team */
|
||||
public const string NativeCarouselFlow = "native-carousel-flow";
|
||||
public const string NativeCreateAccountFlow = "native-create-account-flow";
|
||||
public const string AndroidImportLoginsFlow = "import-logins-flow";
|
||||
public const string AppReviewPrompt = "app-review-prompt";
|
||||
public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android";
|
||||
public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios";
|
||||
public const string AndroidMutualTls = "mutual-tls";
|
||||
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
|
||||
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
|
||||
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
|
||||
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
|
||||
|
||||
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
|
||||
|
||||
/* Platform Team */
|
||||
public const string PersistPopupView = "persist-popup-view";
|
||||
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
||||
public const string WebPush = "web-push";
|
||||
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
|
||||
|
||||
/* Tools Team */
|
||||
public const string ItemShare = "item-share";
|
||||
@ -116,6 +187,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
public const string ExportAttachments = "export-attachments";
|
||||
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
||||
|
||||
/* Vault Team */
|
||||
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
|
||||
@ -125,71 +197,8 @@ public static class FeatureFlagKeys
|
||||
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||
public const string SecurityTasks = "security-tasks";
|
||||
|
||||
/* Auth Team */
|
||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||
|
||||
/* Key Management Team */
|
||||
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
|
||||
public const string SSHAgent = "ssh-agent";
|
||||
public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override";
|
||||
public const string Argon2Default = "argon2-default";
|
||||
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
|
||||
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
|
||||
public const string UserkeyRotationV2 = "userkey-rotation-v2";
|
||||
|
||||
/* Unsorted */
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string EmailVerification = "email-verification";
|
||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
||||
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
|
||||
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||
public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
|
||||
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
|
||||
public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor";
|
||||
public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2";
|
||||
public const string NativeCarouselFlow = "native-carousel-flow";
|
||||
public const string NativeCreateAccountFlow = "native-create-account-flow";
|
||||
public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements";
|
||||
public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain";
|
||||
public const string NotificationRefresh = "notification-refresh";
|
||||
public const string PersistPopupView = "persist-popup-view";
|
||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
||||
public const string TrialPayment = "PM-8163-trial-payment";
|
||||
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
||||
public const string GeneratorToolsModernization = "generator-tools-modernization";
|
||||
public const string NewDeviceVerification = "new-device-verification";
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string InlineMenuTotp = "inline-menu-totp";
|
||||
public const string AppReviewPrompt = "app-review-prompt";
|
||||
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
|
||||
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
|
||||
public const string UsePricingService = "use-pricing-service";
|
||||
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
|
||||
public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android";
|
||||
public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios";
|
||||
public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner";
|
||||
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
|
||||
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
|
||||
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
|
||||
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
|
||||
public const string AndroidMutualTls = "mutual-tls";
|
||||
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
|
||||
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
|
||||
public const string WebPush = "web-push";
|
||||
public const string AndroidImportLoginsFlow = "import-logins-flow";
|
||||
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
|
||||
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
|
||||
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
|
||||
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
|
||||
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
|
||||
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
@ -23,8 +23,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.28" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.85" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.61" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.118" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
@ -61,7 +61,7 @@
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
|
||||
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.7.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||
|
@ -14,5 +14,7 @@ public enum ClientType : byte
|
||||
[Display(Name = "Desktop App")]
|
||||
Desktop = 3,
|
||||
[Display(Name = "Mobile App")]
|
||||
Mobile = 4
|
||||
Mobile = 4,
|
||||
[Display(Name = "CLI")]
|
||||
Cli = 5
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user