refactor(sitesvc): use shared models and provision
Deletes internal/provision and the hand-mirrored Org and User structs. The control plane and sitesvc now share one definition of both.
This commit is contained in:
+4
-3
@@ -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
|
||||
|
||||
+2
-4
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user