mirror of
https://github.com/bitwarden/server
synced 2026-01-04 01:23:25 +00:00
[PM-25913] Fix owners unable to rename provider-managed organization (#6599)
And other refactors: - move update organization method to a command - separate authorization from business logic - add tests - move Billing Team logic into their service
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
|
||||
public interface IOrganizationUpdateCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
|
||||
/// Also optionally updates an organization's public-private keypair if it was not created with one.
|
||||
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
|
||||
/// </summary>
|
||||
/// <param name="request">The update request containing the details to be updated.</param>
|
||||
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.Billing.Organizations.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
public class OrganizationUpdateCommand(
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationBillingService organizationBillingService
|
||||
) : IOrganizationUpdateCommand
|
||||
{
|
||||
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
return await UpdateSelfHostedAsync(organization, request);
|
||||
}
|
||||
|
||||
return await UpdateCloudAsync(organization, request);
|
||||
}
|
||||
|
||||
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
// Store original values for comparison
|
||||
var originalName = organization.Name;
|
||||
var originalBillingEmail = organization.BillingEmail;
|
||||
|
||||
// Apply updates to organization
|
||||
organization.UpdateDetails(request);
|
||||
organization.BackfillPublicPrivateKeys(request);
|
||||
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||
|
||||
// Update billing information in Stripe if required
|
||||
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-host cannot update the organization details because they are set by the license file.
|
||||
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
|
||||
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
|
||||
/// </summary>
|
||||
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
organization.BackfillPublicPrivateKeys(request);
|
||||
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
|
||||
{
|
||||
// Update Stripe if name or billing email changed
|
||||
var shouldUpdateBilling = originalName != organization.Name ||
|
||||
originalBillingEmail != organization.BillingEmail;
|
||||
|
||||
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
public static class OrganizationUpdateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates the organization name and/or billing email.
|
||||
/// Any null property on the request object will be skipped.
|
||||
/// </summary>
|
||||
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
// These values may or may not be sent by the client depending on the operation being performed.
|
||||
// Skip any values not provided.
|
||||
if (request.Name is not null)
|
||||
{
|
||||
organization.Name = request.Name;
|
||||
}
|
||||
|
||||
if (request.BillingEmail is not null)
|
||||
{
|
||||
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the organization public and private keys if provided and not already set.
|
||||
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
|
||||
/// migration that will silently migrate organizations when they change their details.
|
||||
/// </summary>
|
||||
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
|
||||
{
|
||||
organization.PublicKey = request.PublicKey;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
|
||||
{
|
||||
organization.PrivateKey = request.EncryptedPrivateKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
|
||||
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
|
||||
/// </summary>
|
||||
public record OrganizationUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the organization to update.
|
||||
/// </summary>
|
||||
public required Guid OrganizationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new organization name to apply (optional, this is skipped if not provided).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new billing email address to apply (optional, this is skipped if not provided).
|
||||
/// </summary>
|
||||
public string? BillingEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The organization's public key to set (optional, only set if not already present on the organization).
|
||||
/// </summary>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
|
||||
/// </summary>
|
||||
public string? EncryptedPrivateKey { get; init; }
|
||||
}
|
||||
@@ -56,4 +56,15 @@ public interface IOrganizationBillingService
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="organization"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails.</exception>
|
||||
Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the organization name and email on the Stripe customer entry.
|
||||
/// This only updates Stripe, not the Bitwarden database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The caller should ensure that the organization has a GatewayCustomerId before calling this method.
|
||||
/// </remarks>
|
||||
/// <param name="organization">The organization to update in Stripe.</param>
|
||||
/// <exception cref="BillingException">Thrown when the organization does not have a GatewayCustomerId.</exception>
|
||||
Task UpdateOrganizationNameAndEmail(Organization organization);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,35 @@ public class OrganizationBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateOrganizationNameAndEmail(Organization organization)
|
||||
{
|
||||
if (organization.GatewayCustomerId is null)
|
||||
{
|
||||
throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId.");
|
||||
}
|
||||
|
||||
var newDisplayName = organization.DisplayName();
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Email = organization.BillingEmail,
|
||||
Description = newDisplayName,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
// This overwrites the existing custom fields for this organization
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = organization.SubscriberType(),
|
||||
Value = newDisplayName.Length <= 30
|
||||
? newDisplayName
|
||||
: newDisplayName[..30]
|
||||
}]
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
|
||||
@@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
@@ -87,6 +88,7 @@ public static class OrganizationServiceCollectionExtensions
|
||||
private static void AddOrganizationUpdateCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IOrganizationUpdateKeysCommand, OrganizationUpdateKeysCommand>();
|
||||
services.AddScoped<IOrganizationUpdateCommand, OrganizationUpdateCommand>();
|
||||
}
|
||||
|
||||
private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
|
||||
|
||||
Reference in New Issue
Block a user