mirror of
https://github.com/bitwarden/server.git
synced 2025-05-05 19:52:20 -05:00
merge main
This commit is contained in:
commit
3ec56feb3a
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -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
|
||||||
|
45
.github/workflows/build.yml
vendored
45
.github/workflows/build.yml
vendored
@ -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
|
||||||
|
|
||||||
|
38
.github/workflows/ephemeral-environment.yml
vendored
38
.github/workflows/ephemeral-environment.yml
vendored
@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
@ -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'))">
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal 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; } = [];
|
||||||
|
}
|
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal 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>
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey);
|
||||||
SsoExternalId = user.SsoExternalId;
|
SsoExternalId = user.SsoExternalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,11 +124,11 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
|
var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new BadRequestException(managedUserValidationResult.Errors);
|
throw new BadRequestException(claimedUserValidationResult.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
||||||
@ -437,11 +437,11 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, twoFactorEnabled,
|
providerUserOrganizationDetails, twoFactorEnabled,
|
||||||
hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,9 +451,9 @@ public class AccountsController : Controller
|
|||||||
var userId = _userService.GetProperUserId(User);
|
var userId = _userService.GetProperUserId(User);
|
||||||
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
|
||||||
OrganizationUserStatusType.Confirmed);
|
OrganizationUserStatusType.Confirmed);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value);
|
var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value);
|
||||||
|
|
||||||
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser));
|
var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
return new ListResponseModel<ProfileOrganizationResponseModel>(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,9 +471,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,9 +490,9 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id);
|
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
|
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,9 +560,9 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
@ -763,9 +763,9 @@ public class AccountsController : Controller
|
|||||||
await _userService.SaveUserAsync(user);
|
await _userService.SaveUserAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsManagingUserAsync(Guid userId)
|
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||||
return organizationManagingUser.Select(o => o.Id);
|
return organizationsClaimingUser.Select(o => o.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal file
11
src/Api/Auth/Models/Request/UntrustDevicesModel.cs
Normal 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!;
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
bool premiumFromOrganization,
|
bool premiumFromOrganization,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser) : base("profile")
|
IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
|
||||||
{
|
{
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
AvatarColor = user.AvatarColor;
|
AvatarColor = user.AvatarColor;
|
||||||
CreationDate = user.CreationDate;
|
CreationDate = user.CreationDate;
|
||||||
VerifyDevices = user.VerifyDevices;
|
VerifyDevices = user.VerifyDevices;
|
||||||
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser));
|
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser));
|
||||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||||
ProviderOrganizations =
|
ProviderOrganizations =
|
||||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||||
|
@ -1091,9 +1091,9 @@ public class CiphersController : Controller
|
|||||||
throw new BadRequestException(ModelState);
|
throw new BadRequestException(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization.
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
}
|
}
|
||||||
|
@ -104,13 +104,13 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id);
|
var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
|
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
|
||||||
|
|
||||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||||
|
|
||||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||||
organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
bool userTwoFactorEnabled,
|
bool userTwoFactorEnabled,
|
||||||
bool userHasPremiumFromOrganization,
|
bool userHasPremiumFromOrganization,
|
||||||
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
||||||
IEnumerable<Guid> organizationIdsManagingUser,
|
IEnumerable<Guid> organizationIdsClaimingingUser,
|
||||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
: base("sync")
|
: base("sync")
|
||||||
{
|
{
|
||||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser);
|
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
|
||||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||||
Ciphers = ciphers.Select(cipher =>
|
Ciphers = ciphers.Select(cipher =>
|
||||||
new CipherDetailsResponseModel(
|
new CipherDetailsResponseModel(
|
||||||
|
@ -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>()
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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.
|
@ -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);
|
||||||
}
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
namespace Bit.Core.Auth.Models.Mail;
|
namespace Bit.Core.Auth.Models.Mail;
|
||||||
|
|
||||||
public class CannotDeleteManagedAccountViewModel : BaseMailModel
|
public class CannotDeleteClaimedAccountViewModel : BaseMailModel
|
||||||
{
|
{
|
||||||
}
|
}
|
@ -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);
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -53,23 +53,10 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
|||||||
var user = await _userRepository.GetByEmailAsync(email);
|
var user = await _userRepository.GetByEmailAsync(email);
|
||||||
var userExists = user != null;
|
var userExists = user != null;
|
||||||
|
|
||||||
// Delays enabled by default; flag must be enabled to remove the delays.
|
|
||||||
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
|
|
||||||
|
|
||||||
if (!_globalSettings.EnableEmailVerification)
|
if (!_globalSettings.EnableEmailVerification)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (userExists)
|
if (userExists)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (delaysEnabled)
|
|
||||||
{
|
|
||||||
// Add delay to prevent timing attacks
|
|
||||||
// Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms
|
|
||||||
// as it should be long enough to prevent timing attacks but not too long to be noticeable to the user.
|
|
||||||
await Task.Delay(Random.Shared.Next(100, 130));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException($"Email {email} is already taken");
|
throw new BadRequestException($"Email {email} is already taken");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,11 +74,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
|
|||||||
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
|
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delaysEnabled)
|
|
||||||
{
|
|
||||||
// Add random delay between 100ms-130ms to prevent timing attacks
|
|
||||||
await Task.Delay(Random.Shared.Next(100, 130));
|
|
||||||
}
|
|
||||||
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
|
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,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>();
|
||||||
|
@ -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
|
||||||
|
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal file
58
src/Core/Billing/Services/IBusinessUnitConverter.cs
Normal 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);
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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,
|
||||||
|
@ -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}}
|
@ -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}}
|
@ -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);
|
@ -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}";
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,96 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
|
||||||
|
internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions<X509ChainOptions>
|
||||||
|
{
|
||||||
|
const string CertificateSearchPattern = "*.crt";
|
||||||
|
|
||||||
|
private readonly ILogger<PostConfigureX509ChainOptions> _logger;
|
||||||
|
private readonly IHostEnvironment _hostEnvironment;
|
||||||
|
private readonly GlobalSettings _globalSettings;
|
||||||
|
|
||||||
|
public PostConfigureX509ChainOptions(
|
||||||
|
ILogger<PostConfigureX509ChainOptions> logger,
|
||||||
|
IHostEnvironment hostEnvironment,
|
||||||
|
GlobalSettings globalSettings)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_hostEnvironment = hostEnvironment;
|
||||||
|
_globalSettings = globalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PostConfigure(string? name, X509ChainOptions options)
|
||||||
|
{
|
||||||
|
// We don't register or request a named instance of these options,
|
||||||
|
// so don't customize it.
|
||||||
|
if (name != Options.DefaultName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only allow this setting to be configured on self host.
|
||||||
|
if (!_globalSettings.SelfHosted)
|
||||||
|
{
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AdditionalCustomTrustCertificates != null)
|
||||||
|
{
|
||||||
|
// Additional certificates were added directly, this overwrites the need to
|
||||||
|
// read them from the directory.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Additional custom trust certificates were added directly, skipping loading them from '{Directory}'",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory))
|
||||||
|
{
|
||||||
|
// The default directory is volume mounted via the default Bitwarden setup process.
|
||||||
|
// If the directory doesn't exist it could indicate a error in configuration but this
|
||||||
|
// directory is never expected in a normal development environment so lower the log
|
||||||
|
// level in that case.
|
||||||
|
var logLevel = _hostEnvironment.IsDevelopment()
|
||||||
|
? LogLevel.Debug
|
||||||
|
: LogLevel.Warning;
|
||||||
|
_logger.Log(
|
||||||
|
logLevel,
|
||||||
|
"An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var certificates = new List<X509Certificate2>();
|
||||||
|
|
||||||
|
foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern))
|
||||||
|
{
|
||||||
|
certificates.Add(new X509Certificate2(certFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0)
|
||||||
|
{
|
||||||
|
// They have intentionally given us a non-default directory but there weren't certificates, that is odd.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No additional custom trust certificates were found in '{Directory}'",
|
||||||
|
options.AdditionalCustomTrustCertificatesDirectory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AdditionalCustomTrustCertificates = certificates;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
using Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class X509ChainCustomizationServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures X509ChainPolicy customization through the root level <c>X509ChainOptions</c> configuration section
|
||||||
|
/// and configures the primary <see cref="HttpMessageHandler"/> to use custom certificate validation
|
||||||
|
/// when customized to do so.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
|
||||||
|
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
|
||||||
|
public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddOptions<X509ChainOptions>()
|
||||||
|
.BindConfiguration(nameof(X509ChainOptions));
|
||||||
|
|
||||||
|
// Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple
|
||||||
|
// times even if this method is called multiple times.
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<X509ChainOptions>, PostConfigureX509ChainOptions>());
|
||||||
|
|
||||||
|
services.AddHttpClient()
|
||||||
|
.ConfigureHttpClientDefaults(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||||
|
{
|
||||||
|
var x509ChainOptions = sp.GetRequiredService<IOptions<X509ChainOptions>>().Value;
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler();
|
||||||
|
|
||||||
|
if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback))
|
||||||
|
{
|
||||||
|
handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
return callback(certificate, chain, errors);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
81
src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs
Normal file
81
src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace Bit.Core.Platform.X509ChainCustomization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allows for customization of the <see cref="X509ChainPolicy"/> and access to a custom server certificate validator
|
||||||
|
/// if customization has been made.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class X509ChainOptions
|
||||||
|
{
|
||||||
|
// This is the directory that we historically used to allow certificates be added inside our container
|
||||||
|
// and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call
|
||||||
|
// `update-ca-certificates` but since that operation requires root we can't do it in a rootless container.
|
||||||
|
// Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41
|
||||||
|
public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A directory where additional certificates should be read from and included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only certificates suffixed with <c>*.crt</c> will be read. If <see cref="AdditionalCustomTrustCertificates"/> is
|
||||||
|
/// set, then this directory will not be read from.
|
||||||
|
/// </remarks>
|
||||||
|
public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of additional certificates that should be included in <see cref="X509ChainPolicy.CustomTrustStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// If this value is set manually, then <see cref="AdditionalCustomTrustCertificatesDirectory"/> will be ignored.
|
||||||
|
/// </remarks>
|
||||||
|
public List<X509Certificate2>? AdditionalCustomTrustCertificates { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to retrieve a custom remote certificate validation callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="callback"></param>
|
||||||
|
/// <returns>Returns <see langword="true"/> when we have custom remote certification that should be added,
|
||||||
|
/// <see langword="false"/> when no custom validation is needed and the default validation callback should
|
||||||
|
/// be used instead.
|
||||||
|
/// </returns>
|
||||||
|
[MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))]
|
||||||
|
public bool TryGetCustomRemoteCertificateValidationCallback(
|
||||||
|
[MaybeNullWhen(false)] out Func<X509Certificate2?, X509Chain?, SslPolicyErrors, bool> callback)
|
||||||
|
{
|
||||||
|
callback = null;
|
||||||
|
if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do this outside of the callback so that we aren't opening the root store every request.
|
||||||
|
using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly);
|
||||||
|
var rootCertificates = store.Certificates;
|
||||||
|
|
||||||
|
// Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581
|
||||||
|
callback = (certificate, chain, errors) =>
|
||||||
|
{
|
||||||
|
if (chain == null || certificate == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
|
||||||
|
// We want our additional certificates to be in addition to the machines root store.
|
||||||
|
chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates);
|
||||||
|
|
||||||
|
foreach (var additionalCertificate in AdditionalCustomTrustCertificates)
|
||||||
|
{
|
||||||
|
chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate);
|
||||||
|
}
|
||||||
|
return chain.Build(certificate);
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,7 @@ public interface IMailService
|
|||||||
ProductTierType productTier,
|
ProductTierType productTier,
|
||||||
IEnumerable<ProductType> products);
|
IEnumerable<ProductType> products);
|
||||||
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
|
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
|
||||||
Task SendCannotDeleteManagedAccountEmailAsync(string email);
|
Task SendCannotDeleteClaimedAccountEmailAsync(string email);
|
||||||
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
||||||
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
||||||
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
|
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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)
|
||||||
|
@ -314,9 +314,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await IsManagedByAnyOrganizationAsync(user.Id))
|
if (await IsClaimedByAnyOrganizationAsync(user.Id))
|
||||||
{
|
{
|
||||||
await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email);
|
await _mailService.SendCannotDeleteClaimedAccountEmailAsync(user.Email);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,11 +545,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||||
}
|
}
|
||||||
|
|
||||||
var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail);
|
var claimedUserValidationResult = await ValidateClaimedUserDomainAsync(user, newEmail);
|
||||||
|
|
||||||
if (!managedUserValidationResult.Succeeded)
|
if (!claimedUserValidationResult.Succeeded)
|
||||||
{
|
{
|
||||||
return managedUserValidationResult;
|
return claimedUserValidationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider,
|
if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider,
|
||||||
@ -617,18 +617,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IdentityResult> ValidateManagedUserDomainAsync(User user, string newEmail)
|
public async Task<IdentityResult> ValidateClaimedUserDomainAsync(User user, string newEmail)
|
||||||
{
|
{
|
||||||
var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id);
|
var claimingOrganization = await GetOrganizationsClaimingUserAsync(user.Id);
|
||||||
|
|
||||||
if (!managingOrganizations.Any())
|
if (!claimingOrganization.Any())
|
||||||
{
|
{
|
||||||
return IdentityResult.Success;
|
return IdentityResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newDomain = CoreHelpers.GetEmailDomain(newEmail);
|
var newDomain = CoreHelpers.GetEmailDomain(newEmail);
|
||||||
|
|
||||||
var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(managingOrganizations.Select(org => org.Id));
|
var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(claimingOrganization.Select(org => org.Id));
|
||||||
|
|
||||||
if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
|
if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
|
||||||
{
|
{
|
||||||
@ -1366,13 +1366,13 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
return IsLegacyUser(user);
|
return IsLegacyUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsManagedByAnyOrganizationAsync(Guid userId)
|
public async Task<bool> IsClaimedByAnyOrganizationAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var managingOrganizations = await GetOrganizationsManagingUserAsync(userId);
|
var organizationsClaimingUser = await GetOrganizationsClaimingUserAsync(userId);
|
||||||
return managingOrganizations.Any();
|
return organizationsClaimingUser.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Organization>> GetOrganizationsManagingUserAsync(Guid userId)
|
public async Task<IEnumerable<Organization>> GetOrganizationsClaimingUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
{
|
{
|
||||||
|
@ -103,7 +103,7 @@ public class NoopMailService : IMailService
|
|||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendCannotDeleteManagedAccountEmailAsync(string email)
|
public Task SendCannotDeleteClaimedAccountEmailAsync(string email)
|
||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
_ => []
|
_ => []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
|
@ -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]
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user