[management] Keep embedded IdP deployments on a single account (#7380)

This commit is contained in:
Bethuel Mmbaga
2026-09-04 11:03:54 +03:00
committed by GitHub
parent 13ab50b901
commit 066af82c3e
12 changed files with 623 additions and 19 deletions
+2
View File
@@ -50,6 +50,7 @@ The build requires `CGO_ENABLED=1` because it links the SQLite driver used by `S
| `--domain` | string | `""` | Sets both dashboard and API domain (convenience shorthand) |
| `--dashboard-domain` | string | *(required)* | Dashboard domain (for redirect URIs) |
| `--api-domain` | string | *(required)* | API domain (for Dex issuer and callback URLs) |
| `--single-account-mode-domain` | string | `netbird.selfhosted` | Domain single account mode groups users under. Used only when the account has no domain of its own; passing one that conflicts with the account's existing domain is an error |
| `--dry-run` | bool | `false` | Preview changes without writing |
| `--force` | bool | `false` | Skip interactive confirmation prompt |
| `--skip-config` | bool | `false` | Skip config generation (DB-only migration) |
@@ -68,6 +69,7 @@ All flags can be overridden via environment variables. Env vars take precedence
| `NETBIRD_CONFIG_PATH` | `--config` |
| `NETBIRD_DATA_DIR` | `--datadir` |
| `NETBIRD_IDP_SEED_INFO` | `--idp-seed-info` |
| `NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN` | `--single-account-mode-domain` |
| `NETBIRD_DRY_RUN` | `--dry-run` (set to `"true"`) |
| `NETBIRD_FORCE` | `--force` (set to `"true"`) |
| `NETBIRD_SKIP_CONFIG` | `--skip-config` (set to `"true"`) |
+16 -5
View File
@@ -6,16 +6,18 @@ import (
"os"
"strconv"
"github.com/netbirdio/netbird/management/server/idp/migration"
"github.com/netbirdio/netbird/util"
)
type migrationConfig struct {
// Data
dashboardURL string
apiURL string
configPath string
dataDir string
idpSeedInfo string
dashboardURL string
apiURL string
configPath string
dataDir string
idpSeedInfo string
singleAccountDomain string
// Options
dryRun bool
@@ -51,6 +53,7 @@ func configFromArgs(args []string) (*migrationConfig, error) {
fs.StringVar(&cfg.configPath, "config", "", "path to management.json (required)")
fs.StringVar(&cfg.dataDir, "datadir", "", "override data directory from config")
fs.StringVar(&cfg.idpSeedInfo, "idp-seed-info", "", "base64-encoded connector JSON (overrides auto-detection)")
fs.StringVar(&cfg.singleAccountDomain, "single-account-mode-domain", "", "domain single account mode groups users under, used only when the account has no domain of its own (default "+migration.DefaultSingleAccountDomain+")")
fs.BoolVar(&cfg.dryRun, "dry-run", false, "preview changes without writing")
fs.BoolVar(&cfg.force, "force", false, "skip confirmation prompt")
fs.BoolVar(&cfg.skipConfig, "skip-config", false, "skip config generation (DB migration only)")
@@ -118,6 +121,10 @@ func applyOverrides(cfg *migrationConfig, domain string) {
cfg.idpSeedInfo = val
}
if val, ok := os.LookupEnv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN"); ok {
cfg.singleAccountDomain = val
}
// Enforce dry run if any value is provided
if sval, ok := os.LookupEnv("NETBIRD_DRY_RUN"); ok {
if val, err := strconv.ParseBool(sval); err == nil {
@@ -170,5 +177,9 @@ func validateConfig(cfg *migrationConfig) error {
return fmt.Errorf("--dashboard-domain is required")
}
if _, err := migration.NormalizeSingleAccountDomain(cfg.singleAccountDomain); err != nil {
return err
}
return nil
}
+26 -1
View File
@@ -71,6 +71,10 @@ func run(cfg *migrationConfig) error {
return err
}
if err := preflightAccounts(cfg, mgmtConfig); err != nil {
return err
}
if !cfg.skipPopulateUserInfo {
err := populateUserInfoFromIDP(cfg, mgmtConfig)
if err != nil {
@@ -102,6 +106,22 @@ func run(cfg *migrationConfig) error {
return generateConfig(cfg, connectorConfig)
}
func preflightAccounts(cfg *migrationConfig, mgmtConfig *nbconfig.Config) error {
ctx := context.Background()
migStore, migEventStore, cleanup, err := openStores(ctx, mgmtConfig, cfg.dataDir)
if err != nil {
return err
}
defer cleanup()
srv := &migrationServer{store: migStore, eventStore: migEventStore}
if err := migration.RequireSingleAccount(srv); err != nil {
return err
}
return migration.CheckSingleAccountDomain(srv, cfg.singleAccountDomain)
}
// validateSchema opens the store and checks that all required tables and columns
// exist. If anything is missing, it returns a descriptive error telling the user
// to upgrade their management server.
@@ -224,6 +244,8 @@ func migrateDB(cfg *migrationConfig, mgmtConfig *nbconfig.Config, connectorConfi
}
defer cleanup()
srv := &migrationServer{store: migStore, eventStore: migEventStore}
pending, err := previewUsers(ctx, migStore)
if err != nil {
return err
@@ -243,11 +265,14 @@ func migrateDB(cfg *migrationConfig, mgmtConfig *nbconfig.Config, connectorConfi
}
}
srv := &migrationServer{store: migStore, eventStore: migEventStore}
if err := migration.MigrateUsersToStaticConnectors(srv, connectorConfig); err != nil {
return fmt.Errorf("migrate users: %w", err)
}
if err := migration.EnsureSingleAccountDomain(srv, cfg.singleAccountDomain); err != nil {
return fmt.Errorf("prepare single account mode: %w", err)
}
if !cfg.dryRun {
log.Info("DB migration completed successfully")
}
+74
View File
@@ -485,3 +485,77 @@ func TestGenerateConfig(t *testing.T) {
assert.True(t, os.IsNotExist(err))
})
}
func TestValidateConfigRejectsUnusableSingleAccountDomain(t *testing.T) {
base := func() migrationConfig {
return migrationConfig{
configPath: "/tmp/management.json",
dataDir: "/tmp/datadir",
idpSeedInfo: "seed",
apiURL: "https://api.example.com",
dashboardURL: "https://app.example.com",
singleAccountDomain: migration.DefaultSingleAccountDomain,
}
}
t.Run("usable domain is accepted", func(t *testing.T) {
cfg := base()
require.NoError(t, validateConfig(&cfg))
})
t.Run("empty falls back to the default", func(t *testing.T) {
cfg := base()
cfg.singleAccountDomain = ""
require.NoError(t, validateConfig(&cfg))
})
// Rejected up front so the migration cannot fail after it has rewritten user IDs.
t.Run("single label domain is rejected", func(t *testing.T) {
cfg := base()
cfg.singleAccountDomain = "corp"
err := validateConfig(&cfg)
require.Error(t, err)
assert.ErrorIs(t, err, migration.ErrUnusableDomain)
})
}
func TestApplyOverrides_SingleAccountDomainFromEnv(t *testing.T) {
t.Run("env var overrides the flag", func(t *testing.T) {
t.Setenv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN", "corp.example.com")
cfg, err := configFromArgs([]string{
"--config", "/tmp/management.json",
"--datadir", "/tmp/datadir",
"--idp-seed-info", "seed",
"--domain", "example.com",
"--single-account-mode-domain", "flag.example.com",
})
require.NoError(t, err)
assert.Equal(t, "corp.example.com", cfg.singleAccountDomain)
})
t.Run("unset leaves the flag value", func(t *testing.T) {
cfg, err := configFromArgs([]string{
"--config", "/tmp/management.json",
"--datadir", "/tmp/datadir",
"--idp-seed-info", "seed",
"--domain", "example.com",
"--single-account-mode-domain", "flag.example.com",
})
require.NoError(t, err)
assert.Equal(t, "flag.example.com", cfg.singleAccountDomain)
})
t.Run("unusable env value is rejected", func(t *testing.T) {
t.Setenv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN", "corp")
_, err := configFromArgs([]string{
"--config", "/tmp/management.json",
"--datadir", "/tmp/datadir",
"--idp-seed-info", "seed",
"--domain", "example.com",
})
require.Error(t, err)
assert.ErrorIs(t, err, migration.ErrUnusableDomain)
})
}