1
0
mirror of https://github.com/bitwarden/server synced 2025-12-17 16:53:23 +00:00

Merge branch 'main' of github.com:bitwarden/server into arch/seeder-api

# Conflicts:
#	bitwarden-server.sln
#	util/Seeder/Factories/UserSeeder.cs
This commit is contained in:
Hinton
2025-10-21 17:47:52 -04:00
281 changed files with 38453 additions and 4577 deletions

View File

@@ -123,3 +123,12 @@ csharp_style_namespace_declarations = file_scoped:warning
# Switch expression # Switch expression
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
# CA2253: Named placeholders should nto be numeric values
dotnet_diagnostic.CA2253.severity = suggestion
# CA2254: Template should be a static expression
dotnet_diagnostic.CA2254.severity = warning
# CA1727: Use PascalCase for named placeholders
dotnet_diagnostic.CA1727.severity = suggestion

3
.github/CODEOWNERS vendored
View File

@@ -96,6 +96,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
# The PushType enum is expected to be editted by anyone without need for Platform review # The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs src/Core/Platform/Push/PushType.cs
# SDK
util/RustSdk @bitwarden/team-sdk-sme
# Multiple owners - DO NOT REMOVE (BRE) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props Directory.Build.props

View File

@@ -10,6 +10,11 @@
"nuget", "nuget",
], ],
packageRules: [ packageRules: [
{
groupName: "cargo minor",
matchManagers: ["cargo"],
matchUpdateTypes: ["minor"],
},
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],

View File

@@ -30,7 +30,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@@ -117,7 +117,7 @@ jobs:
fi fi
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -269,7 +269,7 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0 uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
@@ -299,7 +299,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -425,7 +425,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Print environment - name: Print environment
run: | run: |

View File

@@ -47,7 +47,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@@ -179,7 +179,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Print environment - name: Print environment
run: | run: |

View File

@@ -30,7 +30,15 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
- name: Install rust - name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable

1
.gitignore vendored
View File

@@ -234,3 +234,4 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json /identity.json
/api.json /api.json
/api.public.json /api.public.json
.serena/

View File

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

View File

@@ -148,22 +148,30 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
} }
else if (organization.IsStripeEnabled()) else if (organization.IsStripeEnabled())
{ {
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId); var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired) if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{ {
return; return;
} }
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
{ {
Coupon = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail
}); });
if (subscription.Customer.Discount?.Coupon != null)
{
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{ {
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30 DaysUntilDue = 30,
}); });
await _subscriberService.RemovePaymentSource(organization); await _subscriberService.RemovePaymentSource(organization);

View File

@@ -481,7 +481,6 @@ public class ProviderBillingService(
City = billingAddress.City, City = billingAddress.City,
State = billingAddress.State State = billingAddress.State
}, },
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
Description = provider.DisplayBusinessName(), Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail, Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions InvoiceSettings = new CustomerInvoiceSettingsOptions
@@ -663,6 +662,7 @@ public class ProviderBillingService(
: CollectionMethod.SendInvoice, : CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = usePaymentMethod ? null : 30, DaysUntilDue = usePaymentMethod ? null : 30,
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } }, Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
OffSession = true, OffSession = true,
@@ -671,7 +671,6 @@ public class ProviderBillingService(
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
}; };
try try
{ {
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

View File

@@ -157,6 +157,6 @@ public class Startup
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
// Log startup // Log startup
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started."); logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
} }
} }

View File

@@ -156,16 +156,18 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId) sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
.Returns(GetSubscription(organization.GatewaySubscriptionId)); options => options.Expand.Contains("customer")))
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => Arg.Is<SubscriptionUpdateOptions>(options =>
@@ -368,10 +370,21 @@ public class RemoveOrganizationFromProviderCommandTests
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com")); Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
} }
private static Subscription GetSubscription(string subscriptionId) => private static Subscription GetSubscription(string subscriptionId, string customerId) =>
new() new()
{ {
Id = subscriptionId, Id = subscriptionId,
CustomerId = customerId,
Customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-id"
}
}
},
Status = StripeConstants.SubscriptionStatus.Active, Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem> Items = new StripeList<SubscriptionItem>
{ {

View File

@@ -436,7 +436,7 @@ public class PatchGroupCommandTests
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
// Assert: logging // Assert: logging
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default); sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning("");
} }
[Theory] [Theory]

View File

@@ -472,6 +472,7 @@ public class OrganizationsController : Controller
organization.UseRiskInsights = model.UseRiskInsights; organization.UseRiskInsights = model.UseRiskInsights;
organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
//secrets //secrets
organization.SmSeats = model.SmSeats; organization.SmSeats = model.SmSeats;

View File

