diff --git a/admin/internal/api/people.go b/admin/internal/api/people.go index 360b793..3a7c475 100644 --- a/admin/internal/api/people.go +++ b/admin/internal/api/people.go @@ -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}) +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index bd3f5a9..4f1d4ea 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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) diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go index 265d803..30cbac2 100644 --- a/admin/internal/cloudprov/cloudprov.go +++ b/admin/internal/cloudprov/cloudprov.go @@ -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. diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index b4732ff..ba5e8d5 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -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) }