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:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,9 +24,10 @@ import (
|
||||
// CreateInstance creates a control-plane instance and its owner.
|
||||
//
|
||||
// The owner's password hash is COPIED from the HQ account rather than shared.
|
||||
// Changing the password on either side does not propagate, and they diverge from
|
||||
// that moment — accepted deliberately, because propagating a hash across two
|
||||
// services' databases is a worse problem than two passwords that started equal.
|
||||
// HQ remains the single source of truth: a password change there copies the new
|
||||
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
|
||||
// a failed write left stale. The control plane has no local password-change path
|
||||
// for an hq-sourced row, so there is no competing writer.
|
||||
//
|
||||
// On owner-insert failure the instance is rolled back, so a failed provision
|
||||
// never leaves a slug permanently occupied by an instance nobody owns.
|
||||
|
||||
@@ -178,7 +178,7 @@ func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) err
|
||||
body += fmt.Sprintf(
|
||||
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
|
||||
"Sign in with the same email address and password you use for your Vantage account. "+
|
||||
"Changing one does not change the other.\n",
|
||||
"Changing your Vantage HQ password changes it here too.\n",
|
||||
expires.Format("2 January 2006"))
|
||||
return send(to, instanceName+" is ready", body)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user