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 @@ -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`)

Expand Down
11 changes: 10 additions & 1 deletion src/Core/Billing/Organizations/Models/OrganizationLicense.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="CanUse"/> and <see cref="VerifyData"/> methods
/// 4. In <see cref="VerifyData"/>, wrap new claim comparisons in a conditional
/// HasClaim check so that licenses generated before the claim existed still
/// validate successfully (introduced after PM-33980)
/// </para>
/// <para>
/// This constructor is maintained only for backward compatibility with existing licenses.
Expand Down Expand Up @@ -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<T> returns the type's default (false),
// causing a mismatch that disables the org. Future claims MUST follow this
// same pattern. See PM-33980.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❓ @eliykat worked on this readme file recently with detailed instructions for adding new organization abilities and it contains a detailed section for editing this code for self-host licensing support. I am now wondering if we missed something in those instructions that led to this? Do you think the instructions in that readme file need to also be updated to include these new instructions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@kdenney This PR updates the documentation to add the requirement for the conditional check on new licensing properties, but I think we're going to have further, more detailed discussions on the potential deprecation of this validation flow altogether with a stronger focus on the JWT. Those will be part of a separate discussion since this change will be a hotfix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(Book keeping) @kdenney Discussed in Slack, but I misunderstood which docs you were referring to. Those have been updated here: a3b0de0

return issued <= DateTime.UtcNow &&
expires >= DateTime.UtcNow &&
installationId == globalSettings.Installation.Id &&
Expand Down Expand Up @@ -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);

}

Expand Down
125 changes: 125 additions & 0 deletions test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
/// 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.
/// </summary>
[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<IGlobalSettings>();
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<IGlobalSettings>();
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<IGlobalSettings>();
globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings
{
Id = installationId
});

Assert.False(license.VerifyData(organization, claimsPrincipal, globalSettings));
}

/// <summary>
/// Builds the base set of claims that VerifyData checks, excluding UseMyItems.
/// Callers can add or omit UseMyItems to test specific scenarios.
/// </summary>
private static List<Claim> BuildBaseVerifyDataClaims(Organization organization, Guid installationId)
{
return new List<Claim>
{
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()),
};
}
}
Loading