Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
31022c9
Initial command refactor
eliykat Nov 5, 2025
26b9311
Add failing test
eliykat Nov 5, 2025
05b7794
Use test helpers
eliykat Nov 5, 2025
0f34def
Iterative improvements
eliykat Nov 5, 2025
9df75c4
Use [InjectOrganization] to fetch and pass in org
eliykat Nov 5, 2025
893db72
Use object initializer syntax for request model
eliykat Nov 5, 2025
af6973a
remove claude file
eliykat Nov 5, 2025
1a99a4a
Move provider fetch closer to relevant code
eliykat Nov 5, 2025
ca31ad5
Remove obsolete Identifier validation
eliykat Nov 5, 2025
83c8280
Tweaks
eliykat Nov 5, 2025
8762a20
Merge remote-tracking branch 'origin/main' into ac/pm-25913/web-serveโ€ฆ
eliykat Nov 8, 2025
8830f17
Revert "Use [InjectOrganization] to fetch and pass in org".
eliykat Nov 8, 2025
dc91e03
Remove Business Name and restore selfhosted checks
eliykat Nov 9, 2025
ceb6817
Return updated organization
eliykat Nov 9, 2025
0c05db9
Move StripeAdapter code into Billing ownership
eliykat Nov 9, 2025
7741eee
Allow providers to update billing email
eliykat Nov 9, 2025
015fca5
Review and update xmldoc
eliykat Nov 9, 2025
1667d03
Make name optional as well for consistency
eliykat Nov 9, 2025
3162723
dotnet format
eliykat Nov 9, 2025
6e242e4
Merge remote-tracking branch 'origin/main' into ac/pm-25913/web-serveโ€ฆ
eliykat Nov 15, 2025
7529311
Fix Org Billing authz logic
eliykat Nov 18, 2025
bdc9c16
Simplify implementation and integration tests
eliykat Nov 18, 2025
ccdabf0
Update unit tests to use SutProvider
eliykat Nov 18, 2025
cbc07c1
dotnet format
eliykat Nov 18, 2025
8276723
Merge remote-tracking branch 'origin/main' into ac/pm-25913/web-serveโ€ฆ
eliykat Nov 18, 2025
4e30d99
Address minor feedback
eliykat Nov 18, 2025
6fd7561
Clarify flow of command
eliykat Nov 18, 2025
2ccbf0b
Tidy up new handler
eliykat Nov 18, 2025
77da9e7
Unwind excessive changes
eliykat Nov 19, 2025
cc85c87
Simplify command flow
eliykat Nov 19, 2025
1b5b6e1
Fix tests
eliykat Nov 19, 2025
8ae9f0f
Merge remote-tracking branch 'origin/main' into ac/pm-25913/web-serveโ€ฆ
eliykat Nov 19, 2025
47506b5
Address Claude feedback
eliykat Nov 19, 2025
4ea7312
Tweak test coverage per Claude feedback
eliykat Nov 19, 2025
84c303d
Additional Claude feedback
eliykat Nov 19, 2025
5ec80e5
Merge remote-tracking branch 'origin/main' into ac/pm-25913/web-serveโ€ฆ
eliykat Nov 20, 2025
ef7c112
Mock pricingClient in api tests
eliykat Nov 20, 2025
ae97c8a
Revert pricingClient mock, use latest plan instead
eliykat Nov 20, 2025
299f5ad
Undo change to csproj
eliykat Nov 20, 2025
aa3c6f1
linting
eliykat Nov 20, 2025
4771e63
Move logic to extension methods, consistent naming
eliykat Nov 21, 2025
7470685
Merge branch 'main' into ac/pm-25913/web-server-msp-organization-owneโ€ฆ
eliykat Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 19 additions & 29 deletions src/Api/AdminConsole/Controllers/OrganizationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
Expand Down Expand Up @@ -70,6 +69,7 @@ public class OrganizationsController : Controller
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;

public OrganizationsController(
IOrganizationRepository organizationRepository,
Expand All @@ -94,7 +94,8 @@ public OrganizationsController(
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
IOrganizationUpdateCommand organizationUpdateCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand All @@ -119,6 +120,7 @@ public OrganizationsController(
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
_organizationUpdateCommand = organizationUpdateCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -224,36 +226,31 @@ public async Task<OrganizationResponseModel> CreateWithoutPaymentAsync([FromBody
return new OrganizationResponseModel(result.Organization, plan);
}

[HttpPut("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
[HttpPut("{organizationId:guid}")]
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
// If billing email is being changed, require subscription editing permissions.
// Otherwise, organization owner permissions are sufficient.
var requiresBillingPermission = model.BillingEmail is not null;
var authorized = requiresBillingPermission
? await _currentContext.EditSubscription(organizationId)
: await _currentContext.OrganizationOwner(organizationId);

var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
if (!authorized)
{
throw new NotFoundException();
return TypedResults.Unauthorized();
}

var updateBilling = ShouldUpdateBilling(model, organization);

var hasRequiredPermissions = updateBilling
? await _currentContext.EditSubscription(orgIdGuid)
: await _currentContext.OrganizationOwner(orgIdGuid);

if (!hasRequiredPermissions)
{
throw new NotFoundException();
}
var commandRequest = model.ToCommandRequest(organizationId);
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);

await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
}

[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
{
return await Put(id, model);
}
Expand Down Expand Up @@ -588,11 +585,4 @@ public async Task<PlanType> GetPlanType(string id)

return organization.PlanType;
}

private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
{
var organizationNameChanged = model.Name != organization.Name;
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
}
}
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
๏ปฟ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.ComponentModel.DataAnnotations;
๏ปฟusing System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.Utilities;

namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class OrganizationUpdateRequestModel
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
public string? Name { get; set; }

[EmailAddress]
[Required]
[StringLength(256)]
public string BillingEmail { get; set; }
public Permissions Permissions { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public string? BillingEmail { get; set; }

public OrganizationKeysRequestModel? Keys { get; set; }

public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
{
if (!globalSettings.SelfHosted)
{
// These items come from the license file
existingOrganization.Name = Name;
existingOrganization.BusinessName = BusinessName;
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
}
Keys?.ToOrganization(existingOrganization);
return existingOrganization;
}
OrganizationId = organizationId,
Name = Name,
BillingEmail = BillingEmail,
PublicKey = Keys?.PublicKey,
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
};
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,15 @@ Task UpdatePaymentMethod(
/// <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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,35 @@ public async Task UpdateSubscriptionPlanFrequency(
}
}

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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;
Expand Down Expand Up @@ -87,6 +88,7 @@ private static void AddOrganizationDeleteCommands(this IServiceCollection servic
private static void AddOrganizationUpdateCommands(this IServiceCollection services)
{
services.AddScoped<IOrganizationUpdateKeysCommand, OrganizationUpdateKeysCommand>();
services.AddScoped<IOrganizationUpdateCommand, OrganizationUpdateCommand>();
}

private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
Expand Down
Loading
Loading