Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-11516] Initial license file refactor #5002

Merged
merged 17 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
02fd8b0
Added the ability to create a JWT on an organization license that conโ€ฆ
cturnbull-bitwarden Oct 28, 2024
a473487
Added the ability to create a JWT on a user license that contains allโ€ฆ
cturnbull-bitwarden Oct 31, 2024
6fa8e5a
Added ability to consume JWT licenses
cturnbull-bitwarden Nov 4, 2024
e5357d9
Resolved generic type issues when getting claim value
cturnbull-bitwarden Nov 4, 2024
f650a44
Now validating the jwt signature, exp, and iat
cturnbull-bitwarden Nov 7, 2024
d366592
Moved creation of ClaimsPrincipal outside of licenses given dependecyโ€ฆ
cturnbull-bitwarden Nov 7, 2024
8cd0406
Ran dotnet format. Resolved identity error
cturnbull-bitwarden Nov 7, 2024
d56de07
Updated claim types to use string constants
cturnbull-bitwarden Nov 7, 2024
6b63cb7
Updated jwt expires to be one year
cturnbull-bitwarden Nov 7, 2024
73aa5fb
Fixed bug requiring email verification to be on the token
cturnbull-bitwarden Nov 7, 2024
9fc82a8
Merge branch 'main' into billing/PM-11516/license-refactor
cturnbull-bitwarden Nov 8, 2024
9790d2c
dotnet format
cturnbull-bitwarden Nov 8, 2024
056133e
Merge branch 'main' into billing/PM-11516/license-refactor
cturnbull-bitwarden Nov 11, 2024
51537ab
Patch build process
withinfocus Nov 11, 2024
202f7e4
Merge branch 'main' into billing/PM-11516/license-refactor
cturnbull-bitwarden Nov 12, 2024
ee8763f
Merge branch 'main' into billing/PM-11516/license-refactor
cturnbull-bitwarden Nov 21, 2024
4547e12
Merge branch 'main' into billing/PM-11516/license-refactor
cturnbull-bitwarden Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,8 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey,
string privateKey)
{
var canUse = license.CanUse(_globalSettings, _licensingService, out var exception);
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception);
if (!canUse)
{
throw new BadRequestException(exception);
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
๏ปฟusing Bit.Core.Billing.Caches;
using Bit.Core.Billing.Caches.Implementations;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;

Expand All @@ -15,5 +16,6 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.AddLicenseServices();
}
}
151 changes: 151 additions & 0 deletions src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
๏ปฟusing System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.Business;

namespace Bit.Core.Billing.Licenses.Extensions;

public static class LicenseExtensions
{
public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo)
{
if (subscriptionInfo?.Subscription == null)
{
if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue)
{
return org.ExpirationDate.Value;
}

return DateTime.UtcNow.AddDays(7);
}

var subscription = subscriptionInfo.Subscription;

if (subscription.TrialEndDate > DateTime.UtcNow)
{
return subscription.TrialEndDate.Value;
}

if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow)
{
return org.ExpirationDate.Value;
}

if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180))
{
return subscription.PeriodEndDate
.Value
.AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays);
}

return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1);
}

public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate)
{
if (subscriptionInfo?.Subscription == null ||
subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow ||
org.ExpirationDate < DateTime.UtcNow)
{
return expirationDate;
}

return subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180) ||
DateTime.UtcNow - expirationDate > TimeSpan.FromDays(30)
? DateTime.UtcNow.AddDays(30)
: expirationDate;
}

public static DateTime CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate)
{
if (subscriptionInfo?.Subscription is null)
{
return expirationDate;
}

var subscription = subscriptionInfo.Subscription;

if (subscription.TrialEndDate <= DateTime.UtcNow &&
org.ExpirationDate >= DateTime.UtcNow &&
subscription.PeriodEndDate.HasValue &&
subscription.PeriodDuration > TimeSpan.FromDays(180))
{
return subscription.PeriodEndDate.Value;
}

return expirationDate;
}

