From d663d3a1154731767db0bc3be87af243e57b213c Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 23 Mar 2026 09:37:16 -0500 Subject: [PATCH 1/2] fix(licensing): skip UseMyItems comparison for pre-2026.3.0 license files --- .../Models/OrganizationLicense.cs | 11 +- .../Business/OrganizationLicenseTests.cs | 125 ++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 51adc56ca4fc..ece25e0457ea 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -42,6 +42,9 @@ public OrganizationLicense() /// 1. Use the claims-based system instead of adding properties here /// 2. Add new claims to the license token /// 3. Validate claims in the and methods + /// 4. In , wrap new claim comparisons in a conditional + /// HasClaim check so that licenses generated before the claim existed still + /// validate successfully (introduced after PM-33980) /// /// /// This constructor is maintained only for backward compatibility with existing licenses. @@ -438,6 +441,11 @@ public bool VerifyData( ? organization.PlanType is PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 : organization.PlanType == claimedPlanType; + // IMPORTANT: UseMyItems is the first claim to require a conditional HasClaim + // check because self-hosted instances may hold license files generated before + // this claim existed, where GetValue returns the type's default (false), + // causing a mismatch that disables the org. Future claims MUST follow this + // same pattern. See PM-33980. return issued <= DateTime.UtcNow && expires >= DateTime.UtcNow && installationId == globalSettings.Installation.Id && @@ -469,7 +477,8 @@ public bool VerifyData( useOrganizationDomains == organization.UseOrganizationDomains && useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation && useDisableSmAdsForUsers == organization.UseDisableSmAdsForUsers && - useMyItems == organization.UseMyItems; + (!claimsPrincipal.HasClaim(c => c.Type == nameof(UseMyItems)) + || useMyItems == organization.UseMyItems); } diff --git a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs index 71fcf6cd4565..40b7875d506a 100644 --- a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs +++ b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs @@ -292,4 +292,129 @@ public void OrganizationLicense_UseDisableSmAdsForUsers_ClaimGenerationAndValida // Act & Assert - Verify VerifyData passes with the UseDisableSmAdsForUsers value Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); } + + /// + /// Regression test for PM-33980: Self-hosted orgs disabled after updating to 2026.3.0 + /// because their pre-existing license lacks the UseMyItems claim. VerifyData must skip + /// comparison for claims absent from the license. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public void OrganizationLicense_VerifyData_PassesWhenUseMyItemsClaimAbsent(bool useMyItemsDbValue) + { + // Arrange + var organization = CreateDeterministicOrganization(); + organization.UseMyItems = useMyItemsDbValue; + + var installationId = new Guid("78900000-0000-0000-0000-000000000123"); + + // Build a ClaimsPrincipal with all claims VerifyData checks EXCEPT UseMyItems, + // simulating a license generated before UseMyItems was added. + var claims = BuildBaseVerifyDataClaims(organization, installationId); + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Token must be non-empty to enter the claims-based VerifyData path + var license = new OrganizationLicense { Token = "non-empty", Expires = DateTime.MaxValue }; + + var globalSettings = Substitute.For(); + globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings + { + Id = installationId + }); + + // Act & Assert — VerifyData must pass regardless of the org's UseMyItems DB value + Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public void OrganizationLicense_VerifyData_PassesWhenUseMyItemsClaimPresentAndMatches(bool useMyItemsValue) + { + var organization = CreateDeterministicOrganization(); + organization.UseMyItems = useMyItemsValue; + + var installationId = new Guid("78900000-0000-0000-0000-000000000123"); + + var claims = BuildBaseVerifyDataClaims(organization, installationId); + claims.Add(new Claim(nameof(OrganizationLicense.UseMyItems), useMyItemsValue.ToString())); + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + var license = new OrganizationLicense { Token = "non-empty", Expires = DateTime.MaxValue }; + + var globalSettings = Substitute.For(); + globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings + { + Id = installationId + }); + + Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); + } + + [Fact] + public void OrganizationLicense_VerifyData_FailsWhenUseMyItemsClaimPresentAndMismatches() + { + var organization = CreateDeterministicOrganization(); + organization.UseMyItems = true; + + var installationId = new Guid("78900000-0000-0000-0000-000000000123"); + + var claims = BuildBaseVerifyDataClaims(organization, installationId); + // Claim says false, org says true — should fail + claims.Add(new Claim(nameof(OrganizationLicense.UseMyItems), false.ToString())); + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + var license = new OrganizationLicense { Token = "non-empty", Expires = DateTime.MaxValue }; + + var globalSettings = Substitute.For(); + globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings + { + Id = installationId + }); + + Assert.False(license.VerifyData(organization, claimsPrincipal, globalSettings)); + } + + /// + /// Builds the base set of claims that VerifyData checks, excluding UseMyItems. + /// Callers can add or omit UseMyItems to test specific scenarios. + /// + private static List BuildBaseVerifyDataClaims(Organization organization, Guid installationId) + { + return new List + { + new(nameof(OrganizationLicense.Issued), DateTime.UtcNow.AddDays(-1).ToString()), + new(nameof(OrganizationLicense.Expires), DateTime.MaxValue.ToString()), + new(nameof(OrganizationLicense.InstallationId), installationId.ToString()), + new(nameof(OrganizationLicense.LicenseKey), organization.LicenseKey), + new(nameof(OrganizationLicense.Enabled), organization.Enabled.ToString()), + new(nameof(OrganizationLicense.PlanType), ((int)organization.PlanType).ToString()), + new(nameof(OrganizationLicense.Seats), organization.Seats.ToString()), + new(nameof(OrganizationLicense.MaxCollections), organization.MaxCollections.ToString()), + new(nameof(OrganizationLicense.UseGroups), organization.UseGroups.ToString()), + new(nameof(OrganizationLicense.UseDirectory), organization.UseDirectory.ToString()), + new(nameof(OrganizationLicense.UseTotp), organization.UseTotp.ToString()), + new(nameof(OrganizationLicense.SelfHost), organization.SelfHost.ToString()), + new(nameof(OrganizationLicense.Name), organization.Name), + new(nameof(OrganizationLicense.UsersGetPremium), organization.UsersGetPremium.ToString()), + new(nameof(OrganizationLicense.UseEvents), organization.UseEvents.ToString()), + new(nameof(OrganizationLicense.Use2fa), organization.Use2fa.ToString()), + new(nameof(OrganizationLicense.UseApi), organization.UseApi.ToString()), + new(nameof(OrganizationLicense.UsePolicies), organization.UsePolicies.ToString()), + new(nameof(OrganizationLicense.UseSso), organization.UseSso.ToString()), + new(nameof(OrganizationLicense.UseResetPassword), organization.UseResetPassword.ToString()), + new(nameof(OrganizationLicense.UseKeyConnector), organization.UseKeyConnector.ToString()), + new(nameof(OrganizationLicense.UseScim), organization.UseScim.ToString()), + new(nameof(OrganizationLicense.UseCustomPermissions), organization.UseCustomPermissions.ToString()), + new(nameof(OrganizationLicense.UseSecretsManager), organization.UseSecretsManager.ToString()), + new(nameof(OrganizationLicense.UsePasswordManager), organization.UsePasswordManager.ToString()), + new(nameof(OrganizationLicense.SmSeats), organization.SmSeats.ToString()), + new(nameof(OrganizationLicense.SmServiceAccounts), organization.SmServiceAccounts.ToString()), + new(nameof(OrganizationLicense.UseAdminSponsoredFamilies), organization.UseAdminSponsoredFamilies.ToString()), + new(nameof(OrganizationLicense.UseOrganizationDomains), organization.UseOrganizationDomains.ToString()), + new(nameof(OrganizationLicense.UseAutomaticUserConfirmation), organization.UseAutomaticUserConfirmation.ToString()), + new(nameof(OrganizationLicense.UseDisableSmAdsForUsers), organization.UseDisableSmAdsForUsers.ToString()), + }; + } } From a3b0de0d6ce67490e4cdbc5e0b938f388bd1df77 Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Mon, 23 Mar 2026 11:02:23 -0500 Subject: [PATCH 2/2] docs(licensing): add backward-compatibility guidance to ability flag README --- .../OrganizationFeatures/OrganizationAbility/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md index 80f928417a6b..12158b9f6072 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md @@ -263,7 +263,9 @@ For manual override capability in the admin portal: - `src/Core/Billing/Organizations/Models/OrganizationLicense.cs` - Add the new property to the class - - `VerifyData()` — Add claims validation + - `VerifyData()` — Add claims validation using conditional comparison + (`!claimsPrincipal.HasClaim(...) || claimValue == orgValue`) so that self-hosted instances + with licenses generated before the claim existed are not incorrectly disabled (see PM-33980) - `GetDataBytes()` — Add the new property to the ignored fields section (below the comment `// any new fields added need to be added here so that they're ignored`)