Adds license.FeatureVulnScanning as the one name for the feature and a catalogue row per deployment/tier, following console and oidc: features are opt-in per customer, so no plan bundles it. Documents the subsystem in CLAUDE.md, including that ScopedCollections is the canonical registry instance deletion derives from — there is no separate deletion list, which the plan had wrong.
135 lines
4.8 KiB
Go
135 lines
4.8 KiB
Go
// 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"
|
|
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"
|
|
|
|
FeatureConsole = "console" // browser SSH/RDP/VNC
|
|
FeatureOIDC = "oidc" // per-instance single sign-on
|
|
// FeatureVulnScanning gates package inventory collection as well as the
|
|
// findings themselves. The gate is at collection, not display: an ungated
|
|
// instance stores no inventory, and storage is the expensive half.
|
|
FeatureVulnScanning = "vuln_scanning"
|
|
)
|
|
|
|
// 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"`
|
|
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.
|
|
//
|
|
// 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"`
|
|
SupportLevel string `json:"support_level,omitempty"` // display only
|
|
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
|
|
}
|