diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go new file mode 100644 index 0000000..7c6fae2 --- /dev/null +++ b/admin/internal/cloudprov/cloudprov.go @@ -0,0 +1,85 @@ +// Package cloudprov provisions cloud instances in the control plane. +// +// This is admin's second and final write path into the control-plane database, +// alongside inject. It writes `instances` and `users` and nothing else. A third +// write target, or a write to any other collection from here, is a design change +// and not a refactor — see the spec's "Admin's control-plane write boundary". +// +// Every function here is called from a customer request, so each one leaves the +// control plane in a consistent state or not at all: the caller unwinds in +// reverse order on failure, and RollbackInstance refuses to delete an instance +// that has users. +package cloudprov + +import ( + "context" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/db" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// CreateInstance creates a control-plane instance and its owner. +// +// The owner's password hash is COPIED from the HQ account rather than shared. +// Changing the password on either side does not propagate, and they diverge from +// that moment — accepted deliberately, because propagating a hash across two +// services' databases is a worse problem than two passwords that started equal. +// +// On owner-insert failure the instance is rolled back, so a failed provision +// never leaves a slug permanently occupied by an instance nobody owns. +func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) { + inst, err := provision.CreateInstance(ctx, db.ControlDB(), name) + if err != nil { + return nil, err + } + + u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID, + ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ) + if err != nil { + if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil { + return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr) + } + return nil, err + } + + // hq_user_id is what phase 3 uses to find every row projected from one HQ + // user when its password changes. Set at creation so the owner is not a + // special case later. + if _, err := db.Control("users").UpdateOne(ctx, + bson.M{"user_id": u.UserID}, + bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil { + return nil, fmt.Errorf("set hq_user_id: %w", err) + } + + return inst, nil +} + +// DeleteUser removes one control-plane user. Used only to unwind a failed +// provision. +func DeleteUser(ctx context.Context, instanceID, userID string) error { + _, err := db.Control("users").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}) + return err +} + +// RollbackInstance deletes an instance that has no users. +func RollbackInstance(ctx context.Context, instanceID string) error { + return provision.RollbackInstance(ctx, db.ControlDB(), instanceID) +} + +// OwnerUserID returns the control-plane user_id of an instance's owner, so a +// caller can unwind a partial provision without re-deriving it. +func OwnerUserID(ctx context.Context, instanceID string) (string, error) { + var u sharedmodels.User + err := db.Control("users").FindOne(ctx, bson.M{ + "instance_id": instanceID, + "role": sharedmodels.RoleOwner, + }).Decode(&u) + if err != nil { + return "", err + } + return u.UserID, nil +} diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index 49fce54..6842b57 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { RedisPassword string SigningKey string PublicURL string + AppLoginURL string AllowedOrigins []string TrustProxy bool Addr string @@ -63,6 +64,7 @@ func Load() (Config, error) { RedisPassword: os.Getenv("REDIS_PASSWORD"), SigningKey: os.Getenv("LICENSE_SIGNING_KEY"), PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"), + AppLoginURL: os.Getenv("APP_LOGIN_URL"), TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"), Addr: ":" + envOr("PORT", "8083"), diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index ee5ed2c..4a9fd5c 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -1,9 +1,11 @@ // Package db holds admin's two MongoDB connections. // // Admin() is its own database and it owns every collection there. Control() is -// the control plane's database, and admin's access to it is deliberately narrow: -// it reads `instances` and `users`, and writes exactly three licence fields on -// `instances`. Nothing here should ever grow a write path to another collection. +// the control plane's database. Admin's access to it is narrow and lives in +// exactly two packages: inject writes three licence fields on `instances`, and +// cloudprov creates and rolls back `instances` and `users` when a customer +// provisions a cloud instance. Nothing else may write there, and a third write +// path is a design change rather than a refactor. package db import ( @@ -57,6 +59,13 @@ func Connect(ctx context.Context, cfg config.Config) error { func Admin(name string) *mongo.Collection { return adminDB.Collection(name) } func Control(name string) *mongo.Collection { return controlDB.Collection(name) } +// ControlDB exposes the control-plane database itself, because shared/provision +// takes a database rather than a collection. +// +// It is used by cloudprov and nothing else. Reach for Control(name) unless you +// are calling into shared/provision. +func ControlDB() *mongo.Database { return controlDB } + func Ctx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 10*time.Second) }