public static T GetValue<T>(this ClaimsPrincipal principal, string claimType)
{
var claim = principal.FindFirst(claimType);

if (claim is null)
{
return default;
}

// Handle Guid
if (typeof(T) == typeof(Guid))
{
return Guid.TryParse(claim.Value, out var guid)
? (T)(object)guid
: default;
}

// Handle DateTime
if (typeof(T) == typeof(DateTime))
{
return DateTime.TryParse(claim.Value, out var dateTime)
? (T)(object)dateTime
: default;
}

// Handle TimeSpan
if (typeof(T) == typeof(TimeSpan))
{
return TimeSpan.TryParse(claim.Value, out var timeSpan)
? (T)(object)timeSpan
: default;
}

// Check for Nullable Types
var underlyingType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);

// Handle Enums
if (underlyingType.IsEnum)
{
if (Enum.TryParse(underlyingType, claim.Value, true, out var enumValue))
{
return (T)enumValue; // Cast back to T
}

return default; // Return default value for non-nullable enums or null for nullable enums
}

// Handle other Nullable Types (e.g., int?, bool?)
if (underlyingType == typeof(int))
{
return int.TryParse(claim.Value, out var intValue)
? (T)(object)intValue
: default;
}

if (underlyingType == typeof(bool))
{
return bool.TryParse(claim.Value, out var boolValue)
? (T)(object)boolValue
: default;
}

if (underlyingType == typeof(double))
{
return double.TryParse(claim.Value, out var doubleValue)
? (T)(object)doubleValue
: default;
}