@@ -106,6 +106,8 @@ public class OrganizationEditModel : OrganizationViewModel
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains; UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
_plans = plans; _plans = plans;
} }
@@ -192,6 +194,8 @@ public class OrganizationEditModel : OrganizationViewModel
[Display(Name = "Use Organization Domains")] [Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
[Display(Name = "Automatic User Confirmation")]
public bool UseAutomaticUserConfirmation { get; set; }
/** /**
* Creates a Plan[] object for use in Javascript * Creates a Plan[] object for use in Javascript
* This is mapped manually below to provide some type safety in case the plan objects change * This is mapped manually below to provide some type safety in case the plan objects change
@@ -231,6 +235,7 @@ public class OrganizationEditModel : OrganizationViewModel
LegacyYear = p.LegacyYear, LegacyYear = p.LegacyYear,
Disabled = p.Disabled, Disabled = p.Disabled,
SupportsSecretsManager = p.SupportsSecretsManager, SupportsSecretsManager = p.SupportsSecretsManager,
AutomaticUserConfirmation = p.AutomaticUserConfirmation,
PasswordManager = PasswordManager =
new new
{ {

View File

@@ -159,6 +159,13 @@
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label> <label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div> </div>
} }
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAutomaticUserConfirmation" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAutomaticUserConfirmation"></label>
</div>
}
</div> </div>
<div class="col-3"> <div class="col-3">
<h3>Password Manager</h3> <h3>Password Manager</h3>

View File

@@ -1,83 +0,0 @@
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Migration.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("migrate-providers")]
[SelfHosted(NotSelfHostedOnly = true)]
public class MigrateProvidersController(
IProviderMigrator providerMigrator) : Controller
{
[HttpGet]
[RequirePermission(Permission.Tools_MigrateProviders)]
public IActionResult Index()
{
return View(new MigrateProvidersRequestModel());
}
[HttpPost]
[RequirePermission(Permission.Tools_MigrateProviders)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return RedirectToAction("Index");
}
foreach (var providerId in providerIds)
{
await providerMigrator.Migrate(providerId);
}
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
}
[HttpGet("results")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return View(Array.Empty<ProviderMigrationResult>());
}
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
return View(results);
}
[HttpGet("results/{providerId:guid}")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
{
var result = await providerMigrator.GetResult(providerId);
if (result == null)
{
return RedirectToAction("Index");
}
return View(result);
}
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
? text.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.TrimEntries
)
.Select(id => new Guid(id))
.ToList()
: [];
}

View File

@@ -1,13 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models;
public class MigrateProvidersRequestModel
{
[Required]
[Display(Name = "Provider IDs")]
public string ProviderIds { get; set; }
}

View File

@@ -1,39 +0,0 @@
@using System.Text.Json
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Migration Details: @Model.ProviderName</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
<dt class="col-sm-4 col-lg-3">Result</dt>
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
</dl>
<h3>Client Organizations</h3>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
<th>Previous State</th>
</tr>
</thead>
<tbody>
@foreach (var clientResult in Model.Clients)
{
<tr>
<td>@clientResult.OrganizationId</td>
<td>@clientResult.OrganizationName</td>
<td>@clientResult.Result</td>
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -1,46 +0,0 @@
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
@{
ViewData["Title"] = "Migrate Providers";
}
<h1>Migrate Providers</h1>
<h2>Bulk Consolidated Billing Migration Tool</h2>
<section>
<p>
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
</p>
<p class="alert alert-warning">
Updates made through this tool are irreversible without manual intervention.
</p>
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
<div class="card">
<div class="card-body">
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
174e82fc-70c3-448d-9fe7-00bad2a3ab00
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
</div>
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="Run" class="btn btn-primary"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
</div>
</form>
</section>

View File

@@ -1,28 +0,0 @@
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Results</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
</tr>
</thead>
<tbody>
@foreach (var result in Model)
{
<tr>
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
<td>@result.ProviderName</td>
<td>@result.Result</td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -61,7 +61,7 @@ public class HomeController : Controller
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}"); _logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
return new JsonResult("Unable to fetch latest version") { StatusCode = StatusCodes.Status500InternalServerError }; return new JsonResult("Unable to fetch latest version") { StatusCode = StatusCodes.Status500InternalServerError };
} }
@@ -83,7 +83,7 @@ public class HomeController : Controller
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}"); _logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
return new JsonResult("Unable to fetch installed version") { StatusCode = StatusCodes.Status500InternalServerError }; return new JsonResult("Unable to fetch installed version") { StatusCode = StatusCodes.Status500InternalServerError };
} }

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using System.Text;
using System.Text.Json; using System.Text.Json;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
@@ -10,7 +9,6 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.BitStripe;
using Bit.Core.Platform.Installations; using Bit.Core.Platform.Installations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -33,7 +31,6 @@ public class ToolsController : Controller
private readonly IInstallationRepository _installationRepository; private readonly IInstallationRepository _installationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly IPaymentService _paymentService;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
@@ -46,7 +43,6 @@ public class ToolsController : Controller
IInstallationRepository installationRepository, IInstallationRepository installationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IPaymentService paymentService,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IWebHostEnvironment environment) IWebHostEnvironment environment)
{ {
@@ -58,7 +54,6 @@ public class ToolsController : Controller
_installationRepository = installationRepository; _installationRepository = installationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_paymentService = paymentService;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_environment = environment; _environment = environment;
} }
@@ -341,138 +336,4 @@ public class ToolsController : Controller
throw new Exception("No license to generate."); throw new Exception("No license to generate.");
} }
} }
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
{
options = options ?? new StripeSubscriptionListOptions();
options.Limit = 10;
options.Expand = new List<string>() { "data.customer", "data.latest_invoice" };
options.SelectAll = false;
var subscriptions = await _stripeAdapter.SubscriptionListAsync(options);
options.StartingAfter = subscriptions.LastOrDefault()?.Id;
options.EndingBefore = await StripeSubscriptionsGetHasPreviousPage(subscriptions, options) ?
subscriptions.FirstOrDefault()?.Id :
null;
var isProduction = _environment.IsProduction();
var model = new StripeSubscriptionsModel()
{
Items = subscriptions.Select(s => new StripeSubscriptionRowModel(s)).ToList(),
Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data,
TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync(),
Filter = options
};
return View(model);
}
[HttpPost]
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions([FromForm] StripeSubscriptionsModel model)
{
if (!ModelState.IsValid)
{
var isProduction = _environment.IsProduction();
model.Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data;
model.TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync();
return View(model);
}
if (model.Action == StripeSubscriptionsAction.Export || model.Action == StripeSubscriptionsAction.BulkCancel)
{
var subscriptions = model.Filter.SelectAll ?
await _stripeAdapter.SubscriptionListAsync(model.Filter) :
model.Items.Where(x => x.Selected).Select(x => x.Subscription);
if (model.Action == StripeSubscriptionsAction.Export)
{
return StripeSubscriptionsExport(subscriptions);
}
if (model.Action == StripeSubscriptionsAction.BulkCancel)
{
await StripeSubscriptionsCancel(subscriptions);
}
}
else
{
if (model.Action == StripeSubscriptionsAction.PreviousPage || model.Action == StripeSubscriptionsAction.Search)
{
model.Filter.StartingAfter = null;
}
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
{
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
if (subscription.Status == "canceled")
{
model.Filter.StartingAfter = null;
}
}
model.Filter.EndingBefore = null;
}
}
return RedirectToAction("StripeSubscriptions", model.Filter);
}
// This requires a redundant API call to Stripe because of the way they handle pagination.
// The StartingBefore value has to be inferred from the list we get, and isn't supplied by Stripe.
private async Task<bool> StripeSubscriptionsGetHasPreviousPage(List<Stripe.Subscription> subscriptions, StripeSubscriptionListOptions options)
{
var hasPreviousPage = false;
if (subscriptions.FirstOrDefault()?.Id != null)
{
var previousPageSearchOptions = new StripeSubscriptionListOptions()
{
EndingBefore = subscriptions.FirstOrDefault().Id,
Limit = 1,
Status = options.Status,
CurrentPeriodEndDate = options.CurrentPeriodEndDate,
CurrentPeriodEndRange = options.CurrentPeriodEndRange,
Price = options.Price
};
hasPreviousPage = (await _stripeAdapter.SubscriptionListAsync(previousPageSearchOptions)).Count > 0;
}
return hasPreviousPage;
}
private async Task StripeSubscriptionsCancel(IEnumerable<Stripe.Subscription> subscriptions)
{
foreach (var s in subscriptions)
{
await _stripeAdapter.SubscriptionCancelAsync(s.Id);
if (s.LatestInvoice?.Status == "open")
{
await _stripeAdapter.InvoiceVoidInvoiceAsync(s.LatestInvoiceId);
}
}
}
private FileResult StripeSubscriptionsExport(IEnumerable<Stripe.Subscription> subscriptions)
{
var fieldsToExport = subscriptions.Select(s => new
{
StripeId = s.Id,
CustomerEmail = s.Customer?.Email,
SubscriptionStatus = s.Status,
InvoiceDueDate = s.CurrentPeriodEnd,
SubscriptionProducts = s.Items?.Data.Select(p => p.Plan.Id)
});
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var result = System.Text.Json.JsonSerializer.Serialize(fieldsToExport, options);
var bytes = Encoding.UTF8.GetBytes(result);
return File(bytes, "application/json", "StripeSubscriptionsSearch.json");
}
} }

View File

@@ -52,8 +52,6 @@ public enum Permission
Tools_PromoteProviderServiceUser, Tools_PromoteProviderServiceUser,
Tools_GenerateLicenseFile, Tools_GenerateLicenseFile,
Tools_ManageTaxRates, Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction, Tools_CreateEditTransaction,
Tools_ProcessStripeEvents, Tools_ProcessStripeEvents
Tools_MigrateProviders
} }

View File

@@ -22,7 +22,7 @@ public class AliveJob : BaseJob
{ {
_logger.LogInformation(Constants.BypassFiltersEventId, "Execute job task: Keep alive"); _logger.LogInformation(Constants.BypassFiltersEventId, "Execute job task: Keep alive");
var response = await _httpClient.GetAsync(_globalSettings.BaseServiceUri.Admin); var response = await _httpClient.GetAsync(_globalSettings.BaseServiceUri.Admin);
_logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, " + _logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, {StatusCode}",
response.StatusCode); response.StatusCode);
} }
} }

View File

@@ -1,45 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Models.BitStripe;
namespace Bit.Admin.Models;
public class StripeSubscriptionRowModel
{
public Stripe.Subscription Subscription { get; set; }
public bool Selected { get; set; }
public StripeSubscriptionRowModel() { }
public StripeSubscriptionRowModel(Stripe.Subscription subscription)
{
Subscription = subscription;
}
}
public enum StripeSubscriptionsAction
{
Search,
PreviousPage,
NextPage,
Export,
BulkCancel
}
public class StripeSubscriptionsModel : IValidatableObject
{
public List<StripeSubscriptionRowModel> Items { get; set; }
public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search;
public string Message { get; set; }
public List<Stripe.Price> Prices { get; set; }
public List<Stripe.TestHelpers.TestClock> TestClocks { get; set; }
public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions();
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid")
{
yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions");
}
}
}

View File

@@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Providers.Migration;
#if !OSS #if !OSS
using Bit.Commercial.Core.Utilities; using Bit.Commercial.Core.Utilities;
@@ -92,7 +91,6 @@ public class Startup
services.AddDistributedCache(globalSettings); services.AddDistributedCache(globalSettings);
services.AddBillingOperations(); services.AddBillingOperations();
services.AddHttpClient(); services.AddHttpClient();
services.AddProviderMigration();
#if OSS #if OSS
services.AddOosServices(); services.AddOosServices();

View File

@@ -52,8 +52,7 @@ public static class RolePermissionMapping
Permission.Tools_PromoteAdmin, Permission.Tools_PromoteAdmin,
Permission.Tools_PromoteProviderServiceUser, Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates
Permission.Tools_ManageStripeSubscriptions
} }
}, },
{ "admin", new List<Permission> { "admin", new List<Permission>
@@ -105,7 +104,6 @@ public static class RolePermissionMapping
Permission.Tools_PromoteProviderServiceUser, Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction Permission.Tools_CreateEditTransaction
} }
}, },
@@ -180,10 +178,8 @@ public static class RolePermissionMapping
Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_ChargeBrainTreeCustomer,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction, Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents, Permission.Tools_ProcessStripeEvents
Permission.Tools_MigrateProviders
} }
}, },
{ "sales", new List<Permission> { "sales", new List<Permission>

View File

@@ -13,12 +13,10 @@
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin); var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser); var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile); var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents); var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser || var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser ||
canGenerateLicense || canManageStripeSubscriptions; canGenerateLicense;
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -102,12 +100,6 @@
<a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense"> <a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense">
Generate License Generate License
</a> </a>
}
@if (canManageStripeSubscriptions)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">
Manage Stripe Subscriptions
</a>
} }
@if (canProcessStripeEvents) @if (canProcessStripeEvents)
{ {
@@ -115,12 +107,6 @@
Process Stripe Events Process Stripe Events
</a> </a>
} }
@if (canMigrateProviders)
{
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
Migrate Providers
</a>
}
</ul> </ul>
</li> </li>
} }

View File

@@ -1,277 +0,0 @@
@model StripeSubscriptionsModel
@{
ViewData["Title"] = "Stripe Subscriptions";
}
@section Scripts {
<script>
function onRowSelect(selectingPage = false) {
let checkboxes = document.getElementsByClassName('row-check');
let checkedCheckboxCount = 0;
let bulkActions = document.getElementById('bulkActions');
let selectPage = document.getElementById('selectPage');
for(let i = 0; i < checkboxes.length; i++){
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
checkboxes[i].checked = true;
checkedCheckboxCount += 1;
} else {
checkboxes[i].checked = false;
}
}
if(checkedCheckboxCount > 0) {
bulkActions.classList.remove("d-none");
} else {
bulkActions.classList.add("d-none");
}
let selectAll = document.getElementById('selectAll');
if (checkedCheckboxCount === checkboxes.length) {
selectPage.checked = true;
selectAll.classList.remove("d-none");
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.remove('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.add('d-none');
} else {
selectPage.checked = false;
selectAll.classList.add("d-none");
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = false;
}
}
function onSelectAll() {
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = true;
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.add('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.remove('d-none');
}
function exportSelectedSubscriptions() {
let selectAll = document.getElementById('selectAll');
let httpRequest = new XMLHttpRequest();
httpRequest.open("POST");
httpRequest.send();
}
function cancelSelectedSubscriptions() {
}
</script>
}
<h2>Manage Stripe Subscriptions</h2>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert alert-success"></div>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group">
<div class="input-group-text">
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
<label class="form-check-label me-2" for="beforeRadio">Before</label>
</div>
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
<label class="form-check-label" for="afterRadio">After</label>
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
}
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
</div>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</div>
<hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
<i class="fa fa-check"></i> All subscriptions for this search are selected.
</span>
<div class="alert alert-warning mt-2" role="alert">
Please be aware that bulk operations may take several minutes to complete.
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>
<div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td>
@{
var i0 = i;
}
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
{
var i1 = i;
var j1 = j;
<input
type="hidden"
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
}
<div class="form-check">
@{
var i2 = i;
}
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex align-items-center">
<ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit"
class="page-link"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.PreviousPage">
Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage">
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex gap-2">
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
Export
</button>
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</span>
</span>
</nav>
</form>

View File

@@ -70,6 +70,7 @@ public class OrganizationResponseModel : ResponseModel
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains; UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -118,6 +119,7 @@ public class OrganizationResponseModel : ResponseModel
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@@ -87,6 +87,8 @@ public class ProfileOrganizationResponseModel : ResponseModel
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
} }
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -164,4 +166,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool IsAdminInitiated { get; set; } public bool IsAdminInitiated { get; set; }
public bool SsoEnabled { get; set; } public bool SsoEnabled { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; } public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -52,5 +52,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains; UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
} }

View File

@@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Api.Response; using Bit.Core.Models.Api.Response;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -40,6 +41,7 @@ public class AccountsController : Controller
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly ITwoFactorEmailService _twoFactorEmailService;
private readonly IChangeKdfCommand _changeKdfCommand; private readonly IChangeKdfCommand _changeKdfCommand;
@@ -53,6 +55,7 @@ public class AccountsController : Controller
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService, IFeatureService featureService,
IUserAccountKeysQuery userAccountKeysQuery,
ITwoFactorEmailService twoFactorEmailService, ITwoFactorEmailService twoFactorEmailService,
IChangeKdfCommand changeKdfCommand IChangeKdfCommand changeKdfCommand
) )
@@ -66,6 +69,7 @@ public class AccountsController : Controller
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService; _featureService = featureService;
_userAccountKeysQuery = userAccountKeysQuery;
_twoFactorEmailService = twoFactorEmailService; _twoFactorEmailService = twoFactorEmailService;
_changeKdfCommand = changeKdfCommand; _changeKdfCommand = changeKdfCommand;
} }
@@ -332,7 +336,9 @@ public class AccountsController : Controller
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, var accountKeys = await _userAccountKeysQuery.Run(user);
var response = new ProfileResponseModel(user, accountKeys, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, twoFactorEnabled, providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg, organizationIdsClaimingActiveUser); hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
@@ -364,8 +370,9 @@ public class AccountsController : Controller
var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var userAccountKeys = await _userAccountKeysQuery.Run(user);
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser); var response = new ProfileResponseModel(user, userAccountKeys, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
return response; return response;
} }
@@ -389,8 +396,9 @@ public class AccountsController : Controller
var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var accountKeys = await _userAccountKeysQuery.Run(user);
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); var response = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return response; return response;
} }

View File

@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
@@ -8,6 +9,7 @@ using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -21,7 +23,8 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class AccountsController( public class AccountsController(
IUserService userService, IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) : Controller ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller
{ {
[HttpPost("premium")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync( public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -58,8 +61,9 @@ public class AccountsController(
var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var accountKeys = await userAccountKeysQuery.Run(user);
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled,
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return new PaymentResponseModel return new PaymentResponseModel
{ {

View File

@@ -38,9 +38,7 @@ public class OrganizationBillingController(
return Error.NotFound(); return Error.NotFound();
} }
var response = OrganizationMetadataResponse.From(metadata); return TypedResults.Ok(metadata);
return TypedResults.Ok(response);
} }
[HttpGet("history")] [HttpGet("history")]

View File

@@ -132,7 +132,7 @@ public class ProviderBillingController(
} }
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] }); new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] });
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);

View File

@@ -4,6 +4,7 @@ using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requests.Subscriptions;
using Bit.Api.Billing.Models.Requirements; using Bit.Api.Billing.Models.Requirements;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Queries;
@@ -25,6 +26,7 @@ public class OrganizationBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
IGetBillingAddressQuery getBillingAddressQuery, IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery, IGetCreditQuery getCreditQuery,
IGetOrganizationMetadataQuery getOrganizationMetadataQuery,
IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
IGetPaymentMethodQuery getPaymentMethodQuery, IGetPaymentMethodQuery getPaymentMethodQuery,
IRestartSubscriptionCommand restartSubscriptionCommand, IRestartSubscriptionCommand restartSubscriptionCommand,
@@ -113,6 +115,23 @@ public class OrganizationBillingVNextController(
return Handle(result); return Handle(result);
} }
[Authorize<MemberOrProviderRequirement>]
[HttpGet("metadata")]
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
[InjectOrganization]
public async Task<IResult> GetMetadataAsync(
[BindNever] Organization organization)
{
var metadata = await getOrganizationMetadataQuery.Run(organization);
if (metadata == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(metadata);
}
[Authorize<MemberOrProviderRequirement>] [Authorize<MemberOrProviderRequirement>]
[HttpGet("warnings")] [HttpGet("warnings")]
[InjectOrganization] [InjectOrganization]

View File

@@ -1,31 +0,0 @@
using Bit.Core.Billing.Organizations.Models;
namespace Bit.Api.Billing.Models.Responses;
public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid,
bool HasSubscription,
bool HasOpenInvoice,
bool IsSubscriptionCanceled,
DateTime? InvoiceDueDate,
DateTime? InvoiceCreatedDate,
DateTime? SubPeriodEndDate,
int OrganizationOccupiedSeats)
{
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new(
metadata.IsEligibleForSelfHost,
metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid,
metadata.HasSubscription,
metadata.HasOpenInvoice,
metadata.IsSubscriptionCanceled,
metadata.InvoiceDueDate,
metadata.InvoiceCreatedDate,
metadata.SubPeriodEndDate,
metadata.OrganizationOccupiedSeats);
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
@@ -10,7 +11,7 @@ namespace Bit.Api.Billing.Models.Responses;
public record ProviderSubscriptionResponse( public record ProviderSubscriptionResponse(
string Status, string Status,
DateTime CurrentPeriodEndDate, DateTime? CurrentPeriodEndDate,
decimal? DiscountPercentage, decimal? DiscountPercentage,
string CollectionMethod, string CollectionMethod,
IEnumerable<ProviderPlanResponse> Plans, IEnumerable<ProviderPlanResponse> Plans,
@@ -51,10 +52,12 @@ public record ProviderSubscriptionResponse(
var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100; var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100;
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
return new ProviderSubscriptionResponse( return new ProviderSubscriptionResponse(
subscription.Status, subscription.Status,
subscription.CurrentPeriodEnd, subscription.GetCurrentPeriodEnd(),
subscription.Customer?.Discount?.Coupon?.PercentOff, discount?.Coupon?.PercentOff,
subscription.CollectionMethod, subscription.CollectionMethod,
providerPlanResponses, providerPlanResponses,
accountCredit, accountCredit,

View File

@@ -1,33 +0,0 @@
using Bit.Api.Models.Response;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
[Route("users")]
[Authorize("Application")]
public class UsersController : Controller
{
private readonly IUserRepository _userRepository;
public UsersController(
IUserRepository userRepository)
{
_userRepository = userRepository;
}
[HttpGet("{id}/public-key")]
public async Task<UserKeyResponseModel> Get(string id)
{
var guidId = new Guid(id);
var key = await _userRepository.GetPublicKeyAsync(guidId);
if (key == null)
{
throw new NotFoundException();
}
return new UserKeyResponseModel(guidId, key);
}
}

View File

@@ -106,8 +106,7 @@ public class AccountsKeyManagementController : Controller
{ {
OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash, OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash,
UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey, AccountKeys = model.AccountKeys.ToAccountKeysData(),
AccountPublicKey = model.AccountKeys.AccountPublicKey,
MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(), MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(),
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData), EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),

View File

@@ -0,0 +1,39 @@
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using UserKeyResponseModel = Bit.Api.Models.Response.UserKeyResponseModel;
namespace Bit.Api.KeyManagement.Controllers;
[Route("users")]
[Authorize("Application")]
public class UsersController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
public UsersController(IUserRepository userRepository, IUserAccountKeysQuery userAccountKeysQuery)
{
_userRepository = userRepository;
_userAccountKeysQuery = userAccountKeysQuery;
}
[HttpGet("{id}/public-key")]
public async Task<UserKeyResponseModel> GetPublicKeyAsync([FromRoute] Guid id)
{
var key = await _userRepository.GetPublicKeyAsync(id) ?? throw new NotFoundException();
return new UserKeyResponseModel(id, key);
}
[HttpGet("{id}/keys")]
public async Task<PublicKeysResponseModel> GetAccountKeysAsync([FromRoute] Guid id)
{
var user = await _userRepository.GetByIdAsync(id) ?? throw new NotFoundException();
var accountKeys = await _userAccountKeysQuery.Run(user) ?? throw new NotFoundException("User account keys not found.");
return new PublicKeysResponseModel(accountKeys);
}
}

View File

@@ -1,4 +1,5 @@
#nullable enable using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.KeyManagement.Models.Requests;
@@ -7,4 +8,44 @@ public class AccountKeysRequestModel
{ {
[EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; } [EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; }
public required string AccountPublicKey { get; set; } public required string AccountPublicKey { get; set; }
public PublicKeyEncryptionKeyPairRequestModel? PublicKeyEncryptionKeyPair { get; set; }
public SignatureKeyPairRequestModel? SignatureKeyPair { get; set; }
public SecurityStateModel? SecurityState { get; set; }
public UserAccountKeysData ToAccountKeysData()
{
// This will be cleaned up, after a compatibility period, at which point PublicKeyEncryptionKeyPair and SignatureKeyPair will be required.
// TODO: https://bitwarden.atlassian.net/browse/PM-23751
if (PublicKeyEncryptionKeyPair == null)
{
return new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData
(
UserKeyEncryptedAccountPrivateKey,
AccountPublicKey
),
};
}
else
{
if (SignatureKeyPair == null || SecurityState == null)
{
return new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(),
};
}
else
{
return new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(),
SignatureKeyPairData = SignatureKeyPair.ToSignatureKeyPairData(),
SecurityStateData = SecurityState.ToSecurityState()
};
}
}
}
} }

View File

@@ -1,5 +1,4 @@
#nullable enable using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.KeyManagement.Models.Requests;

View File

@@ -0,0 +1,20 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class PublicKeyEncryptionKeyPairRequestModel
{
[EncryptedString] public required string WrappedPrivateKey { get; set; }
public required string PublicKey { get; set; }
public string? SignedPublicKey { get; set; }
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
{
return new PublicKeyEncryptionKeyPairData(
WrappedPrivateKey,
PublicKey,
SignedPublicKey
);
}
}

View File

@@ -1,5 +1,4 @@
#nullable enable using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.KeyManagement.Models.Requests;

View File

@@ -0,0 +1,28 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class SignatureKeyPairRequestModel
{
public required string SignatureAlgorithm { get; set; }
[EncryptedString] public required string WrappedSigningKey { get; set; }
public required string VerifyingKey { get; set; }
public SignatureKeyPairData ToSignatureKeyPairData()
{
if (SignatureAlgorithm != "ed25519")
{
throw new ArgumentException(
$"Unsupported signature algorithm: {SignatureAlgorithm}"
);
}
var algorithm = Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519;
return new SignatureKeyPairData(
algorithm,
WrappedSigningKey,
VerifyingKey
);
}
}

View File

@@ -1,5 +1,4 @@
#nullable enable using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;

View File

@@ -1,5 +1,4 @@
#nullable enable using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
namespace Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.KeyManagement.Models.Requests;

View File

@@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Models.Request.Organizations;
public class OrganizationVerifyBankRequestModel
{
[Required]
[Range(1, 99)]
public int? Amount1 { get; set; }
[Required]
[Range(1, 99)]
public int? Amount2 { get; set; }
}

View File

@@ -5,6 +5,8 @@ using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -13,6 +15,7 @@ namespace Bit.Api.Models.Response;
public class ProfileResponseModel : ResponseModel public class ProfileResponseModel : ResponseModel
{ {
public ProfileResponseModel(User user, public ProfileResponseModel(User user,
UserAccountKeysData userAccountKeysData,
IEnumerable<OrganizationUserOrganizationDetails> organizationsUserDetails, IEnumerable<OrganizationUserOrganizationDetails> organizationsUserDetails,
IEnumerable<ProviderUserProviderDetails> providerUserDetails, IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
@@ -35,6 +38,7 @@ public class ProfileResponseModel : ResponseModel
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
Key = user.Key; Key = user.Key;
PrivateKey = user.PrivateKey; PrivateKey = user.PrivateKey;
AccountKeys = userAccountKeysData != null ? new PrivateKeysResponseModel(userAccountKeysData) : null;
SecurityStamp = user.SecurityStamp; SecurityStamp = user.SecurityStamp;
ForcePasswordReset = user.ForcePasswordReset; ForcePasswordReset = user.ForcePasswordReset;
UsesKeyConnector = user.UsesKeyConnector; UsesKeyConnector = user.UsesKeyConnector;
@@ -60,7 +64,9 @@ public class ProfileResponseModel : ResponseModel
public string Culture { get; set; } public string Culture { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public string Key { get; set; } public string Key { get; set; }
[Obsolete("Use AccountKeys instead.")]
public string PrivateKey { get; set; } public string PrivateKey { get; set; }
public PrivateKeysResponseModel AccountKeys { get; set; }
public string SecurityStamp { get; set; } public string SecurityStamp { get; set; }
public bool ForcePasswordReset { get; set; } public bool ForcePasswordReset { get; set; }
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }

View File

@@ -326,6 +326,6 @@ public class Startup
} }
// Log startup // Log startup
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started."); logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
} }
} }

View File

@@ -166,7 +166,7 @@ public class SendsController : Controller
} }
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
return; return;
} }
} }

View File

@@ -152,7 +152,7 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
else else
{ {
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ExceptionHandlerFilterAttribute>>(); var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ExceptionHandlerFilterAttribute>>();
logger.LogError(0, exception, exception.Message); logger.LogError(0, exception, "Unhandled exception");
errorMessage = "An unhandled server error has occurred."; errorMessage = "An unhandled server error has occurred.";
context.HttpContext.Response.StatusCode = 500; context.HttpContext.Response.StatusCode = 500;
} }

View File

@@ -754,6 +754,11 @@ public class CiphersController : Controller
} }
} }
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move an archived item to an organization.");
}
ValidateClientVersionForFido2CredentialSupport(cipher); ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone(); var original = cipher.Clone();
@@ -1263,6 +1268,11 @@ public class CiphersController : Controller
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
} }
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
} }
var shareCiphers = new List<(CipherDetails, DateTime?)>(); var shareCiphers = new List<(CipherDetails, DateTime?)>();
@@ -1275,6 +1285,11 @@ public class CiphersController : Controller
ValidateClientVersionForFido2CredentialSupport(existingCipher); ValidateClientVersionForFido2CredentialSupport(existingCipher);
if (existingCipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
} }
@@ -1578,7 +1593,7 @@ public class CiphersController : Controller
} }
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
return; return;
} }
} }

View File

@@ -11,6 +11,8 @@ using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -42,6 +44,7 @@ public class SyncController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
public SyncController( public SyncController(
IUserService userService, IUserService userService,
@@ -57,7 +60,8 @@ public class SyncController : Controller
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService, IFeatureService featureService,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery)
{ {
_userService = userService; _userService = userService;
_folderRepository = folderRepository; _folderRepository = folderRepository;
@@ -73,6 +77,7 @@ public class SyncController : Controller
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_userAccountKeysQuery = userAccountKeysQuery;
} }
[HttpGet("")] [HttpGet("")]
@@ -116,7 +121,14 @@ public class SyncController : Controller
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, UserAccountKeysData userAccountKeys = null;
// JIT TDE users and some broken/old users may not have a private key.
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
{
userAccountKeys = await _userAccountKeysQuery.Run(user);
}
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response; return response;

View File

@@ -7,7 +7,8 @@ using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Response; using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
@@ -24,6 +25,7 @@ public class SyncResponseModel() : ResponseModel("sync")
public SyncResponseModel( public SyncResponseModel(
GlobalSettings globalSettings, GlobalSettings globalSettings,
User user, User user,
UserAccountKeysData userAccountKeysData,
bool userTwoFactorEnabled, bool userTwoFactorEnabled,
bool userHasPremiumFromOrganization, bool userHasPremiumFromOrganization,
IDictionary<Guid, OrganizationAbility> organizationAbilities, IDictionary<Guid, OrganizationAbility> organizationAbilities,
@@ -40,7 +42,7 @@ public class SyncResponseModel() : ResponseModel("sync")
IEnumerable<Send> sends) IEnumerable<Send> sends)
: this() : this()
{ {
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
Folders = folders.Select(f => new FolderResponseModel(f)); Folders = folders.Select(f => new FolderResponseModel(f));
Ciphers = ciphers.Select(cipher => Ciphers = ciphers.Select(cipher =>

View File

@@ -7,9 +7,7 @@ public class BillingSettings
{ {
public virtual string JobsKey { get; set; } public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string StripeWebhookSecret20231016 { get; set; }
public virtual string StripeWebhookSecret20240620 { get; set; }
public virtual string BitPayWebhookKey { get; set; } public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();

View File

@@ -120,9 +120,7 @@ public class StripeController : Controller
return deliveryContainer.ApiVersion switch return deliveryContainer.ApiVersion switch
{ {
"2024-06-20" => HandleVersionWith(_billingSettings.StripeWebhookSecret20240620), "2025-08-27.basil" => HandleVersionWith(_billingSettings.StripeWebhookSecret20250827Basil),
"2023-10-16" => HandleVersionWith(_billingSettings.StripeWebhookSecret20231016),
"2022-08-01" => HandleVersionWith(_billingSettings.StripeWebhookSecret),
_ => HandleDefault(deliveryContainer.ApiVersion) _ => HandleDefault(deliveryContainer.ApiVersion)
}; };

View File

@@ -1,4 +1,5 @@
using Event = Stripe.Event; using Bit.Core.Billing.Constants;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
@@ -35,13 +36,13 @@ public class InvoiceCreatedHandler(
if (usingPayPal && invoice is if (usingPayPal && invoice is
{ {
AmountDue: > 0, AmountDue: > 0,
Paid: false, Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically", CollectionMethod: "charge_automatically",
BillingReason: BillingReason:
"subscription_create" or "subscription_create" or
"subscription_cycle" or "subscription_cycle" or
"automatic_pending_invoice_item_invoice", "automatic_pending_invoice_item_invoice",
SubscriptionId: not null and not "" Parent.SubscriptionDetails: not null
}) })
{ {
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);

View File

@@ -1,4 +1,5 @@
using Stripe; using Bit.Core.Billing.Constants;
using Stripe;
using Event = Stripe.Event; using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
@@ -26,12 +27,14 @@ public class PaymentFailedHandler : IPaymentFailedHandler
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
{ {
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice)) if (invoice.Status == StripeConstants.InvoiceStatus.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
{ {
return; return;
} }
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); if (invoice.Parent?.SubscriptionDetails != null)
{
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
// attempt count 4 = 11 days after initial failure // attempt count 4 = 11 days after initial failure
if (invoice.AttemptCount <= 3 || if (invoice.AttemptCount <= 3 ||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) !subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
@@ -39,14 +42,15 @@ public class PaymentFailedHandler : IPaymentFailedHandler
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
} }
} }
}
private static bool ShouldAttemptToPayInvoice(Invoice invoice) => private static bool ShouldAttemptToPayInvoice(Invoice invoice) =>
invoice is invoice is
{ {
AmountDue: > 0, AmountDue: > 0,
Paid: false, Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically", CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null Parent.SubscriptionDetails: not null
}; };
} }

View File

@@ -1,7 +1,9 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -29,12 +31,17 @@ public class PaymentSucceededHandler(
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
{ {
var invoice = await stripeEventService.GetInvoice(parsedEvent, true); var invoice = await stripeEventService.GetInvoice(parsedEvent, true);
if (!invoice.Paid || invoice.BillingReason != "subscription_create") if (invoice.Status != StripeConstants.InvoiceStatus.Paid || invoice.BillingReason != "subscription_create")
{ {
return; return;
} }
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId); if (invoice.Parent?.SubscriptionDetails == null)
{
return;
}
var subscription = await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active) if (subscription?.Status != StripeSubscriptionStatus.Active)
{ {
return; return;
@@ -96,7 +103,7 @@ public class PaymentSucceededHandler(
return; return;
} }
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
organization = await organizationRepository.GetByIdAsync(organization.Id); organization = await organizationRepository.GetByIdAsync(organization.Id);
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!); await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
} }
@@ -107,7 +114,7 @@ public class PaymentSucceededHandler(
return; return;
} }
await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); await userService.EnablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
} }
} }
} }

View File

@@ -28,9 +28,14 @@ public class ProviderEventService(
return; return;
} }
var invoice = await stripeEventService.GetInvoice(parsedEvent); var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["discounts"]);
var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary<string, string>(); if (invoice.Parent is not { Type: "subscription_details" })
{
return;
}
var metadata = (await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
var hasProviderId = metadata.TryGetValue("providerId", out var providerId); var hasProviderId = metadata.TryGetValue("providerId", out var providerId);
@@ -68,7 +73,9 @@ public class ProviderEventService(
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
var discountedPercentage = (100 - totalPercentOff) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
@@ -96,7 +103,9 @@ public class ProviderEventService(
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0; var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
var discountedPercentage = (100 - totalPercentOff) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;

View File

@@ -2,6 +2,7 @@
#nullable disable #nullable disable
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -87,25 +88,6 @@ public class StripeEventUtilityService : IStripeEventUtilityService
/// <returns></returns> /// <returns></returns>
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge) public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
{ {
Guid? organizationId = null;
Guid? userId = null;
Guid? providerId = null;
if (charge.InvoiceId != null)
{
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
if (invoice?.SubscriptionId != null)
{
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
}
}
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
return (organizationId, userId, providerId);
}
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
{ {
Customer = charge.CustomerId Customer = charge.CustomerId
@@ -118,7 +100,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
continue; continue;
} }
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue || userId.HasValue || providerId.HasValue) if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{ {
@@ -256,10 +238,10 @@ public class StripeEventUtilityService : IStripeEventUtilityService
invoice is invoice is
{ {
AmountDue: > 0, AmountDue: > 0,
Paid: false, Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically", CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null Parent.SubscriptionDetails: not null
}; };
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer) private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
@@ -272,7 +254,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService
return false; return false;
} }
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); if (invoice.Parent?.SubscriptionDetails == null)
{
_logger.LogWarning("Invoice parent was not a subscription.");
return false;
}
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
{ {

View File

@@ -1,5 +1,6 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services; using Bit.Core.Services;
using Event = Stripe.Event; using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
@@ -50,11 +51,11 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
return; return;
} }
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
} }
else if (userId.HasValue) else if (userId.HasValue)
{ {
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
} }
} }
} }

View File

@@ -5,6 +5,8 @@ using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -82,12 +84,14 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
switch (subscription.Status) switch (subscription.Status)
{ {
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
when organizationId.HasValue: when organizationId.HasValue:
{ {
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
if (subscription.Status == StripeSubscriptionStatus.Unpaid && if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" }) subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
{ {
@@ -114,7 +118,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
await VoidOpenInvoices(subscription.Id); await VoidOpenInvoices(subscription.Id);
} }
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
break; break;
} }
@@ -154,7 +158,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{ {
if (userId.HasValue) if (userId.HasValue)
{ {
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd);
} }
break; break;
} }
@@ -162,17 +166,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription)) if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
{ {
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
} }
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
} }
else if (userId.HasValue) else if (userId.HasValue)
{ {
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd); await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd);
} }
} }
@@ -280,9 +284,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
?.Coupon ?.Coupon
?.Id == "sm-standalone"; ?.Id == "sm-standalone";
var subscriptionHasSecretsManagerTrial = subscription.Discount var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id)
?.Coupon .Contains(StripeConstants.CouponIDs.SecretsManagerStandalone);
?.Id == "sm-standalone";
if (customerHasSecretsManagerTrial) if (customerHasSecretsManagerTrial)
{ {

View File

@@ -36,17 +36,16 @@ public class UpcomingInvoiceHandler(
{ {
var invoice = await stripeEventService.GetInvoice(parsedEvent); var invoice = await stripeEventService.GetInvoice(parsedEvent);
if (string.IsNullOrEmpty(invoice.SubscriptionId)) var customer =
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
var subscription = customer.Subscriptions.FirstOrDefault();
if (subscription == null)
{ {
logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
return; return;
} }
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer.tax", "customer.tax_ids"]
});
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue) if (organizationId.HasValue)
@@ -58,7 +57,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id); await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -137,7 +136,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id); await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value); await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
} }
@@ -199,13 +198,14 @@ public class UpcomingInvoiceHandler(
private async Task AlignOrganizationTaxConcernsAsync( private async Task AlignOrganizationTaxConcernsAsync(
Organization organization, Organization organization,
Subscription subscription, Subscription subscription,
Customer customer,
string eventId) string eventId)
{ {
var nonUSBusinessUse = var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families && organization.PlanType.GetProductTier() != ProductTierType.Families &&
subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{ {
try try
{ {
@@ -246,10 +246,11 @@ public class UpcomingInvoiceHandler(
private async Task AlignProviderTaxConcernsAsync( private async Task AlignProviderTaxConcernsAsync(
Provider provider, Provider provider,
Subscription subscription, Subscription subscription,
Customer customer,
string eventId) string eventId)
{ {
if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{ {
try try
{ {

View File

@@ -57,9 +57,7 @@
"billingSettings": { "billingSettings": {
"jobsKey": "SECRET", "jobsKey": "SECRET",
"stripeWebhookKey": "SECRET", "stripeWebhookKey": "SECRET",
"stripeWebhookSecret": "SECRET", "stripeWebhookSecret20250827Basil": "SECRET",
"stripeWebhookSecret20231016": "SECRET",
"stripeWebhookSecret20240620": "SECRET",
"bitPayWebhookKey": "SECRET", "bitPayWebhookKey": "SECRET",
"appleWebhookKey": "SECRET", "appleWebhookKey": "SECRET",
"payPal": { "payPal": {

View File

@@ -129,6 +129,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// </summary> /// </summary>
public bool SyncSeats { get; set; } public bool SyncSeats { get; set; }
/// <summary>
/// If set to true, user accounts created within the organization are automatically confirmed without requiring additional verification steps.
/// </summary>
public bool UseAutomaticUserConfirmation { get; set; }
public void SetNewId() public void SetNewId()
{ {
if (Id == default(Guid)) if (Id == default(Guid))

View File

@@ -28,6 +28,7 @@ public class OrganizationAbility
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains; UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -49,4 +50,5 @@ public class OrganizationAbility
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -66,4 +66,5 @@ public class OrganizationUserOrganizationDetails
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; } public bool? IsAdminInitiated { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -51,4 +51,5 @@ public class ProviderUserOrganizationDetails
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; } public ProviderType ProviderType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -89,7 +89,7 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
AuthRequestExpiresAfter = _globalSettings.PasswordlessAuth.AdminRequestExpiration AuthRequestExpiresAfter = _globalSettings.PasswordlessAuth.AdminRequestExpiration
} }
); );
processor.Process((Exception e) => _logger.LogError(e.Message)); processor.Process((Exception e) => _logger.LogError("Error processing organization auth request: {Message}", e.Message));
await processor.Save((IEnumerable<OrganizationAdminAuthRequest> authRequests) => _authRequestRepository.UpdateManyAsync(authRequests)); await processor.Save((IEnumerable<OrganizationAdminAuthRequest> authRequests) => _authRequestRepository.UpdateManyAsync(authRequests));
await processor.SendPushNotifications((ar) => _pushNotificationService.PushAuthRequestResponseAsync(ar)); await processor.SendPushNotifications((ar) => _pushNotificationService.PushAuthRequestResponseAsync(ar));
await processor.SendApprovalEmailsForProcessedRequests(SendApprovalEmail); await processor.SendApprovalEmailsForProcessedRequests(SendApprovalEmail);
@@ -114,7 +114,7 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque
// This should be impossible // This should be impossible
if (user == null) if (user == null)
{ {
_logger.LogError($"User {authRequest.UserId} not found. Trusted device admin approval email not sent."); _logger.LogError("User {UserId} not found. Trusted device admin approval email not sent.", authRequest.UserId);
return; return;
} }

View File

@@ -13,25 +13,11 @@ public class VNextSavePolicyCommand(
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IEventService eventService, IEventService eventService,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers, IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
TimeProvider timeProvider, TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory) IPolicyEventHandlerFactory policyEventHandlerFactory)
: IVNextSavePolicyCommand : IVNextSavePolicyCommand
{ {
private readonly IReadOnlyDictionary<PolicyType, IEnforceDependentPoliciesEvent> _policyValidationEvents = MapToDictionary(policyValidationEventHandlers);
private static Dictionary<PolicyType, IEnforceDependentPoliciesEvent> MapToDictionary(IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers)
{
var policyValidationEventsDict = new Dictionary<PolicyType, IEnforceDependentPoliciesEvent>();
foreach (var policyValidationEvent in policyValidationEventHandlers)
{
if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent))
{
throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy.");
}
}
return policyValidationEventsDict;
}
public async Task<Policy> SaveAsync(SavePolicyModel policyRequest) public async Task<Policy> SaveAsync(SavePolicyModel policyRequest)
{ {
@@ -111,33 +97,27 @@ public class VNextSavePolicyCommand(
PolicyUpdate policyUpdateRequest, PolicyUpdate policyUpdateRequest,
Policy? currentPolicy, Policy? currentPolicy,
Dictionary<PolicyType, Policy> savedPoliciesDict) Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var result = policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(policyUpdateRequest.Type);
result.Switch(
validator =>
{ {
var isCurrentlyEnabled = currentPolicy?.Enabled == true; var isCurrentlyEnabled = currentPolicy?.Enabled == true;
var isBeingEnabled = policyUpdateRequest.Enabled && !isCurrentlyEnabled;
var isBeingDisabled = !policyUpdateRequest.Enabled && isCurrentlyEnabled;
switch (policyUpdateRequest.Enabled) if (isBeingEnabled)
{ {
case true when !isCurrentlyEnabled: ValidateEnablingRequirements(policyUpdateRequest.Type, savedPoliciesDict);
ValidateEnablingRequirements(validator, savedPoliciesDict); }
return; else if (isBeingDisabled)
case false when isCurrentlyEnabled: {
ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict); ValidateDisablingRequirements(policyUpdateRequest.Type, savedPoliciesDict);
break;
} }
},
_ => { });
} }
private void ValidateDisablingRequirements( private void ValidateDisablingRequirements(
IEnforceDependentPoliciesEvent validator,
PolicyType policyType, PolicyType policyType,
Dictionary<PolicyType, Policy> savedPoliciesDict) Dictionary<PolicyType, Policy> savedPoliciesDict)
{ {
var dependentPolicyTypes = _policyValidationEvents.Values var dependentPolicyTypes = policyUpdateEventHandlers
.OfType<IEnforceDependentPoliciesEvent>()
.Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType)) .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType))
.Select(otherValidator => otherValidator.Type) .Select(otherValidator => otherValidator.Type)
.Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) &&
@@ -147,15 +127,20 @@ public class VNextSavePolicyCommand(
switch (dependentPolicyTypes) switch (dependentPolicyTypes)
{ {
case { Count: 1 }: case { Count: 1 }:
throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy."); throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {policyType.GetName()} policy.");
case { Count: > 1 }: case { Count: > 1 }:
throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy."); throw new BadRequestException($"Turn off all of the policies that require the {policyType.GetName()} policy.");
} }
} }
private static void ValidateEnablingRequirements( private void ValidateEnablingRequirements(
IEnforceDependentPoliciesEvent validator, PolicyType policyType,
Dictionary<PolicyType, Policy> savedPoliciesDict) Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var result = policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(policyType);
result.Switch(
validator =>
{ {
var missingRequiredPolicyTypes = validator.RequiredPolicies var missingRequiredPolicyTypes = validator.RequiredPolicies
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
@@ -163,8 +148,10 @@ public class VNextSavePolicyCommand(
if (missingRequiredPolicyTypes.Count != 0) if (missingRequiredPolicyTypes.Count != 0)
{ {
throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy."); throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {policyType.GetName()} policy.");
} }
},
_ => { /* Policy has no required dependencies */ });
} }
private async Task ExecutePreUpsertSideEffectAsync( private async Task ExecutePreUpsertSideEffectAsync(

View File

@@ -22,8 +22,10 @@ public static class PolicyServiceCollectionExtensions
services.AddPolicyValidators(); services.AddPolicyValidators();
services.AddPolicyRequirements(); services.AddPolicyRequirements();
services.AddPolicySideEffects(); services.AddPolicySideEffects();
services.AddPolicyUpdateEvents();
} }
[Obsolete("Use AddPolicyUpdateEvents instead.")]
private static void AddPolicyValidators(this IServiceCollection services) private static void AddPolicyValidators(this IServiceCollection services)
{ {
services.AddScoped<IPolicyValidator, TwoFactorAuthenticationPolicyValidator>(); services.AddScoped<IPolicyValidator, TwoFactorAuthenticationPolicyValidator>();
@@ -34,11 +36,23 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>(); services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
} }
[Obsolete("Use AddPolicyUpdateEvents instead.")]
private static void AddPolicySideEffects(this IServiceCollection services) private static void AddPolicySideEffects(this IServiceCollection services)
{ {
services.AddScoped<IPostSavePolicySideEffect, OrganizationDataOwnershipPolicyValidator>(); services.AddScoped<IPostSavePolicySideEffect, OrganizationDataOwnershipPolicyValidator>();
} }
private static void AddPolicyUpdateEvents(this IServiceCollection services)
{
services.AddScoped<IPolicyUpdateEvent, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, TwoFactorAuthenticationPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, SingleOrgPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
}
private static void AddPolicyRequirements(this IServiceCollection services) private static void AddPolicyRequirements(this IServiceCollection services)
{ {
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();

View File

@@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -12,11 +13,16 @@ public class FreeFamiliesForEnterprisePolicyValidator(
IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository) IOrganizationRepository organizationRepository)
: IPolicyValidator : IPolicyValidator, IOnPolicyPreUpdateEvent
{ {
public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy; public PolicyType Type => PolicyType.FreeFamiliesSponsorshipPolicy;
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })

View File

@@ -3,10 +3,11 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class MaximumVaultTimeoutPolicyValidator : IPolicyValidator public class MaximumVaultTimeoutPolicyValidator : IPolicyValidator, IEnforceDependentPoliciesEvent
{ {
public PolicyType Type => PolicyType.MaximumVaultTimeout; public PolicyType Type => PolicyType.MaximumVaultTimeout;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg]; public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];

View File

@@ -1,24 +1,32 @@
 
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
/// <summary>
/// Please do not extend or expand this validator. We're currently in the process of refactoring our policy validator pattern.
/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution.
/// </summary>
public class OrganizationDataOwnershipPolicyValidator( public class OrganizationDataOwnershipPolicyValidator(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories, IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories,
IFeatureService featureService) IFeatureService featureService)
: OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect, IOnPolicyPostUpdateEvent
{ {
public PolicyType Type => PolicyType.OrganizationDataOwnership;
public async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
await ExecuteSideEffectsAsync(policyRequest, postUpsertedPolicyState, previousPolicyState);
}
public async Task ExecuteSideEffectsAsync( public async Task ExecuteSideEffectsAsync(
SavePolicyModel policyRequest, SavePolicyModel policyRequest,
Policy postUpdatedPolicy, Policy postUpdatedPolicy,
@@ -68,5 +76,4 @@ public class OrganizationDataOwnershipPolicyValidator(
userOrgIds, userOrgIds,
defaultCollectionName); defaultCollectionName);
} }
} }

