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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user