diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go new file mode 100644 index 0000000..eeebbcc --- /dev/null +++ b/admin/internal/api/customer.go @@ -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) + } +} diff --git a/admin/internal/licensing/link.go b/admin/internal/licensing/link.go new file mode 100644 index 0000000..ebfc4a6 --- /dev/null +++ b/admin/internal/licensing/link.go @@ -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(¤t); 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, + }) +}