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:
mrhid6
2026-07-26 16:30:33 +01:00
co-authored by Claude Opus 5
parent 0f5ad1d836
commit 14b855fa9f
2 changed files with 118 additions and 0 deletions
+2
View File
@@ -15,6 +15,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
@@ -76,6 +77,7 @@ func main() {
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
+116
View File
@@ -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)
}
}