diff --git a/admin/cmd/main.go b/admin/cmd/main.go index f55b4fb..2e10114 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/mrhid6/vantage/admin/internal/api" "github.com/mrhid6/vantage/admin/internal/auth" "github.com/mrhid6/vantage/admin/internal/config" "github.com/mrhid6/vantage/admin/internal/db" @@ -72,7 +73,7 @@ func main() { srv := &http.Server{ Addr: cfg.Addr, - Handler: http.NotFoundHandler(), // replaced in Task 8 + Handler: api.Routes(cfg), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go new file mode 100644 index 0000000..631cedb --- /dev/null +++ b/admin/internal/api/routes.go @@ -0,0 +1,80 @@ +// Package api mounts admin's HTTP surface. +// +// The route table is the single place scoping is guaranteed. Customer routes +// live behind RequireCustomer and every handler that names an instance calls +// ownedInstance. A new customer route that skips that helper is a scoping bug, +// so keep them together and review them together. +package api + +import ( + "net/http" + "slices" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/config" +) + +func Routes(cfg config.Config) http.Handler { + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + r.Use(cors(cfg.AllowedOrigins)) + + if cfg.TrustProxy { + _ = r.SetTrustedProxies(nil) + } + + r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + r.POST("/auth/staff/login", auth.HandleStaffLogin) + r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login + r.POST("/auth/logout", auth.HandleLogout) + r.GET("/auth/verify", auth.HandleVerify) + + cust := r.Group("/api") + cust.Use(auth.RequireCustomer()) + { + cust.GET("/account", getAccount) + cust.POST("/instances/link", linkInstance) + cust.POST("/instances/:id/relink", relinkInstance) + cust.GET("/instances/:id/license", getInstanceLicense) + cust.GET("/instances/:id/license/download", downloadInstanceLicense) + cust.GET("/subscriptions", listSubscriptions) + } + + staff := r.Group("/api/staff") + staff.Use(auth.RequireStaff()) + { + staff.GET("/accounts", staffListAccounts) + staff.POST("/accounts", staffCreateAccount) + staff.GET("/accounts/:id", staffGetAccount) + staff.GET("/instances", staffListInstances) + staff.POST("/instances", staffCreateInstance) + staff.POST("/instances/:id/issue", staffIssue) + staff.POST("/instances/:id/relink", staffRelink) + staff.GET("/licenses", staffListLicenses) + staff.GET("/plans", staffListPlans) + staff.PUT("/plans/:tier", staffUpdatePlan) + staff.GET("/audit", staffAudit) + staff.GET("/health/injection", staffInjectionHealth) + } + + return r +} + +func cors(allowed []string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin != "" && slices.Contains(allowed, origin) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", "Content-Type") + c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go new file mode 100644 index 0000000..6d78382 --- /dev/null +++ b/admin/internal/api/staff.go @@ -0,0 +1,341 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func staffListAccounts(c *gin.Context) { + filter := bson.M{} + if q := c.Query("q"); q != "" { + filter["$or"] = []bson.M{ + {"name": bson.M{"$regex": q, "$options": "i"}}, + {"billing_email": bson.M{"$regex": q, "$options": "i"}}, + } + } + cur, err := db.Admin("accounts").Find(c.Request.Context(), filter, + options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}})) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + accounts := []models.Account{} + if err := cur.All(c.Request.Context(), &accounts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, accounts) +} + +func staffCreateAccount(c *gin.Context) { + var body struct { + Name string `json:"name"` + BillingEmail string `json:"billing_email"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"}) + return + } + acct := models.Account{ + AccountID: uuid.NewString(), + Name: body.Name, + BillingEmail: body.BillingEmail, + Status: models.AccountActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, acct) +} + +func staffGetAccount(c *gin.Context) { + ctx := c.Request.Context() + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}) + instances := []models.Instance{} + if cur != nil { + _ = cur.All(ctx, &instances) + } + c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances}) +} + +func staffListInstances(c *gin.Context) { + filter := bson.M{} + for param, field := range map[string]string{ + "account_id": "account_id", + "deployment": "deployment", + "status": "status", + } { + if v := c.Query(param); v != "" { + filter[field] = v + } + } + if c.Query("expiring") == "true" { + // Instances whose licence expires within 14 days, for renewal chasing. + var ids []string + cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{ + "superseded_by": bson.M{"$exists": false}, + "expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)}, + }) + if err == nil { + var lics []models.License + if cur.All(c.Request.Context(), &lics) == nil { + for _, l := range lics { + ids = append(ids, l.InstanceID) + } + } + } + filter["instance_id"] = bson.M{"$in": ids} + } + + cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter, + options.Find().SetLimit(500)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + instances := []models.Instance{} + if err := cur.All(c.Request.Context(), &instances); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, instances) +} + +// staffCreateInstance attaches an instance to an account. +// +// For cloud, this ADOPTS an instance that already exists in the control plane — +// the control-plane row is the source of truth for its name and slug, and this +// refuses if no such instance exists, because an admin row pointing at nothing +// would issue licences nobody can use. +// +// For self-hosted it does the same job as the customer-facing link endpoint, so +// staff can link on a customer's behalf during support. +// +// This is how existing cloud instances get licensed: adopt, then issue. +func staffCreateInstance(c *gin.Context) { + var body struct { + InstanceID string `json:"instance_id"` + AccountID string `json:"account_id"` + Deployment string `json:"deployment"` + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"}) + return + } + ctx := c.Request.Context() + + if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such account"}) + return + } + + inst := models.Instance{ + InstanceID: body.InstanceID, + AccountID: body.AccountID, + Name: body.Name, + Deployment: body.Deployment, + Status: models.StatusActive, + CreatedAt: time.Now().UTC(), + } + + if body.Deployment == license.DeploymentCloud { + var remote sharedmodels.Instance + if err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"}) + return + } + inst.Name = remote.Name + inst.Slug = remote.Slug + } + + if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + s := auth.Current(c) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID}) + c.JSON(http.StatusCreated, inst) +} + +func staffIssue(c *gin.Context) { + var body struct { + Tier string `json:"tier"` + Term string `json:"term"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"}) + return + } + if body.Reason == "" { + body.Reason = models.ReasonManual + } + + s := auth.Current(c) + lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{ + InstanceID: c.Param("id"), + Tier: body.Tier, + Term: body.Term, + Reason: body.Reason, + IssuedBy: s.Email, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var inst models.Instance + if db.Admin("admin_instances").FindOne(c.Request.Context(), + bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil { + deliver(c, &inst, lic) + } + c.JSON(http.StatusCreated, lic) +} + +// staffRelink has no attempt cap. The customer-facing limit exists to put a +// human in front of the fourth attempt; this is that human. +func staffRelink(c *gin.Context) { + var body struct { + InstanceID string `json:"instance_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) + return + } + ctx := c.Request.Context() + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, lic) +} + +func staffListLicenses(c *gin.Context) { + filter := bson.M{} + if v := c.Query("instance_id"); v != "" { + filter["instance_id"] = v + } + if v := c.Query("account_id"); v != "" { + filter["account_id"] = v + } + cur, err := db.Admin("licenses").Find(c.Request.Context(), filter, + options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}})) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + lics := []models.License{} + if err := cur.All(c.Request.Context(), &lics); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, lics) +} + +func staffListPlans(c *gin.Context) { + cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + plans := []models.Plan{} + if err := cur.All(c.Request.Context(), &plans); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, plans) +} + +// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences +// snapshotted their plan at issue time and are unaffected — the same rule as +// workflow_runs.steps_snapshot. +func staffUpdatePlan(c *gin.Context) { + var body models.Plan + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"}) + return + } + set := bson.M{ + "name": body.Name, + "limits": body.Limits, + "features": body.Features, + "paddle_product_id": body.PaddleProductID, + "paddle_price_ids": body.PaddlePriceIDs, + "active": body.Active, + } + if _, err := db.Admin("plans").UpdateOne(c.Request.Context(), + bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func staffAudit(c *gin.Context) { + cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{}, + options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}})) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + entries := []models.AuditEntry{} + if err := cur.All(c.Request.Context(), &entries); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, entries) +} + +// staffInjectionHealth lists instances whose last injection failed. This is the +// page to look at when a customer says their cloud instance is read-only. +func staffInjectionHealth(c *gin.Context) { + cur, err := db.Admin("admin_instances").Find(c.Request.Context(), + bson.M{"inject_failed_at": bson.M{"$exists": true}}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + failed := []models.Instance{} + if err := cur.All(c.Request.Context(), &failed); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)}) +}