1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-06 12:12:18 -05:00

Merge branch 'main' into auth/pm-12432/remove-2fa-authenticator-token-feature-flag

This commit is contained in:
Todd Martin 2025-04-30 21:33:12 -04:00 committed by GitHub
commit 9dbc9a78d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
365 changed files with 52581 additions and 1710 deletions

2
.github/CODEOWNERS vendored
View File

@ -37,6 +37,8 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
**/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @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
**/KeyManagement @bitwarden/team-key-management-dev

View File

@ -9,6 +9,19 @@
"nuget",
],
packageRules: [
{
// Group all release-related workflows for GitHub Actions together for BRE.
groupName: "github-action",
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml",
".github/workflows/repository-management.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
addLabels: ["hold"]
},
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],

View File

@ -627,55 +627,16 @@ jobs:
}
})
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
if: |
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
setup-ephemeral-environment:
name: Setup Ephemeral Environment
needs: build-docker
if: |
needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with:
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
project: server
sync_environment: true
pull_request_number: ${{ github.event.number }}
secrets: inherit

View File

@ -5,34 +5,12 @@ on:
types: [labeled]
jobs:
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
runs-on: ubuntu-24.04
setup-ephemeral-environment:
name: Setup Ephemeral Environment
if: github.event.label.name == 'ephemeral-environment'
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
}
})
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with:
project: server
pull_request_number: ${{ github.event.number }}
sync_environment: true
secrets: inherit

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.4.1</Version>
<Version>2025.4.3</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand
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);

View File

@ -110,9 +110,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IEnumerable<string> organizationOwnerEmails)
{
if (provider.IsBillable() &&
organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId))
organization.IsValidClient())
{
// An organization converted to a business unit will not have a Customer since it was given to the business unit.
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
}
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,

View File

@ -692,10 +692,10 @@ public class ProviderService : IProviderService
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
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;
case ProviderType.Reseller:

View File

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

View File

@ -21,7 +21,6 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -46,7 +45,6 @@ public class ProviderBillingService(
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService
{
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider,
Organization organization,
@ -312,7 +310,6 @@ public class ProviderBillingService(
return memoryStream.ToArray();
}
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider,
Guid userId)
@ -791,7 +788,7 @@ public class ProviderBillingService(
Provider provider,
Organization organization)
{
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
if (provider.Type == ProviderType.BusinessUnit)
{
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
}

View File

@ -51,7 +51,7 @@ public static class ProviderPriceAdapter
/// <param name="subscription">The provider's subscription.</param>
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <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>
public static string GetPriceId(
Provider provider,
@ -78,7 +78,7 @@ public static class ProviderPriceAdapter
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType
},
ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
? planType switch
{
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="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <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>
public static string GetActivePriceId(
Provider provider,
@ -120,7 +120,7 @@ public static class ProviderPriceAdapter
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
_ => throw invalidPlanType
},
ProviderType.MultiOrganizationEnterprise => planType switch
ProviderType.BusinessUnit => planType switch
{
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,

View File

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

View File

@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using Duende.IdentityModel;
using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;

View File

@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Duende.IdentityModel;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

View File

@ -19,10 +19,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

View File

@ -7,9 +7,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;

View File

@ -441,9 +441,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true,
"license": "MIT"
},
@ -455,9 +455,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -687,9 +687,9 @@
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"bin": {
@ -821,9 +821,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"dev": true,
"funding": [
{
@ -975,9 +975,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.103",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"dev": true,
"license": "ISC"
},
@ -1248,9 +1248,9 @@
}
},
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"dev": true,
"license": "MIT"
},
@ -1501,9 +1501,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@ -2107,9 +2107,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.11",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz",
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==",
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2163,9 +2163,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{

View File

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

View File

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

View File

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

View File

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

View File

@ -124,8 +124,20 @@ services:
profiles:
- servicebus
redis:
image: redis:alpine
container_name: bw-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
profiles:
- redis
volumes:
mssql_dev_data:
postgres_dev_data:
mysql_dev_data:
rabbitmq_data:
redis_data:

0
dev/ef_migrate.ps1 Normal file → Executable file
View File

View File

@ -25,6 +25,12 @@
"Subscriptions": [
{
"Name": "events-write-subscription"
},
{
"Name": "events-slack-subscription"
},
{
"Name": "events-webhook-subscription"
}
]
}

View File

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

View File

@ -462,6 +462,7 @@ public class OrganizationsController : Controller
organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager;
organization.UseRiskInsights = model.UseRiskInsights;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
//secrets
organization.SmSeats = model.SmSeats;

View File

@ -3,11 +3,13 @@ using System.Net;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
@ -23,6 +25,7 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Admin.AdminConsole.Controllers;
@ -44,6 +47,7 @@ public class ProvidersController : Controller
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly IStripeAdapter _stripeAdapter;
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
@ -63,7 +67,8 @@ public class ProvidersController : Controller
IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient)
IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
{
_organizationRepository = organizationRepository;
_organizationService = organizationService;
@ -79,6 +84,7 @@ public class ProvidersController : Controller
_providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_stripeAdapter = stripeAdapter;
_stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -133,10 +139,10 @@ public class ProvidersController : Controller
return View(new CreateResellerProviderModel());
}
[HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
[HttpGet("providers/create/business-unit")]
public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMultiOrganizationEnterpriseProviderModel
return View(new CreateBusinessUnitProviderModel
{
OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats
@ -157,7 +163,7 @@ public class ProvidersController : Controller
{
ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
_ => View(model)
};
}
@ -198,10 +204,10 @@ public class ProvidersController : Controller
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/multi-organization-enterprise")]
[HttpPost("providers/create/business-unit")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
{
if (!ModelState.IsValid)
{
@ -209,7 +215,7 @@ public class ProvidersController : Controller
}
var provider = model.ToProvider();
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
await _createProviderCommand.CreateBusinessUnitAsync(
provider,
model.OwnerEmail,
model.Plan.Value,
@ -306,8 +312,25 @@ public class ProvidersController : Controller
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
{
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice
}
});
}
}
break;
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
{
var existingMoePlan = providerPlans.Single();
@ -345,14 +368,18 @@ public class ProvidersController : Controller
if (!provider.IsBillable())
{
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>(), false);
}
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var payByInvoice =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
return new ProviderEditModel(
provider, users, providerOrganizations,
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
}
[RequirePermission(Permission.Provider_ResendEmailInvite)]
@ -443,6 +470,19 @@ public class ProvidersController : Controller
[RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> Delete(Guid id, string providerName)
{
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (provider.Status == ProviderStatusType.Pending)
{
await _providerService.DeleteAsync(provider);
return NoContent();
}
if (string.IsNullOrWhiteSpace(providerName))
{
return BadRequest("Invalid provider name");
@ -455,13 +495,6 @@ public class ProvidersController : Controller
return BadRequest("You must unlink all clients before you can delete a provider");
}
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid provider name");

View File

@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
public class CreateBusinessUnitProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
return new Provider
{
Type = ProviderType.MultiOrganizationEnterprise
Type = ProviderType.BusinessUnit
};
}
@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
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.");
}
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.");
}
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.");
}
}

