Files
pulse/pkg/licensing/features.go
rcourtman 6be2af1c19 Let providers evaluate MSP without asking permission first
Two mandatory round-trips stood between an interested MSP and their first
screen, and neither was technical.

setup.sh required four image digests shipped as literal <pin>
placeholders, so the only way to get them was to ask. All four images are
publicly readable, so there was never anything to hand out. setup.sh now
resolves each blank pin to an immutable digest from its published tag via
buildx imagetools and writes it back to .env; hand-set values are left
alone.

setup.sh then died outright without a licence file, so nobody could start
the stack, create a workspace, or see the portal until a human minted a
licence for them. The control plane already ran unlicensed via
ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence
path that is set but missing is still a hard failure, since that is a
misconfiguration rather than a choice.

Unlicensed now means evaluation rather than the cheapest paid tier. The
env fallback defaulted to msp_starter, handing every unlicensed
deployment the full 5-client Starter allowance and leaving no boundary
between evaluating and buying. Adds msp_eval at 2 workspaces: same
capabilities, smaller cap, not purchasable, not on the public ladder.

An isolation guarantee is the one claim a provider cannot evaluate from a
screenshot, and both MSP leads this year went quiet at exactly this step.

Contracts: cloud-paid records the unlicensed plan rule and the
strictly-below-paid invariant; deployment-installability records
credential-free, correspondence-free installability.

Verification: TestMSPEvalCapStaysBelowCheapestPaidTier,
TestCanonicalizePlanVersion_MSPEval,
TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was
negative-tested by reintroducing a <pin> placeholder and confirming it
fails. ensure_image_pins exercised against the live registries.
licensing, cloudcp, control-plane and installtests all green.
2026-08-03 16:31:24 +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_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
}