1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-05 19:52:20 -05:00

merge main

This commit is contained in:
Jimmy Vo 2025-04-15 12:15:04 -04:00
commit 3ec56feb3a
No known key found for this signature in database
GPG Key ID: 7CB834D6F4FFCA11
124 changed files with 6659 additions and 1065 deletions

2
.github/CODEOWNERS vendored
View File

@ -37,6 +37,8 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev
src/Identity @bitwarden/team-auth-dev src/Identity @bitwarden/team-auth-dev
src/Core/Identity @bitwarden/team-auth-dev
src/Core/IdentityServer @bitwarden/team-auth-dev
# Key Management team # Key Management team
**/KeyManagement @bitwarden/team-key-management-dev **/KeyManagement @bitwarden/team-key-management-dev

View File

@ -627,55 +627,16 @@ jobs:
} }
}) })
trigger-ee-updates: setup-ephemeral-environment:
name: Trigger Ephemeral Environment updates name: Setup Ephemeral Environment
if: | needs: build-docker
needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04
needs:
- build-docker
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates
if: | if: |
needs.build-artifacts.outputs.has_secrets == 'true' needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request' && github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with: with:
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
project: server project: server
sync_environment: true
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
secrets: inherit secrets: inherit

View File

@ -5,34 +5,12 @@ on:
types: [labeled] types: [labeled]
jobs: jobs:
trigger-ee-updates: setup-ephemeral-environment:
name: Trigger Ephemeral Environment updates name: Setup Ephemeral Environment
runs-on: ubuntu-24.04
if: github.event.label.name == 'ephemeral-environment' if: github.event.label.name == 'ephemeral-environment'
steps: uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
- name: Log in to Azure - CI subscription with:
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 project: server
with: pull_request_number: ${{ github.event.number }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} sync_environment: true
secrets: inherit
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})

View File

@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
} }
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
{ {
var providerId = await CreateProviderAsync(provider, ownerEmail); var providerId = await CreateProviderAsync(provider, ownerEmail);

View File

@ -692,10 +692,10 @@ public class ProviderService : IProviderService
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise: case ProviderType.BusinessUnit:
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
{ {
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
} }
break; break;
case ProviderType.Reseller: case ProviderType.Reseller:

View File

@ -0,0 +1,462 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
using OneOf;
using Stripe;
namespace Bit.Commercial.Core.Billing;
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
public class BusinessUnitConverter(
IDataProtectionProvider dataProtectionProvider,
GlobalSettings globalSettings,
ILogger<BusinessUnitConverter> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPricingClient pricingClient,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository) : IBusinessUnitConverter
{
private readonly IDataProtector _dataProtector =
dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector");
public async Task<Guid> FinalizeConversion(
Organization organization,
Guid userId,
string token,
string providerKey,
string organizationKey)
{
var user = await userRepository.GetByIdAsync(userId);
var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token);
var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly);
// Bring organization under management.
organization.Plan = updatedPlan.Name;
organization.PlanType = updatedPlan.Type;
organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections;
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
organization.UsePolicies = updatedPlan.HasPolicies;
organization.UseSso = updatedPlan.HasSso;
organization.UseGroups = updatedPlan.HasGroups;
organization.UseEvents = updatedPlan.HasEvents;
organization.UseDirectory = updatedPlan.HasDirectory;
organization.UseTotp = updatedPlan.HasTotp;
organization.Use2fa = updatedPlan.Has2fa;
organization.UseApi = updatedPlan.HasApi;
organization.UseResetPassword = updatedPlan.HasResetPassword;
organization.SelfHost = updatedPlan.HasSelfHost;
organization.UsersGetPremium = updatedPlan.UsersGetPremium;
organization.UseCustomPermissions = updatedPlan.HasCustomPermissions;
organization.UseScim = updatedPlan.HasScim;
organization.UseKeyConnector = updatedPlan.HasKeyConnector;
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
organization.BillingEmail = provider.BillingEmail!;
organization.GatewayCustomerId = null;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
// Enable organization access via key exchange.
providerOrganization.Key = organizationKey;
// Complete provider setup.
provider.Gateway = GatewayType.Stripe;
provider.GatewayCustomerId = subscription.CustomerId;
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
// Enable provider access via key exchange.
providerUser.Key = providerKey;
providerUser.Status = ProviderUserStatusType.Confirmed;
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = []
}
});
var metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = string.Empty,
[StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(),
["convertedFrom"] = organization.Id.ToString()
};
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = provider.DisplayName()?.Length <= 30
? provider.DisplayName()
: provider.DisplayName()?[..30]
}
]
},
Metadata = metadata
});
// Find the existing password manager price on the subscription.
var passwordManagerItem = subscription.Items.First(item =>
{
var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan()
? existingPlan.PasswordManager.StripePlanId
: existingPlan.PasswordManager.StripeSeatPlanId;
return item.Price.Id == priceId;
});
// Get the new business unit price.
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type);
// Replace the existing password manager price with the new business unit price.
var updateSubscription =
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
new SubscriptionUpdateOptions
{
Items = [
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Deleted = true
},
new SubscriptionItemOptions
{
Price = updatedPriceId,
Quantity = organization.Seats
}
],
Metadata = metadata
});
await Task.WhenAll(updateCustomer, updateSubscription);
// Complete database updates for provider setup.
await Task.WhenAll(
organizationRepository.ReplaceAsync(organization),
providerOrganizationRepository.ReplaceAsync(providerOrganization),
providerRepository.ReplaceAsync(provider),
providerUserRepository.ReplaceAsync(providerUser));
return provider.Id;
}
public async Task<OneOf<Guid, List<string>>> InitiateConversion(
Organization organization,
string providerAdminEmail)
{
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
var problems = await ValidateInitiationAsync(organization, user);
if (problems is { Count: > 0 })
{
return problems;
}
var provider = await providerRepository.CreateAsync(new Provider
{
Name = organization.Name,
BillingEmail = organization.BillingEmail,
Status = ProviderStatusType.Pending,
UseEvents = true,
Type = ProviderType.BusinessUnit
});
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var managedPlanType = plan.IsAnnual
? PlanType.EnterpriseAnnually
: PlanType.EnterpriseMonthly;
var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id
});
var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = provider.Id,
PlanType = managedPlanType,
SeatMinimum = 0,
PurchasedSeats = organization.Seats,
AllocatedSeats = organization.Seats
});
var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user!.Id,
Email = user.Email,
Status = ProviderUserStatusType.Invited,
Type = ProviderUserType.ProviderAdmin
});
await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser);
await SendInviteAsync(organization, user.Email);
return provider.Id;
}
public Task ResendConversionInvite(
Organization organization,
string providerAdminEmail) =>
IfConversionInProgressAsync(organization, providerAdminEmail,
async (_, _, providerUser) =>
{
if (!string.IsNullOrEmpty(providerUser.Email))
{
await SendInviteAsync(organization, providerUser.Email);
}
});
public Task ResetConversion(
Organization organization,
string providerAdminEmail) =>
IfConversionInProgressAsync(organization, providerAdminEmail,
async (provider, providerOrganization, providerUser) =>
{
var tasks = new List<Task>
{
providerOrganizationRepository.DeleteAsync(providerOrganization),
providerUserRepository.DeleteAsync(providerUser)
};
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans is { Count: > 0 })
{
tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync));
}
await Task.WhenAll(tasks);
await providerRepository.DeleteAsync(provider);
});
#region Utilities
private async Task IfConversionInProgressAsync(
Organization organization,
string providerAdminEmail,
Func<Provider, ProviderOrganization, ProviderUser, Task> callback)
{
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
if (user == null)
{
return;
}
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider is not
{
Type: ProviderType.BusinessUnit,
Status: ProviderStatusType.Pending
})
{
return;
}
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
if (providerUser is
{
Type: ProviderUserType.ProviderAdmin,
Status: ProviderUserStatusType.Invited
})
{
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
await callback(provider, providerOrganization!, providerUser);
}
}
private async Task SendInviteAsync(
Organization organization,
string providerAdminEmail)
{
var token = _dataProtector.Protect(
$"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail);
}
private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync(
Organization organization,
User? user,
string token)
{
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
{
Fail("Organization must be on an enterprise plan.");
}
var subscription = await subscriberService.GetSubscription(organization);
if (subscription is not
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
})
{
Fail("Organization must have a valid subscription.");
}
if (user == null)
{
Fail("Provider admin must be a Bitwarden user.");
}
if (!CoreHelpers.TokenIsValid(
"BusinessUnitConversionInvite",
_dataProtector,
token,
user.Email,
organization.Id,
globalSettings.OrganizationInviteExpirationHours))
{
Fail("Email token is invalid.");
}
var organizationUser =
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
if (organizationUser is not
{
Status: OrganizationUserStatusType.Confirmed
})
{
Fail("Provider admin must be a confirmed member of the organization being converted.");
}
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider is not
{
Type: ProviderType.BusinessUnit,
Status: ProviderStatusType.Pending
})
{
Fail("Linked provider is not a pending business unit.");
}
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
if (providerUser is not
{
Type: ProviderUserType.ProviderAdmin,
Status: ProviderUserStatusType.Invited
})
{
Fail("Provider admin has not been invited.");
}
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
return (subscription, provider, providerOrganization!, providerUser);
[DoesNotReturn]
void Fail(string scopedError)
{
logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}",
organization.Id, scopedError);
throw new BillingException();
}
}
private async Task<List<string>?> ValidateInitiationAsync(
Organization organization,
User? user)
{
var problems = new List<string>();
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
{
problems.Add("Organization must be on an enterprise plan.");
}
var subscription = await subscriberService.GetSubscription(organization);
if (subscription is not
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
})
{
problems.Add("Organization must have a valid subscription.");
}
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
if (providerOrganization != null)
{
problems.Add("Organization is already linked to a provider.");
}
if (user == null)
{
problems.Add("Provider admin must be a Bitwarden user.");
}
else
{
var organizationUser =
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
if (organizationUser is not
{
Status: OrganizationUserStatusType.Confirmed
})
{
problems.Add("Provider admin must be a confirmed member of the organization being converted.");
}
}
return problems.Count == 0 ? null : problems;
}
#endregion
}

View File

@ -791,7 +791,7 @@ public class ProviderBillingService(
Provider provider, Provider provider,
Organization organization) Organization organization)
{ {
if (provider.Type == ProviderType.MultiOrganizationEnterprise) if (provider.Type == ProviderType.BusinessUnit)
{ {
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType; return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
} }

View File

@ -51,7 +51,7 @@ public static class ProviderPriceAdapter
/// <param name="subscription">The provider's subscription.</param> /// <param name="subscription">The provider's subscription.</param>
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param> /// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns> /// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception> /// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception> /// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetPriceId( public static string GetPriceId(
Provider provider, Provider provider,
@ -78,7 +78,7 @@ public static class ProviderPriceAdapter
PlanType.EnterpriseMonthly => MSP.Active.Enterprise, PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType _ => throw invalidPlanType
}, },
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any() ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
? planType switch ? planType switch
{ {
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually, PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
@ -103,7 +103,7 @@ public static class ProviderPriceAdapter
/// <param name="provider">The provider to get the Stripe price ID for.</param> /// <param name="provider">The provider to get the Stripe price ID for.</param>
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param> /// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns> /// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.MultiOrganizationEnterprise"/>.</exception> /// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception> /// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetActivePriceId( public static string GetActivePriceId(
Provider provider, Provider provider,
@ -120,7 +120,7 @@ public static class ProviderPriceAdapter
PlanType.EnterpriseMonthly => MSP.Active.Enterprise, PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType _ => throw invalidPlanType
}, },
ProviderType.MultiOrganizationEnterprise => planType switch ProviderType.BusinessUnit => planType switch
{ {
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,

View File

@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>(); services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>(); services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>(); services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
} }
} }

View File

@ -63,7 +63,7 @@ public class CreateProviderCommandTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_Success( public async Task CreateBusinessUnitAsync_Success(
Provider provider, Provider provider,
User user, User user,
PlanType plan, PlanType plan,
@ -71,13 +71,13 @@ public class CreateProviderCommandTests
SutProvider<CreateProviderCommand> sutProvider) SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange // Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise; provider.Type = ProviderType.BusinessUnit;
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user); userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act // Act
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats); await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats);
// Assert // Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
@ -85,7 +85,7 @@ public class CreateProviderCommandTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws( public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws(
Provider provider, Provider provider,
SutProvider<CreateProviderCommand> sutProvider) SutProvider<CreateProviderCommand> sutProvider)
{ {
@ -94,7 +94,7 @@ public class CreateProviderCommandTests
// Act // Act
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default)); () => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default));
// Assert // Assert
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);

View File

