Skip to content

Commit 3956e81

Browse files
committed
Merge branch 'main' into billing/PM-28100/2019-families-email
# Conflicts: # src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs # test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
2 parents 75ef19d + 1334ed8 commit 3956e81

File tree

18 files changed

+2248
-382
lines changed

18 files changed

+2248
-382
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ jobs:
280280
output-format: sarif
281281

282282
- name: Upload Grype results to GitHub
283-
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
283+
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
284284
with:
285285
sarif_file: ${{ steps.container-scan.outputs.sarif }}
286286
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}

src/Api/AdminConsole/Controllers/OrganizationsController.cs

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
using Bit.Api.Models.Request.Organizations;
1313
using Bit.Api.Models.Response;
1414
using Bit.Core;
15-
using Bit.Core.AdminConsole.Entities;
1615
using Bit.Core.AdminConsole.Enums;
1716
using Bit.Core.AdminConsole.Models.Business.Tokenables;
1817
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -70,6 +69,7 @@ public class OrganizationsController : Controller
7069
private readonly IPolicyRequirementQuery _policyRequirementQuery;
7170
private readonly IPricingClient _pricingClient;
7271
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
72+
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
7373

7474
public OrganizationsController(
7575
IOrganizationRepository organizationRepository,
@@ -94,7 +94,8 @@ public OrganizationsController(
9494
IOrganizationDeleteCommand organizationDeleteCommand,
9595
IPolicyRequirementQuery policyRequirementQuery,
9696
IPricingClient pricingClient,
97-
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
97+
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
98+
IOrganizationUpdateCommand organizationUpdateCommand)
9899
{
99100
_organizationRepository = organizationRepository;
100101
_organizationUserRepository = organizationUserRepository;
@@ -119,6 +120,7 @@ public OrganizationsController(
119120
_policyRequirementQuery = policyRequirementQuery;
120121
_pricingClient = pricingClient;
121122
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
123+
_organizationUpdateCommand = organizationUpdateCommand;
122124
}
123125

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

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

232-
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
233-
if (organization == null)
239+
if (!authorized)
234240
{
235-
throw new NotFoundException();
241+
return TypedResults.Unauthorized();
236242
}
237243

238-
var updateBilling = ShouldUpdateBilling(model, organization);
239-
240-
var hasRequiredPermissions = updateBilling
241-
? await _currentContext.EditSubscription(orgIdGuid)
242-
: await _currentContext.OrganizationOwner(orgIdGuid);
243-
244-
if (!hasRequiredPermissions)
245-
{
246-
throw new NotFoundException();
247-
}
244+
var commandRequest = model.ToCommandRequest(organizationId);
245+
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
248246

249-
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
250-
var plan = await _pricingClient.GetPlan(organization.PlanType);
251-
return new OrganizationResponseModel(organization, plan);
247+
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
248+
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
252249
}
253250

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

589586
return organization.PlanType;
590587
}
591-
592-
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
593-
{
594-
var organizationNameChanged = model.Name != organization.Name;
595-
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
596-
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
597-
}
598588
}
Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,28 @@
1-
// FIXME: Update this file to be null safe and then delete the line below
2-
#nullable disable
3-
4-
using System.ComponentModel.DataAnnotations;
1+
using System.ComponentModel.DataAnnotations;
52
using System.Text.Json.Serialization;
6-
using Bit.Core.AdminConsole.Entities;
7-
using Bit.Core.Models.Data;
8-
using Bit.Core.Settings;
3+
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
94
using Bit.Core.Utilities;
105

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

138
public class OrganizationUpdateRequestModel
149
{
15-
[Required]
1610
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
1711
[JsonConverter(typeof(HtmlEncodingStringConverter))]
18-
public string Name { get; set; }
19-
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
20-
[JsonConverter(typeof(HtmlEncodingStringConverter))]
21-
public string BusinessName { get; set; }
12+
public string? Name { get; set; }
13+
2214
[EmailAddress]
23-
[Required]
2415
[StringLength(256)]
25-
public string BillingEmail { get; set; }
26-
public Permissions Permissions { get; set; }
27-
public OrganizationKeysRequestModel Keys { get; set; }
16+
public string? BillingEmail { get; set; }
17+
18+
public OrganizationKeysRequestModel? Keys { get; set; }
2819

29-
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
20+
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
3021
{
31-
if (!globalSettings.SelfHosted)
32-
{
33-
// These items come from the license file
34-
existingOrganization.Name = Name;
35-
existingOrganization.BusinessName = BusinessName;
36-
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
37-
}
38-
Keys?.ToOrganization(existingOrganization);
39-
return existingOrganization;
40-
}
22+
OrganizationId = organizationId,
23+
Name = Name,
24+
BillingEmail = BillingEmail,
25+
PublicKey = Keys?.PublicKey,
26+
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
27+
};
4128
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
3+
4+
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
5+
6+
public interface IOrganizationUpdateCommand
7+
{
8+
/// <summary>
9+
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
10+
/// Also optionally updates an organization's public-private keypair if it was not created with one.
11+
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
12+
/// </summary>
13+
/// <param name="request">The update request containing the details to be updated.</param>
14+
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
15+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
3+
using Bit.Core.Billing.Organizations.Services;
4+
using Bit.Core.Enums;
5+
using Bit.Core.Exceptions;
6+
using Bit.Core.Repositories;
7+
using Bit.Core.Services;
8+
using Bit.Core.Settings;
9+
10+
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
11+
12+
public class OrganizationUpdateCommand(
13+
IOrganizationService organizationService,
14+
IOrganizationRepository organizationRepository,
15+
IGlobalSettings globalSettings,
16+
IOrganizationBillingService organizationBillingService
17+
) : IOrganizationUpdateCommand
18+
{
19+
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
20+
{
21+
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
22+
if (organization == null)
23+
{
24+
throw new NotFoundException();
25+
}
26+
27+
if (globalSettings.SelfHosted)
28+
{
29+
return await UpdateSelfHostedAsync(organization, request);
30+
}
31+
32+
return await UpdateCloudAsync(organization, request);
33+
}
34+
35+
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
36+
{
37+
// Store original values for comparison
38+
var originalName = organization.Name;
39+
var originalBillingEmail = organization.BillingEmail;
40+
41+
// Apply updates to organization
42+
organization.UpdateDetails(request);
43+
organization.BackfillPublicPrivateKeys(request);
44+
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
45+
46+
// Update billing information in Stripe if required
47+
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
48+
49+
return organization;
50+
}
51+
52+
/// <summary>
53+
/// Self-host cannot update the organization details because they are set by the license file.
54+
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
55+
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
56+
/// </summary>
57+
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
58+
{
59+
organization.BackfillPublicPrivateKeys(request);
60+
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
61+
return organization;
62+
}
63+
64+
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
65+
{
66+
// Update Stripe if name or billing email changed
67+
var shouldUpdateBilling = originalName != organization.Name ||
68+
originalBillingEmail != organization.BillingEmail;
69+
70+
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
71+
{
72+
return;
73+
}
74+
75+
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
76+
}
77+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
3+
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
4+
5+
public static class OrganizationUpdateExtensions
6+
{
7+
/// <summary>
8+
/// Updates the organization name and/or billing email.
9+
/// Any null property on the request object will be skipped.
10+
/// </summary>
11+
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
12+
{
13+
// These values may or may not be sent by the client depending on the operation being performed.
14+
// Skip any values not provided.
15+
if (request.Name is not null)
16+
{
17+
organization.Name = request.Name;
18+
}
19+
20+
if (request.BillingEmail is not null)
21+
{
22+
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
23+
}
24+
}
25+
26+
/// <summary>
27+
/// Updates the organization public and private keys if provided and not already set.
28+
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
29+
/// migration that will silently migrate organizations when they change their details.
30+
/// </summary>
31+
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
32+
{
33+
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
34+
{
35+
organization.PublicKey = request.PublicKey;
36+
}
37+
38+
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
39+
{
40+
organization.PrivateKey = request.EncryptedPrivateKey;
41+
}
42+
}
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
2+
3+
/// <summary>
4+
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
5+
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
6+
/// </summary>
7+
public record OrganizationUpdateRequest
8+
{
9+
/// <summary>
10+
/// The ID of the organization to update.
11+
/// </summary>
12+
public required Guid OrganizationId { get; init; }
13+
14+
/// <summary>
15+
/// The new organization name to apply (optional, this is skipped if not provided).
16+
/// </summary>
17+
public string? Name { get; init; }
18+
19+
/// <summary>
20+
/// The new billing email address to apply (optional, this is skipped if not provided).
21+
/// </summary>
22+
public string? BillingEmail { get; init; }
23+
24+
/// <summary>
25+
/// The organization's public key to set (optional, only set if not already present on the organization).
26+
/// </summary>
27+
public string? PublicKey { get; init; }
28+
29+
/// <summary>
30+
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
31+
/// </summary>
32+
public string? EncryptedPrivateKey { get; init; }
33+
}

src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,15 @@ Task UpdatePaymentMethod(
5656
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="organization"/> is <see langword="null"/>.</exception>
5757
/// <exception cref="BillingException">Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails.</exception>
5858
Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType);
59+
60+
/// <summary>
61+
/// Updates the organization name and email on the Stripe customer entry.
62+
/// This only updates Stripe, not the Bitwarden database.
63+
/// </summary>
64+
/// <remarks>
65+
/// The caller should ensure that the organization has a GatewayCustomerId before calling this method.
66+
/// </remarks>
67+
/// <param name="organization">The organization to update in Stripe.</param>
68+
/// <exception cref="BillingException">Thrown when the organization does not have a GatewayCustomerId.</exception>
69+
Task UpdateOrganizationNameAndEmail(Organization organization);
5970
}

src/Core/Billing/Organizations/Services/OrganizationBillingService.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,35 @@ public async Task UpdateSubscriptionPlanFrequency(
176176
}
177177
}
178178

179+
public async Task UpdateOrganizationNameAndEmail(Organization organization)
180+
{
181+
if (organization.GatewayCustomerId is null)
182+
{
183+
throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId.");
184+
}
185+
186+
var newDisplayName = organization.DisplayName();
187+
188+
await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId,
189+
new CustomerUpdateOptions
190+
{
191+
Email = organization.BillingEmail,
192+
Description = newDisplayName,
193+
InvoiceSettings = new CustomerInvoiceSettingsOptions
194+
{
195+
// This overwrites the existing custom fields for this organization
196+
CustomFields = [
197+
new CustomerInvoiceSettingsCustomFieldOptions
198+
{
199+
Name = organization.SubscriberType(),
200+
Value = newDisplayName.Length <= 30
201+
? newDisplayName
202+
: newDisplayName[..30]
203+
}]
204+
},
205+
});
206+
}
207+
179208
#region Utilities
180209

181210
private async Task<Customer> CreateCustomerAsync(

0 commit comments

Comments
 (0)