feat(admin): account roles and the instance_members index

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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:23:02 +01:00
co-authored by Claude Opus 5
parent 310b3ab03f
commit a7b9af4422
5 changed files with 196 additions and 4 deletions
+20
View File
@@ -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
}
+101
View File
@@ -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
}
+60
View File
@@ -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"`
}
+11 -4
View File
@@ -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 {