Skip to content

Commit

Permalink
Merge branch 'main' into ac/pm-15637/custom-manage-account-recovery-e…
Browse files Browse the repository at this point in the history
…mail-notification-for-device-approval-requests
  • Loading branch information
r-tome authored Feb 5, 2025
2 parents d2330e2 + 617bb50 commit 166e08e
Show file tree
Hide file tree
Showing 42 changed files with 929 additions and 136 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,6 @@ jobs:
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io"
/d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>

<Version>2025.1.5</Version>
<Version>2025.1.4</Version>

<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down Expand Up @@ -64,4 +64,4 @@
</ItemGroup>
</Target>

</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
Expand All @@ -24,17 +27,101 @@
namespace Bit.Commercial.Core.Billing;

public class ProviderBillingService(
IEventService eventService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService
{
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider,
Organization organization,
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});

var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = $"Organization was added to Provider with ID {provider.Id}"
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});

var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;

var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;

if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
}

var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);

// TODO: Replace with PricingClient
var plan = StaticStore.GetPlan(managedPlanType);
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.BillingEmail = provider.BillingEmail!;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;

var providerOrganization = new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id,
Key = key
};

await Task.WhenAll(
organizationRepository.ReplaceAsync(organization),
providerOrganizationRepository.CreateAsync(providerOrganization),
ScaleSeats(provider, organization.PlanType, organization.Seats!.Value)
);

await eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Added);
}

public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
Expand Down Expand Up @@ -206,6 +293,81 @@ public async Task<byte[]> GenerateClientInvoiceReport(
return memoryStream.ToArray();
}

[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider,
Guid userId)
{
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId);

if (providerUser is not { Status: ProviderUserStatusType.Confirmed })
{
throw new UnauthorizedAccessException();
}

var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type);

var active = (await Task.WhenAll(candidates.Select(async organization =>
{
var subscription = await subscriberService.GetSubscription(organization);
return (organization, subscription);
})))
.Where(pair => pair.subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
}).ToList();

if (active.Count == 0)
{
return [];
}

return await Task.WhenAll(active.Select(async pair =>
{
var (organization, _) = pair;

var planName = DerivePlanName(provider, organization);

var addable = new AddableOrganization(
organization.Id,
organization.Name,
planName,
organization.Seats!.Value);

if (providerUser.Type != ProviderUserType.ServiceUser)
{
return addable;
}

var applicablePlanType = await GetManagedPlanTypeAsync(provider, organization);

var requiresPurchase =
await SeatAdjustmentResultsInPurchase(provider, applicablePlanType, organization.Seats!.Value);

return addable with { Disabled = requiresPurchase };
}));

string DerivePlanName(Provider localProvider, Organization localOrganization)
{
if (localProvider.Type == ProviderType.Msp)
{
return localOrganization.PlanType switch
{
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => "Enterprise",
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => "Teams",
_ => throw new BillingException()
};
}

// TODO: Replace with PricingClient
var plan = StaticStore.GetPlan(localOrganization.PlanType);
return plan.Name;
}
}

public async Task ScaleSeats(
Provider provider,
PlanType planType,
Expand Down Expand Up @@ -582,4 +744,21 @@ private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanTyp

return providerPlan;
}

private async Task<PlanType> GetManagedPlanTypeAsync(
Provider provider,
Organization organization)
{
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
{
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
}

return organization.PlanType switch
{
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly,
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly,
_ => throw new BillingException()
};
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Requests;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
Expand All @@ -7,13 +9,15 @@
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.Billing.Controllers;
namespace Bit.Api.AdminConsole.Controllers;

[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
Expand All @@ -22,7 +26,10 @@ public class ProviderClientsController(
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
private readonly ICurrentContext _currentContext = currentContext;

[HttpPost]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> CreateAsync(
[FromRoute] Guid providerId,
[FromBody] CreateClientOrganizationRequestBody requestBody)
Expand Down Expand Up @@ -80,6 +87,7 @@ await providerBillingService.CreateCustomerForClientOrganization(
}

[HttpPut("{providerOrganizationId:guid}")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> UpdateAsync(
[FromRoute] Guid providerId,
[FromRoute] Guid providerOrganizationId,
Expand Down Expand Up @@ -113,7 +121,7 @@ public async Task<IResult> UpdateAsync(
clientOrganization.PlanType,
seatAdjustment);

if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
Expand All @@ -127,4 +135,58 @@ public async Task<IResult> UpdateAsync(

return TypedResults.Ok();
}

[HttpGet("addable")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
{
return Error.NotFound();
}

var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);

if (provider == null)
{
return result;
}

var userId = _currentContext.UserId;

if (!userId.HasValue)
{
return Error.Unauthorized();
}

var addable =
await providerBillingService.GetAddableOrganizations(provider, userId.Value);

return TypedResults.Ok(addable);
}

[HttpPost("existing")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> AddExistingOrganizationAsync(
[FromRoute] Guid providerId,
[FromBody] AddExistingOrganizationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);

if (provider == null)
{
return result;
}

var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId);

if (organization == null)
{
return Error.BadRequest("The organization being added to the provider does not exist.");
}

await providerBillingService.AddExistingOrganization(provider, organization, requestBody.Key);

return TypedResults.Ok();
}
}
4 changes: 2 additions & 2 deletions src/Api/AdminConsole/Public/Controllers/EventsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public EventsController(
/// If no filters are provided, it will return the last 30 days of event for the organization.
/// </remarks>
[HttpGet]
[ProducesResponseType(typeof(ListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
var dateRange = request.ToDateRange();
Expand Down Expand Up @@ -65,7 +65,7 @@ public async Task<IActionResult> List([FromQuery] EventFilterRequestModel reques
}

var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new ListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
return new JsonResult(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
Expand Down Expand Up @@ -107,7 +106,7 @@ public async Task<PreValidateSponsorshipResponseModel> PreValidateSponsorshipTok
{
var isFreeFamilyPolicyEnabled = false;
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
Expand Down
Loading

0 comments on commit 166e08e

Please sign in to comment.