View File

@ -86,6 +86,7 @@ public class OrganizationEditModel : OrganizationViewModel
UseApi = org.UseApi;
UseSecretsManager = org.UseSecretsManager;
UseRiskInsights = org.UseRiskInsights;
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
UseResetPassword = org.UseResetPassword;
SelfHost = org.SelfHost;
UsersGetPremium = org.UsersGetPremium;
@ -154,6 +155,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")]
public bool SelfHost { get; set; }
[Display(Name = "Users Get Premium")]
@ -295,6 +298,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.UseApi = UseApi;
existingOrganization.UseSecretsManager = UseSecretsManager;
existingOrganization.UseRiskInsights = UseRiskInsights;
existingOrganization.UseAdminSponsoredFamilies = UseAdminSponsoredFamilies;
existingOrganization.UseResetPassword = UseResetPassword;
existingOrganization.SelfHost = SelfHost;
existingOrganization.UsersGetPremium = UsersGetPremium;

View File

@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans,
bool payByInvoice,
string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
{
@ -33,8 +34,9 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type;
PayByInvoice = payByInvoice;
if (Type == ProviderType.MultiOrganizationEnterprise)
if (Type == ProviderType.BusinessUnit)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
public string GatewaySubscriptionId { get; set; }
public string GatewayCustomerUrl { get; }
public string GatewaySubscriptionUrl { get; }
[Display(Name = "Pay By Invoice")]
public bool PayByInvoice { get; set; }
[Display(Name = "Provider Type")]
public ProviderType Type { get; set; }
@ -100,7 +104,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);

View File

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

View File

@ -1,8 +1,13 @@
@using Bit.Admin.Enums;
@using Bit.Admin.Models
@using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@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 IFeatureService FeatureService
@model OrganizationEditModel
@{
ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name;
@ -13,6 +18,13 @@
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
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 {
@ -114,6 +126,15 @@
Enterprise Trial
</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)
{
<button class="btn btn-outline-danger me-2"

View File

@ -1,15 +1,15 @@
@using Bit.Core.Billing.Enums
@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>
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
<form method="post" asp-action="CreateBusinessUnit">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
@ -19,14 +19,14 @@
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
var businessUnitPlanTypes = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<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>
</select>
</div>

View File

@ -74,20 +74,20 @@
</div>
break;
}
case ProviderType.MultiOrganizationEnterprise:
case ProviderType.BusinessUnit:
{
<div class="row">
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = new List<PlanType>
var businessUnitPlanTypes = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<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>
</select>
</div>
@ -136,6 +136,17 @@
</div>
</div>
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable())
{
<div class="row">
<div class="col-sm">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" asp-for="PayByInvoice">
<label class="form-check-label" asp-for="PayByInvoice"></label>
</div>
</div>
</div>
}
}
</form>
@await Html.PartialAsync("Organizations", Model)
@ -172,17 +183,29 @@
<div class="p-3">
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
</div>
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span>
<form>
<div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
@if (Model.Provider.Status == ProviderStatusType.Pending)
{
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible.
</span>
</div>
}
else
{
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
</span>
<form>
<div class="mb-3">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>

View File

@ -1,9 +1,11 @@
@using Bit.Admin.Enums;
@using Bit.Core
@using Bit.Core.Enums
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.SharedWeb.Utilities
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
@inject Bit.Core.Services.IFeatureService FeatureService
@model OrganizationEditModel
@ -146,6 +148,13 @@
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
}
</div>
<div class="col-3">
<h3>Password Manager</h3>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -442,9 +442,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true,
"license": "MIT"
},
@ -456,9 +456,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -688,9 +688,9 @@
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"bin": {
@ -822,9 +822,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"dev": true,
"funding": [
{
@ -976,9 +976,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.103",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"dev": true,
"license": "ISC"
},
@ -1249,9 +1249,9 @@
}
},
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"dev": true,
"license": "MIT"
},
@ -1502,9 +1502,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@ -2108,9 +2108,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.11",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz",
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==",
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2172,9 +2172,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{

View File

@ -0,0 +1,21 @@
#nullable enable
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// An attribute which requires authorization using the specified requirement.
/// This uses the standard ASP.NET authorization middleware.
/// </summary>
/// <typeparam name="T">The IAuthorizationRequirement that will be used to authorize the user.</typeparam>
public class AuthorizeAttribute<T>
: AuthorizeAttribute, IAuthorizationRequirementData
where T : IAuthorizationRequirement, new()
{
public IEnumerable<IAuthorizationRequirement> GetRequirements()
{
var requirement = new T();
return [requirement];
}
}

View File

@ -0,0 +1,79 @@
#nullable enable
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
namespace Bit.Api.AdminConsole.Authorization;
public static class HttpContextExtensions
{
public const string NoOrgIdError =
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
/// <summary>
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
/// Subsequent calls will retrieve the cached value.
/// Results are stored by type and therefore must be of a unique type.
/// </summary>
public static async Task<T> WithFeaturesCacheAsync<T>(this HttpContext httpContext, Func<Task<T>> callback)
{
var cachedResult = httpContext.Features.Get<T>();
if (cachedResult != null)
{
return cachedResult;
}
var result = await callback();
httpContext.Features.Set(result);
return result;
}
/// <summary>
/// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
/// </summary>
/// <remarks>
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
/// </remarks>
public static async Task<bool> IsProviderUserForOrgAsync(
this HttpContext httpContext,
IProviderUserRepository providerUserRepository,
Guid userId,
Guid organizationId)
{
var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
return organizations.Any(o => o.OrganizationId == organizationId);
}
/// <summary>
/// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
/// </summary>
/// <remarks>
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
/// </remarks>
private static async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizationsAsync(
this HttpContext httpContext,
IProviderUserRepository providerUserRepository,
Guid userId)
=> await httpContext.WithFeaturesCacheAsync(() =>
providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
/// <summary>
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
/// </summary>
/// <param name="httpContext"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static Guid GetOrganizationId(this HttpContext httpContext)
{
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
{
throw new InvalidOperationException(NoOrgIdError);
}
return orgId;
}
}

View File

@ -0,0 +1,31 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// A requirement that implements this interface will be handled by <see cref="OrganizationRequirementHandler"/>,
/// which calls AuthorizeAsync with the organization details from the route.
/// This is used for simple role-based checks.
/// This may only be used on endpoints with {orgId} in their path.
/// </summary>
public interface IOrganizationRequirement : IAuthorizationRequirement
{
/// <summary>
/// Whether to authorize a request that has this requirement.
/// </summary>
/// <param name="organizationClaims">
/// The CurrentContextOrganization for the user if they are a member of the organization.
/// This is null if they are not a member.
/// </param>
/// <param name="isProviderUserForOrg">
/// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
/// This requires a database query, call it last.
/// </param>
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
public Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg);
}

View File

@ -0,0 +1,119 @@
#nullable enable
using System.Security.Claims;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Authorization;
public static class OrganizationClaimsExtensions
{
/// <summary>
/// Parses a user's claims and returns an object representing their claims for the specified organization.
/// </summary>
/// <param name="user">The user who has the claims.</param>
/// <param name="organizationId">The organizationId to look for in the claims.</param>
/// <returns>
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for that organization, or null
/// if the user does not have any claims for that organization.
/// </returns>
public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId)
{
var hasClaim = GetClaimsParser(user, organizationId);
var role = GetRoleFromClaims(hasClaim);
if (!role.HasValue)
{
// Not an organization member
return null;
}
return new CurrentContextOrganization
{
Id = organizationId,
Type = role.Value,
AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess),
Permissions = role == OrganizationUserType.Custom
? GetPermissionsFromClaims(hasClaim)
: new Permissions()
};
}
/// <summary>
/// Returns a function for evaluating claims for the specified user and organizationId.
/// The function returns true if the claim type exists and false otherwise.
/// </summary>
private static Func<string, bool> GetClaimsParser(ClaimsPrincipal user, Guid organizationId)
{
// Group claims by ClaimType
var claimsDict = user.Claims
.GroupBy(c => c.Type)
.ToDictionary(
c => c.Key,
c => c.ToList());
return claimType
=> claimsDict.TryGetValue(claimType, out var claims) &&
claims
.ParseGuids()
.Any(v => v == organizationId);
}
/// <summary>
/// Parses the provided claims into proper Guids, or ignore them if they are not valid guids.
/// </summary>
private static IEnumerable<Guid> ParseGuids(this IEnumerable<Claim> claims)
{
foreach (var claim in claims)
{
if (Guid.TryParse(claim.Value, out var guid))
{
yield return guid;
}
}
}
private static OrganizationUserType? GetRoleFromClaims(Func<string, bool> hasClaim)
{
if (hasClaim(Claims.OrganizationOwner))
{
return OrganizationUserType.Owner;
}
if (hasClaim(Claims.OrganizationAdmin))
{
return OrganizationUserType.Admin;
}
if (hasClaim(Claims.OrganizationCustom))
{
return OrganizationUserType.Custom;
}
if (hasClaim(Claims.OrganizationUser))
{
return OrganizationUserType.User;
}
return null;
}
private static Permissions GetPermissionsFromClaims(Func<string, bool> hasClaim)
=> new()
{
AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs),
AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport),
AccessReports = hasClaim(Claims.CustomPermissions.AccessReports),
CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections),
EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection),
DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection),
ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups),
ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies),
ManageSso = hasClaim(Claims.CustomPermissions.ManageSso),
ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers),
ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword),
ManageScim = hasClaim(Claims.CustomPermissions.ManageScim),
};
}

