diff --git a/sitesvc/go.mod b/sitesvc/go.mod index b98190d..e28028d 100644 --- a/sitesvc/go.mod +++ b/sitesvc/go.mod @@ -1,16 +1,15 @@ module github.com/mrhid6/vantage/sitesvc -go 1.26 +go 1.26.4 require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - go.mongodb.org/mongo-driver/v2 v2.2.2 + go.mongodb.org/mongo-driver/v2 v2.8.0 golang.org/x/crypto v0.54.0 ) require ( - github.com/golang/snappy v1.0.0 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect @@ -19,3 +18,5 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect ) + +replace github.com/mrhid6/vantage/shared => ../shared diff --git a/sitesvc/go.sum b/sitesvc/go.sum index 3f296f9..4ed559c 100644 --- a/sitesvc/go.sum +++ b/sitesvc/go.sum @@ -1,7 +1,5 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,8 +17,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c= -go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= diff --git a/sitesvc/internal/models/models.go b/sitesvc/internal/models/models.go index 3a66bbc..4f3bd5b 100644 --- a/sitesvc/internal/models/models.go +++ b/sitesvc/internal/models/models.go @@ -6,42 +6,12 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -/* -Org and User mirror server/internal/models field for field, because sitesvc -writes into the same collections the control plane reads. - -These two structs and the rules in internal/provision are the only places -sitesvc duplicates control-plane logic. If the control plane's shape changes, -these must change with it. -*/ - -type Org struct { - ID bson.ObjectID `bson:"_id,omitempty"` - OrgID string `bson:"org_id"` - Name string `bson:"name"` - Slug string `bson:"slug"` - CreatedAt time.Time `bson:"created_at"` -} - -type User struct { - ID bson.ObjectID `bson:"_id,omitempty"` - UserID string `bson:"user_id"` - OrgID string `bson:"org_id"` - Email string `bson:"email"` - PasswordHash string `bson:"password_hash,omitempty"` - Role string `bson:"role"` - AuthSource string `bson:"auth_source"` - CreatedAt time.Time `bson:"created_at"` - LastLogin *time.Time `bson:"last_login,omitempty"` -} - - - - - - - - +// PendingSignup lives here rather than in the shared module because only +// sitesvc writes site_pending_signups. The control plane does not know the +// collection exists. +// +// Org and User used to be mirrored here by hand. They now come from +// github.com/mrhid6/vantage/shared/models, which is the only copy. type PendingSignup struct { ID bson.ObjectID `bson:"_id,omitempty"` PendingID string `bson:"pending_id"` diff --git a/sitesvc/internal/provision/provision.go b/sitesvc/internal/provision/provision.go deleted file mode 100644 index f2d4bdf..0000000 --- a/sitesvc/internal/provision/provision.go +++ /dev/null @@ -1,61 +0,0 @@ -package provision - -import ( - "fmt" - "regexp" - "strings" -) - -/* -Slug rules mirrored from the control plane (server/internal/services: Slugify in -stepscan.go, reservedSlugs and CreateOrg in orgs.go). - -They live here rather than being imported because sitesvc is a separate module -with no dependency on the server. That is a deliberate trade: sitesvc stays -small and independent, at the cost of this one duplicated rule set. - -Keep the two in step. If the control plane's slug handling, reserved names or -bcrypt cost change, change them here in the same commit nothing enforces the -match automatically, and a divergence would create tenants under rules the app -does not agree with. -*/ - -const ( - MinSlugLength = 3 - MaxSlugLength = 40 - BcryptCost = 12 -) - -var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) - -var ReservedSlugs = map[string]bool{ - "www": true, "api": true, "app": true, "admin": true, "auth": true, - "install": true, "static": true, "_next": true, "default": true, -} - -func Slugify(name string) string { - s := strings.ToLower(name) - s = slugStrip.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} - -func BaseSlug(name string) (string, error) { - base := Slugify(name) - if len(base) < MinSlugLength { - return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength) - } - if len(base) > MaxSlugLength { - base = base[:MaxSlugLength] - } - if ReservedSlugs[base] { - return "", fmt.Errorf("that organisation name is reserved") - } - return base, nil -} - -func NextSlug(base string, attempt int) string { - if attempt < 2 { - return base - } - return fmt.Sprintf("%s-%d", base, attempt) -} diff --git a/sitesvc/internal/store/store.go b/sitesvc/internal/store/store.go index 17e6f79..2e8492b 100644 --- a/sitesvc/internal/store/store.go +++ b/sitesvc/internal/store/store.go @@ -12,8 +12,10 @@ import ( "time" "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/indexes" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" "github.com/mrhid6/vantage/sitesvc/internal/models" - "github.com/mrhid6/vantage/sitesvc/internal/provision" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -24,10 +26,12 @@ import ( const PendingTTL = 24 * time.Hour +// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so +// errors.Is keeps working for existing callers in internal/api. var ( - ErrEmailTaken = errors.New("email already registered") + ErrEmailTaken = provision.ErrEmailTaken ErrBadToken = errors.New("verification link is invalid or has expired") - ErrNameRejected = errors.New("organisation name rejected") + ErrNameRejected = provision.ErrNameRejected ) var database *mongo.Database @@ -78,18 +82,11 @@ func EnsureIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, err := col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return fmt.Errorf("users.email index: %w", err) - } - - if _, err := col("orgs").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) + // users.email and orgs.slug are declared in the shared module so both + // services agree. Re-declaring at boot means sitesvc does not depend on the + // control plane having started first. + if err := indexes.EnsureCoreIndexes(ctx, database); err != nil { + return err } _, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{ @@ -166,7 +163,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string -func Verify(ctx context.Context, rawToken string) (*models.Org, error) { +func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) { var pending models.PendingSignup err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{ "token_hash": hashToken(rawToken), @@ -179,89 +176,26 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) { return nil, err } - org, err := createOrg(ctx, pending.OrgName) + org, err := provision.CreateOrg(ctx, database, pending.OrgName) if err != nil { return nil, err } - user := models.User{ - UserID: uuid.NewString(), - OrgID: org.OrgID, - Email: pending.Email, - PasswordHash: pending.PasswordHash, - Role: "owner", - AuthSource: "local", - CreatedAt: time.Now().UTC(), - } - if _, err := col("users").InsertOne(ctx, user); err != nil { - - - - - if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil { + // The password was hashed when the signup was recorded; only the hash + // survives to this point. + _, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email, + pending.PasswordHash, sharedmodels.RoleOwner, "local") + if err != nil { + // Leaving an org behind would permanently occupy a slug nobody owns. + if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil { log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr) } - if mongo.IsDuplicateKeyError(err) { - return nil, ErrEmailTaken - } return nil, err } return org, nil } - - -func createOrg(ctx context.Context, name string) (*models.Org, error) { - base, err := provision.BaseSlug(name) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) - } - - for attempt := 1; attempt <= 50; attempt++ { - slug := provision.NextSlug(base, attempt) - - n, err := col("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 := col("orgs").InsertOne(ctx, org); err != nil { - - - if mongo.IsDuplicateKeyError(err) { - continue - } - return nil, err - } - return &org, nil - } - return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) -} - - - -func rollbackOrg(ctx context.Context, orgID string) error { - n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID}) - if err != nil { - return err - } - if n > 0 { - return fmt.Errorf("refusing to roll back org %s: it has %d user(s)", orgID, n) - } - _, err = col("orgs").DeleteOne(ctx, bson.M{"org_id": orgID}) - return err -} - func randomToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil {