diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 350b328..e2b1ffc 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -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) diff --git a/admin/internal/hqsync/hqsync.go b/admin/internal/hqsync/hqsync.go new file mode 100644 index 0000000..9f7ee6e --- /dev/null +++ b/admin/internal/hqsync/hqsync.go @@ -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) + } +}