diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 80758dc..9698c35 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -13,6 +13,7 @@ import ( "github.com/joho/godotenv" "github.com/mrhid6/vantage/admin/internal/config" "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" "github.com/mrhid6/vantage/admin/internal/models" ) @@ -24,6 +25,8 @@ func main() { log.Fatalf("configuration error: %v", err) } + licensing.SetSigningKey(cfg.SigningKey) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) if err := db.Connect(ctx, cfg); err != nil { cancel() diff --git a/admin/go.mod b/admin/go.mod index 1bc1c72..e12fcd5 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -3,6 +3,7 @@ module github.com/mrhid6/vantage/admin go 1.26.4 require ( + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 go.mongodb.org/mongo-driver/v2 v2.8.0 diff --git a/admin/go.sum b/admin/go.sum index 1e6b933..3e94703 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0= github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/admin/internal/audit/audit.go b/admin/internal/audit/audit.go new file mode 100644 index 0000000..25fbd90 --- /dev/null +++ b/admin/internal/audit/audit.go @@ -0,0 +1,21 @@ +// Package audit records who did what. Every issuance, link, relink and sign-in +// attempt lands here. +package audit + +import ( + "context" + "log" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" +) + +// Write never returns an error: an audit failure must not roll back the action +// it describes. It logs instead, loudly enough to notice. +func Write(ctx context.Context, e models.AuditEntry) { + e.CreatedAt = time.Now().UTC() + if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil { + log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err) + } +} diff --git a/admin/internal/licensing/issue.go b/admin/internal/licensing/issue.go new file mode 100644 index 0000000..118374b --- /dev/null +++ b/admin/internal/licensing/issue.go @@ -0,0 +1,178 @@ +// Package licensing issues licences. It is the only place that signs. +package licensing + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + ErrUnknownTier = errors.New("unknown tier") + ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type") + ErrFreeLimit = errors.New("this account already has a Free instance") + ErrUnknownInstance = errors.New("instance not found") +) + +type IssueInput struct { + InstanceID string + Tier string + Term string // "monthly" or "annual"; ignored when ExpiresAt is set + ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term + Reason string + IssuedBy string // staff email, "system", or "paddle:" +} + +// signingKey is set once at boot from LICENSE_SIGNING_KEY. +var signingKey string + +func SetSigningKey(k string) { signingKey = k } + +// Issue signs a licence, records it, supersedes its predecessor and updates the +// instance. +// +// It does NOT deliver. Recording and delivery are deliberately separate and +// ordered: a licence recorded but not delivered is recoverable, because the +// customer can download it. A licence delivered but not recorded is a support +// mystery with no paper trail. Callers deliver after this returns. +func Issue(ctx context.Context, in IssueInput) (*models.License, error) { + if signingKey == "" { + return nil, errors.New("no signing key configured") + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil { + return nil, ErrUnknownInstance + } + + plan, err := models.GetPlan(ctx, in.Tier) + if err != nil { + return nil, ErrUnknownTier + } + + // This single comparison is what makes Free cloud-only. Free's plan is + // deployment "cloud", so it can never be issued against a self-hosted + // instance, and verification on the instance would reject it anyway. + if plan.Deployment != inst.Deployment { + return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment) + } + + if plan.Tier == license.TierFree { + if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil { + return nil, err + } + } + + now := time.Now().UTC() + expires := in.ExpiresAt + if expires.IsZero() { + switch in.Term { + case "monthly": + expires = now.AddDate(0, 1, 0).Add(models.GracePeriod) + case "annual", "": + expires = now.AddDate(1, 0, 0).Add(models.GracePeriod) + default: + return nil, fmt.Errorf("unknown term %q", in.Term) + } + } + + payload := license.License{ + ID: uuid.NewString(), + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + InstanceName: inst.Name, + Tier: plan.Tier, + Deployment: plan.Deployment, + IssuedAt: now, + ExpiresAt: expires, + // Snapshotted, not referenced: editing a plan tomorrow must not change + // what this licence grants. + Limits: plan.Limits, + Features: plan.Features, + } + + blob, err := license.Sign(payload, signingKey) + if err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + + rec := models.License{ + LicenseID: payload.ID, + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Tier: plan.Tier, + Deployment: plan.Deployment, + Limits: plan.Limits, + Features: plan.Features, + IssuedAt: now, + ExpiresAt: expires, + Blob: blob, + IssuedBy: in.IssuedBy, + Reason: in.Reason, + } + if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil { + return nil, fmt.Errorf("record licence: %w", err) + } + + // Supersede rather than delete. The history is the support tool. + if inst.CurrentLicense != "" { + if _, err := db.Admin("licenses").UpdateOne(ctx, + bson.M{"license_id": inst.CurrentLicense}, + bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil { + return nil, fmt.Errorf("supersede previous licence: %w", err) + } + } + + set := bson.M{ + "current_license": rec.LicenseID, + "tier": plan.Tier, + "status": models.StatusActive, + } + if in.Reason == models.ReasonRenewal { + set["relink_count"] = 0 // the cap is per term + } + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil { + return nil, fmt.Errorf("update instance: %w", err) + } + + audit.Write(ctx, models.AuditEntry{ + Actor: in.IssuedBy, + Action: "license.issued", + AccountID: inst.AccountID, + Target: inst.InstanceID, + Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s", + plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID), + }) + + return &rec, nil +} + +// checkFreeLimit enforces one Free instance per account. +// +// Cancelled instances do not count: a customer who cancelled their Free instance +// is allowed another one. +func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error { + n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ + "account_id": accountID, + "tier": license.TierFree, + "status": bson.M{"$ne": models.StatusCancelled}, + "instance_id": bson.M{"$ne": exceptInstanceID}, + }) + if err != nil { + return err + } + if n > 0 { + return ErrFreeLimit + } + return nil +}