View File

@ -0,0 +1,49 @@
#nullable enable
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// Handles any requirement that implements <see cref="IOrganizationRequirement"/>.
/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to
/// determine whether the action is authorized.
/// </summary>
public class OrganizationRequirementHandler(
IHttpContextAccessor httpContextAccessor,
IProviderUserRepository providerUserRepository,
IUserService userService)
: AuthorizationHandler<IOrganizationRequirement>
{
public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request.";
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
throw new InvalidOperationException(NoHttpContextError);
}
var organizationId = httpContext.GetOrganizationId();
var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId);
var userId = userService.GetProperUserId(httpContext.User);
if (userId == null)
{
throw new InvalidOperationException(NoUserIdError);
}
Task<bool> IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId);
var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg);
if (authorized)
{
context.Succeed(requirement);
}
}
}

View File

@ -0,0 +1,20 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
public class ManageUsersRequirement : IOrganizationRequirement
{
public async Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims switch
{
{ Type: OrganizationUserType.Owner } => true,
{ Type: OrganizationUserType.Admin } => true,
{ Permissions.ManageUsers: true } => true,
_ => await isProviderUserForOrg()
};
}

View File

@ -0,0 +1,16 @@
#nullable enable
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
/// <summary>
/// Requires that the user is a member of the organization or a provider for the organization.
/// </summary>
public class MemberOrProviderRequirement : IOrganizationRequirement
{
public async Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims is not null || await isProviderUserForOrg();
}

View File

@ -0,0 +1,106 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationConfigurationResponseModel> CreateAsync(
Guid organizationId,
Guid integrationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
}
[HttpPut("{configurationId:guid}")]
public async Task<OrganizationIntegrationConfigurationResponseModel> UpdateAsync(
Guid organizationId,
Guid integrationId,
Guid configurationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
}
[HttpDelete("{configurationId:guid}")]
[HttpPost("{configurationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -0,0 +1,74 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationResponseModel> CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpPut("{integrationId:guid}")]
public async Task<OrganizationIntegrationResponseModel> UpdateAsync(Guid organizationId, Guid integrationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpDelete("{integrationId:guid}")]
[HttpPost("{integrationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -56,8 +56,8 @@ public class OrganizationUsersController : Controller
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
@ -83,8 +83,8 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService,
IPricingClient pricingClient,
@ -109,8 +109,8 @@ public class OrganizationUsersController : Controller
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand;
_getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
_pricingClient = pricingClient;
@ -127,11 +127,11 @@ public class OrganizationUsersController : Controller
throw new NotFoundException();
}
var managedByOrganization = await GetManagedByOrganizationStatusAsync(
var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync(
organizationUser.OrganizationId,
[organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections);
if (includeGroups)
{
@ -175,13 +175,13 @@ public class OrganizationUsersController : Controller
}
);
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
.Select(o =>
{
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var managedByOrganization = organizationUsersManagementStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
var claimedByOrganization = organizationUsersClaimedStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization);
return orgUser;
});
@ -313,7 +313,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName);
await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
}
@ -494,7 +494,7 @@ public class OrganizationUsersController : Controller
}
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
{
throw new BadRequestException("Incorrect password");
@ -591,7 +591,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
}
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
@ -610,7 +610,7 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
@ -717,14 +717,14 @@ public class OrganizationUsersController : Controller
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))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus;
var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
return usersOrganizationClaimedStatus;
}
}

View File

@ -65,6 +65,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@ -88,7 +89,8 @@ public class OrganizationsController : Controller
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient)
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -112,6 +114,7 @@ public class OrganizationsController : Controller
_organizationDeleteCommand = organizationDeleteCommand;
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
}
[HttpGet("{id}")]
@ -140,10 +143,10 @@ public class OrganizationsController : Controller
var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId,
OrganizationUserStatusType.Confirmed);
var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId);
var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id);
var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
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);
}
@ -277,9 +280,9 @@ public class OrganizationsController : Controller
}
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);
@ -490,7 +493,7 @@ public class OrganizationsController : Controller
}
[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);
if (user == null)
@ -498,7 +501,7 @@ public class OrganizationsController : Controller
throw new UnauthorizedAccessException();
}
var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey,
var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey,
model.EncryptedPrivateKey);
return new OrganizationKeysResponseModel(org);
}

View File

