mirror of
https://github.com/bitwarden/server
synced 2026-01-15 06:53:26 +00:00
[PM-27731] Updated organization licenses to save the correct values from the token (#6546)
* Updated organization licenses to save the correct values from the token * Added additional test cases around licenses * Added missing properties from Organization to UpdateOrganizationLicenseCommand.UpdateLicenseAsync() * Add tests to validate license property synchronization pipeline * `dotnet format`
This commit is contained in:
@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseApi = UseApi,
|
||||
UseResetPassword = UseResetPassword,
|
||||
UseSecretsManager = UseSecretsManager,
|
||||
UsePasswordManager = UsePasswordManager,
|
||||
SelfHost = SelfHost,
|
||||
UsersGetPremium = UsersGetPremium,
|
||||
UseCustomPermissions = UseCustomPermissions,
|
||||
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
UseOrganizationDomains = UseOrganizationDomains,
|
||||
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
}
|
||||
|
||||
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
|
||||
// This ensures that CanUseLicense validation has access to the correct values from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
|
||||
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
|
||||
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
|
||||
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
|
||||
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
|
||||
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
|
||||
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
|
||||
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
|
||||
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
|
||||
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
|
||||
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
|
||||
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
|
||||
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
|
||||
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
|
||||
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
|
||||
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
|
||||
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
|
||||
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
|
||||
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
|
||||
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
|
||||
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
|
||||
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
|
||||
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
|
||||
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
|
||||
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
|
||||
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
|
||||
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
|
||||
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
|
||||
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
|
||||
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
|
||||
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
|
||||
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
|
||||
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
|
||||
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
|
||||
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
|
||||
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
|
||||
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
|
||||
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
|
||||
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
|
||||
}
|
||||
|
||||
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
|
||||
selfHostedOrganization.CanUseLicense(license, out exception);
|
||||
|
||||
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
throw new BadRequestException(exception);
|
||||
}
|
||||
|
||||
var useAutomaticUserConfirmation = claimsPrincipal?
|
||||
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
|
||||
|
||||
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
|
||||
await WriteLicenseFileAsync(selfHostedOrganization, license);
|
||||
await UpdateOrganizationAsync(selfHostedOrganization, license);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
@@ -982,6 +984,16 @@ public class UserService : UserManager<User>, IUserService
|
||||
throw new BadRequestException(exceptionMessage);
|
||||
}
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(UserLicenseConstants.LicenseKey);
|
||||
license.Premium = claimsPrincipal.GetValue<bool>(UserLicenseConstants.Premium);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(UserLicenseConstants.MaxStorageGb);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
|
||||
}
|
||||
|
||||
var dir = $"{_globalSettings.LicenseDirectory}/user";
|
||||
Directory.CreateDirectory(dir);
|
||||
using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json"));
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Xunit;
|
||||
|
||||
@@ -115,4 +118,105 @@ public class OrganizationTests
|
||||
|
||||
Assert.True(organization.UseDisableSmAdsForUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromLicense_AppliesAllLicenseProperties()
|
||||
{
|
||||
// This test ensures that when a new property is added to OrganizationLicense,
|
||||
// it is also applied to the Organization in UpdateFromLicense().
|
||||
// This is the fourth step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Get all public properties from OrganizationLicense
|
||||
var licenseProperties = typeof(OrganizationLicense)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet();
|
||||
|
||||
// 2. Define properties that don't need to be applied to Organization
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
// Internal/computed properties
|
||||
"SignatureBytes", // Computed from Signature property
|
||||
"ValidLicenseVersion", // Internal property, not serialized
|
||||
"CurrentLicenseFileVersion", // Constant field, not an instance property
|
||||
"Hash", // Signature-related, not applied to org
|
||||
"Signature", // Signature-related, not applied to org
|
||||
"Token", // The JWT itself, not applied to org
|
||||
"Version", // License version, not stored on org
|
||||
|
||||
// Properties intentionally excluded from UpdateFromLicense
|
||||
"Id", // Self-hosted org has its own unique Guid
|
||||
"MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense)
|
||||
|
||||
// Properties not stored on Organization model
|
||||
"LicenseType", // Not a property on Organization
|
||||
"InstallationId", // Not a property on Organization
|
||||
"Issued", // Not a property on Organization
|
||||
"Refresh", // Not a property on Organization
|
||||
"ExpirationWithoutGracePeriod", // Not a property on Organization
|
||||
"Trial", // Not a property on Organization
|
||||
"Expires", // Mapped to ExpirationDate on Organization (different name)
|
||||
|
||||
// Deprecated properties not applied
|
||||
"LimitCollectionCreationDeletion", // Deprecated, not applied
|
||||
"AllowAdminAccessToAllCollectionItems", // Deprecated, not applied
|
||||
};
|
||||
|
||||
// 3. Get properties that should be applied
|
||||
var propertiesThatShouldBeApplied = licenseProperties
|
||||
.Except(excludedProperties)
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Read Organization.UpdateFromLicense source code
|
||||
var organizationSourcePath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs");
|
||||
var sourceCode = File.ReadAllText(organizationSourcePath);
|
||||
|
||||
// 5. Find all property assignments in UpdateFromLicense method
|
||||
// Pattern matches: PropertyName = license.PropertyName
|
||||
// This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires"
|
||||
var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)";
|
||||
var matches = Regex.Matches(sourceCode, assignmentPattern);
|
||||
|
||||
var appliedProperties = new HashSet<string>();
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
// Get the license property name (right side of assignment)
|
||||
var licensePropertyName = match.Groups[2].Value;
|
||||
appliedProperties.Add(licensePropertyName);
|
||||
}
|
||||
|
||||
// Special case: Expires is mapped to ExpirationDate
|
||||
if (appliedProperties.Contains("Expires"))
|
||||
{
|
||||
appliedProperties.Add("Expires"); // Already added, but being explicit
|
||||
}
|
||||
|
||||
// 6. Find missing applications
|
||||
var missingApplications = propertiesThatShouldBeApplied
|
||||
.Except(appliedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 7. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (missingApplications.Any())
|
||||
{
|
||||
errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n";
|
||||
errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n";
|
||||
foreach (var prop in missingApplications)
|
||||
{
|
||||
errorMessage += $" {prop} = license.{prop};\n";
|
||||
}
|
||||
errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly.";
|
||||
}
|
||||
|
||||
// 8. Assert - if this fails, the error message guides the developer to add the application
|
||||
Assert.True(
|
||||
!missingApplications.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal file
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Reflection;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Licenses;
|
||||
|
||||
public class LicenseConstantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty()
|
||||
{
|
||||
// This test ensures that when a new property is added to OrganizationLicense,
|
||||
// a corresponding constant is added to OrganizationLicenseConstants.
|
||||
// This is the first step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Get all public properties from OrganizationLicense
|
||||
var licenseProperties = typeof(OrganizationLicense)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet();
|
||||
|
||||
// 2. Get all constants from OrganizationLicenseConstants
|
||||
var constants = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToHashSet();
|
||||
|
||||
// 3. Define properties that don't need constants (internal/computed/non-claims properties)
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
"SignatureBytes", // Computed from Signature property
|
||||
"ValidLicenseVersion", // Internal property, not serialized
|
||||
"CurrentLicenseFileVersion", // Constant field, not an instance property
|
||||
"Hash", // Signature-related, not in claims system
|
||||
"Signature", // Signature-related, not in claims system
|
||||
"Token", // The JWT itself, not a claim within the token
|
||||
"Version" // Not in claims system (only in deprecated property-based licenses)
|
||||
};
|
||||
|
||||
// 4. Find license properties without corresponding constants
|
||||
var propertiesWithoutConstants = licenseProperties
|
||||
.Except(constants)
|
||||
.Except(excludedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 5. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (propertiesWithoutConstants.Any())
|
||||
{
|
||||
errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n";
|
||||
errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n";
|
||||
foreach (var prop in propertiesWithoutConstants)
|
||||
{
|
||||
errorMessage += $" public const string {prop} = nameof({prop});\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Assert - if this fails, the error message guides the developer to add the constant
|
||||
Assert.True(
|
||||
!propertiesWithoutConstants.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Reflection;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Models;
|
||||
using Bit.Core.Billing.Licenses.Services.Implementations;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Licenses.Services.Implementations;
|
||||
|
||||
public class OrganizationLicenseClaimsFactoryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization)
|
||||
{
|
||||
// This test ensures that when a constant is added to OrganizationLicenseConstants,
|
||||
// it is also added to the OrganizationLicenseClaimsFactory to generate claims.
|
||||
// This is the second step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Populate all nullable properties to ensure claims can be generated
|
||||
// The factory only adds claims for properties that have values
|
||||
organization.Name = "Test Organization";
|
||||
organization.BillingEmail = "billing@test.com";
|
||||
organization.BusinessName = "Test Business";
|
||||
organization.Plan = "Enterprise";
|
||||
organization.LicenseKey = "test-license-key";
|
||||
organization.Seats = 100;
|
||||
organization.MaxCollections = 50;
|
||||
organization.MaxStorageGb = 10;
|
||||
organization.SmSeats = 25;
|
||||
organization.SmServiceAccounts = 10;
|
||||
organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired
|
||||
|
||||
// Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims
|
||||
// ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions
|
||||
var licenseContext = new LicenseContext
|
||||
{
|
||||
InstallationId = Guid.NewGuid(),
|
||||
SubscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
Subscription = new SubscriptionInfo.BillingSubscription(null!)
|
||||
{
|
||||
TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past
|
||||
PeriodStartDate = DateTime.UtcNow,
|
||||
PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days)
|
||||
Status = "active"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Generate claims
|
||||
var factory = new OrganizationLicenseClaimsFactory();
|
||||
var claims = await factory.GenerateClaims(organization, licenseContext);
|
||||
|
||||
// 3. Get all constants from OrganizationLicenseConstants
|
||||
var allConstants = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Get claim types from generated claims
|
||||
var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet();
|
||||
|
||||
// 5. Find constants that don't have corresponding claims
|
||||
var constantsWithoutClaims = allConstants
|
||||
.Except(generatedClaimTypes)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
// 6. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (constantsWithoutClaims.Any())
|
||||
{
|
||||
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n";
|
||||
errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}"));
|
||||
errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n";
|
||||
foreach (var constant in constantsWithoutClaims)
|
||||
{
|
||||
errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n";
|
||||
}
|
||||
errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally.";
|
||||
}
|
||||
|
||||
// 7. Assert - if this fails, the error message guides the developer to add claim generation
|
||||
Assert.True(
|
||||
!constantsWithoutClaims.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Organizations.Commands;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup license for CanUse validation
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-1);
|
||||
license.Expires = DateTime.Now.AddDays(1);
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.InstallationId = globalSettings.Installation.Id;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.Token = "test-token"; // Indicates this is a claims-based license
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
|
||||
// Create a ClaimsPrincipal with all organization license claims
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
|
||||
new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()),
|
||||
new(OrganizationLicenseConstants.Name, "Test Organization"),
|
||||
new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"),
|
||||
new(OrganizationLicenseConstants.BusinessName, "Test Business"),
|
||||
new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()),
|
||||
new(OrganizationLicenseConstants.Seats, "100"),
|
||||
new(OrganizationLicenseConstants.MaxCollections, "50"),
|
||||
new(OrganizationLicenseConstants.UsePolicies, "true"),
|
||||
new(OrganizationLicenseConstants.UseSso, "true"),
|
||||
new(OrganizationLicenseConstants.UseKeyConnector, "true"),
|
||||
new(OrganizationLicenseConstants.UseScim, "true"),
|
||||
new(OrganizationLicenseConstants.UseGroups, "true"),
|
||||
new(OrganizationLicenseConstants.UseDirectory, "true"),
|
||||
new(OrganizationLicenseConstants.UseEvents, "true"),
|
||||
new(OrganizationLicenseConstants.UseTotp, "true"),
|
||||
new(OrganizationLicenseConstants.Use2fa, "true"),
|
||||
new(OrganizationLicenseConstants.UseApi, "true"),
|
||||
new(OrganizationLicenseConstants.UseResetPassword, "true"),
|
||||
new(OrganizationLicenseConstants.Plan, "Enterprise"),
|
||||
new(OrganizationLicenseConstants.SelfHost, "true"),
|
||||
new(OrganizationLicenseConstants.UsersGetPremium, "true"),
|
||||
new(OrganizationLicenseConstants.UseCustomPermissions, "true"),
|
||||
new(OrganizationLicenseConstants.Enabled, "true"),
|
||||
new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.LicenseKey, "test-license-key"),
|
||||
new(OrganizationLicenseConstants.UsePasswordManager, "true"),
|
||||
new(OrganizationLicenseConstants.UseSecretsManager, "true"),
|
||||
new(OrganizationLicenseConstants.SmSeats, "25"),
|
||||
new(OrganizationLicenseConstants.SmServiceAccounts, "10"),
|
||||
new(OrganizationLicenseConstants.UseRiskInsights, "true"),
|
||||
new(OrganizationLicenseConstants.UseOrganizationDomains, "true"),
|
||||
new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"),
|
||||
new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"),
|
||||
new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"),
|
||||
new(OrganizationLicenseConstants.UsePhishingBlocker, "true"),
|
||||
new(OrganizationLicenseConstants.MaxStorageGb, "5"),
|
||||
new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")),
|
||||
new(OrganizationLicenseConstants.Trial, "false"),
|
||||
new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"),
|
||||
new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true")
|
||||
};
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns(claimsPrincipal);
|
||||
|
||||
// Setup selfHostedOrg for CanUseLicense validation
|
||||
selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license
|
||||
selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license
|
||||
selfHostedOrg.GroupCount = 1;
|
||||
selfHostedOrg.UseGroups = true;
|
||||
selfHostedOrg.UsePolicies = true;
|
||||
selfHostedOrg.UseSso = true;
|
||||
selfHostedOrg.UseKeyConnector = true;
|
||||
selfHostedOrg.UseScim = true;
|
||||
selfHostedOrg.UseCustomPermissions = true;
|
||||
selfHostedOrg.UseResetPassword = true;
|
||||
|
||||
try
|
||||
{
|
||||
await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null);
|
||||
|
||||
// Assertion: license file should be written to disk
|
||||
var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json");
|
||||
await using var fs = File.OpenRead(filePath);
|
||||
var licenseFromFile = await JsonSerializer.DeserializeAsync<OrganizationLicense>(fs);
|
||||
|
||||
AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes");
|
||||
|
||||
// Assertion: organization should be updated with ALL properties extracted from claims
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.Received(1)
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Is<Organization>(org =>
|
||||
org.Name == "Test Organization" &&
|
||||
org.BillingEmail == "billing@test.com" &&
|
||||
org.BusinessName == "Test Business" &&
|
||||
org.PlanType == PlanType.EnterpriseAnnually &&
|
||||
org.Seats == 100 &&
|
||||
org.MaxCollections == 50 &&
|
||||
org.UsePolicies == true &&
|
||||
org.UseSso == true &&
|
||||
org.UseKeyConnector == true &&
|
||||
org.UseScim == true &&
|
||||
org.UseGroups == true &&
|
||||
org.UseDirectory == true &&
|
||||
org.UseEvents == true &&
|
||||
org.UseTotp == true &&
|
||||
org.Use2fa == true &&
|
||||
org.UseApi == true &&
|
||||
org.UseResetPassword == true &&
|
||||
org.Plan == "Enterprise" &&
|
||||
org.SelfHost == true &&
|
||||
org.UsersGetPremium == true &&
|
||||
org.UseCustomPermissions == true &&
|
||||
org.Enabled == true &&
|
||||
org.LicenseKey == "test-license-key" &&
|
||||
org.UsePasswordManager == true &&
|
||||
org.UseSecretsManager == true &&
|
||||
org.SmSeats == 25 &&
|
||||
org.SmServiceAccounts == 10 &&
|
||||
org.UseRiskInsights == true &&
|
||||
org.UseOrganizationDomains == true &&
|
||||
org.UseAdminSponsoredFamilies == true &&
|
||||
org.UseAutomaticUserConfirmation == true &&
|
||||
org.UseDisableSmAdsForUsers == true &&
|
||||
org.UsePhishingBlocker == true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temporary directory
|
||||
if (Directory.Exists(OrganizationLicenseDirectory.Value))
|
||||
{
|
||||
Directory.Delete(OrganizationLicenseDirectory.Value, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup license for CanUse validation
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-1);
|
||||
license.Expires = DateTime.Now.AddDays(1);
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.Token = "test-token"; // Indicates this is a claims-based license
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
|
||||
// Create a ClaimsPrincipal with WRONG installation ID
|
||||
var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
|
||||
new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()),
|
||||
new(OrganizationLicenseConstants.Enabled, "true"),
|
||||
new(OrganizationLicenseConstants.SelfHost, "true")
|
||||
};
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns(claimsPrincipal);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
|
||||
|
||||
Assert.Contains("The installation ID does not match the current installation.", exception.Message);
|
||||
|
||||
// Verify organization was NOT saved
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.DidNotReceive()
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup legacy license (no Token, no claims)
|
||||
license.Token = null; // Legacy license
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-2);
|
||||
license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.InstallationId = globalSettings.Installation.Id;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.SelfHost = true;
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns((ClaimsPrincipal)null); // No claims for legacy license
|
||||
|
||||
// Passing values for SelfHostedOrganizationDetails.CanUseLicense
|
||||
license.Seats = null;
|
||||
license.MaxCollections = null;
|
||||
license.UseGroups = true;
|
||||
license.UsePolicies = true;
|
||||
license.UseSso = true;
|
||||
license.UseKeyConnector = true;
|
||||
license.UseScim = true;
|
||||
license.UseCustomPermissions = true;
|
||||
license.UseResetPassword = true;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
|
||||
|
||||
Assert.Contains("The license has expired.", exception.Message);
|
||||
|
||||
// Verify organization was NOT saved
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.DidNotReceive()
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided()
|
||||
{
|
||||
// This test ensures that when new properties are added to OrganizationLicense,
|
||||
// they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand.
|
||||
// If a new constant is added to OrganizationLicenseConstants but not extracted,
|
||||
// this test will fail with a clear message showing which properties are missing.
|
||||
|
||||
// 1. Get all OrganizationLicenseConstants
|
||||
var constantFields = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToList();
|
||||
|
||||
// 2. Define properties that should be excluded (not claims-based or intentionally not extracted)
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
"Version", // Not in claims system (only in deprecated property-based licenses)
|
||||
"Hash", // Signature-related, not extracted from claims
|
||||
"Signature", // Signature-related, not extracted from claims
|
||||
"SignatureBytes", // Computed from Signature, not a claim
|
||||
"Token", // The JWT itself, not extracted from claims
|
||||
"Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID
|
||||
};
|
||||
|
||||
// 3. Get properties that should be extracted from claims
|
||||
var propertiesThatShouldBeExtracted = constantFields
|
||||
.Where(c => !excludedProperties.Contains(c))
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Read UpdateOrganizationLicenseCommand source code
|
||||
var commandSourcePath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"..", "..", "..", "..", "..",
|
||||
"src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs");
|
||||
var sourceCode = await File.ReadAllTextAsync(commandSourcePath);
|
||||
|
||||
// 5. Find all GetValue calls that extract properties from claims
|
||||
// Pattern matches: license.PropertyName = claimsPrincipal.GetValue<Type>(OrganizationLicenseConstants.PropertyName)
|
||||
var extractedProperties = new HashSet<string>();
|
||||
var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)";
|
||||
var matches = Regex.Matches(sourceCode, getValuePattern);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
extractedProperties.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// 6. Find missing extractions
|
||||
var missingExtractions = propertiesThatShouldBeExtracted
|
||||
.Except(extractedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 7. Build error message with guidance if there are missing extractions
|
||||
var errorMessage = "";
|
||||
if (missingExtractions.Any())
|
||||
{
|
||||
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n";
|
||||
errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n";
|
||||
foreach (var prop in missingExtractions)
|
||||
{
|
||||
errorMessage += $" license.{prop} = claimsPrincipal.GetValue<TYPE>(OrganizationLicenseConstants.{prop});\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Assert - if this fails, the error message guides the developer to add the extraction
|
||||
// Note: We don't check for "extra extractions" because that would be a compile error
|
||||
// (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist)
|
||||
Assert.True(
|
||||
!missingExtractions.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
|
||||
// Wrapper to compare 2 objects that are different types
|
||||
private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user