mirror of
https://github.com/bitwarden/server
synced 2026-01-16 07:23:15 +00:00
* 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`
428 lines
21 KiB
C#
428 lines
21 KiB
C#
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;
|
|
using Bit.Test.Common.AutoFixture;
|
|
using Bit.Test.Common.AutoFixture.Attributes;
|
|
using Bit.Test.Common.Helpers;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
|
|
namespace Bit.Core.Test.Billing.Organizations.Commands;
|
|
|
|
[SutProviderCustomize]
|
|
public class UpdateOrganizationLicenseCommandTests
|
|
{
|
|
private static string LicenseDirectory => Path.GetDirectoryName(OrganizationLicenseDirectory.Value);
|
|
private static Lazy<string> OrganizationLicenseDirectory => new(() =>
|
|
{
|
|
// Create a temporary directory to write the license file to
|
|
var directory = Path.Combine(Path.GetTempPath(), "bitwarden/");
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
return directory;
|
|
});
|
|
|
|
[Theory, BitAutoData]
|
|
public async Task UpdateLicenseAsync_UpdatesLicenseFileAndOrganization(
|
|
SelfHostedOrganizationDetails selfHostedOrg,
|
|
OrganizationLicense license,
|
|
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
|
{
|
|
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
|
globalSettings.LicenseDirectory = LicenseDirectory;
|
|
globalSettings.SelfHosted = true;
|
|
|
|
// Passing values for OrganizationLicense.CanUse
|
|
// NSubstitute cannot override non-virtual members so we have to ensure the real method passes
|
|
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;
|
|
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
|
sutProvider.GetDependency<ILicensingService>()
|
|
.GetClaimsPrincipalFromLicense(license)
|
|
.Returns((ClaimsPrincipal)null);
|
|
|
|
// Passing values for SelfHostedOrganizationDetails.CanUseLicense
|
|
// NSubstitute cannot override non-virtual members so we have to ensure the real method passes
|
|
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;
|
|
|
|
try
|
|
{
|
|
await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null);
|
|
|
|
// Assertion: should have saved the license file 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: should have updated and saved the organization
|
|
// Properties excluded from the comparison below are exceptions to the rule that the Organization mirrors
|
|
// the OrganizationLicense
|
|
await sutProvider.GetDependency<IOrganizationService>()
|
|
.Received(1)
|
|
.ReplaceAndUpdateCacheAsync(Arg.Is<Organization>(
|
|
org => AssertPropertyEqual(license, org,
|
|
"Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType",
|
|
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
|
|
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
|
|
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
|
|
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker", "UseDisableSmAdsForUsers") &&
|
|
// Same property but different name, use explicit mapping
|
|
org.ExpirationDate == license.Expires));
|
|
}
|
|
finally
|
|
{
|
|
// Clean up temporary directory
|
|
Directory.Delete(OrganizationLicenseDirectory.Value, true);
|
|
}
|
|
}
|
|
|
|
[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)
|
|
{
|
|
AssertHelper.AssertPropertyEqual(expected, actual, excludedPropertyStrings);
|
|
return true;
|
|
}
|
|
}
|