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:
mrhid6
2026-07-26 16:26:56 +01:00
co-authored by Claude Opus 5
parent b37ed967b6
commit 0c663945ee
6 changed files with 461 additions and 8 deletions
+265
View File
@@ -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})
}
+20 -1
View File
@@ -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)
+3 -1
View File
@@ -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
}
+110 -6
View File
@@ -28,9 +28,9 @@ const BcryptCost = 12
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
// CreateCustomerUser creates an unverified self-hosted customer login and emails
// the verification link. Called during purchase (spec 5) and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return err
@@ -49,6 +49,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
PasswordHash: string(hash),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
@@ -79,6 +80,91 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
return nil
}
// CreateInvitedUser creates a passwordless, unverified member of an existing
// account and emails them a link to set a password.
//
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
// never succeed, so the row cannot sign in and cannot usefully be projected
// into an instance until the invitee has been through /accept-invite. That is
// also why a grant refuses an unverified user.
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return err
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expiry := time.Now().UTC().Add(VerifyWindow)
u := models.CustomerUser{
UserID: uuid.NewString(),
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
u.UserID, u.Email, dErr)
}
return err
}
return nil
}
// HandleAcceptInvite consumes an invitation token and sets the password.
//
// Verification and password-setting are one step for an invitee, because the
// link IS the proof of address and there is nothing to verify separately.
func HandleAcceptInvite(c *gin.Context) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
if len(body.Password) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
sum := sha256.Sum256([]byte(body.Token))
now := time.Now().UTC()
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
})
if err != nil || res.MatchedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
c.JSON(http.StatusOK, gin.H{"accepted": true})
}
// HandleSignup creates a self-hosted customer: an account, an unverified user,
// and a verification email.
//
@@ -134,7 +220,7 @@ func HandleSignup(c *gin.Context) {
return
}
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
// Roll the account back rather than strand one with no owner. Detached
// from ctx for the same reason as the user rollback above: a stalled mail
// server cancels the request, and a rollback that needs the request to
@@ -163,10 +249,28 @@ func HandleVerify(c *gin.Context) {
}
sum := sha256.Sum256([]byte(token))
now := time.Now().UTC()
ctx := c.Request.Context()
hashed := hex.EncodeToString(sum[:])
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
// Peek first. An invited row has no password yet, so consuming its token
// here would verify an account nobody can sign in to and burn the only
// link that could fix it.
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
}).Decode(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
if u.PasswordHash == "" {
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
return
}
res, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
+51
View File
@@ -2,8 +2,12 @@ package auth
import (
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
const ctxSession = "admin_session_obj"
@@ -58,3 +62,50 @@ func RequireCustomer() gin.HandlerFunc {
c.Next()
}
}
const ctxCustomerUser = "admin_customer_user"
// CurrentUser returns the calling customer's own row, loaded once per request
// by RequireAccountRole.
//
// It is nil behind RequireCustomer alone. A handler that needs the role must
// sit behind RequireAccountRole, which is the only thing that loads it.
func CurrentUser(c *gin.Context) *models.CustomerUser {
if v, ok := c.Get(ctxCustomerUser); ok {
if u, ok := v.(*models.CustomerUser); ok {
return u
}
}
return nil
}
// RequireAccountRole admits a customer holding one of the given account roles.
//
// The role is read from the database on every request rather than carried in
// the session. A session lives 24 hours; a demotion that only takes effect
// when someone signs out again is not a demotion.
func RequireAccountRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
s := Current(c)
if s == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if !slices.Contains(roles, u.AccountRole) {
// 403 rather than 404 here: this is the caller's OWN account, so
// there is no existence to disclose — the 404 rule protects other
// accounts' resources, not the caller's view of their own.
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "your account role does not allow this"})
return
}
c.Set(ctxCustomerUser, &u)
c.Next()
}
}
+12
View File
@@ -143,6 +143,18 @@ func SendVerification(to, token string) error {
link+"\n\nThis link expires in 24 hours.\n")
}
// SendInvite asks someone to join an existing account and set their own
// password. It names the account, because an unexpected invitation from a
// service you have never used is otherwise indistinguishable from spam.
func SendInvite(to, accountName, token string) error {
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
"Set your password and finish joining:\n\n%s\n\n"+
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
"nothing happens until you open the link.\n", accountName, link))
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {