feat(admin): linking, relink and the scoped customer API

Every customer handler that names an instance resolves it through
ownedInstance, which returns 404 rather than 403 for another account's
instance -- a 403 confirms the instance exists, which is an existence
oracle over customer data.

The unique index on admin_instances.instance_id, not the pre-check, is what
actually prevents two accounts owning one instance. Relink issues a
replacement covering the REMAINING term, so it cannot be used to extend a
subscription, and the old licence is not revoked because offline
verification has no revocation -- its instance binding is what stops it.

The route table lands with the staff handlers in the next commit so every
commit builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:09:13 +01:00
co-authored by Claude Opus 5
parent 07a3756b18
commit c829cc41d9
2 changed files with 293 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
//
// EVERY customer handler that names an instance must go through this. It returns
// 404 for another account's instance rather than 403: a 403 confirms the
// instance exists, which is an existence oracle over customer data.
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
s := auth.Current(c)
if s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return nil, false
}
var inst models.Instance
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &inst, true
}
func getAccount(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(ctx, &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}
func linkInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, inst)
}
func relinkInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrRelinkLimit) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
c.JSON(http.StatusOK, lic)
}
func getInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
c.JSON(http.StatusOK, lic)
}
func downloadInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
}
func listSubscriptions(c *gin.Context) {
s := auth.Current(c)
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
subs := []models.Subscription{}
if err := cur.All(c.Request.Context(), &subs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subs)
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
// Delivery failures are logged, never returned: the licence is already recorded,
// which is the part that must not be lost.
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(c.Request.Context(), lic)
return
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+121
View File
@@ -0,0 +1,121 @@
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"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var (
ErrBadUUID = errors.New("that does not look like an instance ID")
ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
ErrRelinkLimit = errors.New("relink limit reached for this term; contact support")
)
// LinkInstance attaches a self-hosted instance UUID to an account.
//
// The duplicate error deliberately does not say WHICH account holds it. It is a
// small enumeration surface, but there is no reason to leave it open.
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
if _, err := uuid.Parse(instanceID); err != nil {
return nil, ErrBadUUID
}
// A self-hosted UUID must not collide with a cloud instance either.
if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
inst := models.Instance{
InstanceID: instanceID,
AccountID: accountID,
Name: name,
Deployment: license.DeploymentSelfHosted,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
// The unique index is what actually prevents two accounts owning
// one instance. The check above is a nicety; this is the guarantee.
return nil, ErrAlreadyLinked
}
return nil, err
}
audit.Write(ctx, models.AuditEntry{
Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
return &inst, nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
// a way to extend a subscription.
//
// The old licence is not revoked, because offline verification has no
// revocation. It simply no longer matches any UUID the customer controls, and
// its binding stops it working on another machine anyway.
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
if _, err := uuid.Parse(newID); err != nil {
return nil, ErrBadUUID
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
return nil, ErrUnknownInstance
}
// The cap is a signal, not a defence. Its job is to put a human in front of
// the fourth attempt, so staff bypass it.
if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
return nil, ErrRelinkLimit
}
if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
// Preserve the remaining term from the current licence.
remaining := time.Now().UTC().Add(models.GracePeriod)
var current models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err == nil {
remaining = current.ExpiresAt
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
actor := accountID
if staff {
actor = "staff"
}
audit.Write(ctx, models.AuditEntry{
Actor: actor, Action: "instance.relinked", AccountID: accountID,
Target: newID, Detail: "was " + oldID})
return Issue(ctx, IssueInput{
InstanceID: newID,
Tier: inst.Tier,
ExpiresAt: remaining,
Reason: models.ReasonRelink,
IssuedBy: actor,
})
}