View File

@@ -3,12 +3,13 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class RequireSsoPolicyValidator : IPolicyValidator public class RequireSsoPolicyValidator : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
{ {
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
@@ -20,6 +21,11 @@ public class RequireSsoPolicyValidator : IPolicyValidator
public PolicyType Type => PolicyType.RequireSso; public PolicyType Type => PolicyType.RequireSso;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg]; public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (policyUpdate is not { Enabled: true }) if (policyUpdate is not { Enabled: true })

View File

@@ -4,12 +4,13 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class ResetPasswordPolicyValidator : IPolicyValidator public class ResetPasswordPolicyValidator : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
{ {
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
public PolicyType Type => PolicyType.ResetPassword; public PolicyType Type => PolicyType.ResetPassword;
@@ -20,6 +21,11 @@ public class ResetPasswordPolicyValidator : IPolicyValidator
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
} }
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (policyUpdate is not { Enabled: true } || if (policyUpdate is not { Enabled: true } ||

View File

@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
@@ -17,7 +18,7 @@ using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class SingleOrgPolicyValidator : IPolicyValidator public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent
{ {
public PolicyType Type => PolicyType.SingleOrg; public PolicyType Type => PolicyType.SingleOrg;
private const string OrganizationNotFoundErrorMessage = "Organization not found."; private const string OrganizationNotFoundErrorMessage = "Organization not found.";
@@ -57,6 +58,16 @@ public class SingleOrgPolicyValidator : IPolicyValidator
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@@ -16,7 +17,7 @@ using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator, IOnPolicyPreUpdateEvent
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IMailService _mailService; private readonly IMailService _mailService;
@@ -46,6 +47,11 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
} }
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })

View File

@@ -87,4 +87,13 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection); Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
/// <summary>
/// It will only confirm if the user is in the `Accepted` state.
///
/// This is an idempotent operation.
/// </summary>
/// <param name="organizationUser">Accepted OrganizationUser to confirm</param>
/// <returns>True, if the user was updated. False, if not performed.</returns>
Task<bool> ConfirmOrganizationUserAsync(OrganizationUser organizationUser);
} }

