diff --git a/server/cmd/main.go b/server/cmd/main.go index 57ae5fd..4e203bd 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -34,13 +34,15 @@ func main() { if err := services.RunMigrations(); err != nil { log.Fatalf("migration failed: %v", err) } - if err := services.MigrateMissedOrgScopes(); err != nil { - log.Fatalf("missed org scope migration failed: %v", err) - } - // Must run before the unique settings indexes are built. + // Must run before the unique settings indexes are built, and before 0003: + // 0003 can create a "default" org, which would push 0002 into its ambiguous + // multi-org branch and leave the settings doc unstamped. if err := services.MigrateSettingsOrg(); err != nil { log.Fatalf("settings org migration failed: %v", err) } + if err := services.MigrateMissedOrgScopes(); err != nil { + log.Fatalf("missed org scope migration failed: %v", err) + } if err := services.EnsureSecretIndexes(); err != nil { log.Printf("warning: failed to ensure secret indexes: %v", err) diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go index 7a3ec31..5cc94f0 100644 --- a/server/internal/auth/local.go +++ b/server/internal/auth/local.go @@ -1,9 +1,11 @@ package auth import ( + "fmt" "net/http" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/models" "github.com/mrhid6/vantage/server/internal/services" ) @@ -89,7 +91,36 @@ func HandleBootstrap(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"}) return } - org, err := services.CreateOrg(body.OrgName) + // An upgrade from single-tenant arrives here with no users but with the org + // the migrations created and stamped onto every legacy document. Creating a + // second org would put the owner somewhere else entirely, and since every + // org-scoped read filters on org_id, the operator would land in an empty + // Vantage with all their real data still under the migrated org — silent, + // total-looking data loss. So adopt the existing org instead, and only + // create when there genuinely is none. Same `switch orgCount` shape as + // migration 0002. + orgCount, err := services.CountOrgs() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var org *models.Org + switch orgCount { + case 0: + org, err = services.CreateOrg(body.OrgName) + case 1: + var existing *models.Org + existing, err = services.FirstOrg() + if err == nil { + org, err = services.AdoptOrg(existing.OrgID, body.OrgName) + } + default: + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf( + "cannot bootstrap: %d organizations already exist but no users do; "+ + "create the owner against the intended org rather than through setup, "+ + "or remove the unintended orgs and retry", orgCount)}) + return + } if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go index daebdbd..a17916f 100644 --- a/server/internal/services/migrate.go +++ b/server/internal/services/migrate.go @@ -2,7 +2,8 @@ package services import ( "context" - "log" + "errors" + "fmt" "time" "github.com/google/uuid" @@ -51,11 +52,19 @@ func EnsureAuthIndexes() error { // that ran either one converges on the same org. func defaultBackfillOrg(ctx context.Context) (*models.Org, error) { var org models.Org - if err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org); err != nil { + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) + switch { + case err == nil: + case errors.Is(err, mongo.ErrNoDocuments): + // Only a genuine absence justifies an insert. Treating a timeout or a + // decode failure as "absent" would race the fatal unique orgs.slug index + // and turn a transient blip into a boot crash. org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { return nil, err } + default: + return nil, err } return &org, nil } @@ -166,13 +175,17 @@ func MigrateMissedOrgScopes() error { // the org from the record in ownerCol it points at. Orphans (owner already // deleted) are left alone; they are unreachable either way. func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error { - var ids []string + // Decoded loosely: a single null or non-string value in the collection would + // fail a []string decode and abort the migration — and therefore boot — over + // one unusable document. Skip what we cannot use instead. + var raw []bson.RawValue if err := db.Col(col).Distinct(ctx, localField, - bson.M{"org_id": bson.M{"$exists": false}}).Decode(&ids); err != nil { + bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil { return err } - for _, id := range ids { - if id == "" { + for _, rv := range raw { + id, ok := rv.StringValueOK() + if !ok || id == "" { continue } var owner struct { @@ -230,9 +243,16 @@ func MigrateSettingsOrg() error { } default: // Ambiguous: several orgs but an unstamped settings doc. Guessing - // would hand one org another's SMTP config and ESO token, so leave - // it for an operator to resolve. - log.Printf("settings org migration: %d unstamped settings doc(s) with %d orgs present; skipping", n, orgCount) + // would hand one org another's SMTP config and ESO token. Continuing + // is not an option either: the unique settings.org_id index built + // straight after this indexes every unstamped doc as null, so two or + // more of them collide and boot fails there instead — with a far less + // useful message. Stop here, where we can name the remedy. + return fmt.Errorf( + "settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+ + "cannot infer the owner. Set org_id manually on each settings document "+ + "(db.settings.updateOne({_id:},{$set:{org_id:\"\"}})), deleting any "+ + "duplicates, then restart", n, orgCount) } if org.OrgID != "" { if _, err := db.Col("settings").UpdateMany(ctx, diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go index a2634d0..27f345a 100644 --- a/server/internal/services/orgs.go +++ b/server/internal/services/orgs.go @@ -61,6 +61,65 @@ func ListOrgIDs() ([]string, error) { return ids, nil } +// CountOrgs returns the number of organizations on the instance. Used by +// first-run bootstrap to tell "empty instance" from "upgraded single-tenant +// instance whose data already sits under a migration-created org". +func CountOrgs() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("orgs").CountDocuments(ctx, bson.M{}) +} + +// FirstOrg returns the sole/earliest org. Callers must have established that +// exactly one exists before treating it as authoritative. +func FirstOrg() (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil { + return nil, err + } + return &o, nil +} + +// AdoptOrg renames an existing org to name, re-slugging it when the new slug is +// clean to take. It exists for the upgrade path: migration 0001 stamps every +// legacy document with the "default" org's ID, so bootstrap must claim that org +// rather than mint a second one — otherwise the operator signs in to an empty +// instance while all their servers and keys stay behind under "default". +// +// The slug is only changed when the derived one is usable and free; anything +// else keeps the current slug, including the reserved "default", which stays +// valid because it is pre-existing rather than newly chosen. +func AdoptOrg(orgID, name string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + set := bson.M{"name": name} + + slug := Slugify(name) + if len(slug) > 40 { + slug = slug[:40] + } + if len(slug) >= 3 && !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 + } + if n == 0 { + set["slug"] = slug + } + } + + if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return GetOrg(orgID) +} + func CreateOrg(name string) (*models.Org, error) { base := Slugify(name) if len(base) < 3 { diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index f2d6ec0..35a44b4 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -33,11 +33,16 @@ func EnsureSecretIndexes() error { } // isIndexNotFound reports whether err is Mongo's IndexNotFound (27), returned -// when dropping an index that was never created. +// when dropping an index that was never created, or NamespaceNotFound (26), +// returned when the collection itself does not exist yet. Both mean "there is +// no legacy index to drop" — on a fresh install nothing has written to these +// collections, so the drop must be tolerated or the index creation that follows +// it never runs and a brand-new deployment crash-loops at startup. func isIndexNotFound(err error) bool { var ce mongo.CommandError if errors.As(err, &ce) { - return ce.Code == 27 || ce.Name == "IndexNotFound" + return ce.Code == 27 || ce.Name == "IndexNotFound" || + ce.Code == 26 || ce.Name == "NamespaceNotFound" } return false }