@ -1,6 +1,5 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Requests;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
@ -17,7 +16,6 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
@ -140,11 +138,6 @@ public class ProviderClientsController(
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
{
return Error.NotFound();
}
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
if (provider == null)

View File

@ -0,0 +1,80 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/slack")]
[Authorize("Application")]
public class SlackIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
ISlackService slackService) : Controller
{
[HttpGet("redirect")]
public async Task<IActionResult> RedirectAsync(Guid organizationId)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var redirectUrl = slackService.GetRedirectUrl(callbackUrl);
if (string.IsNullOrEmpty(redirectUrl))
{
throw new NotFoundException();
}
return Redirect(redirectUrl);
}
[HttpGet("create", Name = nameof(CreateAsync))]
public async Task<IActionResult> CreateAsync(Guid organizationId, [FromQuery] string code)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
if (string.IsNullOrEmpty(code))
{
throw new BadRequestException("Missing code from Slack.");
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl);
if (string.IsNullOrEmpty(token))
{
throw new BadRequestException("Invalid response from Slack.");
}
var integration = await integrationRepository.CreateAsync(new OrganizationIntegration
{
OrganizationId = organizationId,
Type = IntegrationType.Slack,
Configuration = JsonSerializer.Serialize(new SlackIntegration(token)),
});
var location = $"/organizations/{organizationId}/integrations/{integration.Id}";
return Created(location, new OrganizationIntegrationResponseModel(integration));
}
}

View File

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationConfigurationRequestModel
{
public string? Configuration { get; set; }
[Required]
public EventType EventType { get; set; }
public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<SlackIntegrationConfiguration>();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<WebhookIntegrationConfiguration>();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{
return new OrganizationIntegrationConfiguration()
{
OrganizationIntegrationId = organizationIntegrationId,
Configuration = Configuration,
EventType = EventType,
Template = Template
};
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModel : IValidatableObject
{
public string? Configuration { get; set; }
public IntegrationType Type { get; set; }
public OrganizationIntegration ToOrganizationIntegration(Guid organizationId)
{
return new OrganizationIntegration()
{
OrganizationId = organizationId,
Configuration = Configuration,
Type = Type,
};
}
public OrganizationIntegration ToOrganizationIntegration(OrganizationIntegration currentIntegration)
{
currentIntegration.Configuration = Configuration;
return currentIntegration;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) });
break;
case IntegrationType.Slack:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) });
break;
case IntegrationType.Webhook:
if (Configuration is not null)
{
yield return new ValidationResult(
"Webhook integrations must not include configuration.",
new[] { nameof(Configuration) });
}
break;
default:
yield return new ValidationResult(
$"Integration type '{Type}' is not recognized.",
new[] { nameof(Type) });
break;
}
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
{
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;
EventType = organizationIntegrationConfiguration.EventType;
Template = organizationIntegrationConfiguration.Template;
}
public Guid Id { get; set; }
public string? Configuration { get; set; }
public DateTime CreationDate { get; set; }
public EventType EventType { get; set; }
public string? Template { get; set; }
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationResponseModel : ResponseModel
{
public OrganizationIntegrationResponseModel(OrganizationIntegration organizationIntegration, string obj = "organizationIntegration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegration);
Id = organizationIntegration.Id;
Type = organizationIntegration.Type;
}
public Guid Id { get; set; }
public IntegrationType Type { get; set; }
}

View File

@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
public Guid Id { get; set; }
@ -110,6 +111,7 @@ public class OrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@ -66,24 +66,34 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
{
public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser,
bool managedByOrganization,
bool claimedByOrganization,
string ssoExternalId,
IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails")
{
ManagedByOrganization = managedByOrganization;
ClaimedByOrganization = claimedByOrganization;
SsoExternalId = ssoExternalId;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
}
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization,
bool claimedByOrganization,
IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails")
{
ManagedByOrganization = managedByOrganization;
ClaimedByOrganization = claimedByOrganization;
SsoExternalId = organizationUser.SsoExternalId;
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; }
@ -117,7 +127,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj)
{
if (organizationUser == null)
@ -134,7 +144,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups;
// Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization;
ClaimedByOrganization = claimedByOrganization;
}
public string Name { get; set; }
@ -142,11 +152,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; }
public bool TwoFactorEnabled { 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>
/// 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.
/// </summary>
public bool ManagedByOrganization { get; set; }
public bool ClaimedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; }
}

View File

@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization,
IEnumerable<Guid> organizationIdsManagingUser)
IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization")
{
Id = organization.OrganizationId;
@ -70,8 +70,9 @@ public class ProfileOrganizationResponseModel : ResponseModel
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId);
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
if (organization.SsoConfig != null)
{
@ -133,15 +134,27 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <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>
/// <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.
/// </remarks>
/// <returns>
/// False if the Account Deprovisioning feature flag is disabled.
/// </returns>
public bool UserIsManagedByOrganization { get; set; }
public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
}

View File

@ -50,5 +50,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
}

View File

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

View File

@ -221,8 +221,7 @@ public class MembersController : Controller
/// Remove a member.
/// </summary>
/// <remarks>
/// Permanently removes a member from the organization. This cannot be undone.
/// The user account will still remain. The user is only removed from the organization.
/// Removes a member from the organization. This cannot be undone. The user account will still remain.
/// </remarks>
/// <param name="id">The identifier of the member to be removed.</param>
[HttpDelete("{id}")]

View File

