Files
vantage/shared/license/license.go
T
mrhid6 f60c509b47
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
Agent Release / build (push) Successful in 1m0s
Agent Release / msi (push) Successful in 2m18s
feat: vuln_scanning entitlement and documentation
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.
2026-08-06 14:44:11 +01:00

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
}