From cf318470b849f507db8d9456f5d7f09f8120c892 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 12:55:44 +0100 Subject: [PATCH] feat(admin): drop the unscoped control-plane login branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HQ sign-in names no instance, so a lookup of control-plane users by email alone cannot be scoped — and users.email is no longer globally unique, so it would return an arbitrary match. Every customer authenticates against customer_users instead. Legacy cloud customers get an HQ login from staff via the new POST /api/staff/accounts/:id/users, alongside the manual instance attach the spec README already describes. Co-Authored-By: Claude Opus 5 --- admin/internal/api/routes.go | 7 ++- admin/internal/api/staff.go | 38 +++++++++++++++ admin/internal/auth/cloud.go | 94 ------------------------------------ 3 files changed, 44 insertions(+), 95 deletions(-) delete mode 100644 admin/internal/auth/cloud.go diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index f4898e9..e8ad0b3 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -27,7 +27,11 @@ func Routes(cfg config.Config) http.Handler { 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 + // Every customer authenticates against admin's own customer_users. There is + // deliberately no path that looks a customer up in the control plane by + // email alone: HQ sign-in names no instance, so such a lookup could not be + // scoped, and users.email is no longer globally unique. + r.POST("/auth/login", auth.HandleCustomerLogin) r.POST("/auth/logout", auth.HandleLogout) r.GET("/auth/verify", auth.HandleVerify) r.GET("/auth/me", getMe) @@ -50,6 +54,7 @@ func Routes(cfg config.Config) http.Handler { staff.GET("/accounts", staffListAccounts) staff.POST("/accounts", staffCreateAccount) staff.GET("/accounts/:id", staffGetAccount) + staff.POST("/accounts/:id/users", staffCreateAccountUser) staff.GET("/instances", staffListInstances) staff.POST("/instances", staffCreateInstance) staff.GET("/instances/:id", staffGetInstance) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 656254e..9515d26 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -448,3 +449,40 @@ func staffInjectionHealth(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)}) } + +// staffCreateAccountUser gives an account an HQ login. +// +// This is how a legacy cloud customer — one whose instance predates HQ accounts +// — gets into the portal, alongside the manual instance attach the spec README +// describes. It reuses CreateCustomerUser, so the row is unverified until the +// emailed link is opened and is rolled back if that email cannot be sent. +func staffCreateAccountUser(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "email and a password of at least 12 characters are required"}) + return + } + ctx := c.Request.Context() + accountID := c.Param("id") + + if n, err := db.Admin("accounts").CountDocuments(ctx, + bson.M{"account_id": accountID}); err != nil || n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such account"}) + return + } + + email := strings.ToLower(strings.TrimSpace(body.Email)) + if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + s := auth.Current(c) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email}) + c.JSON(http.StatusCreated, gin.H{"pending": true}) +} diff --git a/admin/internal/auth/cloud.go b/admin/internal/auth/cloud.go deleted file mode 100644 index 5975f28..0000000 --- a/admin/internal/auth/cloud.go +++ /dev/null @@ -1,94 +0,0 @@ -package auth - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/mrhid6/vantage/admin/internal/audit" - "github.com/mrhid6/vantage/admin/internal/db" - adminmodels "github.com/mrhid6/vantage/admin/internal/models" - sharedmodels "github.com/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" - "golang.org/x/crypto/bcrypt" -) - -// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's -// users collection, with the credentials they already have. -// -// Two consequences worth stating plainly, because they are real and were -// accepted deliberately: -// -// 1. A cloud user's control-plane password now also unlocks billing. Any -// password change or compromise has a wider blast radius than before. -// 2. Only control-plane role "owner" may sign in here. admin and member are -// refused — billing is an owner concern. -// -// Mitigations: rate limits, an identical error for every failure, and an audit -// entry for every attempt. -func HandleCloudLogin(c *gin.Context) { - var body struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) - return - } - email := strings.ToLower(strings.TrimSpace(body.Email)) - ctx := c.Request.Context() - - if !allowAttempt(email, c.ClientIP()) { - c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) - return - } - - reject := func(reason string) { - audit.Write(ctx, adminmodels.AuditEntry{ - Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason}) - c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) - } - - // A self-hosted customer_users row wins over a control-plane user with the - // same address. Documented so the behaviour is chosen rather than emergent. - if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 { - HandleCustomerLogin(c) - return - } - - var u sharedmodels.User - if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil { - bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) - reject("unknown email") - return - } - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { - reject("bad password") - return - } - if u.Role != sharedmodels.RoleOwner { - reject("role " + u.Role + " is not permitted") - return - } - - // Resolve the admin-side account that owns this user's instance. - var inst adminmodels.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil { - reject("no account for instance " + u.InstanceID) - return - } - - id, err := Save(ctx, Session{ - UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) - return - } - SetCookie(c, id) - clearAttempts(email) - audit.Write(ctx, adminmodels.AuditEntry{ - Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()}) - c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email}) -}