diff --git a/shared/license/license.go b/shared/license/license.go new file mode 100644 index 0000000..e15cd88 --- /dev/null +++ b/shared/license/license.go @@ -0,0 +1,76 @@ +// Package license defines the Vantage licence payload and its offline +// verification. +// +// A licence is a signed blob (ECDSA P-384 with SHA-256). The server checks a +// signature, an expiry, a deployment mode and an instance ID, and asks nobody's +// permission. That buys air-gapped self-hosting and means no instance depends +// on the licensing service being reachable. +// +// It costs revocation: once issued, a licence is valid until it expires +// whatever the billing system later says. Self Hosted is sold annually only so +// that window is bounded. +package license + +import "time" + +const ( + TierFree = "free" + TierProfessional = "professional" + TierSelfHosted = "self_hosted" + + DeploymentCloud = "cloud" + DeploymentSelfHosted = "self_hosted" + + FeatureConsole = "console" // browser SSH/RDP/VNC + FeatureOIDC = "oidc" // per-instance single sign-on +) + +// Unlimited is the sentinel for "no cap" in every Limits field. +const Unlimited = -1 + +// Limits are the countable caps a licence grants. +type Limits struct { + MaxServers int `json:"max_servers"` + MaxSecretGroups int `json:"max_secret_groups"` + MaxChannels int `json:"max_channels"` +} + +// License is the signed payload. +// +// InstanceID is always populated: the self-hosted purchase flow links the +// instance UUID before the licence is signed, so there is no unbound licence +// and no claim protocol. +type License struct { + ID string `json:"id"` // uuid, for support and audit + InstanceID string `json:"instance_id"` // the instance this licence is bound to + AccountID string `json:"account_id"` // admin-side customer, informational + InstanceName string `json:"instance_name"` // display only + Tier string `json:"tier"` + Deployment string `json:"deployment"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Limits Limits `json:"limits"` + Features []string `json:"features"` +} + +// HasFeature reports whether the licence grants a named feature. +// +// Callers must use this rather than switching on Tier. Adding a tier, or +// changing what a tier includes, must never require a server release. +func (l License) HasFeature(name string) bool { + for _, f := range l.Features { + if f == name { + return true + } + } + return false +} + +// WithinLimit reports whether one more of something is allowed. +// A max of Unlimited always allows. +func WithinLimit(current, max int) bool { + if max == Unlimited { + return true + } + return current < max +} diff --git a/shared/license/plans.go b/shared/license/plans.go new file mode 100644 index 0000000..5d969a2 --- /dev/null +++ b/shared/license/plans.go @@ -0,0 +1,47 @@ +package license + +// Plan is the contents of a tier 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. +// +// lkctl uses this table to issue by hand until then. +type Plan struct { + Tier string + Name string + Deployment 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}, + Features: nil, + }, + TierProfessional: { + Tier: TierProfessional, + Name: "Professional", + Deployment: DeploymentCloud, + Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited}, + Features: []string{FeatureConsole, FeatureOIDC}, + }, + TierSelfHosted: { + Tier: TierSelfHosted, + Name: "Self Hosted", + Deployment: DeploymentSelfHosted, + Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited}, + Features: []string{FeatureConsole, FeatureOIDC}, + }, +} + +// PlanFor returns the seed plan for a tier. +func PlanFor(tier string) (Plan, bool) { + p, ok := plans[tier] + return p, ok +}