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