View File

@@ -61,6 +61,7 @@ public static class OrganizationFactory
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains), claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains),
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies =
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies), claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies),
UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation),
}; };
public static Organization Create( public static Organization Create(

View File

@@ -1,5 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Bit.Core.KeyManagement.Models.Response; using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
namespace Bit.Core.Auth.Models.Api.Response; namespace Bit.Core.Auth.Models.Api.Response;

View File

@@ -64,10 +64,12 @@ public static class InvoiceExtensions
} }
} }
var tax = invoice.TotalTaxes?.Sum(invoiceTotalTax => invoiceTotalTax.Amount) ?? 0;
// Add fallback tax from invoice-level tax if present and not already included // Add fallback tax from invoice-level tax if present and not already included
if (invoice.Tax.HasValue && invoice.Tax.Value > 0) if (tax > 0)
{ {
var taxAmount = invoice.Tax.Value / 100m; var taxAmount = tax / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
} }

View File

@@ -31,6 +31,7 @@ public static class ServiceCollectionExtensions
services.AddPaymentOperations(); services.AddPaymentOperations();
services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationLicenseCommandsQueries();
services.AddPremiumCommands(); services.AddPremiumCommands();
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>(); services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>(); services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>(); services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();

View File

@@ -0,0 +1,25 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class SubscriptionExtensions
{
/*
* For the time being, this is the simplest migration approach from v45 to v48 as
* we do not support multi-cadence subscriptions. Each subscription item should be on the
* same billing cycle. If this changes, we'll need a significantly more robust approach.
*
* Because we can't guarantee a subscription will have items, this has to be nullable.
*/
public static (DateTime? Start, DateTime? End)? GetCurrentPeriod(this Subscription subscription)
{
var item = subscription.Items?.FirstOrDefault();
return item is null ? null : (item.CurrentPeriodStart, item.CurrentPeriodEnd);
}
public static DateTime? GetCurrentPeriodStart(this Subscription subscription) =>
subscription.Items?.FirstOrDefault()?.CurrentPeriodStart;
public static DateTime? GetCurrentPeriodEnd(this Subscription subscription) =>
subscription.Items?.FirstOrDefault()?.CurrentPeriodEnd;
}

