refactor(server): use shared models, provision and indexes
Also removes the duplicate Slugify in stepscan.go; workflow step slugs now use the shared definition too.
This commit is contained in:
@@ -1,15 +1,8 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
"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"`
|
||||
}
|
||||
// Org is defined in the shared module because sitesvc writes the same
|
||||
// documents. Aliased rather than re-declared so existing call sites are
|
||||
// unchanged and the two services cannot drift.
|
||||
type Org = shared.Org
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
type (
|
||||
Settings = shared.Settings
|
||||
AlertSettings = shared.AlertSettings
|
||||
EmailSettings = shared.EmailSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
type AlertSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
|
||||
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
|
||||
}
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
|
||||
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Password string `bson:"password" json:"password"`
|
||||
FromAddr string `bson:"from_addr" json:"from_addr"`
|
||||
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
|
||||
UseTLS bool `bson:"use_tls" json:"use_tls"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type SecretsSettings struct {
|
||||
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
|
||||
ReadTokenSet bool `bson:"-" json:"read_token_set"`
|
||||
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
|
||||
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
type User = shared.User
|
||||
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleAdmin = "admin"
|
||||
RoleMember = "member"
|
||||
RoleOwner = shared.RoleOwner
|
||||
RoleAdmin = shared.RoleAdmin
|
||||
RoleMember = shared.RoleMember
|
||||
)
|
||||
|
||||
func ValidRole(role string) bool {
|
||||
switch role {
|
||||
case RoleOwner, RoleAdmin, RoleMember:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
AuthSource string `bson:"auth_source" json:"auth_source"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
}
|
||||
func ValidRole(role string) bool { return shared.ValidRole(role) }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
@@ -41,7 +42,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
continue
|
||||
}
|
||||
s.Source = "default"
|
||||
s.Slug = Slugify(s.Name)
|
||||
s.Slug = provision.Slugify(s.Name)
|
||||
if s.Slug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/indexes"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
@@ -22,21 +23,16 @@ var scopedCollections = []string{
|
||||
}
|
||||
|
||||
func EnsureAuthIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
// users.email and orgs.slug are declared in the shared module so the
|
||||
// control plane and sitesvc cannot disagree about them.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// org_oidc is control-plane only, so its index stays here.
|
||||
if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "org_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
|
||||
@@ -6,18 +6,13 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var reservedSlugs = map[string]bool{
|
||||
"www": true, "api": true, "app": true, "admin": true, "auth": true,
|
||||
"install": true, "static": true, "_next": true, "default": true,
|
||||
}
|
||||
|
||||
func GetOrg(orgID string) (*models.Org, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -86,11 +81,11 @@ func AdoptOrg(orgID, name string) (*models.Org, error) {
|
||||
|
||||
set := bson.M{"name": name}
|
||||
|
||||
slug := Slugify(name)
|
||||
if len(slug) > 40 {
|
||||
slug = slug[:40]
|
||||
slug := provision.Slugify(name)
|
||||
if len(slug) > provision.MaxSlugLength {
|
||||
slug = slug[:provision.MaxSlugLength]
|
||||
}
|
||||
if len(slug) >= 3 && !reservedSlugs[slug] {
|
||||
if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] {
|
||||
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -109,45 +104,20 @@ func AdoptOrg(orgID, name string) (*models.Org, error) {
|
||||
return GetOrg(orgID)
|
||||
}
|
||||
|
||||
// CreateOrg creates an organisation and seeds its default workflow steps.
|
||||
//
|
||||
// The creation rules live in shared/provision because sitesvc creates
|
||||
// organisations too. Seeding stays here: shared must not know about workflow
|
||||
// steps.
|
||||
func CreateOrg(name string) (*models.Org, error) {
|
||||
base := Slugify(name)
|
||||
if len(base) < 3 {
|
||||
return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)")
|
||||
}
|
||||
if len(base) > 40 {
|
||||
base = base[:40]
|
||||
}
|
||||
if reservedSlugs[base] {
|
||||
return nil, fmt.Errorf("organization name is reserved")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
slug := base
|
||||
for i := 2; ; i++ {
|
||||
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
|
||||
o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("organization slug already taken")
|
||||
}
|
||||
o, err := provision.CreateOrg(ctx, db.Database, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
|
||||
} else {
|
||||
|
||||
@@ -34,11 +34,6 @@ func DeriveOutputs(script string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
// Slugify lived here and was mirrored by hand in sitesvc. It now has a single
|
||||
// definition in shared/provision, which both services import.
|
||||
|
||||
|
||||
func Slugify(name string) string {
|
||||
s := strings.ToLower(name)
|
||||
s = slugStrip.ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -58,37 +57,16 @@ func GetUserInOrg(orgID, userID string) (*models.User, error) {
|
||||
}
|
||||
|
||||
func CreateUser(orgID, email, password, 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,
|
||||
Role: role,
|
||||
AuthSource: authSource,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.PasswordHash = string(hash)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
return nil, err
|
||||
|
||||
u, err := provision.CreateUser(ctx, db.Database, orgID, email, password, role, authSource)
|
||||
if errors.Is(err, provision.ErrEmailTaken) {
|
||||
// Preserve the exact error string the API returned before this call
|
||||
// was delegated to the shared module.
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
return u, nil
|
||||
return u, err
|
||||
}
|
||||
|
||||
func GetUserByEmail(email string) (*models.User, error) {
|
||||
|
||||
Reference in New Issue
Block a user