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
-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})
}