From 769839a70d4a000475c8d7776d2bf6851ffc14a0 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 19:06:23 +0100 Subject: [PATCH] feat(admin): cloud owner login against the control plane Cloud customers sign in with the control-plane credentials they already hold, so there is no second password to manage. Two accepted consequences, documented at the handler: their control-plane password now also unlocks billing, and only role owner may sign in -- admin and member are refused because billing is an owner concern. A self-hosted customer_users row wins over a control-plane user with the same address, so the precedence is chosen rather than emergent. Adds the reads of control users this needs; the write surface is still one UpdateOne on instances. Co-Authored-By: Claude Opus 5 --- admin/internal/auth/cloud.go | 94 +++++++++++++++++++++++++++++++++ admin/internal/auth/customer.go | 12 +++++ 2 files changed, 106 insertions(+) create mode 100644 admin/internal/auth/cloud.go create mode 100644 admin/internal/auth/customer.go diff --git a/admin/internal/auth/cloud.go b/admin/internal/auth/cloud.go new file mode 100644 index 0000000..5975f28 --- /dev/null +++ b/admin/internal/auth/cloud.go @@ -0,0 +1,94 @@ +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}) +} diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go new file mode 100644 index 0000000..0e31e84 --- /dev/null +++ b/admin/internal/auth/customer.go @@ -0,0 +1,12 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// HandleCustomerLogin is replaced by the real self-hosted login in Task 7. +func HandleCustomerLogin(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented yet"}) +}