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