Pulse/pkg/licensing/features.go

474 lines
17 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"
"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" // Basic OIDC/SSO authentication
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" // Automatic remediation (one-click apply)
FeatureKubernetesAI = "kubernetes_ai" // AI analysis of K8s (NOT basic monitoring)
FeatureAgentProfiles = "agent_profiles" // Centralized agent configuration profiles
FeatureRBAC = "rbac" // Role-Based Access Control
FeatureAuditLogging = "audit_logging" // Persistent audit logs with signing
FeatureAdvancedSSO = "advanced_sso" // SAML, Multi-provider, Role Mapping
FeatureAdvancedReporting = "advanced_reporting" // PDF/CSV reporting engine
// MSP/Enterprise tier features
FeatureMultiUser = "multi_user" // Multi-user (likely merged with RBAC)
FeatureWhiteLabel = "white_label" // Custom branding - NOT IMPLEMENTED YET
FeatureMultiTenant = "multi_tenant" // Multi-tenant organizations
FeatureUnlimited = "unlimited" // Unlimited instances (for MSP/volume 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
TierCloud Tier = "cloud"
TierMSP Tier = "msp"
TierEnterprise Tier = "enterprise"
)
// TierMonitoredSystemLimits defines the maximum monitored-system count per tier.
// A value of 0 means unlimited (enforced at the MSP/Enterprise level).
var TierMonitoredSystemLimits = map[Tier]int{
TierFree: 5,
TierRelay: 8,
TierPro: 15,
TierProPlus: 50,
TierProAnnual: 15, // Legacy: same as Pro
TierLifetime: 15, // Legacy: same as Pro
TierCloud: 0, // Cloud tiers have per-plan limits set in license claims
TierMSP: 0, // MSP tiers have per-plan pool limits set in license claims
TierEnterprise: 0, // Custom
}
// CloudPlanMonitoredSystemLimits maps hosted and continuity plan version strings to
// per-plan monitored-system limits. When a tenant is provisioned or its subscription
// changes, the provisioner uses this map to populate
// BillingState.Limits[MaxMonitoredSystemsLicenseGateKey].
//
// This intentionally includes the grandfathered recurring v5/v1 continuity
// plans that still renew through Stripe. Those subscriptions are not "unknown"
// just because they are no longer sold; they remain canonical paid states that
// must preserve their plan identity during webhook-driven billing updates.
var CloudPlanMonitoredSystemLimits = map[string]int{
// Individual Cloud tiers
"cloud_starter": 10,
"cloud_power": 30,
"cloud_max": 75,
"cloud_founding": 10, // Founding rate = Starter limits
// Grandfathered recurring Pulse Pro continuity plans
"v5_pro_monthly_grandfathered": 10,
"v5_pro_annual_grandfathered": 10,
// MSP tiers — host pool limits from pricing spec
"msp_starter": 50, // MSP Starter: 10 clients, 50 host pool
"msp_growth": 150, // MSP Growth: 25 clients, 150 host pool
"msp_scale": 400, // MSP Scale: 50 clients, 400 host pool
}
// 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
}
// UnknownPlanDefaultMonitoredSystemLimit is the safe-default monitored-system limit applied when a
// plan version is not recognized. Fail-closed: unknown plans get the smallest
// tier limit rather than unlimited access.
const UnknownPlanDefaultMonitoredSystemLimit = 10
// 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_starter": 10, // MSP Starter: up to 10 clients
"msp_growth": 25, // MSP Growth: up to 25 clients
"msp_scale": 50, // MSP Scale: up to 50 clients
}
// 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
}
// LimitsForCloudPlan returns the monitored-system limit map for a given cloud plan
// version and whether the plan was recognized. If the plan is recognized, the
// map contains MaxMonitoredSystemsLicenseGateKey with the per-plan limit.
// If unrecognized, returns
// a safe default limit (fail-closed) and known=false so callers can decide
// whether to reject, quarantine, or proceed with restricted access.
func LimitsForCloudPlan(planVersion string) (limits map[string]int64, known bool) {
planVersion = CanonicalizePlanVersion(planVersion)
if limit, ok := CloudPlanMonitoredSystemLimits[planVersion]; ok {
return map[string]int64{MaxMonitoredSystemsLicenseGateKey: int64(limit)}, true
}
return map[string]int64{MaxMonitoredSystemsLicenseGateKey: int64(UnknownPlanDefaultMonitoredSystemLimit)}, 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,
TierCloud: 90,
TierMSP: 90,
TierEnterprise: 90,
}
// freeFeatures are the base capabilities available to all users.
var freeFeatures = []string{
FeatureUpdateAlerts,
FeatureSSO,
FeatureAIPatrol, // Patrol is free with BYOK — auto-fix requires 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,
FeatureAdvancedSSO,
FeatureRBAC,
FeatureAuditLogging,
FeatureAdvancedReporting,
)
// mspFeatures adds multi-tenant and unlimited 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, // Same features as Pro, just higher host limit
TierProAnnual: proFeatures, // Legacy: same features as Pro
TierLifetime: proFeatures, // Legacy: same features as Pro
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 limits from tier and canonical monitored-system fields.
func DeriveEntitlements(tier Tier, features []string, maxMonitoredSystems int, maxGuests int) (capabilities []string, limits map[string]int64) {
capabilities = DeriveCapabilitiesFromTier(tier, features)
limits = make(map[string]int64)
if maxMonitoredSystems > 0 {
limits["max_monitored_systems"] = int64(maxMonitoredSystems)
}
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 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, 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 {
switch feature {
case FeatureAIPatrol:
return "Pulse Patrol (Background Health Checks)"
case FeatureAIAlerts:
return "Alert Analysis"
case FeatureAIAutoFix:
return "Pulse Patrol Auto-Fix"
case FeatureKubernetesAI:
return "Kubernetes Analysis"
case FeatureUpdateAlerts:
return "Update Alerts (Container/Package Updates)"
case FeatureRBAC:
return "Role-Based Access Control (RBAC)"
case FeatureMultiUser:
return "Multi-User Mode"
case FeatureWhiteLabel:
return "White-Label Branding"
case FeatureMultiTenant:
return "Multi-Tenant Mode"
case FeatureUnlimited:
return "Unlimited Instances"
case FeatureDemoFixtures:
return "Demo Fixtures (Internal)"
case FeatureAgentProfiles:
return "Centralized Agent Profiles"
case FeatureAuditLogging:
return "Audit Logging"
case FeatureSSO:
return "Basic SSO (OIDC)"
case FeatureAdvancedSSO:
return "Advanced SSO (SAML/Multi-Provider)"
case FeatureRelay:
return "Pulse Relay (Remote Access)"
case FeatureMobileApp:
return "Mobile App Access"
case FeaturePushNotifications:
return "Push Notifications"
case FeatureAdvancedReporting:
return "PDF/CSV Reporting"
case FeatureLongTermMetrics:
return "Extended Metric History"
default:
return feature
}
}