From 51db5ab2e951baec283eba2f76c441a1d695dfe5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 13:39:20 +0100 Subject: [PATCH] feat(shared): add CreateOrg, RollbackOrg and CreateUser Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane previously returned an error when it lost the slug race. --- shared/provision/org.go | 74 ++++++++++++++++++++++++++++++++++++++++ shared/provision/user.go | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 shared/provision/org.go create mode 100644 shared/provision/user.go diff --git a/shared/provision/org.go b/shared/provision/org.go new file mode 100644 index 0000000..ff808d8 --- /dev/null +++ b/shared/provision/org.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 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 new file mode 100644 index 0000000..ec081af --- /dev/null +++ b/shared/provision/user.go @@ -0,0 +1,65 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +// BcryptCost is the work factor for every password hash Vantage writes. +// Changing it changes nothing about existing hashes, which carry their own cost. +const BcryptCost = 12 + +// ErrEmailTaken is returned when the unique index on users.email rejects an insert. +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) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, orgID, 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) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +}