feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
Server Deploy / deploy (push) Successful in 5m22s
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1. Admin: plans re-keyed on (deployment, tier); new catalogue collection holds every Paddle price ID (one row per priceable component); new entitlements collection holds desired beside granted. admin/internal/catalogue owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the base allowance is subtracted in exactly one place. licensing.Issue now snapshots the instance's granted entitlement, never desired. Free is enforced per account AND deployment. Staff endpoints for plans, catalogue and entitlements; Free self-hosted can be claimed and renewed on its annual term; the reaper stays cloud-only. Server: enforces the monitor cap, audit-log retention (daily sweep, skips Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are filled from the seed plan at the single decode site so old blobs never read as zero. Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance screen, and a catalogue-driven PlanConfigurator mounted on the staff instance page. web shows monitors, audit retention and support level on the licence page. Docs: CLAUDE.md, spec index and plan 5 preamble updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,7 @@ type licenceResponse struct {
|
||||
State license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
SupportLevel string `json:"support_level,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
DaysRemaining *int `json:"days_remaining,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
@@ -110,6 +111,7 @@ type licenceResponse struct {
|
||||
|
||||
type licenceUsageResponse struct {
|
||||
Servers int `json:"servers"`
|
||||
Monitors int `json:"monitors"`
|
||||
SecretGroups int `json:"secret_groups"`
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
@@ -117,19 +119,20 @@ type licenceUsageResponse struct {
|
||||
func getLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
st := services.GetLicenseState(instanceID)
|
||||
servers, groups, channels := services.LicenseUsage(instanceID)
|
||||
servers, monitors, groups, channels := services.LicenseUsage(instanceID)
|
||||
|
||||
resp := licenceResponse{
|
||||
InstanceID: instanceID,
|
||||
State: st.Status,
|
||||
Reason: st.Reason,
|
||||
Tier: st.Tier,
|
||||
ExpiresAt: st.ExpiresAt,
|
||||
Limits: st.Limits,
|
||||
Features: st.Features,
|
||||
Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
|
||||
Source: st.Source,
|
||||
Deployment: services.DeploymentMode(),
|
||||
InstanceID: instanceID,
|
||||
State: st.Status,
|
||||
Reason: st.Reason,
|
||||
Tier: st.Tier,
|
||||
SupportLevel: st.SupportLevel,
|
||||
ExpiresAt: st.ExpiresAt,
|
||||
Limits: st.Limits,
|
||||
Features: st.Features,
|
||||
Usage: licenceUsageResponse{Servers: servers, Monitors: monitors, SecretGroups: groups, Channels: channels},
|
||||
Source: st.Source,
|
||||
Deployment: services.DeploymentMode(),
|
||||
}
|
||||
if st.ExpiresAt != nil {
|
||||
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
|
||||
|
||||
@@ -40,6 +40,13 @@ func createMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
if err := services.CheckMonitorLimit(auth.InstanceID(c)); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -98,6 +98,20 @@ func HandleOIDCCallback(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
// The start handler checks this too, but an ungated callback is the half
|
||||
// that matters: a start that refuses is a dead end, while a callback that
|
||||
// completes signs somebody in. A licence that lapsed mid-flow stops the
|
||||
// exchange here rather than after it.
|
||||
//
|
||||
// Resolved from the consumed state rather than from the host, because on
|
||||
// this route the instance is whatever the state said and nobody is signed
|
||||
// in yet.
|
||||
if !services.GetLicenseState(instanceID).Feature("oidc") {
|
||||
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// StartAuditSweeper trims audit logs past their licensed retention.
|
||||
//
|
||||
// An immediate pass then daily, following StartLogSweeper's shape. Daily rather
|
||||
// than hourly because the unit of retention is a day: sweeping twenty-four times
|
||||
// to delete the same nothing is load without a purpose.
|
||||
func StartAuditSweeper() {
|
||||
go func() {
|
||||
sweepAuditLogs()
|
||||
t := time.NewTicker(24 * time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepAuditLogs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sweepAuditLogs deletes entries older than each instance's licensed retention.
|
||||
//
|
||||
// This is the only part of this subsystem that deletes customer data, so it is
|
||||
// deliberately conservative in three ways.
|
||||
//
|
||||
// It reads the CURRENT licence each run rather than caching a value, so raising
|
||||
// a customer's retention takes effect on the next sweep instead of whenever a
|
||||
// process restarts.
|
||||
//
|
||||
// It skips an instance whose licence is not valid. A lapsed instance must not
|
||||
// have its history trimmed on the expired term's allowance — expiry degrades to
|
||||
// read-only, and deleting more of somebody's audit trail is not read-only.
|
||||
//
|
||||
// It skips Unlimited and any non-positive value. A licence that decodes as zero
|
||||
// has already been filled from the plan base at the decode site, so a zero here
|
||||
// means something is wrong and doing nothing is the right response to that.
|
||||
func sweepAuditLogs() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var ids []string
|
||||
if err := db.Col("instances").Distinct(ctx, "instance_id", bson.M{}).Decode(&ids); err != nil {
|
||||
log.Printf("audit sweep: list instances: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
st := GetLicenseState(id)
|
||||
if !st.Active() {
|
||||
continue
|
||||
}
|
||||
days := st.Limits.AuditRetentionDays
|
||||
if days == license.Unlimited || days <= 0 {
|
||||
continue
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
res, err := db.Col("audit_logs").DeleteMany(ctx, bson.M{
|
||||
"instance_id": id,
|
||||
"created_at": bson.M{"$lt": cutoff},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("audit sweep: instance %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
if res.DeletedCount > 0 {
|
||||
log.Printf("audit sweep: instance %s: removed %d entries older than %d days",
|
||||
id, res.DeletedCount, days)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
|
||||
// LicenseState is the resolved licence for one instance.
|
||||
type LicenseState struct {
|
||||
Status license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Status license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
SupportLevel string `json:"support_level,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
// Source is "stored", "env" or "none" — useful when a self-hosted operator
|
||||
@@ -127,13 +128,27 @@ func stateFromResult(res license.Result, source string) LicenseState {
|
||||
for _, f := range res.License.Features {
|
||||
feats[f] = true
|
||||
}
|
||||
|
||||
// A licence signed before max_monitors and audit_retention_days existed
|
||||
// decodes them as 0, which would read as "no monitors" and "trim the audit
|
||||
// log to nothing". Fill from the seed plan for the tier the licence names.
|
||||
//
|
||||
// This is the only decode site, which is why the fill belongs here rather
|
||||
// than at each of the places that reads a limit.
|
||||
limits := res.License.Limits
|
||||
deployment, tier := license.NormaliseTier(res.License.Deployment, res.License.Tier)
|
||||
if base, ok := license.PlanFor(deployment, tier); ok {
|
||||
limits = limits.FillUnset(base.Limits)
|
||||
}
|
||||
|
||||
s := LicenseState{
|
||||
Status: res.State,
|
||||
Reason: res.Reason,
|
||||
Tier: res.License.Tier,
|
||||
Limits: res.License.Limits,
|
||||
Features: feats,
|
||||
Source: source,
|
||||
Status: res.State,
|
||||
Reason: res.Reason,
|
||||
Tier: res.License.Tier,
|
||||
SupportLevel: res.License.SupportLevel,
|
||||
Limits: limits,
|
||||
Features: feats,
|
||||
Source: source,
|
||||
}
|
||||
if !res.License.ExpiresAt.IsZero() {
|
||||
exp := res.License.ExpiresAt
|
||||
|
||||
@@ -89,15 +89,39 @@ func CheckChannelLimit(instanceID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckMonitorLimit refuses a new monitor when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only, like every other check here. An instance already over
|
||||
// its cap keeps every monitor it has and they keep executing — the licence
|
||||
// expiry story is that monitoring never stops, so truncating here would
|
||||
// contradict it.
|
||||
func CheckMonitorLimit(instanceID string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !license.WithinLimit(int(n), st.Limits.MaxMonitors) {
|
||||
return &LimitError{Limit: "max_monitors", Current: int(n), Max: st.Limits.MaxMonitors}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
|
||||
// honestly when an instance is over its cap rather than pretending.
|
||||
func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
|
||||
func LicenseUsage(instanceID string) (servers, monitors, secretGroups, channels int) {
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
|
||||
servers = int(n)
|
||||
}
|
||||
if n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
|
||||
monitors = int(n)
|
||||
}
|
||||
var groups []string
|
||||
if err := db.Col("secrets").Distinct(ctx, "group",
|
||||
bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
|
||||
|
||||
Reference in New Issue
Block a user