// Fallback to Convert.ChangeType for other types including strings
return (T)Convert.ChangeType(claim.Value, underlyingType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
๏ปฟusing Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Licenses.Services;
using Bit.Core.Billing.Licenses.Services.Implementations;
using Bit.Core.Entities;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Billing.Licenses.Extensions;

public static class LicenseServiceCollectionExtensions
{
public static void AddLicenseServices(this IServiceCollection services)
{
services.AddTransient<ILicenseClaimsFactory<Organization>, OrganizationLicenseClaimsFactory>();
services.AddTransient<ILicenseClaimsFactory<User>, UserLicenseClaimsFactory>();
}
}
58 changes: 58 additions & 0 deletions src/Core/Billing/Licenses/LicenseConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
๏ปฟnamespace Bit.Core.Billing.Licenses;

public static class OrganizationLicenseConstants
{
public const string LicenseType = nameof(LicenseType);
public const string LicenseKey = nameof(LicenseKey);
public const string InstallationId = nameof(InstallationId);
public const string Id = nameof(Id);
public const string Name = nameof(Name);
public const string BusinessName = nameof(BusinessName);
public const string BillingEmail = nameof(BillingEmail);
public const string Enabled = nameof(Enabled);
public const string Plan = nameof(Plan);
public const string PlanType = nameof(PlanType);
public const string Seats = nameof(Seats);
public const string MaxCollections = nameof(MaxCollections);
public const string UsePolicies = nameof(UsePolicies);
public const string UseSso = nameof(UseSso);
public const string UseKeyConnector = nameof(UseKeyConnector);
public const string UseScim = nameof(UseScim);
public const string UseGroups = nameof(UseGroups);
public const string UseEvents = nameof(UseEvents);
public const string UseDirectory = nameof(UseDirectory);
public const string UseTotp = nameof(UseTotp);
public const string Use2fa = nameof(Use2fa);
public const string UseApi = nameof(UseApi);
public const string UseResetPassword = nameof(UseResetPassword);
public const string MaxStorageGb = nameof(MaxStorageGb);
public const string SelfHost = nameof(SelfHost);
public const string UsersGetPremium = nameof(UsersGetPremium);
public const string UseCustomPermissions = nameof(UseCustomPermissions);
public const string Issued = nameof(Issued);
public const string UsePasswordManager = nameof(UsePasswordManager);
public const string UseSecretsManager = nameof(UseSecretsManager);
public const string SmSeats = nameof(SmSeats);
public const string SmServiceAccounts = nameof(SmServiceAccounts);
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
public const string Expires = nameof(Expires);
public const string Refresh = nameof(Refresh);
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
public const string Trial = nameof(Trial);
}

public static class UserLicenseConstants
{
public const string LicenseType = nameof(LicenseType);
public const string LicenseKey = nameof(LicenseKey);
public const string Id = nameof(Id);
public const string Name = nameof(Name);
public const string Email = nameof(Email);
public const string Premium = nameof(Premium);
public const string MaxStorageGb = nameof(MaxStorageGb);
public const string Issued = nameof(Issued);
public const string Expires = nameof(Expires);
public const string Refresh = nameof(Refresh);
public const string Trial = nameof(Trial);
}
10 changes: 10 additions & 0 deletions src/Core/Billing/Licenses/Models/LicenseContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
๏ปฟ#nullable enable
using Bit.Core.Models.Business;

namespace Bit.Core.Billing.Licenses.Models;

public class LicenseContext
{
public Guid? InstallationId { get; init; }
public required SubscriptionInfo SubscriptionInfo { get; init; }
}
9 changes: 9 additions & 0 deletions src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
๏ปฟusing System.Security.Claims;
using Bit.Core.Billing.Licenses.Models;

namespace Bit.Core.Billing.Licenses.Services;

public interface ILicenseClaimsFactory<in T>
{
Task<List<Claim>> GenerateClaims(T entity, LicenseContext licenseContext);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
๏ปฟusing System.Globalization;
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Licenses.Models;
using Bit.Core.Enums;
using Bit.Core.Models.Business;

namespace Bit.Core.Billing.Licenses.Services.Implementations;

public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organization>
{
public Task<List<Claim>> GenerateClaims(Organization entity, LicenseContext licenseContext)
{
var subscriptionInfo = licenseContext.SubscriptionInfo;
var expires = entity.CalculateFreshExpirationDate(subscriptionInfo);
var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, expires);
var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo, expires);
var trial = IsTrialing(entity, subscriptionInfo);

var claims = new List<Claim>
{
new(nameof(OrganizationLicenseConstants.LicenseType), LicenseType.Organization.ToString()),
new Claim(nameof(OrganizationLicenseConstants.LicenseKey), entity.LicenseKey),
new(nameof(OrganizationLicenseConstants.InstallationId), licenseContext.InstallationId.ToString()),
new(nameof(OrganizationLicenseConstants.Id), entity.Id.ToString()),
new(nameof(OrganizationLicenseConstants.Name), entity.Name),
new(nameof(OrganizationLicenseConstants.BillingEmail), entity.BillingEmail),
new(nameof(OrganizationLicenseConstants.Enabled), entity.Enabled.ToString()),
new(nameof(OrganizationLicenseConstants.Plan), entity.Plan),
new(nameof(OrganizationLicenseConstants.PlanType), entity.PlanType.ToString()),
new(nameof(OrganizationLicenseConstants.Seats), entity.Seats.ToString()),
new(nameof(OrganizationLicenseConstants.MaxCollections), entity.MaxCollections.ToString()),
new(nameof(OrganizationLicenseConstants.UsePolicies), entity.UsePolicies.ToString()),
new(nameof(OrganizationLicenseConstants.UseSso), entity.UseSso.ToString()),
new(nameof(OrganizationLicenseConstants.UseKeyConnector), entity.UseKeyConnector.ToString()),
new(nameof(OrganizationLicenseConstants.UseScim), entity.UseScim.ToString()),
new(nameof(OrganizationLicenseConstants.UseGroups), entity.UseGroups.ToString()),
new(nameof(OrganizationLicenseConstants.UseEvents), entity.UseEvents.ToString()),
new(nameof(OrganizationLicenseConstants.UseDirectory), entity.UseDirectory.ToString()),
new(nameof(OrganizationLicenseConstants.UseTotp), entity.UseTotp.ToString()),
new(nameof(OrganizationLicenseConstants.Use2fa), entity.Use2fa.ToString()),
new(nameof(OrganizationLicenseConstants.UseApi), entity.UseApi.ToString()),
new(nameof(OrganizationLicenseConstants.UseResetPassword), entity.UseResetPassword.ToString()),
new(nameof(OrganizationLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()),
new(nameof(OrganizationLicenseConstants.SelfHost), entity.SelfHost.ToString()),
new(nameof(OrganizationLicenseConstants.UsersGetPremium), entity.UsersGetPremium.ToString()),
new(nameof(OrganizationLicenseConstants.UseCustomPermissions), entity.UseCustomPermissions.ToString()),
new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.UsePasswordManager), entity.UsePasswordManager.ToString()),
new(nameof(OrganizationLicenseConstants.UseSecretsManager), entity.UseSecretsManager.ToString()),
new(nameof(OrganizationLicenseConstants.SmSeats), entity.SmSeats.ToString()),
new(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()),
new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion), entity.LimitCollectionCreationDeletion.ToString()),
new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()),
new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
};

