Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -128,6 +128,7 @@ public Organization ToOrganization()
UseApi = UseApi,
UseResetPassword = UseResetPassword,
UseSecretsManager = UseSecretsManager,
UsePasswordManager = UsePasswordManager,
SelfHost = SelfHost,
UsersGetPremium = UsersGetPremium,
UseCustomPermissions = UseCustomPermissions,
Expand Down Expand Up @@ -156,6 +157,8 @@ public Organization ToOrganization()
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
UseOrganizationDomains = UseOrganizationDomains,
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
};
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,6 +48,57 @@ public async Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrg
}

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);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The code extracts 29 properties, but two constants appear to be missing from OrganizationLicenseConstants:

  • UseDisableSmAdsForUsers
  • UsePhishingBlocker. Is this intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope, it wasn't. Those two seem to be new additions from when I opened this PR. I'll get them added, good catch

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch @cyprain-okeke , I just pushed a commit addressing this. I also added a set of unit tests to guide future developers through the process of adding a property to the organization license


var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
selfHostedOrganization.CanUseLicense(license, out exception);

Expand All @@ -54,12 +107,6 @@ public async Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrg
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);
}
Expand Down
12 changes: 12 additions & 0 deletions src/Core/Services/Implementations/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
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;
Expand Down Expand Up @@ -870,7 +872,7 @@
{
if (!CoreHelpers.FixedTimeEquals(
user.TwoFactorRecoveryCode,
recoveryCode.Replace(" ", string.Empty).Trim().ToLower()))

Check warning on line 875 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build MSSQL migrator utility (win-x64)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 875 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Sso, ./bitwarden_license/src, true)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 875 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Icons, ./src, true)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 875 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Events, ./src, true)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)

Check warning on line 875 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Build Docker images (Scim, ./bitwarden_license/src, true)

The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in 'UserService.RecoverTwoFactorAsync(User, string)' with a call to 'string.ToLower(CultureInfo)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1304)
{
return false;
}
Expand Down Expand Up @@ -982,6 +984,16 @@
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"));
Expand Down
106 changes: 105 additions & 1 deletion test/Core.Test/AdminConsole/Entities/OrganizationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -115,4 +118,105 @@ public void UseDisableSmAdsForUsers_CanBeSetToTrue()

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 changes: 68 additions & 0 deletions test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
Loading
Loading