diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go index 7c6fae2..265d803 100644 --- a/admin/internal/cloudprov/cloudprov.go +++ b/admin/internal/cloudprov/cloudprov.go @@ -83,3 +83,102 @@ func OwnerUserID(ctx context.Context, instanceID string) (string, error) { } return u.UserID, nil } + +// GrantUser projects an HQ person into a control-plane instance. +// +// The password hash is copied from customer_users rather than re-derived: HQ +// owns the password, and a grant that asked for a password again would create +// a second credential for one person. +// +// The row is written with auth_source "hq" and hq_user_id set, which is what +// makes the control plane refuse to edit it locally and what lets a password +// change find it later. +func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) { + u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID, + email, passwordHash, role, sharedmodels.AuthHQ) + if err != nil { + return nil, err + } + if _, err := db.Control("users").UpdateOne(ctx, + bson.M{"user_id": u.UserID}, + bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil { + // Unwind: a projected row with no hq_user_id is invisible to revoke and + // to password propagation, which is worse than no row at all. + _, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID}) + return nil, fmt.Errorf("set hq_user_id: %w", err) + } + u.HQUserID = hqUserID + return u, nil +} + +// RevokeUser deletes the projected row for one person in one instance. +// +// Deleting rather than disabling is deliberate: the control plane has no +// concept of a disabled user, and a row that still exists is a row that can +// still sign in. +func RevokeUser(ctx context.Context, instanceID, hqUserID string) error { + _, err := db.Control("users").DeleteOne(ctx, bson.M{ + "instance_id": instanceID, + "hq_user_id": hqUserID, + }) + return err +} + +// SetMemberRole changes a projected user's role inside one instance. +func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error { + if !sharedmodels.ValidRole(role) { + return fmt.Errorf("invalid role %q", role) + } + res, err := db.Control("users").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "hq_user_id": hqUserID}, + bson.M{"$set": bson.M{"role": role}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return fmt.Errorf("no projected user in instance %s", instanceID) + } + return nil +} + +// CountOtherOwners counts owners of an instance other than one HQ person. +// +// It counts CONTROL-PLANE owners, so an owner created locally inside the +// instance counts too. That matters: refusing to revoke the last HQ owner of +// an instance that has three local owners would be a refusal with no cause. +// +// $ne matches documents where the field is absent, which is exactly how a +// locally-created owner is stored. +func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) { + return db.Control("users").CountDocuments(ctx, bson.M{ + "instance_id": instanceID, + "role": sharedmodels.RoleOwner, + "hq_user_id": bson.M{"$ne": exceptHQUserID}, + }) +} + +// SetPasswordHash writes one hash to every row projected from one HQ person, +// across every instance, and reports how many it changed. +func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) { + res, err := db.Control("users").UpdateMany(ctx, + bson.M{"hq_user_id": hqUserID}, + bson.M{"$set": bson.M{"password_hash": hash}}) + if err != nil { + return 0, err + } + return res.ModifiedCount, nil +} + +// ProjectedUsers returns every control-plane row projected from one HQ person. +// hqsync uses it to compare hashes. +func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) { + cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID}) + if err != nil { + return nil, err + } + var users []sharedmodels.User + if err := cur.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +}