Pulse/pkg/licensing/features.go
rcourtman 185c6ac80b Let a provider-hosted platform buy and renew its own licence
A provider could stand the MSP bundle up and evaluate it without asking
anyone, but paying still meant emailing its public key to Pulse and
copying a licence file onto the host by hand. With the licence server's
self-serve purchase path (pulse-pro license-server/provider_msp_purchase),
the control plane can do the whole thing itself.

The control plane now signs a challenge with its lease signing key to
fetch the licence its subscription entitles, validates it exactly as
startup does, requires it to bind this platform's key, and keeps it in
the data directory, because the host licence is a read-only secret
mount. Startup prefers that renewed licence while it validates, so a
paying provider keeps starting after the self-issued evaluation on the
host expires. A changed licence restarts the control plane through the
existing graceful shutdown, since the plan version is read at load by
every workspace-limit, backup and status path; client workspaces keep
running. Preflight, proof, recovery and backup accept either signed
source.

The portal gains owner and admin routes for plan state, checkout, the
Stripe billing portal and an immediate licence refresh, and the plan list
comes from the licence server so it shows only what can be bought, at
Stripe's price. Adds the msp_solo plan: 3 client workspaces, the first
paid step above the 2-workspace evaluation.
2026-09-23 23:30:34 +01:00

460 lines
16 KiB
Go

// Package licensing defines shared Pulse feature and tier contracts.
//
// This package exists so private extension modules can depend on canonical
// licensing metadata without importing internal packages.
package licensing
import (
"sort"
"strings"
"time"
)
// Feature constants represent gated features in Pulse.
// These are embedded in license JWTs and checked at runtime.
const (
// Free tier features
FeatureUpdateAlerts = "update_alerts" // Alerts for pending container/package updates
FeatureSSO = "sso" // Core OIDC/SAML SSO authentication
FeatureAdvancedSSO = "advanced_sso" // Compatibility key for SAML and multi-provider SSO; included in Community
FeatureAIPatrol = "ai_patrol" // Background AI health monitoring (BYOK, free with own key)
// Relay tier features (everything in Free, plus:)
FeatureRelay = "relay" // Relay remote access
FeatureMobileApp = "mobile_app" // Mobile app access
FeaturePushNotifications = "push_notifications" // Push notifications
FeatureLongTermMetrics = "long_term_metrics" // Extended historical metrics (14d Relay, 90d Pro)
// Pro tier features (everything in Relay, plus:)
FeatureAIAlerts = "ai_alerts" // AI analysis when alerts fire
FeatureAIAutoFix = "ai_autofix" // Governed Patrol fixes
FeatureKubernetesAI = "kubernetes_ai" // Legacy Kubernetes analysis compatibility gate (NOT basic monitoring)
FeatureAgentProfiles = "agent_profiles" // Centralized agent configuration profiles
FeatureRBAC = "rbac" // Role-Based Access Control
FeatureAuditLogging = "audit_logging" // Persistent audit logs with signing
FeatureAdvancedReporting = "advanced_reporting" // PDF/CSV reporting engine
FeatureExternalProbe = "external_probe" // Availability checks run from outside the monitored site
// MSP/Enterprise tier features
FeatureMultiUser = "multi_user" // Multi-user (likely merged with RBAC)
FeatureWhiteLabel = "white_label" // Custom report branding
FeatureMultiTenant = "multi_tenant" // Multi-tenant organizations
FeatureUnlimited = "unlimited" // Compatibility capability marker for MSP/enterprise deals
// Internal-only runtime capabilities. These must never be added to public
// tier defaults or public pricing contracts.
FeatureDemoFixtures = "demo_fixtures" // Allows release builds in DEMO_MODE to render mock fixture data
)
// Tier represents a license tier.
type Tier string
const (
TierFree Tier = "free"
TierRelay Tier = "relay"
TierPro Tier = "pro"
TierProPlus Tier = "pro_plus"
TierProAnnual Tier = "pro_annual" // Legacy: same features as TierPro
TierLifetime Tier = "lifetime" // Legacy: same features as TierPro
TierBusiness Tier = "business" // Pro features; differentiated by user limit, retention, and support
TierCloud Tier = "cloud"
TierMSP Tier = "msp"
TierEnterprise Tier = "enterprise"
)
// PriceIDToPlanVersion maps Stripe price IDs to canonical plan version strings.
// This is the authoritative reverse lookup: given a price ID from a checkout
// session, subscription, or webhook event, callers can resolve the plan version
// without relying on metadata being set. The map covers all known renewing
// recurring prices that Pulse must interpret canonically, including:
// - v6 Cloud and MSP recurring prices
// - grandfathered v5 recurring renewals
// - still-renewing legacy v1 recurring renewals
var PriceIDToPlanVersion = map[string]string{
// Cloud Starter
"price_1T5kflBrHBocJIGHUqPv1dzV": "cloud_starter", // $29/mo
"price_1T5kfmBrHBocJIGHTS3ymKxM": "cloud_starter", // $249/yr
"price_1T5kfnBrHBocJIGHATQJr79D": "cloud_founding", // $19/mo founding
// Cloud Power
"price_1T5kg2BrHBocJIGHmkoF0zXY": "cloud_power", // $49/mo
"price_1T5kg3BrHBocJIGH2EtzKofV": "cloud_power", // $449/yr
// Cloud Max
"price_1T5kg4BrHBocJIGHHa8Ecqho": "cloud_max", // $79/mo
"price_1T5kg5BrHBocJIGH5AIJ4nVc": "cloud_max", // $699/yr
// MSP Starter
"price_1T5kgTBrHBocJIGHjOs15LI2": "msp_starter", // $149/mo
"price_1T5kgUBrHBocJIGHT6PiOn6x": "msp_starter", // $1,490/yr
// MSP Growth
"price_1T5kgVBrHBocJIGHulNsCTb1": "msp_growth", // $249/mo
"price_1T5kgWBrHBocJIGHTuaNjnJ2": "msp_growth", // $2,490/yr
// MSP Scale
"price_1T5kgWBrHBocJIGHo40iFeRd": "msp_scale", // $399/mo
"price_1T5kgXBrHBocJIGHWlOgTyGV": "msp_scale", // $3,990/yr
// Grandfathered Pulse Pro recurring renewals (v5)
"price_1ShIsdBrHBocJIGH71yQusLG": "v5_pro_monthly_grandfathered", // $9/mo
"price_1ShIsnBrHBocJIGHBKkzsZ3T": "v5_pro_annual_grandfathered", // $79/yr
// Grandfathered Pulse Pro recurring renewals (legacy v1)
"price_1SgDxvBrHBocJIGHStaGuiAX": "v5_pro_monthly_grandfathered", // $19/mo
"price_1SgDxwBrHBocJIGHTKTsIMLc": "v5_pro_annual_grandfathered", // $190/yr
}
// PlanVersionForPriceID returns the canonical plan version for a Stripe price
// ID. Returns ("", false) if the price ID is not recognized.
func PlanVersionForPriceID(priceID string) (string, bool) {
v, ok := PriceIDToPlanVersion[priceID]
return v, ok
}
func IsGrandfatheredRecurringV5PlanVersion(planVersion string) bool {
switch CanonicalizePlanVersion(planVersion) {
case "v5_pro_monthly_grandfathered", "v5_pro_annual_grandfathered":
return true
default:
return false
}
}
// IsSelfHostedCommunityPlanVersion reports whether a persisted billing-state
// plan version denotes the uncapped self-hosted Community/free posture.
func IsSelfHostedCommunityPlanVersion(planVersion string) bool {
switch strings.ToLower(CanonicalizePlanVersion(planVersion)) {
case "community", string(TierFree):
return true
default:
return false
}
}
// IsSelfHostedCoreMonitoringUncappedTier reports whether a tier is part of the
// self-hosted v6 contract where core monitoring volume is not monetized.
func IsSelfHostedCoreMonitoringUncappedTier(tier Tier) bool {
switch Tier(strings.ToLower(strings.TrimSpace(string(tier)))) {
case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime, TierBusiness:
return true
default:
return false
}
}
func IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion string) bool {
normalized := strings.ToLower(CanonicalizePlanVersion(planVersion))
switch normalized {
case "community",
string(TierFree),
string(TierRelay),
string(TierPro),
string(TierProPlus),
string(TierProAnnual),
string(TierLifetime),
string(TierBusiness),
"business_annual",
"v5_lifetime_grandfathered":
return true
default:
return IsGrandfatheredRecurringV5PlanVersion(normalized)
}
}
func stripLegacyCommercialCaps(limits map[string]int64) {
delete(limits, MaxMonitoredSystemsLicenseGateKey)
delete(limits, "max_guests")
}
func stripSelfHostedCommercialVolumeCaps(limits map[string]int64, planVersion string, tier Tier, uncapped bool) {
if limits == nil {
return
}
if uncapped ||
IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion) ||
(planVersion == "" && IsSelfHostedCoreMonitoringUncappedTier(tier)) {
stripLegacyCommercialCaps(limits)
}
}
// CloudPlanWorkspaceLimits maps cloud plan version strings to the maximum
// number of active workspaces (tenants) the account may create. Individual
// Cloud accounts get exactly 1 workspace; MSP tiers get the client caps from
// the pricing spec.
var CloudPlanWorkspaceLimits = map[string]int{
// Individual Cloud tiers — one workspace per account
"cloud_starter": 1,
"cloud_power": 1,
"cloud_max": 1,
"cloud_founding": 1,
// MSP tiers — client caps from pricing spec
"msp_eval": 2, // MSP evaluation: up to 2 clients, unlicensed, not sold
"msp_solo": 3, // MSP Solo: up to 3 clients, the first paid step above the evaluation
"msp_starter": 5, // MSP Starter: up to 5 clients
"msp_growth": 15, // MSP Growth: up to 15 clients
"msp_scale": 40, // MSP Scale: up to 40 clients
}
// PlanVersionMSPEval is the plan a provider-hosted control plane runs on when
// it has no licence file. It exists so a provider can stand the stack up and
// onboard real clients before talking to anyone, which is the only way to
// evaluate an isolation guarantee honestly.
//
// Deliberately capped BELOW the cheapest paid tier. The capabilities are the
// same, so what is being evaluated is the real product; the client cap is what
// converts. Not a purchasable plan and not part of the public pricing ladder.
const PlanVersionMSPEval = "msp_eval"
// UnknownPlanDefaultWorkspaceLimit is the safe-default workspace limit applied
// when a plan version is not recognized. Fail-closed: unknown plans get the
// smallest MSP tier limit.
const UnknownPlanDefaultWorkspaceLimit = 1
// WorkspaceLimitForPlan returns the maximum active workspace count for a given
// cloud plan version and whether the plan was recognized. If unrecognized,
// returns a safe default (1) and known=false.
func WorkspaceLimitForPlan(planVersion string) (limit int, known bool) {
planVersion = CanonicalizePlanVersion(planVersion)
if l, ok := CloudPlanWorkspaceLimits[planVersion]; ok {
return l, true
}
return UnknownPlanDefaultWorkspaceLimit, false
}
// TierHistoryDays defines the maximum metrics history retention per tier.
var TierHistoryDays = map[Tier]int{
TierFree: 7,
TierRelay: 14,
TierPro: 90,
TierProPlus: 90,
TierProAnnual: 90,
TierLifetime: 90,
TierBusiness: 365,
TierCloud: 90,
TierMSP: 90,
TierEnterprise: 90,
}
// freeFeatures are the base capabilities available to all users.
var freeFeatures = []string{
FeatureUpdateAlerts,
FeatureSSO,
FeatureAdvancedSSO,
FeatureAIPatrol, // Patrol is free with BYOK; governed fixes require Pro.
}
// relayFeatures adds remote access and mobile on top of free.
var relayFeatures = appendFeatures(freeFeatures,
FeatureRelay,
FeatureMobileApp,
FeaturePushNotifications,
FeatureLongTermMetrics, // 14 days (vs 7 for free)
)
// proFeatures adds AI automation, fleet management, and compliance on top of relay.
var proFeatures = appendFeatures(relayFeatures,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureKubernetesAI,
FeatureAgentProfiles,
FeatureRBAC,
FeatureAuditLogging,
FeatureAdvancedReporting,
FeatureExternalProbe,
)
// mspFeatures adds multi-tenant policy on top of pro.
var mspFeatures = appendFeatures(proFeatures,
FeatureUnlimited,
FeatureMultiTenant,
)
// enterpriseFeatures adds white-label and multi-user on top of MSP.
var enterpriseFeatures = appendFeatures(mspFeatures,
FeatureMultiUser,
FeatureWhiteLabel,
)
// appendFeatures returns a new slice with extra features appended (no mutation).
func appendFeatures(base []string, extra ...string) []string {
result := make([]string, len(base), len(base)+len(extra))
copy(result, base)
return append(result, extra...)
}
// TierFeatures maps each tier to its included features.
var TierFeatures = map[Tier][]string{
TierFree: freeFeatures,
TierRelay: relayFeatures,
TierPro: proFeatures,
TierProPlus: proFeatures, // Legacy compatibility tier; same runtime features as Pro
TierProAnnual: proFeatures, // Legacy: same features as Pro
TierLifetime: proFeatures, // Legacy: same features as Pro
TierBusiness: proFeatures, // Same features as Pro; differentiated by max_users limit, 365-day history, and support
TierCloud: proFeatures, // Cloud includes all Pro features + managed hosting
TierMSP: mspFeatures,
TierEnterprise: enterpriseFeatures,
}
// DeriveCapabilitiesFromTier derives effective capabilities from tier and explicit features.
func DeriveCapabilitiesFromTier(tier Tier, explicitFeatures []string) []string {
featureSet := make(map[string]struct{})
for _, feature := range TierFeatures[tier] {
featureSet[feature] = struct{}{}
}
for _, feature := range explicitFeatures {
featureSet[feature] = struct{}{}
}
capabilities := make([]string, 0, len(featureSet))
for feature := range featureSet {
capabilities = append(capabilities, feature)
}
sort.Strings(capabilities)
return capabilities
}
// DeriveEntitlements derives capabilities and non-monitoring quantitative limits.
// The monitored-system parameter is retained for old callers but ignored:
// monitored-system volume is no longer a license entitlement.
func DeriveEntitlements(tier Tier, features []string, _ int, maxGuests int) (capabilities []string, limits map[string]int64) {
capabilities = DeriveCapabilitiesFromTier(tier, features)
limits = make(map[string]int64)
if maxGuests > 0 {
limits["max_guests"] = int64(maxGuests)
}
return capabilities, limits
}
// OnboardingOverflowDuration is the window during which free-tier workspaces
// receive +1 host slot after initial setup.
const OnboardingOverflowDuration = 14 * 24 * time.Hour
// OverflowBonus returns the number of bonus host slots granted by the
// onboarding overflow. Returns 1 if the tier is free, overflowGrantedAt
// is set, and the current time is within 14 days of the grant. Otherwise 0.
func OverflowBonus(tier Tier, overflowGrantedAt *int64, now time.Time) int {
if tier != TierFree || overflowGrantedAt == nil {
return 0
}
grantedAt := time.Unix(*overflowGrantedAt, 0)
elapsed := now.Sub(grantedAt)
if elapsed < 0 {
// Future timestamp — treat as not yet granted.
return 0
}
if elapsed < OnboardingOverflowDuration {
return 1
}
return 0
}
// OverflowDaysRemaining returns the number of days remaining in the overflow
// window. Returns 0 if overflow is not active.
func OverflowDaysRemaining(tier Tier, overflowGrantedAt *int64, now time.Time) int {
if OverflowBonus(tier, overflowGrantedAt, now) == 0 {
return 0
}
grantedAt := time.Unix(*overflowGrantedAt, 0)
expiresAt := grantedAt.Add(OnboardingOverflowDuration)
remaining := expiresAt.Sub(now)
days := int(remaining.Hours()/24) + 1 // ceiling: partial day counts as 1
if days < 0 {
return 0
}
return days
}
// TierHasFeature checks if a tier includes a specific feature.
func TierHasFeature(tier Tier, feature string) bool {
features, ok := TierFeatures[tier]
if !ok {
return false
}
for _, f := range features {
if f == feature {
return true
}
}
return false
}
// CapabilityVisibleInPublicPayload reports whether a capability key belongs in
// browser-facing entitlement and runtime payload contracts.
func CapabilityVisibleInPublicPayload(feature string) bool {
switch feature {
case FeatureDemoFixtures:
return false
default:
return true
}
}
// FilterPublicCapabilities strips internal-only capability keys from public API
// payload contracts while preserving caller order for visible features.
func FilterPublicCapabilities(features []string) []string {
if len(features) == 0 {
return []string{}
}
filtered := make([]string, 0, len(features))
for _, feature := range features {
if CapabilityVisibleInPublicPayload(feature) {
filtered = append(filtered, feature)
}
}
return filtered
}
// GetTierDisplayName returns a human-readable name for the tier.
func GetTierDisplayName(tier Tier) string {
switch tier {
case TierFree:
return "Community"
case TierRelay:
return "Relay"
case TierPro:
return "Pro"
case TierProPlus:
return "Pro+"
case TierProAnnual:
return "Pro (Annual)"
case TierLifetime:
return "Pro (Lifetime)"
case TierBusiness:
return "Business"
case TierCloud:
return "Cloud"
case TierMSP:
return "MSP"
case TierEnterprise:
return "Enterprise"
default:
return "Unknown"
}
}
// GetFeatureMinTierName returns the display name of the lowest tier that includes the given feature.
// This is used for user-facing messages like "requires Pulse Relay or above".
// The tier ordering is: Free < Relay < Pro < MSP < Enterprise.
func GetFeatureMinTierName(feature string) string {
orderedTiers := []Tier{TierFree, TierRelay, TierPro, TierBusiness, TierMSP, TierEnterprise}
for _, tier := range orderedTiers {
if TierHasFeature(tier, feature) {
return GetTierDisplayName(tier)
}
}
return "Pro" // fallback
}
// GetFeatureDisplayName returns a human-readable name for a feature.
func GetFeatureDisplayName(feature string) string {
if entry, ok := GetFeatureMetadata(feature); ok {
return entry.DisplayName
}
return feature
}