diff --git a/admin/internal/api/people.go b/admin/internal/api/people.go new file mode 100644 index 0000000..360b793 --- /dev/null +++ b/admin/internal/api/people.go @@ -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}) +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index e647bc0..1377fa9 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 9515d26..01bb0cf 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -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 } diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go index 52da2d2..1568dc0 100644 --- a/admin/internal/auth/customer.go +++ b/admin/internal/auth/customer.go @@ -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{ diff --git a/admin/internal/auth/middleware.go b/admin/internal/auth/middleware.go index b4750e2..babfe66 100644 --- a/admin/internal/auth/middleware.go +++ b/admin/internal/auth/middleware.go @@ -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() + } +} diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index f5d3f5d..b4732ff 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -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 {