@ -0,0 +1,501 @@
#nullable enable
using System.Text;
using Bit.Commercial.Core.Billing;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing;
public class BusinessUnitConverterTests
{
private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
private readonly GlobalSettings _globalSettings = new();
private readonly ILogger<BusinessUnitConverter> _logger = Substitute.For<ILogger<BusinessUnitConverter>>();
private readonly IMailService _mailService = Substitute.For<IMailService>();
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For<IProviderPlanRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IProviderUserRepository _providerUserRepository = Substitute.For<IProviderUserRepository>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private BusinessUnitConverter BuildConverter() => new(
_dataProtectionProvider,
_globalSettings,
_logger,
_mailService,
_organizationRepository,
_organizationUserRepository,
_pricingClient,
_providerOrganizationRepository,
_providerPlanRepository,
_providerRepository,
_providerUserRepository,
_stripeAdapter,
_subscriberService,
_userRepository);
#region FinalizeConversion
[Theory, BitAutoData]
public async Task FinalizeConversion_Succeeds_ReturnsProviderId(
Organization organization,
Guid userId,
string providerKey,
string organizationKey)
{
organization.PlanType = PlanType.EnterpriseAnnually2020;
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
var subscription = new Subscription
{
Id = "subscription_id",
CustomerId = "customer_id",
Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = [
new SubscriptionItem
{
Id = "subscription_item_id",
Price = new Price
{
Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId
}
}
]
}
};
_subscriberService.GetSubscription(organization).Returns(subscription);
var user = new User
{
Id = Guid.NewGuid(),
Email = "provider-admin@example.com"
};
_userRepository.GetByIdAsync(userId).Returns(user);
var token = SetupDataProtection(organization, user.Email);
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
.Returns(organizationUser);
var provider = new Provider
{
Type = ProviderType.BusinessUnit,
Status = ProviderStatusType.Pending
};
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
var providerUser = new ProviderUser
{
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Invited
};
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var providerOrganization = new ProviderOrganization();
_providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
.Returns(enterpriseAnnually2020);
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(enterpriseAnnually);
var businessUnitConverter = BuildConverter();
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
arguments =>
arguments.Items.Count == 2 &&
arguments.Items[0].Id == "subscription_item_id" &&
arguments.Items[0].Deleted == true &&
arguments.Items[1].Price == updatedPriceId &&
arguments.Items[1].Quantity == organization.Seats));
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(arguments =>
arguments.PlanType == PlanType.EnterpriseAnnually &&
arguments.Status == OrganizationStatusType.Managed &&
arguments.GatewayCustomerId == null &&
arguments.GatewaySubscriptionId == null));
await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is<ProviderOrganization>(arguments =>
arguments.Key == organizationKey));
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(arguments =>
arguments.Gateway == GatewayType.Stripe &&
arguments.GatewayCustomerId == subscription.CustomerId &&
arguments.GatewaySubscriptionId == subscription.Id &&
arguments.Status == ProviderStatusType.Billable));
await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(arguments =>
arguments.Key == providerKey &&
arguments.Status == ProviderUserStatusType.Confirmed));
}
/*
* Because the validation for finalization is not an applicative like initialization is,
* I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure.
*/
[Theory, BitAutoData]
public async Task FinalizeConversion_ValidationFails_ThrowsBillingException(
Organization organization,
Guid userId,
string token,
string providerKey,
string organizationKey)
{
organization.PlanType = PlanType.EnterpriseAnnually2020;
var subscription = new Subscription
{
Status = StripeConstants.SubscriptionStatus.Canceled
};
_subscriberService.GetSubscription(organization).Returns(subscription);
var businessUnitConverter = BuildConverter();
await Assert.ThrowsAsync<BillingException>(() =>
businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey));
await _organizationUserRepository.DidNotReceiveWithAnyArgs()
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
}
#endregion
#region InitiateConversion
[Theory, BitAutoData]
public async Task InitiateConversion_Succeeds_ReturnsProviderId(
Organization organization,
string providerAdminEmail)
{
organization.PlanType = PlanType.EnterpriseAnnually;
_subscriberService.GetSubscription(organization).Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Active
});
var user = new User
{
Id = Guid.NewGuid(),
Email = providerAdminEmail
};
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
.Returns(organizationUser);
var provider = new Provider { Id = Guid.NewGuid() };
_providerRepository.CreateAsync(Arg.Is<Provider>(argument =>
argument.Name == organization.Name &&
argument.BillingEmail == organization.BillingEmail &&
argument.Status == ProviderStatusType.Pending &&
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
var plan = StaticStore.GetPlan(organization.PlanType);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
var token = SetupDataProtection(organization, providerAdminEmail);
var businessUnitConverter = BuildConverter();
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
Assert.True(result.IsT0);
var providerId = result.AsT0;
Assert.Equal(provider.Id, providerId);
await _providerOrganizationRepository.Received(1).CreateAsync(
Arg.Is<ProviderOrganization>(argument =>
argument.ProviderId == provider.Id &&
argument.OrganizationId == organization.Id));
await _providerPlanRepository.Received(1).CreateAsync(
Arg.Is<ProviderPlan>(argument =>
argument.ProviderId == provider.Id &&
argument.PlanType == PlanType.EnterpriseAnnually &&
argument.SeatMinimum == 0 &&
argument.PurchasedSeats == organization.Seats &&
argument.AllocatedSeats == organization.Seats));
await _providerUserRepository.Received(1).CreateAsync(
Arg.Is<ProviderUser>(argument =>
argument.ProviderId == provider.Id &&
argument.UserId == user.Id &&
argument.Email == user.Email &&
argument.Status == ProviderUserStatusType.Invited &&
argument.Type == ProviderUserType.ProviderAdmin));
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
organization,
token,
user.Email);
}
[Theory, BitAutoData]
public async Task InitiateConversion_ValidationFails_ReturnsErrors(
Organization organization,
string providerAdminEmail)
{
organization.PlanType = PlanType.TeamsMonthly;
_subscriberService.GetSubscription(organization).Returns(new Subscription
{
Status = StripeConstants.SubscriptionStatus.Canceled
});
var user = new User
{
Id = Guid.NewGuid(),
Email = providerAdminEmail
};
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
.Returns(new ProviderOrganization());
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited };
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
.Returns(organizationUser);
var businessUnitConverter = BuildConverter();
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
Assert.True(result.IsT1);
var problems = result.AsT1;
Assert.Contains("Organization must be on an enterprise plan.", problems);
Assert.Contains("Organization must have a valid subscription.", problems);
Assert.Contains("Organization is already linked to a provider.", problems);
Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems);
}
#endregion
#region ResendConversionInvite
[Theory, BitAutoData]
public async Task ResendConversionInvite_ConversionInProgress_Succeeds(
Organization organization,
string providerAdminEmail)
{
SetupConversionInProgress(organization, providerAdminEmail);
var token = SetupDataProtection(organization, providerAdminEmail);
var businessUnitConverter = BuildConverter();
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
organization,
token,
providerAdminEmail);
}
[Theory, BitAutoData]
public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing(
Organization organization,
string providerAdminEmail)
{
SetupDataProtection(organization, providerAdminEmail);
var businessUnitConverter = BuildConverter();
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync(
Arg.Any<Organization>(),
Arg.Any<string>(),
Arg.Any<string>());
}
#endregion
#region ResetConversion
[Theory, BitAutoData]
public async Task ResetConversion_ConversionInProgress_Succeeds(
Organization organization,
string providerAdminEmail)
{
var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail);
var businessUnitConverter = BuildConverter();
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
await _providerOrganizationRepository.Received(1)
.DeleteAsync(providerOrganization);
await _providerUserRepository.Received(1)
.DeleteAsync(providerUser);
await _providerPlanRepository.Received(1)
.DeleteAsync(providerPlan);
await _providerRepository.Received(1)
.DeleteAsync(provider);
}
[Theory, BitAutoData]
public async Task ResetConversion_NoConversionInProgress_DoesNothing(
Organization organization,
string providerAdminEmail)
{
var businessUnitConverter = BuildConverter();
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs()
.DeleteAsync(Arg.Any<ProviderOrganization>());
await _providerUserRepository.DidNotReceiveWithAnyArgs()
.DeleteAsync(Arg.Any<ProviderUser>());
await _providerPlanRepository.DidNotReceiveWithAnyArgs()
.DeleteAsync(Arg.Any<ProviderPlan>());
await _providerRepository.DidNotReceiveWithAnyArgs()
.DeleteAsync(Arg.Any<Provider>());
}
#endregion
#region Utilities
private string SetupDataProtection(
Organization organization,
string providerAdminEmail)
{
var dataProtector = new MockDataProtector(organization, providerAdminEmail);
_dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector);
return dataProtector.Protect(dataProtector.Token);
}
private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress(
Organization organization,
string providerAdminEmail)
{
var user = new User { Id = Guid.NewGuid() };
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
var provider = new Provider
{
Id = Guid.NewGuid(),
Type = ProviderType.BusinessUnit,
Status = ProviderStatusType.Pending
};
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
var providerUser = new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
UserId = user.Id,
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Invited,
Email = providerAdminEmail
};
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id)
.Returns(providerUser);
var providerOrganization = new ProviderOrganization
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
ProviderId = provider.Id
};
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
.Returns(providerOrganization);
var providerPlan = new ProviderPlan
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseAnnually
};
_providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]);
return (provider, providerOrganization, providerUser, providerPlan);
}
#endregion
}
public class MockDataProtector(
Organization organization,
string providerAdminEmail) : IDataProtector
{
public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}";
public IDataProtector CreateProtector(string purpose) => this;
public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token);
public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token);
}

View File

@ -116,7 +116,7 @@ public class ProviderBillingServiceTests
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange // Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise; provider.Type = ProviderType.BusinessUnit;
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var existingPlan = new ProviderPlan var existingPlan = new ProviderPlan

View File

@ -71,7 +71,7 @@ public class ProviderPriceAdapterTests
var provider = new Provider var provider = new Provider
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise Type = ProviderType.BusinessUnit
}; };
var subscription = new Subscription var subscription = new Subscription
@ -98,7 +98,7 @@ public class ProviderPriceAdapterTests
var provider = new Provider var provider = new Provider
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise Type = ProviderType.BusinessUnit
}; };
var subscription = new Subscription var subscription = new Subscription
@ -141,7 +141,7 @@ public class ProviderPriceAdapterTests
var provider = new Provider var provider = new Provider
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Type = ProviderType.MultiOrganizationEnterprise Type = ProviderType.BusinessUnit
}; };
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);

View File

@ -14,9 +14,6 @@
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" /> <ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Billing\Controllers\" />
</ItemGroup>
<Choose> <Choose>
<When Condition="!$(DefineConstants.Contains('OSS'))"> <When Condition="!$(DefineConstants.Contains('OSS'))">

View File

@ -133,10 +133,10 @@ public class ProvidersController : Controller
return View(new CreateResellerProviderModel()); return View(new CreateResellerProviderModel());
} }
[HttpGet("providers/create/multi-organization-enterprise")] [HttpGet("providers/create/business-unit")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
{ {
return View(new CreateMultiOrganizationEnterpriseProviderModel return View(new CreateBusinessUnitProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats EnterpriseSeatMinimum = enterpriseMinimumSeats
@ -157,7 +157,7 @@ public class ProvidersController : Controller
{ {
ProviderType.Msp => RedirectToAction("CreateMsp"), ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"), ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"), ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
_ => View(model) _ => View(model)
}; };
} }
@ -198,10 +198,10 @@ public class ProvidersController : Controller
return RedirectToAction("Edit", new { id = provider.Id }); return RedirectToAction("Edit", new { id = provider.Id });
} }
[HttpPost("providers/create/multi-organization-enterprise")] [HttpPost("providers/create/business-unit")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)] [RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model) public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -209,7 +209,7 @@ public class ProvidersController : Controller
} }
var provider = model.ToProvider(); var provider = model.ToProvider();
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( await _createProviderCommand.CreateBusinessUnitAsync(
provider, provider,
model.OwnerEmail, model.OwnerEmail,
model.Plan.Value, model.Plan.Value,
@ -307,7 +307,7 @@ public class ProvidersController : Controller
]); ]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break; break;
case ProviderType.MultiOrganizationEnterprise: case ProviderType.BusinessUnit:
{ {
var existingMoePlan = providerPlans.Single(); var existingMoePlan = providerPlans.Single();

View File

@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject public class CreateBusinessUnitProviderModel : IValidatableObject
{ {
[Display(Name = "Owner Email")] [Display(Name = "Owner Email")]
public string OwnerEmail { get; set; } public string OwnerEmail { get; set; }
@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{ {
return new Provider return new Provider
{ {
Type = ProviderType.MultiOrganizationEnterprise Type = ProviderType.BusinessUnit
}; };
} }
@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{ {
if (string.IsNullOrWhiteSpace(OwnerEmail)) if (string.IsNullOrWhiteSpace(OwnerEmail))
{ {
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail); var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
} }
if (EnterpriseSeatMinimum < 0) if (EnterpriseSeatMinimum < 0)
{ {
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum); var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative."); yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
} }
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly) if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
{ {
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan); var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly."); yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
} }
} }

View File

@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise) if (Type == ProviderType.BusinessUnit)
{ {
var plan = providerPlans.SingleOrDefault(); var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0; EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise: case ProviderType.BusinessUnit:
if (Plan == null) if (Plan == null)
{ {
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan); var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);

View File

@ -40,7 +40,7 @@ public class ProviderViewModel
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats)); ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
} }
} }
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise) else if (Provider.Type == ProviderType.BusinessUnit)
{ {
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly) var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0); .Sum(po => po.OccupiedSeats).GetValueOrDefault(0);

View File

@ -1,8 +1,13 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Admin.Models @using Bit.Admin.Models
@using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums @using Bit.Core.Billing.Enums
@using Bit.Core.Enums @using Bit.Core.Billing.Extensions
@using Bit.Core.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject IFeatureService FeatureService
@model OrganizationEditModel @model OrganizationEditModel
@{ @{
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
@ -13,6 +18,13 @@
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete); var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
var canConvertToBusinessUnit =
FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) &&
AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) &&
Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise &&
!string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) &&
Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending };
} }
@section Scripts { @section Scripts {
@ -114,6 +126,15 @@
Enterprise Trial Enterprise Trial
</button> </button>
} }
@if (canConvertToBusinessUnit)
{
<a asp-controller="BusinessUnitConversion"
asp-action="Index"
asp-route-organizationId="@Model.Organization.Id"
class="btn btn-secondary me-2">
Convert to Business Unit
</a>
}
@if (canUnlinkFromProvider && Model.Provider is not null) @if (canUnlinkFromProvider && Model.Provider is not null)
{ {
<button class="btn btn-outline-danger me-2" <button class="btn btn-outline-danger me-2"

View File

@ -1,15 +1,15 @@
@using Bit.Core.Billing.Enums @using Bit.Core.Billing.Enums
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@model CreateMultiOrganizationEnterpriseProviderModel @model CreateBusinessUnitProviderModel
@{ @{
ViewData["Title"] = "Create Multi-organization Enterprise Provider"; ViewData["Title"] = "Create Business Unit Provider";
} }
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1> <h1 class="mb-4">Create Business Unit Provider</h1>
<div> <div>
<form method="post" asp-action="CreateMultiOrganizationEnterprise"> <form method="post" asp-action="CreateBusinessUnit">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3"> <div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label> <label asp-for="OwnerEmail" class="form-label"></label>
@ -19,14 +19,14 @@
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
@{ @{
var multiOrgPlans = new List<PlanType> var businessUnitPlanTypes = new List<PlanType>
{ {
PlanType.EnterpriseAnnually, PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly PlanType.EnterpriseMonthly
}; };
} }
<label asp-for="Plan" class="form-label"></label> <label asp-for="Plan" class="form-label"></label>
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)"> <select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>

View File

@ -74,20 +74,20 @@
</div> </div>
break; break;
} }
case ProviderType.MultiOrganizationEnterprise: case ProviderType.BusinessUnit:
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
@{ @{
var multiOrgPlans = new List<PlanType> var businessUnitPlanTypes = new List<PlanType>
{ {
PlanType.EnterpriseAnnually, PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly PlanType.EnterpriseMonthly
}; };
} }
<label asp-for="Plan" class="form-label"></label> <label asp-for="Plan" class="form-label"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)"> <select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
<option value="">--</option> <option value="">--</option>
</select> </select>
</div> </div>

View File

@ -0,0 +1,185 @@
#nullable enable
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("organizations/billing/{organizationId:guid}/business-unit")]
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
public class BusinessUnitConversionController(
IBusinessUnitConverter businessUnitConverter,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository) : Controller
{
[HttpGet]
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> IndexAsync([FromRoute] Guid organizationId)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
var model = new BusinessUnitConversionModel { Organization = organization };
var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization);
if (invitedProviderAdmin != null)
{
model.ProviderAdminEmail = invitedProviderAdmin.Email;
model.ProviderId = invitedProviderAdmin.ProviderId;
}
var success = ReadSuccessMessage();
if (!string.IsNullOrEmpty(success))
{
model.Success = success;
}
var errors = ReadErrorMessages();
if (errors is { Count: > 0 })
{
model.Errors = errors;
}
return View(model);
}
[HttpPost]
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> InitiateAsync(
[FromRoute] Guid organizationId,
BusinessUnitConversionModel model)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
var result = await businessUnitConverter.InitiateConversion(
organization,
model.ProviderAdminEmail!);
return result.Match(
providerId => RedirectToAction("Edit", "Providers", new { id = providerId }),
errors =>
{
PersistErrorMessages(errors);
return RedirectToAction("Index", new { organizationId });
});
}
[HttpPost("reset")]
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> ResetAsync(
[FromRoute] Guid organizationId,
BusinessUnitConversionModel model)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!);
PersistSuccessMessage("Business unit conversion was successfully reset.");
return RedirectToAction("Index", new { organizationId });
}
[HttpPost("resend-invite")]
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> ResendInviteAsync(
[FromRoute] Guid organizationId,
BusinessUnitConversionModel model)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!);
PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}.");
return RedirectToAction("Index", new { organizationId });
}
private async Task<ProviderUser?> GetInvitedProviderAdminAsync(
Organization organization)
{
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider is not
{
Type: ProviderType.BusinessUnit,
Status: ProviderStatusType.Pending
})
{
return null;
}
var providerUsers =
await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin);
if (providerUsers.Count != 1)
{
return null;
}
var providerUser = providerUsers.First();
return providerUser is
{
Type: ProviderUserType.ProviderAdmin,
Status: ProviderUserStatusType.Invited,
UserId: not null
} ? providerUser : null;
}
private const string _errors = "errors";
private const string _success = "Success";
private void PersistSuccessMessage(string message) => TempData[_success] = message;
private void PersistErrorMessages(List<string> errors)
{
var input = string.Join("|", errors);
TempData[_errors] = input;
}
private string? ReadSuccessMessage() => ReadTempData<string>(_success);
private List<string>? ReadErrorMessages()
{
var output = ReadTempData<string>(_errors);
return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList();
}
private T? ReadTempData<T>(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default;
}

View File

@ -0,0 +1,25 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Admin.Billing.Models;
public class BusinessUnitConversionModel
{
[Required]
[EmailAddress]
[Display(Name = "Provider Admin Email")]
public string? ProviderAdminEmail { get; set; }
[BindNever]
public required Organization Organization { get; set; }
[BindNever]
public Guid? ProviderId { get; set; }
[BindNever]
public string? Success { get; set; }
[BindNever] public List<string>? Errors { get; set; } = [];
}

View File

@ -0,0 +1,75 @@
@model Bit.Admin.Billing.Models.BusinessUnitConversionModel
@{
ViewData["Title"] = "Convert Organization to Business Unit";
}
@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail))
{
<h1>Convert @Model.Organization.Name to Business Unit</h1>
@if (!string.IsNullOrEmpty(Model.Success))
{
<div class="alert alert-success alert-dismissible fade show mb-3" role="alert">
@Model.Success
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (Model.Errors?.Any() ?? false)
{
@foreach (var error in Model.Errors)
{
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
@error
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
}
<p>This organization has a business unit conversion in progress.</p>
<div class="mb-3">
<label asp-for="ProviderAdminEmail" class="form-label"></label>
<input type="email" class="form-control" asp-for="ProviderAdminEmail" disabled></input>
</div>
<div class="d-flex gap-2">
<form method="post" asp-controller="BusinessUnitConversion" asp-action="ResendInvite" asp-route-organizationId="@Model.Organization.Id">
<input type="hidden" asp-for="ProviderAdminEmail" />
<button type="submit" class="btn btn-primary mb-2">Resend Invite</button>
</form>
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Reset" asp-route-organizationId="@Model.Organization.Id">
<input type="hidden" asp-for="ProviderAdminEmail" />
<button type="submit" class="btn btn-danger mb-2">Reset Conversion</button>
</form>
@if (Model.ProviderId.HasValue)
{
<a asp-controller="Providers"
asp-action="Edit"
asp-route-id="@Model.ProviderId"
class="btn btn-secondary mb-2">
Go to Provider
</a>
}
</div>
}
else
{
<h1>Convert @Model.Organization.Name to Business Unit</h1>
@if (Model.Errors?.Any() ?? false)
{
@foreach (var error in Model.Errors)
{
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
@error
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
}
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Initiate" asp-route-organizationId="@Model.Organization.Id">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="ProviderAdminEmail" class="form-label"></label>
<input type="email" class="form-control" asp-for="ProviderAdminEmail" />
</div>
<button type="submit" class="btn btn-primary mb-2">Convert</button>
</form>
}

View File

@ -184,7 +184,7 @@ public class UsersController : Controller
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId) private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{ {
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsManagedByAnyOrganizationAsync(userId) ? await _userService.IsClaimedByAnyOrganizationAsync(userId)
: null; : null;
} }
} }

View File

@ -38,6 +38,7 @@ public enum Permission
Org_Billing_View, Org_Billing_View,
Org_Billing_Edit, Org_Billing_Edit,
Org_Billing_LaunchGateway, Org_Billing_LaunchGateway,
Org_Billing_ConvertToBusinessUnit,
Provider_List_View, Provider_List_View,
Provider_Create, Provider_Create,

View File

@ -42,6 +42,7 @@ public static class RolePermissionMapping
Permission.Org_Billing_View, Permission.Org_Billing_View,
Permission.Org_Billing_Edit, Permission.Org_Billing_Edit,
Permission.Org_Billing_LaunchGateway, Permission.Org_Billing_LaunchGateway,
Permission.Org_Billing_ConvertToBusinessUnit,
Permission.Provider_List_View, Permission.Provider_List_View,
Permission.Provider_Create, Permission.Provider_Create,
Permission.Provider_View, Permission.Provider_View,
@ -90,6 +91,7 @@ public static class RolePermissionMapping
Permission.Org_Billing_View, Permission.Org_Billing_View,
Permission.Org_Billing_Edit, Permission.Org_Billing_Edit,
Permission.Org_Billing_LaunchGateway, Permission.Org_Billing_LaunchGateway,
Permission.Org_Billing_ConvertToBusinessUnit,
Permission.Org_InitiateTrial, Permission.Org_InitiateTrial,
Permission.Provider_List_View, Permission.Provider_List_View,
Permission.Provider_Create, Permission.Provider_Create,
@ -166,6 +168,7 @@ public static class RolePermissionMapping
Permission.Org_Billing_View, Permission.Org_Billing_View,
Permission.Org_Billing_Edit, Permission.Org_Billing_Edit,
Permission.Org_Billing_LaunchGateway, Permission.Org_Billing_LaunchGateway,
Permission.Org_Billing_ConvertToBusinessUnit,
Permission.Org_RequestDelete, Permission.Org_RequestDelete,
Permission.Provider_Edit, Permission.Provider_Edit,
Permission.Provider_View, Permission.Provider_View,

View File

@ -59,8 +59,8 @@ public class OrganizationUsersController : Controller
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
@ -86,8 +86,8 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService, IFeatureService featureService,
IPricingClient pricingClient, IPricingClient pricingClient,
@ -112,8 +112,8 @@ public class OrganizationUsersController : Controller
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
@ -130,11 +130,11 @@ public class OrganizationUsersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
var managedByOrganization = await GetManagedByOrganizationStatusAsync( var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync(
organizationUser.OrganizationId, organizationUser.OrganizationId,
[organizationUser.Id]); [organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections); var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections);
if (includeGroups) if (includeGroups)
{ {
@ -178,13 +178,13 @@ public class OrganizationUsersController : Controller
} }
); );
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers var responses = organizationUsers
.Select(o => .Select(o =>
{ {
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var managedByOrganization = organizationUsersManagementStatus[o.Id]; var claimedByOrganization = organizationUsersClaimedStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization); var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization);
return orgUser; return orgUser;
}); });
@ -594,7 +594,7 @@ public class OrganizationUsersController : Controller
return Unauthorized(); return Unauthorized();
} }
var result = await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
if (result is Success<DeleteUserResponse>) if (result is Success<DeleteUserResponse>)
{ {
@ -621,7 +621,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
var result = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return MapToOrganizationUserBulkResponseModel(result); return MapToOrganizationUserBulkResponseModel(result);
} }
@ -734,14 +734,14 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds) private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{ {
return userIds.ToDictionary(kvp => kvp, kvp => false); return userIds.ToDictionary(kvp => kvp, kvp => false);
} }
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds); var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus; return usersOrganizationClaimedStatus;
} }
} }

View File

@ -65,6 +65,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -88,7 +89,8 @@ public class OrganizationsController : Controller
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand, IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient) IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -112,6 +114,7 @@ public class OrganizationsController : Controller
_organizationDeleteCommand = organizationDeleteCommand; _organizationDeleteCommand = organizationDeleteCommand;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -140,10 +143,10 @@ public class OrganizationsController : Controller
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id);
var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser));
return new ListResponseModel<ProfileOrganizationResponseModel>(responses); return new ListResponseModel<ProfileOrganizationResponseModel>(responses);
} }
@ -277,9 +280,9 @@ public class OrganizationsController : Controller
} }
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id)) && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
{ {
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details."); throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.");
} }
await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id); await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id);
@ -490,7 +493,7 @@ public class OrganizationsController : Controller
} }
[HttpPost("{id}/keys")] [HttpPost("{id}/keys")]
public async Task<OrganizationKeysResponseModel> PostKeys(string id, [FromBody] OrganizationKeysRequestModel model) public async Task<OrganizationKeysResponseModel> PostKeys(Guid id, [FromBody] OrganizationKeysRequestModel model)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null) if (user == null)
@ -498,7 +501,7 @@ public class OrganizationsController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey, var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey,
model.EncryptedPrivateKey); model.EncryptedPrivateKey);
return new OrganizationKeysResponseModel(org); return new OrganizationKeysResponseModel(org);
} }

View File

@ -66,24 +66,34 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
{ {
public OrganizationUserDetailsResponseModel( public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser, OrganizationUser organizationUser,
bool managedByOrganization, bool claimedByOrganization,
string ssoExternalId,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
SsoExternalId = ssoExternalId;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization, bool claimedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
SsoExternalId = organizationUser.SsoExternalId;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public bool ManagedByOrganization { get; set; } [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
public bool ManagedByOrganization
{
get => ClaimedByOrganization;
set => ClaimedByOrganization = value;
}
public bool ClaimedByOrganization { get; set; }
public string SsoExternalId { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
@ -117,7 +127,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)
{ {
if (organizationUser == null) if (organizationUser == null)
@ -134,7 +144,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups; Groups = organizationUser.Groups;
// Prevent reset password when using key connector. // Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization; ClaimedByOrganization = claimedByOrganization;
} }
public string Name { get; set; } public string Name { get; set; }
@ -142,11 +152,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public bool SsoBound { get; set; } public bool SsoBound { get; set; }
[Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")]
public bool ManagedByOrganization
{
get => ClaimedByOrganization;
set => ClaimedByOrganization = value;
}
/// <summary> /// <summary>
/// Indicates if the organization manages the user. If a user is "managed" by an organization, /// Indicates if the organization claimed the user. If a user is "claimed" by an organization,
/// the organization has greater control over their account, and some user actions are restricted. /// the organization has greater control over their account, and some user actions are restricted.
/// </summary> /// </summary>
public bool ManagedByOrganization { get; set; } public bool ClaimedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }

View File

@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public ProfileOrganizationResponseModel( public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization, OrganizationUserOrganizationDetails organization,
IEnumerable<Guid> organizationIdsManagingUser) IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization") : this("profileOrganization")
{ {
Id = organization.OrganizationId; Id = organization.OrganizationId;
@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier; Identifier = organization.Identifier;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions); Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
ResetPasswordEnrolled = organization.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
UserId = organization.UserId; UserId = organization.UserId;
OrganizationUserId = organization.OrganizationUserId; OrganizationUserId = organization.OrganizationUserId;
ProviderId = organization.ProviderId; ProviderId = organization.ProviderId;
@ -70,7 +70,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
if (organization.SsoConfig != null) if (organization.SsoConfig != null)
@ -133,15 +133,26 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// Indicates if the organization manages the user. /// Obsolete.
///
/// See <see cref="UserIsClaimedByOrganization"/>
/// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization
{
get => UserIsClaimedByOrganization;
set => UserIsClaimedByOrganization = value;
}
/// <summary>
/// Indicates if the organization claims the user.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it. /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it.
/// The organization must be enabled and able to have verified domains. /// The organization must be enabled and able to have verified domains.
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// False if the Account Deprovisioning feature flag is disabled. /// False if the Account Deprovisioning feature flag is disabled.
/// </returns> /// </returns>
public bool UserIsManagedByOrganization { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
} }

View File

@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel
UserId = provider.UserId; UserId = provider.UserId;
UseEvents = provider.UseEvents; UseEvents = provider.UseEvents;
ProviderStatus = provider.ProviderStatus; ProviderStatus = provider.ProviderStatus;
ProviderType = provider.ProviderType;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public bool UseEvents { get; set; } public bool UseEvents { get; set; }
public ProviderStatusType ProviderStatus { get; set; } public ProviderStatusType ProviderStatus { get; set; }
public ProviderType ProviderType { get; set; }
} }

View File

@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Email = user.Email; Email = user.Email;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
} }
[SetsRequiredMembers] [SetsRequiredMembers]
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
Status = user.Status; Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null; ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
SsoExternalId = user.SsoExternalId; SsoExternalId = user.SsoExternalId;
} }

View File

@ -124,11 +124,11 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail); var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
if (!managedUserValidationResult.Succeeded) if (!claimedUserValidationResult.Succeeded)
{ {
throw new BadRequestException(managedUserValidationResult.Errors); throw new BadRequestException(claimedUserValidationResult.Errors);
} }
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
@ -437,11 +437,11 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, twoFactorEnabled, providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg, organizationIdsManagingActiveUser); hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -451,9 +451,9 @@ public class AccountsController : Controller
var userId = _userService.GetProperUserId(User); var userId = _userService.GetProperUserId(User);
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value);
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData); return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
} }
@ -471,9 +471,9 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -490,9 +490,9 @@ public class AccountsController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return response; return response;
} }
@ -560,9 +560,9 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id)) && await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
{ {
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
} }
@ -763,9 +763,9 @@ public class AccountsController : Controller
await _userService.SaveUserAsync(user); await _userService.SaveUserAsync(user);
} }
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId) private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{ {
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
return organizationManagingUser.Select(o => o.Id); return organizationsClaimingUser.Select(o => o.Id);
} }
} }

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
#nullable enable
namespace Bit.Api.Auth.Models.Request;
public class UntrustDevicesRequestModel
{
[Required]
public IEnumerable<Guid> Devices { get; set; } = null!;
}

View File

@ -58,10 +58,10 @@ public class AccountsController(
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
userHasPremiumFromOrganization, organizationIdsManagingActiveUser); userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return new PaymentResponseModel return new PaymentResponseModel
{ {
UserProfile = profile, UserProfile = profile,
@ -229,9 +229,9 @@ public class AccountsController(
await paymentService.SaveTaxInfoAsync(user, taxInfo); await paymentService.SaveTaxInfoAsync(user, taxInfo);
} }
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId) private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{ {
var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId); var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationManagingUser.Select(o => o.Id); return organizationsClaimingUser.Select(o => o.Id);
} }
} }

View File

@ -2,6 +2,7 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers;
[Route("organizations/{organizationId:guid}/billing")] [Route("organizations/{organizationId:guid}/billing")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationBillingController( public class OrganizationBillingController(
IBusinessUnitConverter businessUnitConverter,
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService, IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPaymentService paymentService,
@ -296,4 +299,40 @@ public class OrganizationBillingController(
return TypedResults.Ok(); return TypedResults.Ok();
} }
[HttpPost("setup-business-unit")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> SetupBusinessUnitAsync(
[FromRoute] Guid organizationId,
[FromBody] SetupBusinessUnitRequestBody requestBody)
{
var enableOrganizationBusinessUnitConversion =
featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion);
if (!enableOrganizationBusinessUnitConversion)
{
return Error.NotFound();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
if (!await currentContext.OrganizationUser(organizationId))
{
return Error.Unauthorized();
}
var providerId = await businessUnitConverter.FinalizeConversion(
organization,
requestBody.UserId,
requestBody.Token,
requestBody.ProviderKey,
requestBody.OrganizationKey);
return TypedResults.Ok(providerId);
}
} }

View File

@ -409,9 +409,9 @@ public class OrganizationsController(
organizationId, organizationId,
OrganizationUserStatusType.Confirmed); OrganizationUserStatusType.Confirmed);
var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId)) var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId))
.Select(o => o.Id); .Select(o => o.Id);
return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser); return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser);
} }
} }

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class SetupBusinessUnitRequestBody
{
[Required]
public Guid UserId { get; set; }
[Required]
public string Token { get; set; }
[Required]
public string ProviderKey { get; set; }
[Required]
public string OrganizationKey { get; set; }
}

View File

@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -21,6 +22,7 @@ public class DevicesController : Controller
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService; private readonly IDeviceService _deviceService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IUntrustDevicesCommand _untrustDevicesCommand;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<DevicesController> _logger; private readonly ILogger<DevicesController> _logger;
@ -29,6 +31,7 @@ public class DevicesController : Controller
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
IDeviceService deviceService, IDeviceService deviceService,
IUserService userService, IUserService userService,
IUntrustDevicesCommand untrustDevicesCommand,
IUserRepository userRepository, IUserRepository userRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
ILogger<DevicesController> logger) ILogger<DevicesController> logger)
@ -36,6 +39,7 @@ public class DevicesController : Controller
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_deviceService = deviceService; _deviceService = deviceService;
_userService = userService; _userService = userService;
_untrustDevicesCommand = untrustDevicesCommand;
_userRepository = userRepository; _userRepository = userRepository;
_currentContext = currentContext; _currentContext = currentContext;
_logger = logger; _logger = logger;
@ -165,6 +169,19 @@ public class DevicesController : Controller
model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>()); model.OtherDevices ?? Enumerable.Empty<OtherDeviceKeysUpdateRequestModel>());
} }
[HttpPost("untrust")]
public async Task PostUntrust([FromBody] UntrustDevicesRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await _untrustDevicesCommand.UntrustDevices(user, model.Devices);
}
[HttpPut("identifier/{identifier}/token")] [HttpPut("identifier/{identifier}/token")]
[HttpPost("identifier/{identifier}/token")] [HttpPost("identifier/{identifier}/token")]
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)

View File

@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
bool twoFactorEnabled, bool twoFactorEnabled,
bool premiumFromOrganization, bool premiumFromOrganization,
IEnumerable<Guid> organizationIdsManagingUser) : base("profile") IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
{ {
if (user == null) if (user == null)
{ {
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
AvatarColor = user.AvatarColor; AvatarColor = user.AvatarColor;
CreationDate = user.CreationDate; CreationDate = user.CreationDate;
VerifyDevices = user.VerifyDevices; VerifyDevices = user.VerifyDevices;
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations = ProviderOrganizations =
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));

View File

@ -1091,9 +1091,9 @@ public class CiphersController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id)) && await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
{ {
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
} }

View File

@ -104,13 +104,13 @@ public class SyncController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response; return response;
} }

View File

@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
bool userTwoFactorEnabled, bool userTwoFactorEnabled,
bool userHasPremiumFromOrganization, bool userHasPremiumFromOrganization,
IDictionary<Guid, OrganizationAbility> organizationAbilities, IDictionary<Guid, OrganizationAbility> organizationAbilities,
IEnumerable<Guid> organizationIdsManagingUser, IEnumerable<Guid> organizationIdsClaimingingUser,
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
IEnumerable<ProviderUserProviderDetails> providerUserDetails, IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
: base("sync") : base("sync")
{ {
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
Folders = folders.Select(f => new FolderResponseModel(f)); Folders = folders.Select(f => new FolderResponseModel(f));
Ciphers = ciphers.Select(cipher => Ciphers = ciphers.Select(cipher =>
new CipherDetailsResponseModel( new CipherDetailsResponseModel(

View File

@ -1,6 +1,5 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Billing.Jobs; using Bit.Billing.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@ -24,7 +23,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory; private readonly ISchedulerFactory _schedulerFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
@ -39,7 +37,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory, ISchedulerFactory schedulerFactory,
IFeatureService featureService,
IOrganizationEnableCommand organizationEnableCommand, IOrganizationEnableCommand organizationEnableCommand,
IOrganizationDisableCommand organizationDisableCommand, IOrganizationDisableCommand organizationDisableCommand,
IPricingClient pricingClient) IPricingClient pricingClient)
@ -53,7 +50,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_schedulerFactory = schedulerFactory; _schedulerFactory = schedulerFactory;
_featureService = featureService;
_organizationEnableCommand = organizationEnableCommand; _organizationEnableCommand = organizationEnableCommand;
_organizationDisableCommand = organizationDisableCommand; _organizationDisableCommand = organizationDisableCommand;
_pricingClient = pricingClient; _pricingClient = pricingClient;
@ -227,12 +223,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
{ {
var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert);
if (!isResellerManagedOrgAlertEnabled)
{
return;
}
var scheduler = await _schedulerFactory.GetScheduler(); var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<SubscriptionCancellationJob>() var job = JobBuilder.Create<SubscriptionCancellationJob>()

View File

@ -8,6 +8,6 @@ public enum ProviderType : byte
Msp = 0, Msp = 0,
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)] [Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)]
Reseller = 1, Reseller = 1,
[Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)] [Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)]
MultiOrganizationEnterprise = 2, BusinessUnit = 2,
} }

View File

@ -17,4 +17,5 @@ public class ProviderUserProviderDetails
public string Permissions { get; set; } public string Permissions { get; set; }
public bool UseEvents { get; set; } public bool UseEvents { get; set; }
public ProviderStatusType ProviderStatus { get; set; } public ProviderStatusType ProviderStatus { get; set; }
public ProviderType ProviderType { get; set; }
} }

View File

@ -154,6 +154,6 @@ public class VerifyOrganizationDomainCommand(
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId); var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization)); await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization));
} }
} }

View File

@ -19,35 +19,37 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
#nullable enable #nullable enable
public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand
{ {
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeleteManagedOrganizationUserAccountValidator _deleteManagedOrganizationUserAccountValidator; private readonly IDeleteManagedOrganizationUserAccountValidator _deleteManagedOrganizationUserAccountValidator;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<DeleteManagedOrganizationUserAccountCommand> _logger; private readonly ILogger<DeleteClaimedOrganizationUserAccountCommand> _logger;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IPushNotificationService _pushService; private readonly IPushNotificationService _pushService;
private readonly IOrganizationRepository _organizationRepository;
public DeleteManagedOrganizationUserAccountCommand( private readonly IProviderUserRepository _providerUserRepository;
public DeleteClaimedOrganizationUserAccountCommand(
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeleteManagedOrganizationUserAccountValidator deleteManagedOrganizationUserAccountValidator, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository, IUserRepository userRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
ILogger<DeleteManagedOrganizationUserAccountCommand> logger, ILogger<DeleteClaimedOrganizationUserAccountCommand> logger,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
IPushNotificationService pushService) IPushNotificationService pushService)
{ {
_userService = userService; _userService = userService;
_eventService = eventService; _eventService = eventService;
_deleteManagedOrganizationUserAccountValidator = deleteManagedOrganizationUserAccountValidator; _deleteManagedOrganizationUserAccountValidator = deleteManagedOrganizationUserAccountValidator;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersManagementStatusQuery = _getOrganizationUsersManagementStatusQuery;
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_userRepository = userRepository; _userRepository = userRepository;
_currentContext = currentContext; _currentContext = currentContext;
@ -130,6 +132,16 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
} }
return requests; return requests;
// Jimmy move this to the validator
// if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId))
// {
// throw new BadRequestException("Custom users can not delete admins.");
// }
// if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed)
// {
// throw new BadRequestException("Member is not claimed by the organization.");
// }
} }
private async Task<IEnumerable<User>> GetUsersAsync(ICollection<OrganizationUser> orgUsers) private async Task<IEnumerable<User>> GetUsersAsync(ICollection<OrganizationUser> orgUsers)

View File

@ -4,12 +4,12 @@ using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaimedStatusQuery
{ {
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
public GetOrganizationUsersManagementStatusQuery( public GetOrganizationUsersClaimedStatusQuery(
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository) IOrganizationUserRepository organizationUserRepository)
{ {
@ -17,11 +17,11 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
} }
public async Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds) public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
{ {
if (organizationUserIds.Any()) if (organizationUserIds.Any())
{ {
// Users can only be managed by an Organization that is enabled and can have organization domains // Users can only be claimed by an Organization that is enabled and can have organization domains
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
// TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622).
@ -31,7 +31,7 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa
// Get all organization users with claimed domains by the organization // Get all organization users with claimed domains by the organization
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId)); return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
} }
} }

View File

@ -5,7 +5,7 @@ using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IDeleteManagedOrganizationUserAccountCommand public interface IDeleteClaimedOrganizationUserAccountCommand
{ {
/// <summary> /// <summary>
/// Removes a user from an organization and deletes all of their associated user data. /// Removes a user from an organization and deletes all of their associated user data.

View File

@ -1,19 +1,19 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IGetOrganizationUsersManagementStatusQuery public interface IGetOrganizationUsersClaimedStatusQuery
{ {
/// <summary> /// <summary>
/// Checks whether each user in the provided list of organization user IDs is managed by the specified organization. /// Checks whether each user in the provided list of organization user IDs is claimed by the specified organization.
/// </summary> /// </summary>
/// <param name="organizationId">The unique identifier of the organization to check against.</param> /// <param name="organizationId">The unique identifier of the organization to check against.</param>
/// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param> /// <param name="organizationUserIds">A list of OrganizationUserIds to be checked.</param>
/// <remarks> /// <remarks>
/// A managed user is a user whose email domain matches one of the Organization's verified domains. /// A claimed user is a user whose email domain matches one of the Organization's verified domains.
/// The organization must be enabled and be on an Enterprise plan. /// The organization must be enabled and be on an Enterprise plan.
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is claimed by the organization.
/// </returns> /// </returns>
Task<IDictionary<Guid, bool>> GetUsersOrganizationManagementStatusAsync(Guid organizationId, Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds); IEnumerable<Guid> organizationUserIds);
} }

View File

@ -18,7 +18,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
private readonly IPushRegistrationService _pushRegistrationService; private readonly IPushRegistrationService _pushRegistrationService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
@ -38,7 +38,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
IPushRegistrationService pushRegistrationService, IPushRegistrationService pushRegistrationService,
ICurrentContext currentContext, ICurrentContext currentContext,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IFeatureService featureService, IFeatureService featureService,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
@ -49,7 +49,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
_pushRegistrationService = pushRegistrationService; _pushRegistrationService = pushRegistrationService;
_currentContext = currentContext; _currentContext = currentContext;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_featureService = featureService; _featureService = featureService;
_timeProvider = timeProvider; _timeProvider = timeProvider;
} }
@ -161,8 +161,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null)
{ {
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id });
if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed)
{ {
throw new BadRequestException(RemoveClaimedAccountErrorMessage); throw new BadRequestException(RemoveClaimedAccountErrorMessage);
} }
@ -214,8 +214,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId);
} }
var managementStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null
? await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) ? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id))
: filteredUsers.ToDictionary(u => u.Id, u => false); : filteredUsers.ToDictionary(u => u.Id, u => false);
var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>();
foreach (var orgUser in filteredUsers) foreach (var orgUser in filteredUsers)
@ -232,7 +232,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand
throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage); throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage);
} }
if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed)
{ {
throw new BadRequestException(RemoveClaimedAccountErrorMessage); throw new BadRequestException(RemoveClaimedAccountErrorMessage);
} }

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Entities;
public interface IOrganizationUpdateKeysCommand
{
/// <summary>
/// Update the keys for an organization.
/// </summary>
/// <param name="orgId">The ID of the organization to update.</param>
/// <param name="publicKey">The public key for the organization.</param>
/// <param name="privateKey">The private key for the organization.</param>
/// <returns>The updated organization.</returns>
Task<Organization> UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey);
}

View File

@ -0,0 +1,47 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
public class OrganizationUpdateKeysCommand : IOrganizationUpdateKeysCommand
{
private readonly ICurrentContext _currentContext;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
public const string OrganizationKeysAlreadyExistErrorMessage = "Organization Keys already exist.";
public OrganizationUpdateKeysCommand(
ICurrentContext currentContext,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService)
{
_currentContext = currentContext;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
}
public async Task<Organization> UpdateOrganizationKeysAsync(Guid organizationId, string publicKey, string privateKey)
{
if (!await _currentContext.ManageResetPassword(organizationId))
{
throw new UnauthorizedAccessException();
}
// If the keys already exist, error out
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization.PublicKey != null && organization.PrivateKey != null)
{
throw new BadRequestException(OrganizationKeysAlreadyExistErrorMessage);
}
// Update org with generated public/private key
organization.PublicKey = publicKey;
organization.PrivateKey = privateKey;
await _organizationService.UpdateAsync(organization);
return organization;
}
}

View File

@ -7,5 +7,5 @@ public interface ICreateProviderCommand
{ {
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
Task CreateResellerAsync(Provider provider); Task CreateResellerAsync(Provider provider);
Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
} }

View File

@ -43,7 +43,6 @@ public interface IOrganizationService
IEnumerable<ImportedOrganizationUser> newUsers, IEnumerable<string> removeUserExternalIds, IEnumerable<ImportedOrganizationUser> newUsers, IEnumerable<string> removeUserExternalIds,
bool overwriteExisting, EventSystemUser eventSystemUser); bool overwriteExisting, EventSystemUser eventSystemUser);
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
Task<Organization> UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey);
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId, Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,

View File

@ -1418,28 +1418,6 @@ public class OrganizationService : IOrganizationService
} }
} }
public async Task<Organization> UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey)
{
if (!await _currentContext.ManageResetPassword(orgId))
{
throw new UnauthorizedAccessException();
}
// If the keys already exist, error out
var org = await _organizationRepository.GetByIdAsync(orgId);
if (org.PublicKey != null && org.PrivateKey != null)
{
throw new BadRequestException("Organization Keys already exist");
}
// Update org with generated public/private key
org.PublicKey = publicKey;
org.PrivateKey = privateKey;
await UpdateAsync(org);
return org;
}
private async Task UpdateUsersAsync(Group group, HashSet<string> groupUsers, private async Task UpdateUsersAsync(Group group, HashSet<string> groupUsers,
Dictionary<string, Guid> existingUsersIdDict, HashSet<Guid> existingUsers = null) Dictionary<string, Guid> existingUsersIdDict, HashSet<Guid> existingUsers = null)
{ {

View File

@ -2,6 +2,6 @@
namespace Bit.Core.Auth.Models.Mail; namespace Bit.Core.Auth.Models.Mail;
public class CannotDeleteManagedAccountViewModel : BaseMailModel public class CannotDeleteClaimedAccountViewModel : BaseMailModel
{ {
} }

View File

@ -0,0 +1,8 @@
using Bit.Core.Entities;
namespace Bit.Core.Auth.UserFeatures.DeviceTrust;
public interface IUntrustDevicesCommand
{
public Task UntrustDevices(User user, IEnumerable<Guid> devicesToUntrust);
}

View File

@ -0,0 +1,39 @@
using Bit.Core.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.UserFeatures.DeviceTrust;
public class UntrustDevicesCommand : IUntrustDevicesCommand
{
private readonly IDeviceRepository _deviceRepository;
public UntrustDevicesCommand(
IDeviceRepository deviceRepository)
{
_deviceRepository = deviceRepository;
}
public async Task UntrustDevices(User user, IEnumerable<Guid> devicesToUntrust)
{
var userDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
var deviceIdDict = userDevices.ToDictionary(device => device.Id);
// Validate that the user owns all devices that they passed in
foreach (var deviceId in devicesToUntrust)
{
if (!deviceIdDict.ContainsKey(deviceId))
{
throw new UnauthorizedAccessException($"User {user.Id} does not have access to device {deviceId}");
}
}
foreach (var deviceId in devicesToUntrust)
{
var device = deviceIdDict[deviceId];
device.EncryptedPrivateKey = null;
device.EncryptedPublicKey = null;
device.EncryptedUserKey = null;
await _deviceRepository.UpsertAsync(device);
}
}
}

View File

@ -53,23 +53,10 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
var user = await _userRepository.GetByEmailAsync(email); var user = await _userRepository.GetByEmailAsync(email);
var userExists = user != null; var userExists = user != null;
// Delays enabled by default; flag must be enabled to remove the delays.
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
if (!_globalSettings.EnableEmailVerification) if (!_globalSettings.EnableEmailVerification)
{ {
if (userExists) if (userExists)
{ {
if (delaysEnabled)
{
// Add delay to prevent timing attacks
// Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms
// as it should be long enough to prevent timing attacks but not too long to be noticeable to the user.
await Task.Delay(Random.Shared.Next(100, 130));
}
throw new BadRequestException($"Email {email} is already taken"); throw new BadRequestException($"Email {email} is already taken");
} }
@ -87,11 +74,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
await _mailService.SendRegistrationVerificationEmailAsync(email, token); await _mailService.SendRegistrationVerificationEmailAsync(email, token);
} }
if (delaysEnabled)
{
// Add random delay between 100ms-130ms to prevent timing attacks
await Task.Delay(Random.Shared.Next(100, 130));
}
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null // User exists but we will return a 200 regardless of whether the email was sent or not; so return null
return null; return null;
} }

View File

@ -1,5 +1,6 @@
 
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
@ -22,6 +23,7 @@ public static class UserServiceCollectionExtensions
public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings) public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings)
{ {
services.AddScoped<IUserService, UserService>(); services.AddScoped<IUserService, UserService>();
services.AddDeviceTrustCommands();
services.AddUserPasswordCommands(); services.AddUserPasswordCommands();
services.AddUserRegistrationCommands(); services.AddUserRegistrationCommands();
services.AddWebAuthnLoginCommands(); services.AddWebAuthnLoginCommands();
@ -29,6 +31,11 @@ public static class UserServiceCollectionExtensions
services.AddTwoFactorQueries(); services.AddTwoFactorQueries();
} }
public static void AddDeviceTrustCommands(this IServiceCollection services)
{
services.AddScoped<IUntrustDevicesCommand, UntrustDevicesCommand>();
}
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
{ {
services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>(); services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>();

View File

@ -25,19 +25,19 @@ public static class BillingExtensions
public static bool IsBillable(this Provider provider) => public static bool IsBillable(this Provider provider) =>
provider is provider is
{ {
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, Type: ProviderType.Msp or ProviderType.BusinessUnit,
Status: ProviderStatusType.Billable Status: ProviderStatusType.Billable
}; };
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
inviteOrganizationProvider is inviteOrganizationProvider is
{ {
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, Type: ProviderType.Msp or ProviderType.BusinessUnit,
Status: ProviderStatusType.Billable Status: ProviderStatusType.Billable
}; };
public static bool SupportsConsolidatedBilling(this ProviderType providerType) public static bool SupportsConsolidatedBilling(this ProviderType providerType)
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; => providerType is ProviderType.Msp or ProviderType.BusinessUnit;
public static bool IsValidClient(this Organization organization) public static bool IsValidClient(this Organization organization)
=> organization is => organization is

View File

@ -0,0 +1,58 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using OneOf;
namespace Bit.Core.Billing.Services;
public interface IBusinessUnitConverter
{
/// <summary>
/// Finalizes the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
/// saving all the necessary key provided by the client and updating the <paramref name="organization"/>'s subscription to a
/// provider subscription.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="userId">The ID of the organization member who will be the provider admin.</param>
/// <param name="token">The token sent to the client as part of the <see cref="InitiateConversion"/> process.</param>
/// <param name="providerKey">The encrypted provider key used to enable the <see cref="ProviderUser"/>.</param>
/// <param name="organizationKey">The encrypted organization key used to enable the <see cref="ProviderOrganization"/>.</param>
/// <returns>The provider ID</returns>
Task<Guid> FinalizeConversion(
Organization organization,
Guid userId,
string token,
string providerKey,
string organizationKey);
/// <summary>
/// Begins the process of converting the <paramref name="organization"/> to a <see cref="ProviderType.BusinessUnit"/> by
/// creating all the necessary database entities and sending a setup invitation to the <paramref name="providerAdminEmail"/>.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
/// <returns>Either the newly created provider ID or a list of validation failures.</returns>
Task<OneOf<Guid, List<string>>> InitiateConversion(
Organization organization,
string providerAdminEmail);
/// <summary>
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resends the
/// setup invitation to the provider admin.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
Task ResendConversionInvite(
Organization organization,
string providerAdminEmail);
/// <summary>
/// Checks if the <paramref name="organization"/> has a business unit conversion in progress and, if it does, resets that conversion
/// by deleting all the database entities created as part of <see cref="InitiateConversion"/>.
/// </summary>
/// <param name="organization">The organization to convert to a business unit.</param>
/// <param name="providerAdminEmail">The email address of the organization member who will be the provider admin.</param>
Task ResetConversion(
Organization organization,
string providerAdminEmail);
}

View File

@ -93,7 +93,9 @@ public class OrganizationBillingService(
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId)
? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions())
: null;
return new OrganizationMetadata( return new OrganizationMetadata(
isEligibleForSelfHost, isEligibleForSelfHost,

View File

@ -622,47 +622,45 @@ public class SubscriberService(
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
} }
if (string.IsNullOrWhiteSpace(taxInformation.TaxId)) if (!string.IsNullOrWhiteSpace(taxInformation.TaxId))
{ {
return; var taxIdType = taxInformation.TaxIdType;
} if (string.IsNullOrWhiteSpace(taxIdType))
var taxIdType = taxInformation.TaxIdType;
if (string.IsNullOrWhiteSpace(taxIdType))
{
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
taxInformation.TaxId);
if (taxIdType == null)
{ {
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
taxInformation.Country,
taxInformation.TaxId); taxInformation.TaxId);
throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
}
}
try if (taxIdType == null)
{ {
await stripeAdapter.TaxIdCreateAsync(customer.Id, logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInformation.TaxId,
taxInformation.Country);
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
default:
logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInformation.TaxId,
taxInformation.Country, taxInformation.Country,
customer.Id); taxInformation.TaxId);
throw new Exceptions.BadRequestException("billingTaxIdCreationError"); throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError");
}
}
try
{
await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInformation.TaxId,
taxInformation.Country);
throw new Exceptions.BadRequestException("billingInvalidTaxIdError");
default:
logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInformation.TaxId,
taxInformation.Country,
customer.Id);
throw new Exceptions.BadRequestException("billingTaxIdCreationError");
}
} }
} }

View File

