feat(admin): invite people to an account and give them roles
An invitation carries no password. The HQ password is what signs someone into every instance they are granted, so a password the inviter chose would be a shared credential to all of them — the invited row has an empty hash, which cannot authenticate, until /accept-invite sets one. Removing a person revokes every projected instance user first, and refuses outright if any of those is an instance's last owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// listAccountUsers returns the account's people, newest last.
|
||||
//
|
||||
// Any signed-in member may read this. Knowing who your colleagues are is not
|
||||
// privileged, and hiding it would make the members panel unusable for the
|
||||
// people it is meant to inform.
|
||||
func listAccountUsers(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
users := []models.CustomerUser{}
|
||||
if err := cur.All(ctx, &users); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// inviteAccountUser adds a person to the account.
|
||||
//
|
||||
// It never sets a password: see CreateInvitedUser. Only an owner may invite
|
||||
// another owner, mirroring the control plane's own rule that an admin cannot
|
||||
// mint someone with more power than themselves.
|
||||
func inviteAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = models.AccountRoleMember
|
||||
}
|
||||
if !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
// customer_users.email is globally unique, so an address already in use
|
||||
// anywhere cannot be invited here. Say so plainly: unlike signup there is
|
||||
// nothing to conceal, because the inviter already knows this address.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that address already has a Vantage HQ account"})
|
||||
return
|
||||
}
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
|
||||
log.Printf("invite %s to %s: %v", email, s.AccountID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
|
||||
Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"invited": true})
|
||||
}
|
||||
|
||||
// accountUser loads one person and confirms they are on the caller's account.
|
||||
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
|
||||
s := auth.Current(c)
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &u, true
|
||||
}
|
||||
|
||||
// countOtherAccountOwners counts owners of an account other than one person.
|
||||
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
|
||||
s := auth.Current(c)
|
||||
return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
|
||||
"account_id": s.AccountID,
|
||||
"account_role": models.AccountRoleOwner,
|
||||
"user_id": bson.M{"$ne": exceptUserID},
|
||||
})
|
||||
}
|
||||
|
||||
func updateAccountUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
|
||||
me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if _, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": target.UserID},
|
||||
bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// deleteAccountUser removes a person and every instance they hold.
|
||||
//
|
||||
// Grants go first, and the whole request is refused if any of them would strand
|
||||
// an instance with no owner. Removing the person but leaving their projected
|
||||
// rows behind would leave working logins for someone the account has removed —
|
||||
// the exact failure this endpoint exists to prevent.
|
||||
func deleteAccountUser(c *gin.Context) {
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
cur, err := db.Admin("instance_members").Find(ctx,
|
||||
bson.M{"customer_user_id": target.UserID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
members := []models.InstanceMember{}
|
||||
if err := cur.All(ctx, &members); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check every instance BEFORE deleting anything, so a refusal leaves the
|
||||
// person exactly as they were rather than half-revoked.
|
||||
for _, m := range members {
|
||||
if m.Role != sharedmodels.RoleOwner {
|
||||
continue
|
||||
}
|
||||
others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
|
||||
log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "could not remove their instance access; nothing was deleted"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").DeleteOne(ctx,
|
||||
bson.M{"member_id": m.MemberID}); err != nil {
|
||||
log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Admin("customer_users").DeleteOne(ctx,
|
||||
bson.M{"user_id": target.UserID}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
|
||||
IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
func Routes(cfg config.Config) http.Handler {
|
||||
@@ -36,12 +37,30 @@ func Routes(cfg config.Config) http.Handler {
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
r.GET("/auth/me", getMe)
|
||||
r.POST("/auth/signup", auth.HandleSignup)
|
||||
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
|
||||
|
||||
cust := r.Group("/api")
|
||||
cust.Use(auth.RequireCustomer())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
cust.POST("/instances", createInstance)
|
||||
|
||||
// People. Reading is open to any member; changing anything is
|
||||
// owner-or-admin, enforced per route rather than by splitting the group,
|
||||
// so the guard is visible next to the route it guards.
|
||||
cust.GET("/account/users", listAccountUsers)
|
||||
cust.POST("/account/users",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
inviteAccountUser)
|
||||
cust.PUT("/account/users/:id/role",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateAccountUserRole)
|
||||
cust.DELETE("/account/users/:id",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
deleteAccountUser)
|
||||
|
||||
cust.POST("/instances",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createInstance)
|
||||
cust.POST("/instances/link", linkInstance)
|
||||
cust.POST("/instances/:id/relink", relinkInstance)
|
||||
cust.POST("/instances/:id/renew", renewInstance)
|
||||
|
||||
@@ -476,7 +476,9 @@ func staffCreateAccountUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
|
||||
// Staff attaching a legacy customer are attaching the person who runs that
|
||||
// account, so they get owner.
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user