diff --git a/shared/indexes/indexes.go b/shared/indexes/indexes.go index 07617bb..ef11974 100644 --- a/shared/indexes/indexes.go +++ b/shared/indexes/indexes.go @@ -28,11 +28,11 @@ func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error { return fmt.Errorf("users.email index: %w", err) } - if _, err := db.Collection("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "slug", Value: 1}}, Options: options.Index().SetUnique(true), }); err != nil { - return fmt.Errorf("orgs.slug index: %w", err) + return fmt.Errorf("instances.slug index: %w", err) } return nil diff --git a/shared/models/instance.go b/shared/models/instance.go new file mode 100644 index 0000000..1bb140b --- /dev/null +++ b/shared/models/instance.go @@ -0,0 +1,23 @@ +// Package models holds the MongoDB documents written by more than one Vantage +// service. Documents only the control plane touches stay in +// server/internal/models. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Instance is one deployment of Vantage: its own subdomain, users, servers, +// keys, workflows, monitors and secrets. It is the unit a licence attaches to. +// +// A paying customer may hold several. That grouping is called an Account and +// lives only in the admin control plane — this service never sees it. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/shared/models/org.go b/shared/models/org.go deleted file mode 100644 index b3d8d87..0000000 --- a/shared/models/org.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package models holds the MongoDB documents written by more than one Vantage -// service. Documents only the control plane touches stay in -// server/internal/models. -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Org struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` - Name string `bson:"name" json:"name"` - Slug string `bson:"slug" json:"slug"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} diff --git a/shared/models/settings.go b/shared/models/settings.go index d260e39..449ac91 100644 --- a/shared/models/settings.go +++ b/shared/models/settings.go @@ -30,11 +30,11 @@ type SecretsSettings struct { } type Settings struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` - Alerts AlertSettings `bson:"alerts" json:"alerts"` - Email EmailSettings `bson:"email" json:"email"` - Secrets SecretsSettings `bson:"secrets" json:"secrets"` + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Alerts AlertSettings `bson:"alerts" json:"alerts"` + Email EmailSettings `bson:"email" json:"email"` + Secrets SecretsSettings `bson:"secrets" json:"secrets"` WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` } diff --git a/shared/models/user.go b/shared/models/user.go index d1bc186..1e62040 100644 --- a/shared/models/user.go +++ b/shared/models/user.go @@ -23,7 +23,7 @@ func ValidRole(role string) bool { type User struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` UserID string `bson:"user_id" json:"user_id"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` Email string `bson:"email" json:"email"` PasswordHash string `bson:"password_hash,omitempty" json:"-"` Role string `bson:"role" json:"role"` diff --git a/shared/provision/instance.go b/shared/provision/instance.go new file mode 100644 index 0000000..3dc6bde --- /dev/null +++ b/shared/provision/instance.go @@ -0,0 +1,74 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an instance. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateInstance inserts an instance under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// instances.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + inst := models.Instance{ + InstanceID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &inst, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackInstance deletes an instance that has no users. +// +// It refuses an instance that has users. Rollback exists to clean up a +// half-finished signup, and an instance with users is not half-finished. +func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) + } + _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) + return err +} diff --git a/shared/provision/org.go b/shared/provision/org.go deleted file mode 100644 index ff808d8..0000000 --- a/shared/provision/org.go +++ /dev/null @@ -1,74 +0,0 @@ -package provision - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -// ErrNameRejected wraps every reason a name cannot become an organisation. -var ErrNameRejected = errors.New("organisation name rejected") - -const maxSlugAttempts = 50 - -// CreateOrg inserts an organisation under the first free slug derived from name. -// -// The count-then-insert loop is racy on its own. It is safe only because -// orgs.slug carries a unique index: a lost race surfaces as a duplicate-key -// error, which we treat as "that slug is taken" and retry. Do not remove the -// duplicate-key branch, and do not remove the index. -func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) { - base, err := BaseSlug(name) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) - } - - for attempt := 1; attempt <= maxSlugAttempts; attempt++ { - slug := NextSlug(base, attempt) - - n, err := db.Collection("orgs").CountDocuments(ctx, bson.M{"slug": slug}) - if err != nil { - return nil, err - } - if n > 0 { - continue - } - - org := models.Org{ - OrgID: uuid.NewString(), - Name: name, - Slug: slug, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Collection("orgs").InsertOne(ctx, org); err != nil { - if mongo.IsDuplicateKeyError(err) { - continue // lost the race; try the next slug - } - return nil, err - } - return &org, nil - } - return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) -} - -// RollbackOrg deletes an organisation that has no users. -// -// It refuses an organisation that has users. Rollback exists to clean up a -// half-finished signup, and an organisation with users is not half-finished. -func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error { - n, err := db.Collection("users").CountDocuments(ctx, bson.M{"org_id": orgID}) - if err != nil { - return err - } - if n > 0 { - return fmt.Errorf("refusing to roll back organisation %s: it has %d user(s)", orgID, n) - } - _, err = db.Collection("orgs").DeleteOne(ctx, bson.M{"org_id": orgID}) - return err -} diff --git a/shared/provision/user.go b/shared/provision/user.go index ec081af..ab27cc4 100644 --- a/shared/provision/user.go +++ b/shared/provision/user.go @@ -22,7 +22,7 @@ var ErrEmailTaken = errors.New("email already registered") // CreateUser hashes password and inserts the user. An empty password leaves the // hash empty, which is how OIDC users are stored. -func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error) { +func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) { var hash string if password != "" { b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) @@ -31,13 +31,13 @@ func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, } hash = string(b) } - return CreateUserWithHash(ctx, db, orgID, email, hash, role, authSource) + return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource) } // CreateUserWithHash inserts a user whose password was already hashed // elsewhere. sitesvc hashes at signup and only holds the hash by the time the // verification link is opened. -func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error) { +func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return nil, fmt.Errorf("email required") @@ -48,7 +48,7 @@ func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, p u := &models.User{ UserID: uuid.NewString(), - OrgID: orgID, + InstanceID: instanceID, Email: email, PasswordHash: passwordHash, Role: role,