@ -105,7 +105,6 @@ public static class FeatureFlagKeys
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
@ -115,7 +114,6 @@ public static class FeatureFlagKeys
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
public const string DuoRedirect = "duo-redirect"; public const string DuoRedirect = "duo-redirect";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh";
@ -143,13 +141,13 @@ public static class FeatureFlagKeys
/* Billing Team */ /* Billing Team */
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string TrialPayment = "PM-8163-trial-payment"; public const string TrialPayment = "PM-8163-trial-payment";
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
public const string UsePricingService = "use-pricing-service"; public const string UsePricingService = "use-pricing-service";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; 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 PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
/* Key Management Team */ /* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
@ -158,6 +156,7 @@ public static class FeatureFlagKeys
public const string Argon2Default = "argon2-default"; public const string Argon2Default = "argon2-default";
public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string UserkeyRotationV2 = "userkey-rotation-v2";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
/* Mobile Team */ /* Mobile Team */
public const string NativeCarouselFlow = "native-carousel-flow"; public const string NativeCarouselFlow = "native-carousel-flow";
@ -171,14 +170,16 @@ public static class FeatureFlagKeys
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting";
/* Platform Team */ /* Platform Team */
public const string PersistPopupView = "persist-popup-view"; public const string PersistPopupView = "persist-popup-view";
public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string WebPush = "web-push"; public const string WebPush = "web-push";
public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date";
public const string IpcChannelFramework = "ipc-channel-framework";
/* Tools Team */ /* Tools Team */
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
@ -197,6 +198,8 @@ public static class FeatureFlagKeys
public const string RestrictProviderAccess = "restrict-provider-access"; public const string RestrictProviderAccess = "restrict-provider-access";
public const string SecurityTasks = "security-tasks"; public const string SecurityTasks = "security-tasks";
public const string CipherKeyEncryption = "cipher-key-encryption"; public const string CipherKeyEncryption = "cipher-key-encryption";
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -4,9 +4,13 @@
// EncryptedStringAttribute // EncryptedStringAttribute
public enum EncryptionType : byte public enum EncryptionType : byte
{ {
// symmetric
AesCbc256_B64 = 0, AesCbc256_B64 = 0,
AesCbc128_HmacSha256_B64 = 1, AesCbc128_HmacSha256_B64 = 1,
AesCbc256_HmacSha256_B64 = 2, AesCbc256_HmacSha256_B64 = 2,
XChaCha20Poly1305_B64 = 7,
// asymmetric
Rsa2048_OaepSha256_B64 = 3, Rsa2048_OaepSha256_B64 = 3,
Rsa2048_OaepSha1_B64 = 4, Rsa2048_OaepSha1_B64 = 4,
Rsa2048_OaepSha256_HmacSha256_B64 = 5, Rsa2048_OaepSha256_HmacSha256_B64 = 5,

View File

@ -0,0 +1,19 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
You have been invited to set up a new Business Unit Portal within Bitwarden.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Set Up Business Unit Portal Now
</a>
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link:
{{{Url}}}
{{/BasicTextLayout}}

View File

@ -2,4 +2,4 @@
namespace Bit.Core.Models.Data.Organizations; namespace Bit.Core.Models.Data.Organizations;
public record ManagedUserDomainClaimedEmails(IEnumerable<string> EmailList, Organization Organization); public record ClaimedUserDomainClaimedEmails(IEnumerable<string> EmailList, Organization Organization);

View File

@ -0,0 +1,11 @@
namespace Bit.Core.Models.Mail.Billing;
public class BusinessUnitConversionInviteModel : BaseMailModel
{
public string OrganizationId { get; set; }
public string Email { get; set; }
public string Token { get; set; }
public string Url =>
$"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}";
}

View File

@ -32,20 +32,22 @@ public class NotificationHubPushNotificationService : IPushNotificationService
private readonly INotificationHubPool _notificationHubPool; private readonly INotificationHubPool _notificationHubPool;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public NotificationHubPushNotificationService( public NotificationHubPushNotificationService(
IInstallationDeviceRepository installationDeviceRepository, IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool, INotificationHubPool notificationHubPool,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationHubPushNotificationService> logger, ILogger<NotificationHubPushNotificationService> logger,
IGlobalSettings globalSettings) IGlobalSettings globalSettings,
TimeProvider timeProvider)
{ {
_installationDeviceRepository = installationDeviceRepository; _installationDeviceRepository = installationDeviceRepository;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_notificationHubPool = notificationHubPool; _notificationHubPool = notificationHubPool;
_logger = logger; _logger = logger;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
@ -152,7 +154,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
} }

View File

@ -60,6 +60,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationDomainCommandsQueries();
services.AddOrganizationSignUpCommands(); services.AddOrganizationSignUpCommands();
services.AddOrganizationDeleteCommands(); services.AddOrganizationDeleteCommands();
services.AddOrganizationUpdateCommands();
services.AddOrganizationEnableCommands(); services.AddOrganizationEnableCommands();
services.AddOrganizationDisableCommands(); services.AddOrganizationDisableCommands();
services.AddOrganizationAuthCommands(); services.AddOrganizationAuthCommands();
@ -77,6 +78,11 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IOrganizationInitiateDeleteCommand, OrganizationInitiateDeleteCommand>(); services.AddScoped<IOrganizationInitiateDeleteCommand, OrganizationInitiateDeleteCommand>();
} }
private static void AddOrganizationUpdateCommands(this IServiceCollection services)
{
services.AddScoped<IOrganizationUpdateKeysCommand, OrganizationUpdateKeysCommand>();
}
private static void AddOrganizationEnableCommands(this IServiceCollection services) => private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
services.AddScoped<IOrganizationEnableCommand, OrganizationEnableCommand>(); services.AddScoped<IOrganizationEnableCommand, OrganizationEnableCommand>();
@ -121,7 +127,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>(); services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>(); services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>(); services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IDeleteManagedOrganizationUserAccountCommand, DeleteManagedOrganizationUserAccountCommand>(); services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteManagedOrganizationUserAccountValidator, DeleteManagedOrganizationUserAccountValidator>(); services.AddScoped<IDeleteManagedOrganizationUserAccountValidator, DeleteManagedOrganizationUserAccountValidator>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>(); services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
} }
@ -173,7 +179,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>(); services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>(); services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>(); services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
services.AddScoped<IGetOrganizationUsersManagementStatusQuery, GetOrganizationUsersManagementStatusQuery>(); services.AddScoped<IGetOrganizationUsersClaimedStatusQuery, GetOrganizationUsersClaimedStatusQuery>();
services.AddScoped<IRestoreOrganizationUserCommand, RestoreOrganizationUserCommand>(); services.AddScoped<IRestoreOrganizationUserCommand, RestoreOrganizationUserCommand>();

View File

@ -22,17 +22,19 @@ public class AzureQueuePushNotificationService : IPushNotificationService
private readonly QueueClient _queueClient; private readonly QueueClient _queueClient;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public AzureQueuePushNotificationService( public AzureQueuePushNotificationService(
[FromKeyedServices("notifications")] QueueClient queueClient, [FromKeyedServices("notifications")] QueueClient queueClient,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<AzureQueuePushNotificationService> logger) ILogger<AzureQueuePushNotificationService> logger,
TimeProvider timeProvider)
{ {
_queueClient = queueClient; _queueClient = queueClient;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
@ -140,7 +142,7 @@ public class AzureQueuePushNotificationService : IPushNotificationService
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendMessageAsync(type, message, excludeCurrentContext); await SendMessageAsync(type, message, excludeCurrentContext);
} }

View File

@ -24,12 +24,14 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
{ {
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public NotificationsApiPushNotificationService( public NotificationsApiPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationsApiPushNotificationService> logger) ILogger<NotificationsApiPushNotificationService> logger,
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.BaseServiceUri.InternalNotifications, globalSettings.BaseServiceUri.InternalNotifications,
@ -41,6 +43,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
{ {
_globalSettings = globalSettings; _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
@ -148,7 +151,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
var message = new UserPushNotification var message = new UserPushNotification
{ {
UserId = userId, UserId = userId,
Date = DateTime.UtcNow Date = _timeProvider.GetUtcNow().UtcDateTime,
}; };
await SendMessageAsync(type, message, excludeCurrentContext); await SendMessageAsync(type, message, excludeCurrentContext);

View File

@ -27,13 +27,15 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public RelayPushNotificationService( public RelayPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<RelayPushNotificationService> logger) ILogger<RelayPushNotificationService> logger,
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.PushRelayBaseUri, globalSettings.PushRelayBaseUri,
@ -46,6 +48,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
@ -147,7 +150,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
{ {
var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
} }

View File

@ -0,0 +1,96 @@
#nullable enable
using System.Security.Cryptography.X509Certificates;
using Bit.Core.Settings;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Bit.Core.Platform.X509ChainCustomization;
internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions<X509ChainOptions>
{
const string CertificateSearchPattern = "*.crt";
private readonly ILogger<PostConfigureX509ChainOptions> _logger;
private readonly IHostEnvironment _hostEnvironment;
private readonly GlobalSettings _globalSettings;
public PostConfigureX509ChainOptions(
ILogger<PostConfigureX509ChainOptions> logger,
IHostEnvironment hostEnvironment,
GlobalSettings globalSettings)
{
_logger = logger;
_hostEnvironment = hostEnvironment;
_globalSettings = globalSettings;
}
public void PostConfigure(string? name, X509ChainOptions options)
{
// We don't register or request a named instance of these options,
// so don't customize it.
if (name != Options.DefaultName)
{
return;
}
// We only allow this setting to be configured on self host.
if (!_globalSettings.SelfHosted)
{
options.AdditionalCustomTrustCertificatesDirectory = null;
return;
}
if (options.AdditionalCustomTrustCertificates != null)
{
// Additional certificates were added directly, this overwrites the need to
// read them from the directory.
_logger.LogInformation(
"Additional custom trust certificates were added directly, skipping loading them from '{Directory}'",
options.AdditionalCustomTrustCertificatesDirectory
);
return;
}
if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory))
{
return;
}
if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory))
{
// The default directory is volume mounted via the default Bitwarden setup process.
// If the directory doesn't exist it could indicate a error in configuration but this
// directory is never expected in a normal development environment so lower the log
// level in that case.
var logLevel = _hostEnvironment.IsDevelopment()
? LogLevel.Debug
: LogLevel.Warning;
_logger.Log(
logLevel,
"An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.",
options.AdditionalCustomTrustCertificatesDirectory
);
return;
}
var certificates = new List<X509Certificate2>();
foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern))
{
certificates.Add(new X509Certificate2(certFile));
}
if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0)
{
// They have intentionally given us a non-default directory but there weren't certificates, that is odd.
_logger.LogWarning(
"No additional custom trust certificates were found in '{Directory}'",
options.AdditionalCustomTrustCertificatesDirectory
);
}
options.AdditionalCustomTrustCertificates = certificates;
}
}

View File

@ -0,0 +1,53 @@
using Bit.Core.Platform.X509ChainCustomization;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an <see cref="IServiceCollection"/>.
/// </summary>
public static class X509ChainCustomizationServiceCollectionExtensions
{
/// <summary>
/// Configures X509ChainPolicy customization through the root level <c>X509ChainOptions</c> configuration section
/// and configures the primary <see cref="HttpMessageHandler"/> to use custom certificate validation
/// when customized to do so.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<X509ChainOptions>()
.BindConfiguration(nameof(X509ChainOptions));
// Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple
// times even if this method is called multiple times.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<X509ChainOptions>, PostConfigureX509ChainOptions>());
services.AddHttpClient()
.ConfigureHttpClientDefaults(builder =>
{
builder.ConfigurePrimaryHttpMessageHandler(sp =>
{
var x509ChainOptions = sp.GetRequiredService<IOptions<X509ChainOptions>>().Value;
var handler = new HttpClientHandler();
if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback))
{
handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) =>
{
return callback(certificate, chain, errors);
};
}
return handler;
});
});
return services;
}
}

View File

@ -0,0 +1,81 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace Bit.Core.Platform.X509ChainCustomization;
/// <summary>
/// Allows for customization of the <see cref="X509ChainPolicy"/> and access to a custom server certificate validator
/// if customization has been made.
/// </summary>
public sealed class X509ChainOptions
{
// This is the directory that we historically used to allow certificates be added inside our container
// and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call
// `update-ca-certificates` but since that operation requires root we can't do it in a rootless container.
// Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41
public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/";
/// <summary>
/// A directory where additional certificates should be read from and included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
/// </summary>
/// <remarks>
/// Only certificates suffixed with <c>*.crt</c> will be read. If <see cref="AdditionalCustomTrustCertificates"/> is
/// set, then this directory will not be read from.
/// </remarks>
public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory;
/// <summary>
/// A list of additional certificates that should be included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
/// </summary>
/// <remarks>
/// If this value is set manually, then <see cref="AdditionalCustomTrustCertificatesDirectory"/> will be ignored.
/// </remarks>
public List<X509Certificate2>? AdditionalCustomTrustCertificates { get; set; }
/// <summary>
/// Attempts to retrieve a custom remote certificate validation callback.
/// </summary>
/// <param name="callback"></param>
/// <returns>Returns <see langword="true"/> when we have custom remote certification that should be added,
/// <see langword="false"/> when no custom validation is needed and the default validation callback should
/// be used instead.
/// </returns>
[MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))]
public bool TryGetCustomRemoteCertificateValidationCallback(
[MaybeNullWhen(false)] out Func<X509Certificate2?, X509Chain?, SslPolicyErrors, bool> callback)
{
callback = null;
if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0)
{
return false;
}
// Do this outside of the callback so that we aren't opening the root store every request.
using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly);
var rootCertificates = store.Certificates;
// Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581
callback = (certificate, chain, errors) =>
{
if (chain == null || certificate == null)
{
return false;
}
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
// We want our additional certificates to be in addition to the machines root store.
chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates);
foreach (var additionalCertificate in AdditionalCustomTrustCertificates)
{
chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate);
}
return chain.Build(certificate);
};
return true;
}
}

View File

@ -21,7 +21,7 @@ public interface IMailService
ProductTierType productTier, ProductTierType productTier,
IEnumerable<ProductType> products); IEnumerable<ProductType> products);
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
Task SendCannotDeleteManagedAccountEmailAsync(string email); Task SendCannotDeleteClaimedAccountEmailAsync(string email);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
@ -70,6 +70,7 @@ public interface IMailService
Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage);
Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName); Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName);
Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email);
Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email);
Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email);
Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email);
Task SendProviderUserRemoved(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email);
@ -97,7 +98,7 @@ public interface IMailService
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent); Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
string organizationName); string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails); Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails);
} }

View File

@ -134,7 +134,7 @@ public interface IUserService
/// <returns> /// <returns>
/// False if the Account Deprovisioning feature flag is disabled. /// False if the Account Deprovisioning feature flag is disabled.
/// </returns> /// </returns>
Task<bool> IsManagedByAnyOrganizationAsync(Guid userId); Task<bool> IsClaimedByAnyOrganizationAsync(Guid userId);
/// <summary> /// <summary>
/// Verify whether the new email domain meets the requirements for managed users. /// Verify whether the new email domain meets the requirements for managed users.
@ -142,9 +142,9 @@ public interface IUserService
/// <remarks> /// <remarks>
/// </remarks> /// </remarks>
/// <returns> /// <returns>
/// IdentityResult /// IdentityResult
/// </returns> /// </returns>
Task<IdentityResult> ValidateManagedUserDomainAsync(User user, string newEmail); Task<IdentityResult> ValidateClaimedUserDomainAsync(User user, string newEmail);
/// <summary> /// <summary>
/// Gets the organizations that manage the user. /// Gets the organizations that manage the user.
@ -152,6 +152,6 @@ public interface IUserService
/// <returns> /// <returns>
/// An empty collection if the Account Deprovisioning feature flag is disabled. /// An empty collection if the Account Deprovisioning feature flag is disabled.
/// </returns> /// </returns>
/// <inheritdoc cref="IsManagedByAnyOrganizationAsync(Guid)"/> /// <inheritdoc cref="IsClaimedByAnyOrganizationAsync"/>
Task<IEnumerable<Organization>> GetOrganizationsManagingUserAsync(Guid userId); Task<IEnumerable<Organization>> GetOrganizationsClaimingUserAsync(Guid userId);
} }

View File

@ -11,6 +11,7 @@ using Bit.Core.Billing.Models.Mail;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
using Bit.Core.Models.Mail.Billing;
using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.FamiliesForEnterprise;
using Bit.Core.Models.Mail.Provider; using Bit.Core.Models.Mail.Provider;
using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.SecretsManager.Models.Mail;
@ -117,16 +118,16 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendCannotDeleteManagedAccountEmailAsync(string email) public async Task SendCannotDeleteClaimedAccountEmailAsync(string email)
{ {
var message = CreateDefaultMessage("Delete Your Account", email); var message = CreateDefaultMessage("Delete Your Account", email);
var model = new CannotDeleteManagedAccountViewModel var model = new CannotDeleteClaimedAccountViewModel
{ {
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName, SiteName = _globalSettings.SiteName,
}; };
await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model); await AddMessageContentAsync(message, "AdminConsole.CannotDeleteClaimedAccount", model);
message.Category = "CannotDeleteManagedAccount"; message.Category = "CannotDeleteClaimedAccount";
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
@ -474,7 +475,7 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) public async Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList)
{ {
await EnqueueMailAsync(emailList.EmailList.Select(email => await EnqueueMailAsync(emailList.EmailList.Select(email =>
CreateMessage(email, emailList.Organization))); CreateMessage(email, emailList.Organization)));
@ -804,12 +805,10 @@ public class HandlebarsMailService : IMailService
return; return;
} }
var numeric = parameters[0]; if (int.TryParse(parameters[0].ToString(), out var number))
var singularText = parameters[1].ToString();
var pluralText = parameters[2].ToString();
if (numeric is int number)
{ {
var singularText = parameters[1].ToString();
var pluralText = parameters[2].ToString();
writer.WriteSafeString(number == 1 ? singularText : pluralText); writer.WriteSafeString(number == 1 ? singularText : pluralText);
} }
else else
@ -951,6 +950,22 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email)
{
var message = CreateDefaultMessage("Set Up Business Unit", email);
var model = new BusinessUnitConversionInviteModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
OrganizationId = organization.Id.ToString(),
Email = WebUtility.UrlEncode(email),
Token = WebUtility.UrlEncode(token)
};
await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model);
message.Category = "BusinessUnitConversionInvite";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
{ {
var message = CreateDefaultMessage($"Join {providerName}", email); var message = CreateDefaultMessage($"Join {providerName}", email);

View File

@ -1,7 +1,10 @@
using Bit.Core.Settings; using System.Security.Cryptography.X509Certificates;
using Bit.Core.Platform.X509ChainCustomization;
using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit; using MimeKit;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -10,12 +13,14 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
{ {
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ILogger<MailKitSmtpMailDeliveryService> _logger; private readonly ILogger<MailKitSmtpMailDeliveryService> _logger;
private readonly X509ChainOptions _x509ChainOptions;
private readonly string _replyDomain; private readonly string _replyDomain;
private readonly string _replyEmail; private readonly string _replyEmail;
public MailKitSmtpMailDeliveryService( public MailKitSmtpMailDeliveryService(
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<MailKitSmtpMailDeliveryService> logger) ILogger<MailKitSmtpMailDeliveryService> logger,
IOptions<X509ChainOptions> x509ChainOptions)
{ {
if (globalSettings.Mail?.Smtp?.Host == null) if (globalSettings.Mail?.Smtp?.Host == null)
{ {
@ -31,6 +36,7 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
_globalSettings = globalSettings; _globalSettings = globalSettings;
_logger = logger; _logger = logger;
_x509ChainOptions = x509ChainOptions.Value;
} }
public async Task SendEmailAsync(Models.Mail.MailMessage message) public async Task SendEmailAsync(Models.Mail.MailMessage message)
@ -75,6 +81,13 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService
{ {
client.ServerCertificateValidationCallback = (s, c, h, e) => true; client.ServerCertificateValidationCallback = (s, c, h, e) => true;
} }
else if (_x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback))
{
client.ServerCertificateValidationCallback = (sender, cert, chain, errors) =>
{
return callback(new X509Certificate2(cert), chain, errors);
};
}
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl && if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
_globalSettings.Mail.Smtp.Port == 25) _globalSettings.Mail.Smtp.Port == 25)

View File

@ -314,9 +314,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return; return;
} }
if (await IsManagedByAnyOrganizationAsync(user.Id)) if (await IsClaimedByAnyOrganizationAsync(user.Id))
{ {
await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email); await _mailService.SendCannotDeleteClaimedAccountEmailAsync(user.Email);
return; return;
} }
@ -545,11 +545,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
} }
var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail); var claimedUserValidationResult = await ValidateClaimedUserDomainAsync(user, newEmail);
if (!managedUserValidationResult.Succeeded) if (!claimedUserValidationResult.Succeeded)
{ {
return managedUserValidationResult; return claimedUserValidationResult;
} }
if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider, if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider,
@ -617,18 +617,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IdentityResult.Success; return IdentityResult.Success;
} }
public async Task<IdentityResult> ValidateManagedUserDomainAsync(User user, string newEmail) public async Task<IdentityResult> ValidateClaimedUserDomainAsync(User user, string newEmail)
{ {
var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id); var claimingOrganization = await GetOrganizationsClaimingUserAsync(user.Id);
if (!managingOrganizations.Any()) if (!claimingOrganization.Any())
{ {
return IdentityResult.Success; return IdentityResult.Success;
} }
var newDomain = CoreHelpers.GetEmailDomain(newEmail); var newDomain = CoreHelpers.GetEmailDomain(newEmail);
var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(managingOrganizations.Select(org => org.Id)); var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(claimingOrganization.Select(org => org.Id));
if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain)) if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
{ {
@ -1366,13 +1366,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return IsLegacyUser(user); return IsLegacyUser(user);
} }
public async Task<bool> IsManagedByAnyOrganizationAsync(Guid userId) public async Task<bool> IsClaimedByAnyOrganizationAsync(Guid userId)
{ {
var managingOrganizations = await GetOrganizationsManagingUserAsync(userId); var organizationsClaimingUser = await GetOrganizationsClaimingUserAsync(userId);
return managingOrganizations.Any(); return organizationsClaimingUser.Any();
} }
public async Task<IEnumerable<Organization>> GetOrganizationsManagingUserAsync(Guid userId) public async Task<IEnumerable<Organization>> GetOrganizationsClaimingUserAsync(Guid userId)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{ {

View File

@ -103,7 +103,7 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendCannotDeleteManagedAccountEmailAsync(string email) public Task SendCannotDeleteClaimedAccountEmailAsync(string email)
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }
@ -212,6 +212,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email)
{
return Task.FromResult(0);
}
public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
{ {
return Task.FromResult(0); return Task.FromResult(0);
@ -317,7 +322,7 @@ public class NoopMailService : IMailService
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask;
public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName) public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName)
{ {

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities; using System.Collections.Concurrent;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -59,22 +60,21 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId); var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
var memberAccessCipherDetails = GenerateAccessData( var memberAccessCipherDetails = GenerateAccessDataParallel(
orgGroups, orgGroups,
orgCollectionsWithAccess, orgCollectionsWithAccess,
orgItems, orgItems,
organizationUsersTwoFactorEnabled, organizationUsersTwoFactorEnabled,
orgAbility orgAbility);
);
return memberAccessCipherDetails; return memberAccessCipherDetails;
} }
/// <summary> /// <summary>
/// Generates a report for all members of an organization. Containing summary information /// Generates a report for all members of an organization. Containing summary information
/// such as item, collection, and group counts. Including the cipherIds a member is assigned. /// such as item, collection, and group counts. Including the cipherIds a member is assigned.
/// Child collection includes detailed information on the user and group collections along /// Child collection includes detailed information on the user and group collections along
/// with their permissions. /// with their permissions.
/// </summary> /// </summary>
/// <param name="orgGroups">Organization groups collection</param> /// <param name="orgGroups">Organization groups collection</param>
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param> /// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
@ -82,72 +82,72 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param> /// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
/// <param name="orgAbility">Organization ability for account recovery status</param> /// <param name="orgAbility">Organization ability for account recovery status</param>
/// <returns>List of the MemberAccessCipherDetailsModel</returns>; /// <returns>List of the MemberAccessCipherDetailsModel</returns>;
private IEnumerable<MemberAccessCipherDetails> GenerateAccessData( private IEnumerable<MemberAccessCipherDetails> GenerateAccessDataParallel(
ICollection<Group> orgGroups, ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess, ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems, IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
OrganizationAbility orgAbility) OrganizationAbility orgAbility)
{ {
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList();
// Create a dictionary to lookup the group names later.
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
// Get collections grouped and into a dictionary for counts
var collectionItems = orgItems var collectionItems = orgItems
.SelectMany(x => x.CollectionIds, .SelectMany(x => x.CollectionIds,
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId }) (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
.GroupBy(y => y.CollectionId, .GroupBy(y => y.CollectionId,
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers }); (key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString())); var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList());
// Loop through the org users and populate report and access data var memberAccessCipherDetails = new ConcurrentBag<MemberAccessCipherDetails>();
var memberAccessCipherDetails = new List<MemberAccessCipherDetails>();
foreach (var user in orgUsers) Parallel.ForEach(orgUsers, user =>
{ {
var groupAccessDetails = new List<MemberAccessDetails>(); var groupAccessDetails = new List<MemberAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessDetails>(); var userCollectionAccessDetails = new List<MemberAccessDetails>();
foreach (var tCollect in orgCollectionsWithAccess) foreach (var tCollect in orgCollectionsWithAccess)
{ {
var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items); if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items))
var collectionCiphers = hasItems ? items.Select(x => x) : null;
var itemCounts = hasItems ? collectionCiphers.Count() : 0;
if (tCollect.Item2.Groups.Count() > 0)
{ {
var itemCounts = items.Count;
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x => if (tCollect.Item2.Groups.Any())
new MemberAccessDetails {
{ var groupDetails = tCollect.Item2.Groups
CollectionId = tCollect.Item1.Id, .Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id))
CollectionName = tCollect.Item1.Name, .Select(x => new MemberAccessDetails
GroupId = x.Id, {
GroupName = groupNameDictionary[x.Id], CollectionId = tCollect.Item1.Id,
ReadOnly = x.ReadOnly, CollectionName = tCollect.Item1.Name,
HidePasswords = x.HidePasswords, GroupId = x.Id,
Manage = x.Manage, GroupName = groupNameDictionary[x.Id],
ItemCount = itemCounts, ReadOnly = x.ReadOnly,
CollectionCipherIds = items HidePasswords = x.HidePasswords,
}); Manage = x.Manage,
ItemCount = itemCounts,
CollectionCipherIds = items
});
groupAccessDetails.AddRange(groupDetails); groupAccessDetails.AddRange(groupDetails);
} }
// All collections assigned to users and their permissions if (tCollect.Item2.Users.Any())
if (tCollect.Item2.Users.Count() > 0) {
{ var userCollectionDetails = tCollect.Item2.Users
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x => .Where(tCollectUser => tCollectUser.Id == user.Id)
new MemberAccessDetails .Select(x => new MemberAccessDetails
{ {
CollectionId = tCollect.Item1.Id, CollectionId = tCollect.Item1.Id,
CollectionName = tCollect.Item1.Name, CollectionName = tCollect.Item1.Name,
ReadOnly = x.ReadOnly, ReadOnly = x.ReadOnly,
HidePasswords = x.HidePasswords, HidePasswords = x.HidePasswords,
Manage = x.Manage, Manage = x.Manage,
ItemCount = itemCounts, ItemCount = itemCounts,
CollectionCipherIds = items CollectionCipherIds = items
}); });
userCollectionAccessDetails.AddRange(userCollectionDetails);
userCollectionAccessDetails.AddRange(userCollectionDetails);
}
} }
} }
@ -156,7 +156,6 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
UserName = user.Name, UserName = user.Name,
Email = user.Email, Email = user.Email,
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled, TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword, AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
UserGuid = user.Id, UserGuid = user.Id,
UsesKeyConnector = user.UsesKeyConnector UsesKeyConnector = user.UsesKeyConnector
@ -169,9 +168,8 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
userAccessDetails.AddRange(userGroups); userAccessDetails.AddRange(userGroups);
} }
// There can be edge cases where groups don't have a collection
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
if (groupsWithoutCollections.Count() > 0) if (groupsWithoutCollections.Any())
{ {
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
{ {
@ -189,20 +187,20 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
} }
report.AccessDetails = userAccessDetails; report.AccessDetails = userAccessDetails;
var userCiphers = var userCiphers = report.AccessDetails
report.AccessDetails .Where(x => x.ItemCount > 0)
.Where(x => x.ItemCount > 0) .SelectMany(y => y.CollectionCipherIds)
.SelectMany(y => y.CollectionCipherIds) .Distinct();
.Distinct();
report.CipherIds = userCiphers; report.CipherIds = userCiphers;
report.TotalItemCount = userCiphers.Count(); report.TotalItemCount = userCiphers.Count();
// Distinct items only
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
report.CollectionsCount = distinctItems.Count(); report.CollectionsCount = distinctItems.Count();
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
memberAccessCipherDetails.Add(report); memberAccessCipherDetails.Add(report);
} });
return memberAccessCipherDetails; return memberAccessCipherDetails;
} }
} }

View File

@ -16,6 +16,7 @@ public class EncryptedStringAttribute : ValidationAttribute
[EncryptionType.AesCbc256_B64] = 2, // iv|ct [EncryptionType.AesCbc256_B64] = 2, // iv|ct
[EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac [EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac
[EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac [EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac
[EncryptionType.XChaCha20Poly1305_B64] = 1, // cose bytes
[EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt
[EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt
[EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac [EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac

View File

@ -121,8 +121,7 @@ public class AccountsController : Controller
var user = model.ToUser(); var user = model.ToUser();
var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
model.Token, model.OrganizationUserId); model.Token, model.OrganizationUserId);
// delaysEnabled false is only for the new registration with email verification process return ProcessRegistrationResult(identityResult, user);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true);
} }
[HttpPost("register/send-verification-email")] [HttpPost("register/send-verification-email")]
@ -188,7 +187,6 @@ public class AccountsController : Controller
// Users will either have an emailed token or an email verification token - not both. // Users will either have an emailed token or an email verification token - not both.
IdentityResult identityResult = null; IdentityResult identityResult = null;
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
switch (model.GetTokenType()) switch (model.GetTokenType())
{ {
@ -197,32 +195,32 @@ public class AccountsController : Controller
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
model.EmailVerificationToken); model.EmailVerificationToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return ProcessRegistrationResult(identityResult, user);
break; break;
case RegisterFinishTokenType.OrganizationInvite: case RegisterFinishTokenType.OrganizationInvite:
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
model.OrgInviteToken, model.OrganizationUserId); model.OrgInviteToken, model.OrganizationUserId);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return ProcessRegistrationResult(identityResult, user);
break; break;
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return ProcessRegistrationResult(identityResult, user);
break; break;
case RegisterFinishTokenType.EmergencyAccessInvite: case RegisterFinishTokenType.EmergencyAccessInvite:
Debug.Assert(model.AcceptEmergencyAccessId.HasValue); Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return ProcessRegistrationResult(identityResult, user);
break; break;
case RegisterFinishTokenType.ProviderInvite: case RegisterFinishTokenType.ProviderInvite:
Debug.Assert(model.ProviderUserId.HasValue); Debug.Assert(model.ProviderUserId.HasValue);
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
model.ProviderInviteToken, model.ProviderUserId.Value); model.ProviderInviteToken, model.ProviderUserId.Value);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return ProcessRegistrationResult(identityResult, user);
break; break;
default: default:
@ -230,7 +228,7 @@ public class AccountsController : Controller
} }
} }
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) private RegisterResponseModel ProcessRegistrationResult(IdentityResult result, User user)
{ {
if (result.Succeeded) if (result.Succeeded)
{ {
@ -243,10 +241,6 @@ public class AccountsController : Controller
ModelState.AddModelError(string.Empty, error.Description); ModelState.AddModelError(string.Empty, error.Description);
} }
if (delaysEnabled)
{
await Task.Delay(Random.Shared.Next(100, 130));
}
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }

View File

@ -711,7 +711,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
row[creationDateColumn] = cipher.CreationDate; row[creationDateColumn] = cipher.CreationDate;
row[revisionDateColumn] = cipher.RevisionDate; row[revisionDateColumn] = cipher.RevisionDate;
row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value;
row[repromptColumn] = cipher.Reprompt; row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value;
row[keyColummn] = cipher.Key; row[keyColummn] = cipher.Key;
ciphersTable.Rows.Add(row); ciphersTable.Rows.Add(row);

View File

@ -332,7 +332,7 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
var planTypes = providerType switch var planTypes = providerType switch
{ {
ProviderType.Msp => PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes), ProviderType.Msp => PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes),
ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes, ProviderType.BusinessUnit => PlanConstants.EnterprisePlanTypes,
_ => [] _ => []
}; };

View File

@ -35,6 +35,7 @@ public class ProviderUserProviderDetailsReadByUserIdStatusQuery : IQuery<Provide
Permissions = x.pu.Permissions, Permissions = x.pu.Permissions,
UseEvents = x.p.UseEvents, UseEvents = x.p.UseEvents,
ProviderStatus = x.p.Status, ProviderStatus = x.p.Status,
ProviderType = x.p.Type
}); });
} }
} }

View File

@ -863,8 +863,30 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())
{ {
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var entities = Mapper.Map<List<Cipher>>(ciphers); var ciphersToUpdate = ciphers.ToDictionary(c => c.Id);
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, entities);
var existingCiphers = await dbContext.Ciphers
.Where(c => c.UserId == userId && ciphersToUpdate.Keys.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
foreach (var (cipherId, cipher) in ciphersToUpdate)
{
if (!existingCiphers.TryGetValue(cipherId, out var existingCipher))
{
// The Dapper version does not validate that the same amount of items given where updated.
continue;
}
existingCipher.UserId = cipher.UserId;
existingCipher.OrganizationId = cipher.OrganizationId;
existingCipher.Type = cipher.Type;
existingCipher.Data = cipher.Data;
existingCipher.Attachments = cipher.Attachments;
existingCipher.RevisionDate = cipher.RevisionDate;
existingCipher.DeletedDate = cipher.DeletedDate;
existingCipher.Key = cipher.Key;
}
await dbContext.UserBumpAccountRevisionDateAsync(userId); await dbContext.UserBumpAccountRevisionDateAsync(userId);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
} }

View File

@ -67,6 +67,7 @@ using Microsoft.Extensions.Caching.Cosmos;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -279,6 +280,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IMailDeliveryService, NoopMailDeliveryService>(); services.AddSingleton<IMailDeliveryService, NoopMailDeliveryService>();
} }
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IPushNotificationService, MultiServicePushNotificationService>(); services.AddSingleton<IPushNotificationService, MultiServicePushNotificationService>();
if (globalSettings.SelfHosted) if (globalSettings.SelfHosted)
{ {

View File

@ -75,25 +75,25 @@ public class ProvidersControllerTests
} }
#endregion #endregion
#region CreateMultiOrganizationEnterpriseAsync #region CreateBusinessUnitAsync
[BitAutoData] [BitAutoData]
[SutProviderCustomize] [SutProviderCustomize]
[Theory] [Theory]
public async Task CreateMultiOrganizationEnterpriseAsync_WithValidModel_CreatesProvider( public async Task CreateBusinessUnitAsync_WithValidModel_CreatesProvider(
CreateMultiOrganizationEnterpriseProviderModel model, CreateBusinessUnitProviderModel model,
SutProvider<ProvidersController> sutProvider) SutProvider<ProvidersController> sutProvider)
{ {
// Arrange // Arrange
// Act // Act
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); var actual = await sutProvider.Sut.CreateBusinessUnit(model);
// Assert // Assert
Assert.NotNull(actual); Assert.NotNull(actual);
await sutProvider.GetDependency<ICreateProviderCommand>() await sutProvider.GetDependency<ICreateProviderCommand>()
.Received(Quantity.Exactly(1)) .Received(Quantity.Exactly(1))
.CreateMultiOrganizationEnterpriseAsync( .CreateBusinessUnitAsync(
Arg.Is<Provider>(x => x.Type == ProviderType.MultiOrganizationEnterprise), Arg.Is<Provider>(x => x.Type == ProviderType.BusinessUnit),
model.OwnerEmail, model.OwnerEmail,
Arg.Is<PlanType>(y => y == model.Plan), Arg.Is<PlanType>(y => y == model.Plan),
model.EnterpriseSeatMinimum); model.EnterpriseSeatMinimum);
@ -102,16 +102,16 @@ public class ProvidersControllerTests
[BitAutoData] [BitAutoData]
[SutProviderCustomize] [SutProviderCustomize]
[Theory] [Theory]
public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider( public async Task CreateBusinessUnitAsync_RedirectsToExpectedPage_AfterCreatingProvider(
CreateMultiOrganizationEnterpriseProviderModel model, CreateBusinessUnitProviderModel model,
Guid expectedProviderId, Guid expectedProviderId,
SutProvider<ProvidersController> sutProvider) SutProvider<ProvidersController> sutProvider)
{ {
// Arrange // Arrange
sutProvider.GetDependency<ICreateProviderCommand>() sutProvider.GetDependency<ICreateProviderCommand>()
.When(x => .When(x =>
x.CreateMultiOrganizationEnterpriseAsync( x.CreateBusinessUnitAsync(
Arg.Is<Provider>(y => y.Type == ProviderType.MultiOrganizationEnterprise), Arg.Is<Provider>(y => y.Type == ProviderType.BusinessUnit),
model.OwnerEmail, model.OwnerEmail,
Arg.Is<PlanType>(y => y == model.Plan), Arg.Is<PlanType>(y => y == model.Plan),
model.EnterpriseSeatMinimum)) model.EnterpriseSeatMinimum))
@ -122,7 +122,7 @@ public class ProvidersControllerTests
}); });
// Act // Act
var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); var actual = await sutProvider.Sut.CreateBusinessUnit(model);
// Assert // Assert
Assert.NotNull(actual); Assert.NotNull(actual);

View File

@ -260,14 +260,15 @@ public class OrganizationUsersControllerTests
.GetDetailsByIdWithCollectionsAsync(organizationUser.Id) .GetDetailsByIdWithCollectionsAsync(organizationUser.Id)
.Returns((organizationUser, collections)); .Returns((organizationUser, collections));
sutProvider.GetDependency<IGetOrganizationUsersManagementStatusQuery>() sutProvider.GetDependency<IGetOrganizationUsersClaimedStatusQuery>()
.GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id))) .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id)))
.Returns(new Dictionary<Guid, bool> { { organizationUser.Id, true } }); .Returns(new Dictionary<Guid, bool> { { organizationUser.Id, true } });
var response = await sutProvider.Sut.Get(organizationUser.Id, false); var response = await sutProvider.Sut.Get(organizationUser.Id, false);
Assert.Equal(organizationUser.Id, response.Id); Assert.Equal(organizationUser.Id, response.Id);
Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization); Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization);
Assert.Equal(accountDeprovisioningEnabled, response.ClaimedByOrganization);
} }
[Theory] [Theory]
@ -331,7 +332,7 @@ public class OrganizationUsersControllerTests
await sutProvider.Sut.DeleteAccount(orgId, id); await sutProvider.Sut.DeleteAccount(orgId, id);
await sutProvider.GetDependency<IDeleteManagedOrganizationUserAccountCommand>() await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountCommand>()
.Received(1) .Received(1)
.DeleteUserAsync(orgId, id, currentUser.Id); .DeleteUserAsync(orgId, id, currentUser.Id);
} }
@ -359,6 +360,7 @@ public class OrganizationUsersControllerTests
sutProvider.Sut.DeleteAccount(orgId, id)); sutProvider.Sut.DeleteAccount(orgId, id));
} }
<<<<<<< HEAD
// [Theory] // [Theory]
// [BitAutoData] // [BitAutoData]
// public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success( // public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success(
@ -379,6 +381,28 @@ public class OrganizationUsersControllerTests
// .Received(1) // .Received(1)
// .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); // .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
// } // }
=======
[Theory]
[BitAutoData]
public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success(
Guid orgId, OrganizationUserBulkRequestModel model, User currentUser,
List<(Guid, string)> deleteResults, SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountCommand>()
.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id)
.Returns(deleteResults);
var response = await sutProvider.Sut.BulkDeleteAccount(orgId, model);
Assert.Equal(deleteResults.Count, response.Data.Count());
Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error)));
await sutProvider.GetDependency<IDeleteClaimedOrganizationUserAccountCommand>()
.Received(1)
.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
}
>>>>>>> main
[Theory] [Theory]
[BitAutoData] [BitAutoData]

View File

@ -60,6 +60,7 @@ public class OrganizationsControllerTests : IDisposable
private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly OrganizationsController _sut; private readonly OrganizationsController _sut;
public OrganizationsControllerTests() public OrganizationsControllerTests()
@ -86,6 +87,7 @@ public class OrganizationsControllerTests : IDisposable
_organizationDeleteCommand = Substitute.For<IOrganizationDeleteCommand>(); _organizationDeleteCommand = Substitute.For<IOrganizationDeleteCommand>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>(); _policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_pricingClient = Substitute.For<IPricingClient>(); _pricingClient = Substitute.For<IPricingClient>();
_organizationUpdateKeysCommand = Substitute.For<IOrganizationUpdateKeysCommand>();
_sut = new OrganizationsController( _sut = new OrganizationsController(
_organizationRepository, _organizationRepository,
@ -109,7 +111,8 @@ public class OrganizationsControllerTests : IDisposable
_cloudOrganizationSignUpCommand, _cloudOrganizationSignUpCommand,
_organizationDeleteCommand, _organizationDeleteCommand,
_policyRequirementQuery, _policyRequirementQuery,
_pricingClient); _pricingClient,
_organizationUpdateKeysCommand);
} }
public void Dispose() public void Dispose()
@ -138,7 +141,7 @@ public class OrganizationsControllerTests : IDisposable
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user); _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization> { null }); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { null });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId)); var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.", Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
@ -168,10 +171,10 @@ public class OrganizationsControllerTests : IDisposable
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user); _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization> { { foundOrg } }); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { { foundOrg } });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId)); var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
Assert.Contains("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.", Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.",
exception.Message); exception.Message);
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
@ -203,7 +206,7 @@ public class OrganizationsControllerTests : IDisposable
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user); _userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List<Organization>()); _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization>());
await _sut.Leave(orgId); await _sut.Leave(orgId);

Some files were not shown because too many files have changed in this diff Show More