feat(admin): hqsync repairs stale projected passwords
Its own package rather than a pass inside inject: inject writes three licence fields and nothing else, and that narrowness is what makes admin's reach into the control plane reviewable. Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one password differ by salt, so a re-hash would never converge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// Package hqsync keeps projected control-plane users consistent with the HQ
|
||||
// people they were projected from.
|
||||
//
|
||||
// It is separate from inject on purpose. inject writes exactly three licence
|
||||
// fields on `instances` and that narrowness is the reason admin's reach into
|
||||
// the control plane is reviewable at all; a password repair pass bolted onto it
|
||||
// would quietly turn it into "the package that writes whatever admin wants".
|
||||
// This one goes through cloudprov, which is the sanctioned user write path.
|
||||
package hqsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Interval matches inject's reconciler. Fifteen minutes is the worst-case
|
||||
// staleness a password change can suffer, which the spec accepts as
|
||||
// recoverable.
|
||||
const Interval = 15 * time.Minute
|
||||
|
||||
// Reconcile compares every projected user's stored hash against the HQ hash it
|
||||
// came from, and repairs mismatches.
|
||||
//
|
||||
// The comparison is on the hash string, not the password: two bcrypt hashes of
|
||||
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
|
||||
// re-hashing. That is also why propagation copies rather than re-derives.
|
||||
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
cur, err := db.Admin("customer_users").Find(ctx,
|
||||
bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var people []models.CustomerUser
|
||||
if err := cur.All(ctx, &people); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, p := range people {
|
||||
projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: read projections of %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
stale := false
|
||||
for _, u := range projected {
|
||||
checked++
|
||||
if u.PasswordHash != p.PasswordHash {
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
if !stale {
|
||||
// Clear a stale failure flag: the instances agree, whatever the
|
||||
// flag says. Nothing reads the flag to decide what to repair.
|
||||
if p.HQSyncFailedAt != nil {
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: repair %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
repaired += int(n)
|
||||
log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
return checked, repaired, nil
|
||||
}
|
||||
|
||||
// Start runs once at boot, then on a ticker until ctx is cancelled.
|
||||
//
|
||||
// The boot pass is for the same reason inject's is: the likeliest moment for a
|
||||
// half-applied write is a deploy or a crash, and waiting a full interval to
|
||||
// notice means a customer's new password does not work somewhere for fifteen
|
||||
// minutes after we already know how to fix it.
|
||||
func Start(ctx context.Context) {
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
t := time.NewTicker(Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, repaired, err := Reconcile(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: %v", err)
|
||||
return
|
||||
}
|
||||
if repaired > 0 {
|
||||
log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user