feat(admin): cloudprov, the instance provisioning write path

Admin's second and final write path into the control plane. It creates
instances and users and nothing else; inject still owns exactly three
licence fields and is untouched.

The owner's password hash is copied from the HQ account, not shared. The
two diverge on the next password change, which is accepted: propagating a
hash across two databases is worse than two passwords that started equal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 13:14:12 +01:00
co-authored by Claude Opus 5
parent 78a0a610be
commit 983655d2a1
3 changed files with 99 additions and 3 deletions
+85
View File
@@ -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
}
+2
View File
@@ -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"),
+12 -3
View File
@@ -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)
}