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:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user