diff --git a/server/cmd/main.go b/server/cmd/main.go index ee7266e..f29193d 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -101,6 +101,13 @@ func runSchemaSetup() { log.Fatalf("failed to ensure auth indexes: %v", err) } + // 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id) + // index must exist before anything inserts providers, or a concurrent + // re-run could double-insert before the index is there to refuse it. + if err := services.MigrateAuthProviders(); err != nil { + log.Fatalf("auth provider 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/services/migrate_auth_providers.go b/server/internal/services/migrate_auth_providers.go new file mode 100644 index 0000000..615101e --- /dev/null +++ b/server/internal/services/migrate_auth_providers.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + "log" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// legacyInstanceOIDC is the pre-0005 shape: one document per instance, in a +// collection this migration is the last thing to read. +type legacyInstanceOIDC struct { + InstanceID string `bson:"instance_id"` + Issuer string `bson:"issuer"` + ClientID string `bson:"client_id"` + ClientSecretEnc string `bson:"client_secret_enc"` + Enabled bool `bson:"enabled"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// MigrateAuthProviders copies each instance_oidc document into auth_providers. +// +// The ciphertext is copied verbatim rather than decrypted and re-encrypted: a +// migration that needs KEY_ENCRYPTION_KEY fails on an instance that has none +// and strands the SSO configuration it was supposed to preserve. +// +// instance_oidc is left in place and no longer read. Nothing deletes it — a +// migration that drops the only copy of a client secret has no undo. +func MigrateAuthProviders() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + const marker = "0005_auth_providers" + if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + + cur, err := db.Col("instance_oidc").Find(ctx, bson.M{}) + if err != nil { + return err + } + var legacy []legacyInstanceOIDC + if err := cur.All(ctx, &legacy); err != nil { + return err + } + + migrated := 0 + for _, l := range legacy { + if l.InstanceID == "" { + continue + } + // Idempotent by skipping an instance that already has a provider, so a + // re-run after a partial failure completes rather than duplicating. + n, err := db.Col(authProviderCol).CountDocuments(ctx, bson.M{"instance_id": l.InstanceID}) + if err != nil { + return err + } + if n > 0 { + continue + } + providerID, err := randomProviderID() + if err != nil { + return err + } + now := time.Now() + p := models.AuthProvider{ + InstanceID: l.InstanceID, + ProviderID: providerID, + Name: "Single sign-on", + Kind: models.KindOIDC, + Preset: "", + Issuer: l.Issuer, + ClientID: l.ClientID, + ClientSecretEnc: l.ClientSecretEnc, + Scopes: []string{"openid", "profile", "email"}, + Enabled: l.Enabled, + // This provider's redirect URI has changed and nobody has been told + // yet. The settings card raises it until an administrator dismisses. + CallbackNotice: true, + Order: 0, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil { + return err + } + migrated++ + } + if migrated > 0 { + log.Printf("0005: migrated %d OIDC configuration(s) to auth_providers", migrated) + } + + _, err = db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()}) + return err +}