if (entity.BusinessName is not null)
{
claims.Add(new Claim(nameof(OrganizationLicenseConstants.BusinessName), entity.BusinessName));
}

return Task.FromResult(claims);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ“ Out of curiosity, what's the purpose of this method returning a Task without doing anything asynchronously?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This came out of a discussion I had with platform on the approach. The idea was to have a single interface that could be used to generate claims for any object. If we needed to have some async IO in some speculative implementation to calculate the value for a claim, then this would allow for that.

}

private static bool IsTrialing(Organization org, SubscriptionInfo subscriptionInfo) =>
subscriptionInfo?.Subscription is null
? org.PlanType != PlanType.Custom || !org.ExpirationDate.HasValue
: subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
๏ปฟusing System.Globalization;
using System.Security.Claims;
using Bit.Core.Billing.Licenses.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;

namespace Bit.Core.Billing.Licenses.Services.Implementations;

public class UserLicenseClaimsFactory : ILicenseClaimsFactory<User>
{
public Task<List<Claim>> GenerateClaims(User entity, LicenseContext licenseContext)
{
var subscriptionInfo = licenseContext.SubscriptionInfo;

var expires = subscriptionInfo.UpcomingInvoice?.Date?.AddDays(7) ?? entity.PremiumExpirationDate?.AddDays(7);
var refresh = subscriptionInfo.UpcomingInvoice?.Date ?? entity.PremiumExpirationDate;
var trial = (subscriptionInfo.Subscription?.TrialEndDate.HasValue ?? false) &&
subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably leverage TimeProvider here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll note it for the next set of reactors in the next PR. In this one, I wanted to keep the logic as identical as possible to how it's working right now, just with the change of using a JWT


var claims = new List<Claim>
{
new(nameof(UserLicenseConstants.LicenseType), LicenseType.User.ToString()),
new(nameof(UserLicenseConstants.LicenseKey), entity.LicenseKey),
new(nameof(UserLicenseConstants.Id), entity.Id.ToString()),
new(nameof(UserLicenseConstants.Name), entity.Name),
new(nameof(UserLicenseConstants.Email), entity.Email),
new(nameof(UserLicenseConstants.Premium), entity.Premium.ToString()),
new(nameof(UserLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()),
new(nameof(UserLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)),
new(nameof(UserLicenseConstants.Expires), expires.ToString()),
new(nameof(UserLicenseConstants.Refresh), refresh.ToString()),
new(nameof(UserLicenseConstants.Trial), trial.ToString()),
};

return Task.FromResult(claims);
}
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ public static class FeatureFlagKeys
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string InlineMenuTotp = "inline-menu-totp";
public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor";

public static List<string> GetAllKeys()
{
Expand Down
Loading
Loading