feat(admin): one password change reaches every instance

Best-effort by design: refusing the change because one instance was
unreachable would leave the customer holding the password they were trying
to replace. A failure is flagged and hqsync repairs it.

Also corrects two pieces of copy this makes false — CreateInstance's doc
comment and the instance-ready email both claimed the two passwords
diverge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:29:50 +01:00
co-authored by Claude Opus 5
parent cca0ffbeae
commit 0f5ad1d836
4 changed files with 82 additions and 4 deletions
+73
View File
@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
@@ -14,6 +15,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// listAccountUsers returns the account's people, newest last.
@@ -263,3 +265,74 @@ func deleteAccountUser(c *gin.Context) {
IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// changeAccountPassword sets one password and pushes it everywhere.
//
// HQ's hash is the single source of truth for every hq-sourced row, and the
// control plane has no local password-change path for them, so there is no
// competing writer.
//
// Propagation is best-effort ON PURPOSE. Failing the password change because
// one of three instances was briefly unreachable would leave the customer with
// the password they were trying to get rid of; hqsync repairs a stale instance
// within fifteen minutes, which is recoverable.
func changeAccountPassword(c *gin.Context) {
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
return
}
if len(body.NewPassword) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
s := auth.Current(c)
ctx := c.Request.Context()
var me models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
if _, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
pending := false
if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
pending = true
now := time.Now().UTC()
log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
} else {
log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
audit.Write(ctx, models.AuditEntry{
Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
Target: me.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
}
+4
View File
@@ -58,6 +58,10 @@ func Routes(cfg config.Config) http.Handler {
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
deleteAccountUser)
// Any member may change their own password — it is theirs. There is no
// endpoint for changing anyone else's.
cust.PUT("/account/password", changeAccountPassword)
cust.POST("/instances",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createInstance)