@ -124,11 +124,11 @@ public class AccountsController : Controller
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);
@ -284,52 +284,6 @@ public class AccountsController : Controller
throw new BadRequestException(ModelState);
}
[HttpPost("set-key-connector-key")]
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("convert-to-key-connector")]
public async Task PostConvertToKeyConnector()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.ConvertToKeyConnectorAsync(user);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("kdf")]
public async Task PostKdf([FromBody] KdfRequestModel model)
{
@ -437,11 +391,11 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(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,
providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg, organizationIdsManagingActiveUser);
hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response;
}
@ -451,9 +405,9 @@ public class AccountsController : Controller
var userId = _userService.GetProperUserId(User);
var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value,
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);
}
@ -471,9 +425,9 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(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;
}
@ -490,9 +444,9 @@ public class AccountsController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(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;
}
@ -560,9 +514,9 @@ public class AccountsController : Controller
}
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)
&& 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.");
}
@ -763,9 +717,9 @@ public class AccountsController : Controller
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);
return organizationManagingUser.Select(o => o.Id);
var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}

View File

@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Response.WebAuthn;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -31,6 +32,8 @@ public class WebAuthnController : Controller
private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
public WebAuthnController(
IUserService userService,
@ -41,7 +44,9 @@ public class WebAuthnController : Controller
IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand,
ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand)
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService)
{
_userService = userService;
_policyService = policyService;
@ -52,7 +57,8 @@ public class WebAuthnController : Controller
_createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
}
[HttpGet("")]
@ -68,7 +74,7 @@ public class WebAuthnController : Controller
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
{
var user = await VerifyUserAsync(model);
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id);
await ValidateIfUserCanUsePasskeyLogin(user.Id);
var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
@ -101,7 +107,7 @@ public class WebAuthnController : Controller
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
{
var user = await GetUserAsync();
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id);
await ValidateIfUserCanUsePasskeyLogin(user.Id);
var tokenable = _createOptionsDataProtector.Unprotect(model.Token);
if (!tokenable.TokenIsValid(user))
@ -126,6 +132,22 @@ public class WebAuthnController : Controller
}
}
private async Task ValidateIfUserCanUsePasskeyLogin(Guid userId)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
await ValidateRequireSsoPolicyDisabledOrNotApplicable(userId);
return;
}
var requireSsoPolicyRequirement = await _policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(userId);
if (!requireSsoPolicyRequirement.CanUsePasskeyLogin)
{
throw new BadRequestException("Passkeys cannot be created for your account. SSO login is required.");
}
}
[HttpPut()]
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
{

View File

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

View File

@ -58,10 +58,10 @@ public class AccountsController(
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user);
var 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,
userHasPremiumFromOrganization, organizationIdsManagingActiveUser);
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return new PaymentResponseModel
{
UserProfile = profile,
@ -229,9 +229,9 @@ public class AccountsController(
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);
return organizationManagingUser.Select(o => o.Id);
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}

View File

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

View File

@ -84,10 +84,27 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName);
model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes);
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
}

View File

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

View File

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

View File

@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -21,6 +22,7 @@ public class DevicesController : Controller
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService;
private readonly IUserService _userService;
private readonly IUntrustDevicesCommand _untrustDevicesCommand;
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
private readonly ILogger<DevicesController> _logger;
@ -29,6 +31,7 @@ public class DevicesController : Controller
IDeviceRepository deviceRepository,
IDeviceService deviceService,
IUserService userService,
IUntrustDevicesCommand untrustDevicesCommand,
IUserRepository userRepository,
ICurrentContext currentContext,
ILogger<DevicesController> logger)
@ -36,6 +39,7 @@ public class DevicesController : Controller
_deviceRepository = deviceRepository;
_deviceService = deviceService;
_userService = userService;
_untrustDevicesCommand = untrustDevicesCommand;
_userRepository = userRepository;
_currentContext = currentContext;
_logger = logger;
@ -124,6 +128,7 @@ public class DevicesController : Controller
}
[HttpPost("{identifier}/retrieve-keys")]
[Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{
var user = await _userService.GetUserByPrincipalAsync(User);
@ -165,6 +170,19 @@ public class DevicesController : Controller
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")]
[HttpPost("identifier/{identifier}/token")]
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)

View File

@ -0,0 +1,34 @@
using Bit.Core;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
[Route("phishing-domains")]
public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller
{
[HttpGet]
public async Task<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
return Ok(domains);
}
[HttpGet("checksum")]
public async Task<ActionResult<string>> GetChecksumAsync()
{
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
return NotFound();
}
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
return Ok(checksum);
}
}

View File

@ -3,6 +3,7 @@ using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -20,6 +21,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
private readonly ICreateSponsorshipCommand _offerSponsorshipCommand;
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public SelfHostedOrganizationSponsorshipsController(
ICreateSponsorshipCommand offerSponsorshipCommand,
@ -27,7 +29,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
IOrganizationRepository organizationRepository,
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IOrganizationUserRepository organizationUserRepository,
ICurrentContext currentContext
ICurrentContext currentContext,
IFeatureService featureService
)
{
_offerSponsorshipCommand = offerSponsorshipCommand;
@ -36,15 +39,33 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
_organizationSponsorshipRepository = organizationSponsorshipRepository;
_organizationUserRepository = organizationUserRepository;
_currentContext = currentContext;
_featureService = featureService;
}
[HttpPost("{sponsoringOrgId}/families-for-enterprise")]
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName);
model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes);
}
[HttpDelete("{sponsoringOrgId}")]

View File

@ -58,6 +58,13 @@ public class JobsHostedService : BaseJobsHostedService
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
var updatePhishingDomainsTrigger = TriggerBuilder.Create()
.WithIdentity("UpdatePhishingDomainsTrigger")
.StartNow()
.WithSimpleSchedule(x => x
.WithIntervalInHours(24)
.RepeatForever())
.Build();
var jobs = new List<Tuple<Type, ITrigger>>
@ -68,6 +75,7 @@ public class JobsHostedService : BaseJobsHostedService
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
};
if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication)
@ -96,6 +104,7 @@ public class JobsHostedService : BaseJobsHostedService
services.AddTransient<ValidateUsersJob>();
services.AddTransient<ValidateOrganizationsJob>();
services.AddTransient<ValidateOrganizationDomainJob>();
services.AddTransient<UpdatePhishingDomainsJob>();
}
public static void AddCommercialSecretsManagerJobServices(IServiceCollection services)

View File

@ -0,0 +1,97 @@
using Bit.Core;
using Bit.Core.Jobs;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Quartz;
namespace Bit.Api.Jobs;
public class UpdatePhishingDomainsJob : BaseJob
{
private readonly GlobalSettings _globalSettings;
private readonly IPhishingDomainRepository _phishingDomainRepository;
private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery;
private readonly IFeatureService _featureService;
public UpdatePhishingDomainsJob(
GlobalSettings globalSettings,
IPhishingDomainRepository phishingDomainRepository,
ICloudPhishingDomainQuery cloudPhishingDomainQuery,
IFeatureService featureService,
ILogger<UpdatePhishingDomainsJob> logger)
: base(logger)
{
_globalSettings = globalSettings;
_phishingDomainRepository = phishingDomainRepository;
_cloudPhishingDomainQuery = cloudPhishingDomainQuery;
_featureService = featureService;
}
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled.");
return;
}
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl))
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured.");
return;
}
if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings.");
return;
}
var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync();
if (string.IsNullOrWhiteSpace(remoteChecksum))
{
_logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update.");
return;
}
var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync();
if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(Constants.BypassFiltersEventId,
"Phishing domains list is up to date (checksum: {Checksum}). Skipping update.",
currentChecksum);
return;
}
_logger.LogInformation(Constants.BypassFiltersEventId,
"Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.",
currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source");
try
{
var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync();
if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase))
{
domains.Add("phishing.testcategory.com");
}
if (domains.Count > 0)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.",
domains.Count, remoteChecksum);
await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum);
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains.");
}
else
{
_logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update.");
}
}
catch (Exception ex)
{
_logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains.");
}
}
}

View File

@ -24,7 +24,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.KeyManagement.Controllers;
[Route("accounts/key-management")]
[Route("accounts")]
[Authorize("Application")]
public class AccountsKeyManagementController : Controller
{
@ -77,7 +77,7 @@ public class AccountsKeyManagementController : Controller
_deviceValidator = deviceValidator;
}
[HttpPost("regenerate-keys")]
[HttpPost("key-management/regenerate-keys")]
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
@ -93,7 +93,7 @@ public class AccountsKeyManagementController : Controller
}
[HttpPost("rotate-user-account-keys")]
[HttpPost("key-management/rotate-user-account-keys")]
public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
@ -133,4 +133,50 @@ public class AccountsKeyManagementController : Controller
throw new BadRequestException(ModelState);
}
[HttpPost("set-key-connector-key")]
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("convert-to-key-connector")]
public async Task PostConvertToKeyConnectorAsync()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var result = await _userService.ConvertToKeyConnectorAsync(user);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
}

View File

@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.KeyManagement.Models.Requests;
public class SetKeyConnectorKeyRequestModel
{

View File

@ -16,4 +16,10 @@ public class OrganizationSponsorshipCreateRequestModel
[StringLength(256)]
public string FriendlyName { get; set; }
public bool? IsAdminInitiated { get; set; }
[EncryptedString]
[EncryptedStringLength(512)]
public string Notes { get; set; }
}

View File

@ -2,6 +2,7 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.Models.Response;
@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel
Identifier = device.Identifier;
CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted();
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
}
public Guid Id { get; set; }
@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel
public string Identifier { get; set; }
public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedUserKey { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedPublicKey { get; set; }
}

View File

@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
bool twoFactorEnabled,
bool premiumFromOrganization,
IEnumerable<Guid> organizationIdsManagingUser) : base("profile")
IEnumerable<Guid> organizationIdsClaimingUser) : base("profile")
{
if (user == null)
{
@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel
AvatarColor = user.AvatarColor;
CreationDate = user.CreationDate;
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));
ProviderOrganizations =
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));

View File

