feat(admin): grant, re-role and revoke instance members

A grant writes a real control-plane user; the instance_members row is only
admin's index of it, which is why a failed insert unwinds the projection.
Self-hosted instances refuse all three mutations: their users live in a
deployment we cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:28:41 +01:00
co-authored by Claude Opus 5
parent 0c663945ee
commit cca0ffbeae
3 changed files with 268 additions and 0 deletions
+21
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
@@ -19,6 +20,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -277,6 +279,25 @@ func createInstance(c *gin.Context) {
return
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
+237
View File
@@ -0,0 +1,237 @@
package api
import (
"errors"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
// selfHostedRefusal is the one message every membership endpoint gives for a
// self-hosted instance. Their users live in their own deployment, which we
// cannot see and must not write to.
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
func listInstanceMembers(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
cur, err := db.Admin("instance_members").Find(ctx,
bson.M{"instance_id": inst.InstanceID})
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
}
c.JSON(http.StatusOK, members)
}
// grantInstanceMember projects an account person into a cloud instance.
func grantInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
if body.Role == "" {
body.Role = sharedmodels.RoleMember
}
if !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
target, ok := accountUser(c, body.UserID)
if !ok {
return
}
if target.VerifiedAt == nil || target.PasswordHash == "" {
// The projection copies a hash. An unverified invitee has no hash, so
// the row would exist and be unusable — and an address nobody has
// proven they control would hold a login inside a real instance.
c.JSON(http.StatusConflict, gin.H{
"error": "they have not accepted their invitation yet"})
return
}
ctx := c.Request.Context()
s := auth.Current(c)
u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
target.PasswordHash, body.Role, target.UserID)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{
"error": "that address already has a user inside this instance"})
return
}
log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
m := models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: target.UserID,
ControlUserID: u.UserID,
Role: body.Role,
Email: target.Email,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
// Unwind the projection: a control-plane login nobody on this side
// records is a login nobody can revoke through the portal.
if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
target.Email, inst.InstanceID, rErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusCreated, m)
}
// memberRow loads one membership on an instance the caller owns.
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
var m models.InstanceMember
if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
"instance_id": instanceID,
"customer_user_id": customerUserID,
}).Decode(&m); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &m, true
}
func updateInstanceMemberRole(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
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 instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
return
}
if _, err := db.Admin("instance_members").UpdateOne(ctx,
bson.M{"member_id": m.MemberID},
bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func revokeInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
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 instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
return
}
if _, err := db.Admin("instance_members").DeleteOne(ctx,
bson.M{"member_id": m.MemberID}); err != nil {
log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
+10
View File
@@ -66,6 +66,16 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances/:id/renew", renewInstance)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
cust.POST("/instances/:id/members",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
grantInstanceMember)
cust.PUT("/instances/:id/members/:uid/role",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateInstanceMemberRole)
cust.DELETE("/instances/:id/members/:uid",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
revokeInstanceMember)
cust.GET("/subscriptions", listSubscriptions)
}