Pulse/pkg/licensing/models.go
rcourtman 943389827f Scrub stale monitored-system caps on self-hosted uncapped tiers
v5-migrated installations can persist a legacy max_monitored_systems value
(e.g. Community=1) in the license claims. After 5914a4127 made all
self-hosted tiers uncapped, EffectiveLimits() only scrubbed Lifetime and
v5-grandfathered Pro, so migrated Community / Pro / Relay installs kept
emitting the old cap and the "Over plan" banner still rendered on rc.2.

Extend the scrub to every self-hosted tier whose TierMonitoredSystemLimits
entry is 0, and stop monitoredSystemContinuityStatusLocked() from falling
back to the grant's plan limit when the license says uncapped. 0 is now
a first-class "unlimited" signal, not a missing-data sentinel.

The grandfather-floor continuity is still captured for audit, but no
longer enforces on self-hosted tiers. Cloud and MSP limit resolution is
untouched.

Refs #1429
2026-04-17 13:52:02 +01:00

243 lines
8.5 KiB
Go

package licensing
import (
"encoding/json"
"sort"
"time"
)
// Claims represents the JWT claims in a Pulse Pro license.
type Claims struct {
// License ID (unique identifier)
LicenseID string `json:"lid"`
// Email of the license holder
Email string `json:"email"`
// License tier (pro, pro_annual, lifetime, msp, enterprise)
Tier Tier `json:"tier"`
// Issued at (Unix timestamp)
IssuedAt int64 `json:"iat"`
// Expires at (Unix timestamp, 0 for lifetime)
ExpiresAt int64 `json:"exp,omitempty"`
// Features explicitly granted (optional, tier implies features)
Features []string `json:"features,omitempty"`
// Max agents (0 = unlimited)
MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"`
// Max guests (0 = unlimited)
MaxGuests int `json:"max_guests,omitempty"`
// Entitlement primitives (B1) - when present, these override tier-based derivation.
// When absent (nil/empty), entitlements are derived from Tier + existing fields.
Capabilities []string `json:"capabilities,omitempty"`
Limits map[string]int64 `json:"limits,omitempty"`
MetersEnabled []string `json:"meters_enabled,omitempty"`
PlanVersion string `json:"plan_version,omitempty"`
SubState SubscriptionState `json:"subscription_state,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshaling for Claims to handle the
// migration from legacy monitored-system aliases to "max_monitored_systems".
// Existing JWTs and billing.json files may still contain older keys; this shim
// reads them all and prefers the canonical monitored-system field when present.
func (c *Claims) UnmarshalJSON(data []byte) error {
// Unmarshal into the base type (avoids infinite recursion).
type Alias Claims
if err := json.Unmarshal(data, (*Alias)(c)); err != nil {
return err
}
// Migration shim: if the canonical field was absent, adopt the legacy v5
// monitored-system alias that may still exist in older JWTs or billing.json.
if legacy, ok, err := decodeLegacyV5MonitoredSystemLimitFromJSON(data); err == nil && ok {
c.MaxMonitoredSystems = legacy
}
return nil
}
// EffectiveCapabilities returns explicit capabilities when present; otherwise tier-derived capabilities.
func (c Claims) EffectiveCapabilities() []string {
if c.Capabilities != nil && len(c.Capabilities) > 0 {
return c.Capabilities
}
return DeriveCapabilitiesFromTier(c.Tier, c.Features)
}
// isSelfHostedUncappedTier reports whether a tier is a self-hosted tier that
// must remain uncapped for core monitoring regardless of any persisted limit.
// Cloud and MSP tiers resolve their limits from plan version, so they are
// excluded. Enterprise is excluded because its claims carry explicit per-deal
// limits that must be honored.
func isSelfHostedUncappedTier(tier Tier) bool {
switch tier {
case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime:
return true
default:
return false
}
}
// EffectiveLimits returns explicit limits when present; otherwise limits derived from legacy fields.
func (c Claims) EffectiveLimits() map[string]int64 {
limits := NormalizeMonitoredSystemLimits(c.Limits)
if len(limits) == 0 {
limits = make(map[string]int64)
if c.MaxMonitoredSystems > 0 {
limits[MaxMonitoredSystemsLicenseGateKey] = int64(c.MaxMonitoredSystems)
}
if c.MaxGuests > 0 {
limits["max_guests"] = int64(c.MaxGuests)
}
}
if isSelfHostedUncappedTier(c.Tier) || IsGrandfatheredRecurringV5PlanVersion(c.PlanVersion) {
// Self-hosted tiers are uncapped for core monitoring, and grandfathered
// recurring v5 migrations must remain uncapped too. Older tokens or
// migrated activation records may still carry historical per-tier limits
// (e.g. a v5-migrated Community install persisted max_monitored_systems=1);
// scrub them so the current tier policy wins.
delete(limits, MaxMonitoredSystemsLicenseGateKey)
delete(limits, "max_guests")
}
if c.Tier == TierCloud || c.Tier == TierMSP {
if limit, known := CloudPlanMonitoredSystemLimits[CanonicalizePlanVersion(c.PlanVersion)]; known {
limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit)
}
if _, hasSystems := limits[MaxMonitoredSystemsLicenseGateKey]; !hasSystems {
limits[MaxMonitoredSystemsLicenseGateKey] = int64(UnknownPlanDefaultMonitoredSystemLimit)
}
}
return limits
}
// EntitlementMetersEnabled returns metering keys for evaluator sources.
func (c *Claims) EntitlementMetersEnabled() []string {
if c == nil {
return nil
}
return c.MetersEnabled
}
// EntitlementPlanVersion returns plan metadata for evaluator sources.
func (c *Claims) EntitlementPlanVersion() string {
if c == nil {
return ""
}
return CanonicalizePlanVersion(c.PlanVersion)
}
// EntitlementSubscriptionState returns normalized subscription state for evaluator sources.
func (c *Claims) EntitlementSubscriptionState() SubscriptionState {
if c == nil || c.SubState == "" {
return SubStateActive
}
return c.SubState
}
// License represents a validated Pulse Pro license.
type License struct {
// Raw JWT token
Raw string `json:"-"`
// Validated claims
Claims Claims `json:"claims"`
// Validation metadata
ValidatedAt time.Time `json:"validated_at"`
// Grace period end (if license was validated during grace period)
GracePeriodEnd *time.Time `json:"grace_period_end,omitempty"`
}
// IsExpired checks if the license has expired.
func (l *License) IsExpired() bool {
if l.Claims.ExpiresAt == 0 {
return false // Lifetime license never expires
}
return time.Now().Unix() > l.Claims.ExpiresAt
}
// IsLifetime returns true if this is a lifetime license.
func (l *License) IsLifetime() bool {
return l.Claims.ExpiresAt == 0 || l.Claims.Tier == TierLifetime
}
// DaysRemaining returns the number of days until expiration.
// Returns -1 for lifetime licenses.
func (l *License) DaysRemaining() int {
if l.IsLifetime() {
return -1
}
remaining := time.Until(time.Unix(l.Claims.ExpiresAt, 0))
if remaining < 0 {
return 0
}
return int(remaining.Hours() / 24)
}
// ExpiresAt returns the expiration time, or nil for lifetime.
func (l *License) ExpiresAt() *time.Time {
if l.IsLifetime() {
return nil
}
t := time.Unix(l.Claims.ExpiresAt, 0)
return &t
}
// HasFeature checks if the license grants a specific feature.
func (l *License) HasFeature(feature string) bool {
for _, capability := range l.Claims.EffectiveCapabilities() {
if capability == feature {
return true
}
}
return false
}
// AllFeatures returns all features granted by this license.
func (l *License) AllFeatures() []string {
features := append([]string(nil), l.Claims.EffectiveCapabilities()...)
sort.Strings(features)
return features
}
// LicenseState represents the current state of the license.
type LicenseState string
const (
LicenseStateNone LicenseState = "none"
LicenseStateActive LicenseState = "active"
LicenseStateExpired LicenseState = "expired"
LicenseStateGracePeriod LicenseState = "grace_period"
)
// LicenseStatus is the JSON response for license status API.
type LicenseStatus struct {
Valid bool `json:"valid"`
Tier Tier `json:"tier"`
PlanVersion string `json:"plan_version,omitempty"`
Email string `json:"email,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
IsLifetime bool `json:"is_lifetime"`
DaysRemaining int `json:"days_remaining"`
Features []string `json:"features"`
MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"`
MaxGuests int `json:"max_guests,omitempty"`
InGracePeriod bool `json:"in_grace_period,omitempty"`
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
}
// MonitoredSystemContinuityStatus describes the effective monitored-system
// limit continuity applied to a migrated legacy installation.
type MonitoredSystemContinuityStatus struct {
PlanLimit int `json:"plan_limit"`
GrandfatheredFloor int `json:"grandfathered_floor,omitempty"`
EffectiveLimit int `json:"effective_limit"`
CapturePending bool `json:"capture_pending"`
CapturedAt int64 `json:"captured_at,omitempty"`
}