From a7b9af44224359c847e3af5aa9d0a592448db59d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 16:23:02 +0100 Subject: [PATCH] feat(admin): account roles and the instance_members index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 created cloud instances without recording who owns them on this side, because the collection did not exist. The boot backfill reconstructs one member row per instance from the hq-sourced control-plane owner, and marks every existing customer_user an owner — they all created their own account. Backfill lives in models rather than db: db is the connection layer and models already imports it for SeedPlans, so db -> models would cycle. Co-Authored-By: Claude Opus 5 --- admin/cmd/main.go | 4 ++ admin/internal/db/db.go | 20 ++++++ admin/internal/models/backfill.go | 101 ++++++++++++++++++++++++++++++ admin/internal/models/members.go | 60 ++++++++++++++++++ admin/internal/models/models.go | 15 +++-- 5 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 admin/internal/models/backfill.go create mode 100644 admin/internal/models/members.go diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 6c03493..350b328 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -67,6 +67,10 @@ func main() { idxCancel() log.Fatalf("plan seed: %v", err) } + if err := models.Backfill(idxCtx); err != nil { + idxCancel() + log.Fatalf("backfill: %v", err) + } idxCancel() reconcileCtx, stopReconcile := context.WithCancel(context.Background()) diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index 4a9fd5c..49fc5c2 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -118,5 +118,25 @@ func EnsureIndexes(ctx context.Context) error { return fmt.Errorf("index %s: %w", idx.coll, err) } } + + // One person holds at most one user in one instance. This is the property + // that makes a grant idempotent-by-refusal rather than silently doubling a + // projection, and it mirrors users' own (instance_id, email) uniqueness. + if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}}, + Options: options.Index().SetUnique(true). + SetName("instance_customer_user_unique"), + }); err != nil { + return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err) + } + for _, keys := range []bson.D{ + {{Key: "account_id", Value: 1}}, + {{Key: "customer_user_id", Value: 1}}, + } { + if _, err := Admin("instance_members").Indexes().CreateOne(ctx, + mongo.IndexModel{Keys: keys}); err != nil { + return fmt.Errorf("index instance_members: %w", err) + } + } return nil } diff --git a/admin/internal/models/backfill.go b/admin/internal/models/backfill.go new file mode 100644 index 0000000..14db2ca --- /dev/null +++ b/admin/internal/models/backfill.go @@ -0,0 +1,101 @@ +package models + +import ( + "context" + "log" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// Backfill brings pre-phase-3 data up to the membership model. +// +// It runs on every boot and is idempotent by construction: both passes filter +// on the absence of what they write. There is no migrations collection in +// admin, and adding one for two `$exists: false` queries would be more +// machinery than the job deserves. +// +// It lives in models rather than db for the same reason SeedPlans does: db is +// the connection layer and importing models there is an import cycle. +func Backfill(ctx context.Context) error { + // Pass 1: every existing customer_user created their own account, so they + // are all owners. A row with no account_role would otherwise be able to do + // nothing at all once the guards land — including managing the account it + // created. + res, err := db.Admin("customer_users").UpdateMany(ctx, + bson.M{"account_role": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"account_role": AccountRoleOwner}}) + if err != nil { + return err + } + if res.ModifiedCount > 0 { + log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount) + } + + // Pass 2: phase 2 created cloud instances and their owners without an + // instance_members row, because the collection did not exist. Reconstruct + // one per instance from the control-plane owner it actually created. + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentCloud, + "status": bson.M{"$ne": StatusDeleted}, + }) + if err != nil { + return err + } + var instances []Instance + if err := cur.All(ctx, &instances); err != nil { + return err + } + + created := 0 + for _, inst := range instances { + n, err := db.Admin("instance_members").CountDocuments(ctx, + bson.M{"instance_id": inst.InstanceID}) + if err != nil { + return err + } + if n > 0 { + continue + } + + // Only an hq-sourced owner can be reconstructed: a control-plane owner + // with no hq_user_id was created inside the instance and belongs to + // nobody on this side. Leaving it unrecorded is correct. + var owner sharedmodels.User + err = db.Control("users").FindOne(ctx, bson.M{ + "instance_id": inst.InstanceID, + "role": sharedmodels.RoleOwner, + "hq_user_id": bson.M{"$nin": bson.A{nil, ""}}, + }).Decode(&owner) + if err != nil { + if err != mongo.ErrNoDocuments { + return err + } + log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID) + continue + } + + if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{ + MemberID: uuid.NewString(), + AccountID: inst.AccountID, + InstanceID: inst.InstanceID, + CustomerUserID: owner.HQUserID, + ControlUserID: owner.UserID, + Role: sharedmodels.RoleOwner, + Email: owner.Email, + CreatedAt: time.Now().UTC(), + }); err != nil { + return err + } + created++ + } + if created > 0 { + log.Printf("backfill: recorded %d pre-existing instance owners", created) + } + return nil +} diff --git a/admin/internal/models/members.go b/admin/internal/models/members.go new file mode 100644 index 0000000..168fe55 --- /dev/null +++ b/admin/internal/models/members.go @@ -0,0 +1,60 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Account roles. +// +// Deliberately the same three words as the control plane's own roles rather +// than a second vocabulary: a customer who reads "admin" in the portal and +// "admin" in their instance should not have to learn that they mean different +// things. They govern different scopes — this one governs the HQ account — +// but they mean the same thing about power. +// +// Billing stays owner-only. Owners and admins may invite people, create +// instances and grant instance access. +const ( + AccountRoleOwner = "owner" + AccountRoleAdmin = "admin" + AccountRoleMember = "member" +) + +func ValidAccountRole(r string) bool { + switch r { + case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember: + return true + } + return false +} + +// AccountRoleAtLeastAdmin is the single definition of "may manage people and +// instances". Every guard calls this rather than comparing strings, so widening +// the rule is one edit. +func AccountRoleAtLeastAdmin(r string) bool { + return r == AccountRoleOwner || r == AccountRoleAdmin +} + +// InstanceMember records that one HQ person holds a projected user inside one +// cloud instance. +// +// It is admin's index of the projection, not the authority: the control-plane +// `users` row IS the access. This row exists so the portal can list who is on +// an instance without reading the control plane, and so a password change can +// find every row to update without scanning every instance. +// +// ControlUserID is the projected users.user_id. Role is the role that user +// holds INSIDE the instance, which is not the person's account role. +type InstanceMember struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + MemberID string `bson:"member_id" json:"member_id"` + AccountID string `bson:"account_id" json:"account_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"` + ControlUserID string `bson:"control_user_id" json:"control_user_id"` + Role string `bson:"role" json:"role"` + Email string `bson:"email" json:"email"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index 1234277..4b31764 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -155,19 +155,26 @@ type StaffUser struct { CreatedAt time.Time `bson:"created_at" json:"created_at"` } -// CustomerUser is a self-hosted customer's login. Cloud customers do not have -// one — they authenticate against the control plane with credentials they -// already hold. +// CustomerUser is one person on an HQ account. +// +// AccountRole governs what they may do to the ACCOUNT — invite people, create +// instances, grant access. It says nothing about what they may do inside any +// instance; that is the role on their InstanceMember row. type CustomerUser struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` UserID string `bson:"user_id" json:"user_id"` AccountID string `bson:"account_id" json:"account_id"` Email string `bson:"email" json:"email"` PasswordHash string `bson:"password_hash" json:"-"` + AccountRole string `bson:"account_role" json:"account_role"` VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"` VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"` VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + // HQSyncFailedAt is set when a password change could not be written to + // every projected control-plane row. It is visibility only — hqsync repairs + // by comparing hashes, not by reading this field. + HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } type AuditEntry struct {