feat(admin): drop the unscoped control-plane login branch

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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 12:55:44 +01:00
co-authored by Claude Opus 5
parent d703bbc4e8
commit cf318470b8
3 changed files with 44 additions and 95 deletions
+6 -1
View File
@@ -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)
+38
View File
@@ -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})
}
-94
View File
@@ -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})
}