View File

@@ -1,35 +0,0 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class UpcomingInvoiceOptionsExtensions
{
/// <summary>
/// Attempts to enable automatic tax for given upcoming invoice options.
/// </summary>
/// <param name="options"></param>
/// <param name="customer">The existing customer to which the upcoming invoice belongs.</param>
/// <param name="subscription">The existing subscription to which the upcoming invoice belongs.</param>
/// <returns>Returns true when successful, false when conditions are not met.</returns>
public static bool EnableAutomaticTax(
this UpcomingInvoiceOptions options,
Customer customer,
Subscription subscription)
{
if (subscription != null && subscription.AutomaticTax.Enabled)
{
return false;
}
// We might only need to check the automatic tax status.
if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country))
{
return false;
}
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
options.SubscriptionDefaultTaxRates = [];
return true;
}
}

View File

@@ -43,6 +43,7 @@ public static class OrganizationLicenseConstants
public const string Trial = nameof(Trial); public const string Trial = nameof(Trial);
public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies);
public const string UseOrganizationDomains = nameof(UseOrganizationDomains); public const string UseOrganizationDomains = nameof(UseOrganizationDomains);
public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation);
} }
public static class UserLicenseConstants public static class UserLicenseConstants

View File

@@ -56,6 +56,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()), new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()), new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()),
new(nameof(OrganizationLicenseConstants.UseOrganizationDomains), entity.UseOrganizationDomains.ToString()), new(nameof(OrganizationLicenseConstants.UseOrganizationDomains), entity.UseOrganizationDomains.ToString()),
new(nameof(OrganizationLicenseConstants.UseAutomaticUserConfirmation), entity.UseAutomaticUserConfirmation.ToString()),
}; };
if (entity.Name is not null) if (entity.Name is not null)

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Stripe; using Stripe;
@@ -46,7 +47,7 @@ public class BillingHistoryInfo
Url = inv.HostedInvoiceUrl; Url = inv.HostedInvoiceUrl;
PdfUrl = inv.InvoicePdf; PdfUrl = inv.InvoicePdf;
Number = inv.Number; Number = inv.Number;
Paid = inv.Paid; Paid = inv.Status == StripeConstants.InvoiceStatus.Paid;
Amount = inv.Total / 100M; Amount = inv.Total / 100M;
} }

