diff --git a/shared/cmd/lkctl/main.go b/shared/cmd/lkctl/main.go index 0bb29e2..a224065 100644 --- a/shared/cmd/lkctl/main.go +++ b/shared/cmd/lkctl/main.go @@ -68,7 +68,8 @@ func issue(args []string) { instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)") instanceName := fs.String("instance-name", "", "display name") accountID := fs.String("account-id", "", "admin-side account id, optional") - tier := fs.String("tier", "", "free | professional | self_hosted (required)") + tier := fs.String("tier", "", "free | professional | enterprise (required)") + deployment := fs.String("deployment", "cloud", "cloud | self_hosted") term := fs.String("term", "1y", "1m or 1y") expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term") out := fs.String("out", "", "write the blob to this file instead of stdout") @@ -78,9 +79,9 @@ func issue(args []string) { fatal("--instance-id and --tier are required") } - plan, ok := license.PlanFor(*tier) + plan, ok := license.PlanFor(*deployment, *tier) if !ok { - fatal("unknown tier %q", *tier) + fatal("no plan for deployment %q tier %q", *deployment, *tier) } key := os.Getenv("LICENSE_SIGNING_KEY") @@ -107,7 +108,7 @@ func issue(args []string) { // Self Hosted is sold annually only, so the window in which a cancelled // licence keeps working is bounded at a year. - if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" { + if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" { fatal("self_hosted is annual only; use --term=1y or an explicit --expires") } @@ -123,6 +124,7 @@ func issue(args []string) { InstanceName: name, Tier: plan.Tier, Deployment: plan.Deployment, + SupportLevel: plan.SupportLevel, IssuedAt: now, ExpiresAt: exp, Limits: plan.Limits, diff --git a/shared/license/license.go b/shared/license/license.go index e15cd88..6ba27ce 100644 --- a/shared/license/license.go +++ b/shared/license/license.go @@ -16,7 +16,15 @@ import "time" const ( TierFree = "free" TierProfessional = "professional" - TierSelfHosted = "self_hosted" + TierEnterprise = "enterprise" + + // TierSelfHosted is LEGACY and no new licence carries it. + // + // It was a tier when self-hosting was a tier rather than a deployment. Blobs + // already signed with it exist and cannot be rewritten, so it stays a + // recognised value that NormaliseTier maps forward. Never put it in a plan + // row and never offer it in a UI. + TierSelfHosted = "self_hosted" DeploymentCloud = "cloud" DeploymentSelfHosted = "self_hosted" @@ -25,14 +33,59 @@ const ( FeatureOIDC = "oidc" // per-instance single sign-on ) +// Support levels. Carried for display and enforced by nothing — there is no code +// path anywhere that branches on these, and there must not be one. They are here +// so an air-gapped install can tell its operator who to call without reaching +// Vantage HQ. +const ( + SupportCommunity = "community" + SupportEmail24x5 = "email_24_5" + SupportEmailCall24x7 = "email_call_24_7" +) + // Unlimited is the sentinel for "no cap" in every Limits field. const Unlimited = -1 // Limits are the countable caps a licence grants. +// +// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays +// is the odd one out: it bounds a duration rather than a count, and Unlimited +// there means "never trim" rather than "no cap". type Limits struct { - MaxServers int `json:"max_servers"` - MaxSecretGroups int `json:"max_secret_groups"` - MaxChannels int `json:"max_channels"` + MaxServers int `json:"max_servers"` + MaxMonitors int `json:"max_monitors"` + MaxSecretGroups int `json:"max_secret_groups"` + MaxChannels int `json:"max_channels"` + AuditRetentionDays int `json:"audit_retention_days"` +} + +// FillUnset replaces any zero field with the same field from base. +// +// This exists for one reason: a licence signed before a field existed decodes it +// as 0, and 0 would read as the most restrictive possible value — no monitors, +// and an audit log trimmed to nothing. A blob we cannot re-sign must not be +// allowed to mean that. +// +// The cost is that 0 stops being expressible as a real allowance. No plan grants +// zero of anything, so nothing is lost today; a plan that genuinely means zero +// must use a negative-free sentinel of its own rather than reintroducing 0 here. +func (l Limits) FillUnset(base Limits) Limits { + if l.MaxServers == 0 { + l.MaxServers = base.MaxServers + } + if l.MaxMonitors == 0 { + l.MaxMonitors = base.MaxMonitors + } + if l.MaxSecretGroups == 0 { + l.MaxSecretGroups = base.MaxSecretGroups + } + if l.MaxChannels == 0 { + l.MaxChannels = base.MaxChannels + } + if l.AuditRetentionDays == 0 { + l.AuditRetentionDays = base.AuditRetentionDays + } + return l } // License is the signed payload. @@ -47,6 +100,7 @@ type License struct { InstanceName string `json:"instance_name"` // display only Tier string `json:"tier"` Deployment string `json:"deployment"` + SupportLevel string `json:"support_level,omitempty"` // display only IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` Limits Limits `json:"limits"` diff --git a/shared/license/plans.go b/shared/license/plans.go index c4d2197..0da8490 100644 --- a/shared/license/plans.go +++ b/shared/license/plans.go @@ -1,49 +1,129 @@ package license -// Plan is the contents of a tier at issue time. +// Plan is the contents of one (deployment, tier) pair at issue time. // -// This table is the seed. Once the admin service exists (spec 3) it owns the -// authoritative copy in its `plans` collection, and every issued licence -// snapshots the plan it was cut from — so editing a plan never rewrites an -// existing licence, the same rule as workflow_runs.steps_snapshot. +// This table is the seed. The admin service owns the authoritative copy in its +// `plans` collection, and every issued licence snapshots the plan it was cut +// from — so editing a plan never rewrites an existing licence, the same rule as +// workflow_runs.steps_snapshot. // -// lkctl uses this table to issue by hand until then. +// Limits here are the BASE allowance: what the tier grants before anything is +// bought. A metered dimension adds to it, which is why max_servers is a real +// number at Professional and Enterprise rather than Unlimited. type Plan struct { - Tier string - Name string - Deployment string - Limits Limits - Features []string + Tier string + Name string + Deployment string + SupportLevel string + Limits Limits + Features []string } -var plans = map[string]Plan{ - TierFree: { - Tier: TierFree, - Name: "Free", - Deployment: DeploymentCloud, // cloud only, by construction - Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1}, +// planKey is the composite the table is keyed on. +// +// Keying on tier alone was what made Free cloud-only by construction. There is +// now a self-hosted Free plan, so that guarantee is gone and the Free limit is +// enforced per account AND deployment instead. See licensing.checkFreeLimit. +type planKey struct { + Deployment string + Tier string +} + +// baseFree, baseProfessional and baseEnterprise are shared by both deployments. +// +// The allowances are deliberately identical across cloud and self-hosted: what +// differs between the two is the term on offer, not what you get. Duplicating +// them per deployment would be four places to forget. +var ( + baseFree = Limits{ + MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1, + MaxChannels: 1, AuditRetentionDays: 30, + } + baseProfessional = Limits{ + MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, + MaxChannels: Unlimited, AuditRetentionDays: 365, + } + baseEnterprise = Limits{ + MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, + MaxChannels: Unlimited, AuditRetentionDays: Unlimited, + } +) + +var plans = map[planKey]Plan{ + planKey{DeploymentCloud, TierFree}: { + Tier: TierFree, Name: "Free", Deployment: DeploymentCloud, + SupportLevel: SupportCommunity, Limits: baseFree, // Empty rather than nil: nil marshals as JSON null, and this table is // the seed every plan and licence is cut from. Features: []string{}, }, - TierProfessional: { - Tier: TierProfessional, - Name: "Professional", - Deployment: DeploymentCloud, - Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited}, - Features: []string{FeatureConsole, FeatureOIDC}, + planKey{DeploymentCloud, TierProfessional}: { + Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud, + SupportLevel: SupportEmail24x5, Limits: baseProfessional, + // Console and SSO are opt-in per customer, so no tier bundles them. The + // field stays because a future tier might. + Features: []string{}, }, - TierSelfHosted: { - Tier: TierSelfHosted, - Name: "Self Hosted", - Deployment: DeploymentSelfHosted, - Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited}, - Features: []string{FeatureConsole, FeatureOIDC}, + planKey{DeploymentCloud, TierEnterprise}: { + Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud, + SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, + Features: []string{}, + }, + planKey{DeploymentSelfHosted, TierFree}: { + Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted, + SupportLevel: SupportCommunity, Limits: baseFree, + Features: []string{}, + }, + planKey{DeploymentSelfHosted, TierProfessional}: { + Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted, + SupportLevel: SupportEmail24x5, Limits: baseProfessional, + Features: []string{}, + }, + planKey{DeploymentSelfHosted, TierEnterprise}: { + Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted, + SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, + Features: []string{}, }, } -// PlanFor returns the seed plan for a tier. -func PlanFor(tier string) (Plan, bool) { - p, ok := plans[tier] +// PlanFor returns the seed plan for one deployment and tier. +// +// It normalises first, so a legacy self_hosted licence resolves to the plan that +// replaced it rather than to nothing. +func PlanFor(deployment, tier string) (Plan, bool) { + deployment, tier = NormaliseTier(deployment, tier) + p, ok := plans[planKey{deployment, tier}] return p, ok } + +// NormaliseTier maps a legacy tier forward. +// +// tier "self_hosted" predates deployments being separate from tiers. Such a +// licence granted what Professional now grants, on a self-hosted install, so it +// maps to exactly that. Called by PlanFor and by anything reading a tier off an +// already-signed payload. +func NormaliseTier(deployment, tier string) (string, string) { + if tier == TierSelfHosted { + return DeploymentSelfHosted, TierProfessional + } + return deployment, tier +} + +// Tiers is the offer order, for any UI that lists them. +func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} } + +// Deployments is the offer order. +func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} } + +// TermsFor reports which billing terms a deployment sells. +// +// Self-hosted is annual only, and the reason is in this package's doc comment: an +// offline licence cannot be revoked, so the term length IS the revocation +// window. A self-hosted monthly licence would renew that window twelve times a +// year for no commercial gain. +func TermsFor(deployment string) []string { + if deployment == DeploymentSelfHosted { + return []string{"annual"} + } + return []string{"monthly", "annual"} +}