diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 2619149..17bd46b 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -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()}) diff --git a/admin/internal/api/members.go b/admin/internal/api/members.go new file mode 100644 index 0000000..ba567ff --- /dev/null +++ b/admin/internal/api/members.go @@ -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}) +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 1377fa9..bd3f5a9 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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) }