View File

@@ -43,6 +43,8 @@ public abstract record Plan
public SecretsManagerPlanFeatures SecretsManager { get; protected init; } public SecretsManagerPlanFeatures SecretsManager { get; protected init; }
public bool SupportsSecretsManager => SecretsManager != null; public bool SupportsSecretsManager => SecretsManager != null;
public bool AutomaticUserConfirmation { get; init; }
public bool HasNonSeatBasedPasswordManagerPlan() => public bool HasNonSeatBasedPasswordManagerPlan() =>
PasswordManager is { StripePlanId: not null and not "", StripeSeatPlanId: null or "" }; PasswordManager is { StripePlanId: not null and not "", StripeSeatPlanId: null or "" };

View File

@@ -75,7 +75,13 @@ public class PreviewOrganizationTaxCommand(
Quantity = purchase.SecretsManager.Seats Quantity = purchase.SecretsManager.Seats
} }
]); ]);
options.Coupon = CouponIDs.SecretsManagerStandalone; options.Discounts =
[
new InvoiceDiscountOptions
{
Coupon = CouponIDs.SecretsManagerStandalone
}
];
break; break;
default: default:
@@ -180,7 +186,10 @@ public class PreviewOrganizationTaxCommand(
if (subscription.Customer.Discount != null) if (subscription.Customer.Discount != null)
{ {
options.Coupon = subscription.Customer.Discount.Coupon.Id; options.Discounts =
[
new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id }
];
} }
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -277,7 +286,10 @@ public class PreviewOrganizationTaxCommand(
if (subscription.Customer.Discount != null) if (subscription.Customer.Discount != null)
{ {
options.Coupon = subscription.Customer.Discount.Coupon.Id; options.Discounts =
[
new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id }
];
} }
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -329,7 +341,7 @@ public class PreviewOrganizationTaxCommand(
}); });
private static (decimal, decimal) GetAmounts(Invoice invoice) => ( private static (decimal, decimal) GetAmounts(Invoice invoice) => (
Convert.ToDecimal(invoice.Tax) / 100, Convert.ToDecimal(invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
Convert.ToDecimal(invoice.Total) / 100); Convert.ToDecimal(invoice.Total) / 100);
private static InvoiceCreatePreviewOptions GetBaseOptions( private static InvoiceCreatePreviewOptions GetBaseOptions(

View File

@@ -153,6 +153,7 @@ public class OrganizationLicense : ILicense
public LicenseType? LicenseType { get; set; } public LicenseType? LicenseType { get; set; }
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public string Hash { get; set; } public string Hash { get; set; }
public string Signature { get; set; } public string Signature { get; set; }
public string Token { get; set; } public string Token { get; set; }
@@ -226,7 +227,8 @@ public class OrganizationLicense : ILicense
// any new fields added need to be added here so that they're ignored // any new fields added need to be added here so that they're ignored
!p.Name.Equals(nameof(UseRiskInsights)) && !p.Name.Equals(nameof(UseRiskInsights)) &&
!p.Name.Equals(nameof(UseAdminSponsoredFamilies)) && !p.Name.Equals(nameof(UseAdminSponsoredFamilies)) &&
!p.Name.Equals(nameof(UseOrganizationDomains))) !p.Name.Equals(nameof(UseOrganizationDomains)) &&
!p.Name.Equals(nameof(UseAutomaticUserConfirmation)))
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}")
.Aggregate((c, n) => $"{c}|{n}"); .Aggregate((c, n) => $"{c}|{n}");
@@ -421,6 +423,7 @@ public class OrganizationLicense : ILicense
var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts)); var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts));
var useAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(nameof(UseAdminSponsoredFamilies)); var useAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(nameof(UseAdminSponsoredFamilies));
var useOrganizationDomains = claimsPrincipal.GetValue<bool>(nameof(UseOrganizationDomains)); var useOrganizationDomains = claimsPrincipal.GetValue<bool>(nameof(UseOrganizationDomains));
var useAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(nameof(UseAutomaticUserConfirmation));
return issued <= DateTime.UtcNow && return issued <= DateTime.UtcNow &&
expires >= DateTime.UtcNow && expires >= DateTime.UtcNow &&
@@ -450,7 +453,8 @@ public class OrganizationLicense : ILicense
smSeats == organization.SmSeats && smSeats == organization.SmSeats &&
smServiceAccounts == organization.SmServiceAccounts && smServiceAccounts == organization.SmServiceAccounts &&
useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies && useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies &&
useOrganizationDomains == organization.UseOrganizationDomains; useOrganizationDomains == organization.UseOrganizationDomains &&
useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation;
} }

View File

@@ -1,28 +1,10 @@
namespace Bit.Core.Billing.Organizations.Models; namespace Bit.Core.Billing.Organizations.Models;
public record OrganizationMetadata( public record OrganizationMetadata(
bool IsEligibleForSelfHost,
bool IsManaged,
bool IsOnSecretsManagerStandalone, bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid,
bool HasSubscription,
bool HasOpenInvoice,
bool IsSubscriptionCanceled,
DateTime? InvoiceDueDate,
DateTime? InvoiceCreatedDate,
DateTime? SubPeriodEndDate,
int OrganizationOccupiedSeats) int OrganizationOccupiedSeats)
{ {
public static OrganizationMetadata Default => new OrganizationMetadata( public static OrganizationMetadata Default => new OrganizationMetadata(
false, false,
false,
false,
false,
false,
false,
false,
null,
null,
null,
0); 0);
} }

View File

@@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Organizations.Models;
public class OrganizationSale public class OrganizationSale
{ {
private OrganizationSale() { } internal OrganizationSale() { }
public void Deconstruct( public void Deconstruct(
out Organization organization, out Organization organization,

View File

@@ -0,0 +1,95 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Core.Billing.Organizations.Queries;
public interface IGetOrganizationMetadataQuery
{
Task<OrganizationMetadata?> Run(Organization organization);
}
public class GetOrganizationMetadataQuery(
IGlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
ISubscriberService subscriberService) : IGetOrganizationMetadataQuery
{
public async Task<OrganizationMetadata?> Run(Organization organization)
{
if (organization == null)
{
return null;
}
if (globalSettings.SelfHosted)
{
return OrganizationMetadata.Default;
}
var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return OrganizationMetadata.Default with
{
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
var customer = await subscriberService.GetCustomer(organization,
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
var subscription = await subscriberService.GetSubscription(organization);
if (customer == null || subscription == null)
{
return OrganizationMetadata.Default with
{
OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription);
return new OrganizationMetadata(
isOnSecretsManagerStandalone,
orgOccupiedSeats.Total);
}
private async Task<bool> IsOnSecretsManagerStandalone(
Organization organization,
Customer? customer,
Subscription? subscription)
{
if (customer == null || subscription == null)
{
return false;
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.SupportsSecretsManager)
{
return false;
}
var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone;
if (!hasCoupon)
{
return false;
}
var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId);
var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products;
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
}
}

View File

@@ -162,18 +162,24 @@ public class GetOrganizationWarningsQuery(
if (subscription is if (subscription is
{ {
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
LatestInvoice: null or { Status: InvoiceStatus.Paid } LatestInvoice: null or { Status: InvoiceStatus.Paid },
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) Items.Data.Count: > 0
})
{
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
if (currentPeriodEnd != null && (currentPeriodEnd.Value - now).TotalDays <= 14)
{ {
return new ResellerRenewalWarning return new ResellerRenewalWarning
{ {
Type = "upcoming", Type = "upcoming",
Upcoming = new ResellerRenewalWarning.UpcomingRenewal Upcoming = new ResellerRenewalWarning.UpcomingRenewal
{ {
RenewalDate = subscription.CurrentPeriodEnd RenewalDate = currentPeriodEnd.Value
} }
}; };
} }
}
if (subscription is if (subscription is
{ {

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