@ -5,7 +5,7 @@ using Bit.Core.Settings;
using AspNetCoreRateLimit;
using Stripe;
using Bit.Core.Utilities;
using Duende.IdentityModel;
using IdentityModel;
using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
@ -180,6 +182,7 @@ public class Startup
services.AddBillingOperations();
services.AddReportingServices();
services.AddImportServices();
services.AddPhishingDomainServices(globalSettings);
// Authorization Handlers
services.AddAuthorizationHandlers();
@ -215,6 +218,19 @@ public class Startup
{
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Slack
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
}
public void Configure(

View File

@ -77,10 +77,9 @@ public class ImportCiphersController : Controller
//An User is allowed to import if CanCreate Collections or has AccessToImportExport
var authorized = await CheckOrgImportPermission(collections, orgId);
if (!authorized)
{
throw new NotFoundException();
throw new BadRequestException("Not enough privileges to import into this organization.");
}
var userId = _userService.GetProperUserId(User).Value;
@ -103,21 +102,59 @@ public class ImportCiphersController : Controller
.Select(c => c.Id)
.ToHashSet();
//We need to verify if the user is trying to import into existing collections
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));
//When importing into existing collection, we need to verify if the user has permissions
if (existingCollections.Any() && !(await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
// when there are no collections, then we can import
if (collections.Count == 0)
{
return false;
};
//Users allowed to import if they CanCreate Collections
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return false;
return true;
}
return true;
// are we trying to import into existing collections?
var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id));
// are we trying to create new collections?
var hasNewCollections = collections.Any(tc => !orgCollectionIds.Contains(tc.Id));
// suppose we have both new and existing collections
if (hasNewCollections && existingCollections.Any())
{
// since we are creating new collection, user must have import/manage and create collection permission
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded
&& (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
// can import collections and create new ones
return true;
}
else
{
// user does not have permission to import
return false;
}
}
// suppose we have new collections and none of our collections exist
if (hasNewCollections && !existingCollections.Any())
{
// user is trying to create new collections
// we need to check if the user has permission to create collections
if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return true;
}
else
{
// user does not have permission to create new collections
return false;
}
}
// in many import formats, we don't create collections, we just import ciphers into an existing collection
// When importing, we need to verify if the user has ImportCiphers permission
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
return true;
};
return false;
}
}

View File

@ -1,7 +1,12 @@
using Bit.Api.Tools.Authorization;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Bit.Core.IdentityServer;
using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Repositories.Implementations;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
@ -105,5 +110,28 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, VaultExportAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecurityTaskOrganizationAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationRequirementHandler>();
}
public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings)
{
services.AddHttpClient("PhishingDomains", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden");
client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow
});
services.AddSingleton<AzurePhishingDomainStorageService>();
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
if (globalSettings.SelfHosted)
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
}
else
{
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
}
}
}

View File

@ -177,12 +177,7 @@ public class CiphersController : Controller
}
await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue);
var response = new CipherResponseModel(
cipher,
user,
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings);
return response;
return await Get(cipher.Id);
}
[HttpPost("admin")]
@ -1091,9 +1086,9 @@ public class CiphersController : Controller
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)
&& 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.");
}

View File

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

View File

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

View File

@ -37,6 +37,10 @@
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"phishingDomain": {
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
}
}
}

View File

@ -71,6 +71,9 @@
"accessKeySecret": "SECRET",
"region": "SECRET"
},
"phishingDomain": {
"updateUrl": "SECRET"
},
"distributedIpRateLimiting": {
"enabled": true,
"maxRedisTimeoutsThreshold": 10,

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,

View File

@ -1,4 +1,10 @@
using Bit.Billing.Constants;
using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Stripe;
using Event = Stripe.Event;
@ -10,20 +16,124 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
private readonly IStripeEventService _stripeEventService;
private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
public PaymentMethodAttachedHandler(
ILogger<PaymentMethodAttachedHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService)
IStripeEventUtilityService stripeEventUtilityService,
IFeatureService featureService,
IProviderRepository providerRepository)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService;
_featureService = featureService;
_providerRepository = providerRepository;
}
public async Task HandleAsync(Event parsedEvent)
{
var updateMSPToChargeAutomatically =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically);
if (updateMSPToChargeAutomatically)
{
await HandleVNextAsync(parsedEvent);
}
else
{
await HandleVCurrentAsync(parsedEvent);
}
}
private async Task HandleVNextAsync(Event parsedEvent)
{
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]);
if (paymentMethod == null)
{
_logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
return;
}
var customer = paymentMethod.Customer;
var subscriptions = customer?.Subscriptions;
// This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page.
var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription =>
subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) &&
subscription.Status != StripeConstants.SubscriptionStatus.Canceled &&
subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice);
/*
* If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved,
* we need to try and set the default payment method and update the collection method to be "charge_automatically".
*/
if (invoicedProviderSubscription != null &&
!customer.ApprovedToPayByInvoice() &&
Guid.TryParse(invoicedProviderSubscription.Metadata[StripeConstants.MetadataKeys.ProviderId], out var providerId))
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is { Type: ProviderType.Msp })
{
if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id)
{
try
{
await _stripeFacade.UpdateCustomer(customer.Id,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = paymentMethod.Id
}
});
}
catch (Exception exception)
{
_logger.LogWarning(exception,
"Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook",
customer.Id);
}
}
try
{
await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id,
new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically
});
}
catch (Exception exception)
{
_logger.LogWarning(exception,
"Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook",
customer.Id);
}
}
}
var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();
if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0)
{
return;
}
foreach (var unpaidSubscription in unpaidSubscriptions)
{
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
}
}
private async Task HandleVCurrentAsync(Event parsedEvent)
{
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
if (paymentMethod is null)

View File

@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
public async Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken);
public async Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,

View File

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

View File

@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// </summary>
public bool UseRiskInsights { get; set; }
/// <summary>
/// If set to true, admins can initiate organization-issued sponsorships.
/// </summary>
public bool UseAdminSponsoredFamilies { get; set; }
public void SetNewId()
{
if (Id == default(Guid))

View File

@ -2,6 +2,8 @@
public enum IntegrationType : int
{
Slack = 1,
Webhook = 2,
CloudBillingSync = 1,
Scim = 2,
Slack = 3,
Webhook = 4,
}

View File

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

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfiguration(string channelId);

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