Compare commits

...

21 Commits

Author SHA1 Message Date
jnfrati
0a5efb8d19 various fixes for docs and behavior 2026-03-17 18:10:54 +01:00
jnfrati
86b148dcd8 removed unused Printf 2026-03-17 16:13:19 +01:00
jnfrati
e4e6294ea1 applied lint fixes, updated documentation and removed unused code 2026-03-17 15:13:19 +01:00
jnfrati
527bdcfa1d implemented check schema functionality to ensure migration can go through 2026-03-17 14:17:45 +01:00
jnfrati
7237c9ac1b updated migration guide 2026-03-17 14:17:10 +01:00
jnfrati
30db103331 implemented populate user info functionality 2026-03-17 11:21:15 +01:00
calderbit
287748932b removed automatic user name and email updates on GetUserFromUserAuth 2026-03-16 16:59:42 +01:00
Ashley Mensah
a4945fb644 refactor idp/dev/config.go mapConnectorToDex() function 2026-03-13 18:58:12 +01:00
Ashley Mensah
f60d86862c fix client id/client secret migration, add instructions for idp config 2026-03-13 18:42:10 +01:00
Ashley Mensah
c4836c03e3 add idp migration binary to goreleaser, update docs accordingly 2026-03-13 14:49:22 +01:00
Ashley Mensah
0d8ffd177c update development guide doc 2026-03-13 14:41:30 +01:00
Ashley Mensah
90d1a7bc0b move idp migration sql store tests to separate file 2026-03-13 14:32:38 +01:00
Ashley Mensah
b716049e24 Merge branch 'main' into combined-migration-2 2026-03-13 14:24:44 +01:00
Ashley Mensah
0188034d43 refactor tools/idp-migrate/main.go (run function) to lower cognitive complexity, fix code smells 2026-03-13 14:18:40 +01:00
Ashley Mensah
dbe83c57c8 move idp migration related code to separate sql store file 2026-03-13 14:13:41 +01:00
Ashley Mensah
1775067a15 added migration instructions 2026-03-12 19:56:09 +01:00
Ashley Mensah
7ff7690eb4 added developer doc for migration 2026-03-12 17:28:49 +01:00
Ashley Mensah
51ddd69a1b Decouple migration interfaces from core store and add standalone migration tool
Move ListUsers/UpdateUserID out of store.Store and activity.Store into migration-specific interfaces (MigrationStore, MigrationEventStore) so migration code can be cleanly removed later. Add tools/idp-migrate CLI that migrates user IDs and generates EmbeddedIdP management.json config
2026-03-12 17:21:28 +01:00
calderbit
a1d8c275ca reduce cognitive load on modules IdpManager function 2026-02-18 01:13:29 -03:00
calderbit
9769397347 implemented support for activity store 2026-02-18 01:13:29 -03:00
calderbit
7a10fa61c7 management support for idp seed info 2026-02-18 01:13:16 -03:00
15 changed files with 3751 additions and 3 deletions

View File

@@ -154,6 +154,26 @@ builds:
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
mod_timestamp: "{{ .CommitTimestamp }}"
- id: netbird-idp-migrate
dir: tools/idp-migrate
env:
- CGO_ENABLED=1
- >-
{{- if eq .Runtime.Goos "linux" }}
{{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }}
{{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }}
{{- end }}
binary: netbird-idp-migrate
goos:
- linux
goarch:
- amd64
- arm64
- arm
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
universal_binaries:
- id: netbird
@@ -166,6 +186,10 @@ archives:
- netbird-wasm
name_template: "{{ .ProjectName }}_{{ .Version }}"
format: binary
- id: netbird-idp-migrate
builds:
- netbird-idp-migrate
name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
nfpms:
- maintainer: Netbird <dev@netbird.io>

View File

@@ -170,20 +170,66 @@ type Connector struct {
}
// ToStorageConnector converts a Connector to storage.Connector type.
// It maps custom connector types (e.g., "zitadel", "entra") to Dex-native types
// and augments the config with OIDC defaults when needed.
func (c *Connector) ToStorageConnector() (storage.Connector, error) {
data, err := json.Marshal(c.Config)
dexType, augmentedConfig := mapConnectorToDex(c.Type, c.Config)
data, err := json.Marshal(augmentedConfig)
if err != nil {
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
}
return storage.Connector{
ID: c.ID,
Type: c.Type,
Type: dexType,
Name: c.Name,
Config: data,
}, nil
}
// mapConnectorToDex maps custom connector types to Dex-native types and applies
// OIDC defaults. This ensures static connectors from config files or env vars
// are stored with types that Dex can open.
func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) {
switch connType {
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
return "oidc", applyOIDCDefaults(connType, config)
default:
return connType, config
}
}
// applyOIDCDefaults clones the config map, sets common OIDC defaults,
// and applies provider-specific overrides.
func applyOIDCDefaults(connType string, config map[string]interface{}) map[string]interface{} {
augmented := make(map[string]interface{}, len(config)+4)
for k, v := range config {
augmented[k] = v
}
setDefault(augmented, "scopes", []string{"openid", "profile", "email"})
setDefault(augmented, "insecureEnableGroups", true)
setDefault(augmented, "insecureSkipEmailVerified", true)
switch connType {
case "zitadel":
setDefault(augmented, "getUserInfo", true)
case "entra":
setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"})
case "okta", "pocketid":
augmented["scopes"] = []string{"openid", "profile", "email", "groups"}
}
return augmented
}
// setDefault sets a key in the map only if it doesn't already exist.
func setDefault(m map[string]interface{}, key string, value interface{}) {
if _, ok := m[key]; !ok {
m[key] = value
}
}
// StorageConfig is a configuration that can create a storage.
type StorageConfig interface {
Open(logger *slog.Logger) (storage.Storage, error)

View File

@@ -2,11 +2,14 @@ package dex
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/dexidp/dex/storage"
sqllib "github.com/dexidp/dex/storage/sql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -197,6 +200,295 @@ enablePasswordDB: true
t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID)
}
// openTestStorage creates a SQLite storage in the given directory for testing.
func openTestStorage(t *testing.T, tmpDir string) storage.Storage {
t.Helper()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
stor, err := (&sqllib.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger)
require.NoError(t, err)
return stor
}
func TestStaticConnectors_CreatedFromYAML(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dex-static-conn-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
yamlContent := `
issuer: http://localhost:5556/dex
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5556
enablePasswordDB: true
connectors:
- type: oidc
id: my-oidc
name: My OIDC Provider
config:
issuer: https://accounts.example.com
clientID: test-client-id
clientSecret: test-client-secret
redirectURI: http://localhost:5556/dex/callback
`
configPath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
require.NoError(t, err)
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
// Open storage and run initializeStorage directly (avoids Dex server
// trying to dial the OIDC issuer)
stor := openTestStorage(t, tmpDir)
defer stor.Close()
err = initializeStorage(ctx, stor, yamlConfig)
require.NoError(t, err)
// Verify connector was created in storage
conn, err := stor.GetConnector(ctx, "my-oidc")
require.NoError(t, err)
assert.Equal(t, "my-oidc", conn.ID)
assert.Equal(t, "My OIDC Provider", conn.Name)
assert.Equal(t, "oidc", conn.Type)
// Verify config fields were serialized correctly
var configMap map[string]interface{}
err = json.Unmarshal(conn.Config, &configMap)
require.NoError(t, err)
assert.Equal(t, "https://accounts.example.com", configMap["issuer"])
assert.Equal(t, "test-client-id", configMap["clientID"])
}
func TestStaticConnectors_UpdatedOnRestart(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dex-static-conn-update-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
dbFile := filepath.Join(tmpDir, "dex.db")
// First: load config with initial connector
yamlContent1 := `
issuer: http://localhost:5556/dex
storage:
type: sqlite3
config:
file: ` + dbFile + `
web:
http: 127.0.0.1:5556
enablePasswordDB: true
connectors:
- type: oidc
id: my-oidc
name: Original Name
config:
issuer: https://accounts.example.com
clientID: original-client-id
clientSecret: original-secret
`
configPath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(configPath, []byte(yamlContent1), 0644)
require.NoError(t, err)
yamlConfig1, err := LoadConfig(configPath)
require.NoError(t, err)
stor := openTestStorage(t, tmpDir)
err = initializeStorage(ctx, stor, yamlConfig1)
require.NoError(t, err)
// Verify initial state
conn, err := stor.GetConnector(ctx, "my-oidc")
require.NoError(t, err)
assert.Equal(t, "Original Name", conn.Name)
var configMap1 map[string]interface{}
err = json.Unmarshal(conn.Config, &configMap1)
require.NoError(t, err)
assert.Equal(t, "original-client-id", configMap1["clientID"])
// Close storage to simulate restart
stor.Close()
// Second: load updated config against the same DB
yamlContent2 := `
issuer: http://localhost:5556/dex
storage:
type: sqlite3
config:
file: ` + dbFile + `
web:
http: 127.0.0.1:5556
enablePasswordDB: true
connectors:
- type: oidc
id: my-oidc
name: Updated Name
config:
issuer: https://accounts.example.com
clientID: updated-client-id
clientSecret: updated-secret
`
err = os.WriteFile(configPath, []byte(yamlContent2), 0644)
require.NoError(t, err)
yamlConfig2, err := LoadConfig(configPath)
require.NoError(t, err)
stor2 := openTestStorage(t, tmpDir)
defer stor2.Close()
err = initializeStorage(ctx, stor2, yamlConfig2)
require.NoError(t, err)
// Verify connector was updated, not duplicated
allConnectors, err := stor2.ListConnectors(ctx)
require.NoError(t, err)
nonLocalCount := 0
for _, c := range allConnectors {
if c.ID != "local" {
nonLocalCount++
}
}
assert.Equal(t, 1, nonLocalCount, "connector should be updated, not duplicated")
conn2, err := stor2.GetConnector(ctx, "my-oidc")
require.NoError(t, err)
assert.Equal(t, "Updated Name", conn2.Name)
var configMap2 map[string]interface{}
err = json.Unmarshal(conn2.Config, &configMap2)
require.NoError(t, err)
assert.Equal(t, "updated-client-id", configMap2["clientID"])
}
func TestStaticConnectors_MultipleConnectors(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dex-static-conn-multi-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
yamlContent := `
issuer: http://localhost:5556/dex
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5556
enablePasswordDB: true
connectors:
- type: oidc
id: my-oidc
name: My OIDC Provider
config:
issuer: https://accounts.example.com
clientID: oidc-client-id
clientSecret: oidc-secret
- type: google
id: my-google
name: Google Login
config:
clientID: google-client-id
clientSecret: google-secret
`
configPath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
require.NoError(t, err)
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
stor := openTestStorage(t, tmpDir)
defer stor.Close()
err = initializeStorage(ctx, stor, yamlConfig)
require.NoError(t, err)
allConnectors, err := stor.ListConnectors(ctx)
require.NoError(t, err)
// Build a map for easier assertion
connByID := make(map[string]storage.Connector)
for _, c := range allConnectors {
connByID[c.ID] = c
}
// Verify both static connectors exist
oidcConn, ok := connByID["my-oidc"]
require.True(t, ok, "oidc connector should exist")
assert.Equal(t, "My OIDC Provider", oidcConn.Name)
assert.Equal(t, "oidc", oidcConn.Type)
var oidcConfig map[string]interface{}
err = json.Unmarshal(oidcConn.Config, &oidcConfig)
require.NoError(t, err)
assert.Equal(t, "oidc-client-id", oidcConfig["clientID"])
googleConn, ok := connByID["my-google"]
require.True(t, ok, "google connector should exist")
assert.Equal(t, "Google Login", googleConn.Name)
assert.Equal(t, "google", googleConn.Type)
var googleConfig map[string]interface{}
err = json.Unmarshal(googleConn.Config, &googleConfig)
require.NoError(t, err)
assert.Equal(t, "google-client-id", googleConfig["clientID"])
// Verify local connector still exists alongside them (enablePasswordDB: true)
localConn, ok := connByID["local"]
require.True(t, ok, "local connector should exist")
assert.Equal(t, "local", localConn.Type)
}
func TestStaticConnectors_EmptyList(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dex-static-conn-empty-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
yamlContent := `
issuer: http://localhost:5556/dex
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5556
enablePasswordDB: true
`
configPath := filepath.Join(tmpDir, "config.yaml")
err = os.WriteFile(configPath, []byte(yamlContent), 0644)
require.NoError(t, err)
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
provider, err := NewProviderFromYAML(ctx, yamlConfig)
require.NoError(t, err)
defer func() { _ = provider.Stop(ctx) }()
// No static connectors configured, so ListConnectors should return empty
connectors, err := provider.ListConnectors(ctx)
require.NoError(t, err)
assert.Empty(t, connectors)
// But local connector should still exist
localConn, err := provider.Storage().GetConnector(ctx, "local")
require.NoError(t, err)
assert.Equal(t, "local", localConn.ID)
}
func TestNewProvider_ContinueOnConnectorFailure(t *testing.T) {
ctx := context.Background()

View File

@@ -116,9 +116,11 @@ func (s *BaseServer) IdpManager() idp.Manager {
return Create(s, func() idp.Manager {
var idpManager idp.Manager
var err error
// Use embedded IdP service if embedded Dex is configured and enabled.
// Legacy IdpManager won't be used anymore even if configured.
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled
if embeddedEnabled {
idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics())
if err != nil {
log.Fatalf("failed to create embedded IDP service: %v", err)

View File

@@ -0,0 +1,61 @@
package store
// This file contains migration-only methods on Store.
// They satisfy the migration.MigrationEventStore interface via duck typing.
// Delete this file when migration tooling is no longer needed.
import (
"context"
"fmt"
"gorm.io/gorm"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp/migration"
)
// CheckSchema verifies that all tables and columns required by the migration exist in the event database.
func (store *Store) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError {
migrator := store.db.Migrator()
var errs []migration.SchemaError
for _, check := range checks {
if !migrator.HasTable(check.Table) {
errs = append(errs, migration.SchemaError{Table: check.Table})
continue
}
for _, col := range check.Columns {
if !migrator.HasColumn(check.Table, col) {
errs = append(errs, migration.SchemaError{Table: check.Table, Column: col})
}
}
}
return errs
}
// UpdateUserID updates all references to oldUserID in events and deleted_users tables.
func (store *Store) UpdateUserID(ctx context.Context, oldUserID, newUserID string) error {
return store.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&activity.Event{}).
Where("initiator_id = ?", oldUserID).
Update("initiator_id", newUserID).Error; err != nil {
return fmt.Errorf("update events.initiator_id: %w", err)
}
if err := tx.Model(&activity.Event{}).
Where("target_id = ?", oldUserID).
Update("target_id", newUserID).Error; err != nil {
return fmt.Errorf("update events.target_id: %w", err)
}
// Raw exec: GORM can't update a PK via Model().Update()
if err := tx.Exec(
"UPDATE deleted_users SET id = ? WHERE id = ?", newUserID, oldUserID,
).Error; err != nil {
return fmt.Errorf("update deleted_users.id: %w", err)
}
return nil
})
}

View File

@@ -0,0 +1,161 @@
package store
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/util/crypt"
)
func TestUpdateUserID(t *testing.T) {
ctx := context.Background()
newStore := func(t *testing.T) *Store {
t.Helper()
key, _ := crypt.GenerateKey()
s, err := NewSqlStore(ctx, t.TempDir(), key)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close(ctx) }) //nolint
return s
}
t.Run("updates initiator_id in events", func(t *testing.T) {
store := newStore(t)
accountID := "account_1"
_, err := store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "old-user",
TargetID: "some-peer",
AccountID: accountID,
})
assert.NoError(t, err)
err = store.UpdateUserID(ctx, "old-user", "new-user")
assert.NoError(t, err)
result, err := store.Get(ctx, accountID, 0, 10, false)
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "new-user", result[0].InitiatorID)
})
t.Run("updates target_id in events", func(t *testing.T) {
store := newStore(t)
accountID := "account_1"
_, err := store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "some-admin",
TargetID: "old-user",
AccountID: accountID,
})
assert.NoError(t, err)
err = store.UpdateUserID(ctx, "old-user", "new-user")
assert.NoError(t, err)
result, err := store.Get(ctx, accountID, 0, 10, false)
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "new-user", result[0].TargetID)
})
t.Run("updates deleted_users id", func(t *testing.T) {
store := newStore(t)
accountID := "account_1"
// Save an event with email/name meta to create a deleted_users row for "old-user"
_, err := store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "admin",
TargetID: "old-user",
AccountID: accountID,
Meta: map[string]any{
"email": "user@example.com",
"name": "Test User",
},
})
assert.NoError(t, err)
err = store.UpdateUserID(ctx, "old-user", "new-user")
assert.NoError(t, err)
// Save another event referencing new-user with email/name meta.
// This should upsert (not conflict) because the PK was already migrated.
_, err = store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "admin",
TargetID: "new-user",
AccountID: accountID,
Meta: map[string]any{
"email": "user@example.com",
"name": "Test User",
},
})
assert.NoError(t, err)
// The deleted user info should be retrievable via Get (joined on target_id)
result, err := store.Get(ctx, accountID, 0, 10, false)
assert.NoError(t, err)
assert.Len(t, result, 2)
for _, ev := range result {
assert.Equal(t, "new-user", ev.TargetID)
}
})
t.Run("no-op when old user ID does not exist", func(t *testing.T) {
store := newStore(t)
err := store.UpdateUserID(ctx, "nonexistent-user", "new-user")
assert.NoError(t, err)
})
t.Run("only updates matching user leaves others unchanged", func(t *testing.T) {
store := newStore(t)
accountID := "account_1"
_, err := store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "user-a",
TargetID: "peer-1",
AccountID: accountID,
})
assert.NoError(t, err)
_, err = store.Save(ctx, &activity.Event{
Timestamp: time.Now().UTC(),
Activity: activity.PeerAddedByUser,
InitiatorID: "user-b",
TargetID: "peer-2",
AccountID: accountID,
})
assert.NoError(t, err)
err = store.UpdateUserID(ctx, "user-a", "user-a-new")
assert.NoError(t, err)
result, err := store.Get(ctx, accountID, 0, 10, false)
assert.NoError(t, err)
assert.Len(t, result, 2)
for _, ev := range result {
if ev.TargetID == "peer-1" {
assert.Equal(t, "user-a-new", ev.InitiatorID)
} else {
assert.Equal(t, "user-b", ev.InitiatorID)
}
}
})
}

View File

@@ -48,6 +48,8 @@ type EmbeddedIdPConfig struct {
// Existing local users are preserved and will be able to login again if re-enabled.
// Cannot be enabled if no external identity provider connectors are configured.
LocalAuthDisabled bool
// StaticConnectors are additional connectors to seed during initialization
StaticConnectors []dex.Connector
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
@@ -157,6 +159,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
RedirectURIs: cliRedirectURIs,
},
},
StaticConnectors: c.StaticConnectors,
}
// Add owner user if provided

View File

@@ -0,0 +1,235 @@
// Package migration provides utility functions for migrating from the external IdP solution in pre v0.62.0
// to the new embedded IdP manager (Dex based), which is the default in v0.62.0 and later.
// It includes functions to seed connectors and migrate existing users to use these connectors.
package migration
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/types"
)
// Server is the dependency interface that migration functions use to access
// the main data store and the activity event store.
type Server interface {
Store() Store
EventStore() EventStore // may return nil
}
const idpSeedInfoKey = "IDP_SEED_INFO"
const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
func isDryRun() bool {
return os.Getenv(dryRunEnvKey) == "true"
}
var ErrNoSeedInfo = errors.New("no seed info found in environment")
// SeedConnectorFromEnv reads the IDP_SEED_INFO env var, base64-decodes it,
// and JSON-unmarshals it into a dex.Connector. Returns nil if not set.
func SeedConnectorFromEnv() (*dex.Connector, error) {
val, ok := os.LookupEnv(idpSeedInfoKey)
if !ok || val == "" {
return nil, ErrNoSeedInfo
}
decoded, err := base64.StdEncoding.DecodeString(val)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
var conn dex.Connector
if err := json.Unmarshal(decoded, &conn); err != nil {
return nil, fmt.Errorf("json unmarshal: %w", err)
}
return &conn, nil
}
// MigrateUsersToStaticConnectors re-keys every user ID in the main store (and
// the activity store, if present) so that it encodes the given connector ID,
// skipping users that have already been migrated. Set NB_IDP_MIGRATION_DRY_RUN=true
// to log what would happen without writing any changes.
func MigrateUsersToStaticConnectors(s Server, conn *dex.Connector) error {
ctx := context.Background()
if isDryRun() {
log.Info("[DRY RUN] migration dry-run mode enabled, no changes will be written")
}
users, err := s.Store().ListUsers(ctx)
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
}
// Reconciliation pass: fix activity store for users already migrated in main DB
// but whose activity references may still use old IDs (from a previous partial failure).
if s.EventStore() != nil && !isDryRun() {
if err := reconcileActivityStore(ctx, s.EventStore(), users); err != nil {
return err
}
}
var migratedCount, skippedCount int
for _, user := range users {
_, _, decErr := dex.DecodeDexUserID(user.Id)
if decErr == nil {
skippedCount++
continue
}
newUserID := dex.EncodeDexUserID(user.Id, conn.ID)
if isDryRun() {
log.Infof("[DRY RUN] would migrate user %s -> %s (account: %s)", user.Id, newUserID, user.AccountID)
migratedCount++
continue
}
if err := migrateUser(ctx, s, user.Id, user.AccountID, newUserID); err != nil {
return err
}
migratedCount++
}
if isDryRun() {
log.Infof("[DRY RUN] migration summary: %d users would be migrated, %d already migrated", migratedCount, skippedCount)
} else {
log.Infof("migration complete: %d users migrated, %d already migrated", migratedCount, skippedCount)
}
return nil
}
// reconcileActivityStore updates activity store references for users already migrated
// in the main DB whose activity entries may still use old IDs from a previous partial failure.
func reconcileActivityStore(ctx context.Context, eventStore EventStore, users []*types.User) error {
for _, user := range users {
originalID, _, err := dex.DecodeDexUserID(user.Id)
if err != nil {
// skip users that aren't migrated, they will be handled in the main migration loop
continue
}
if err := eventStore.UpdateUserID(ctx, originalID, user.Id); err != nil {
return fmt.Errorf("reconcile activity store for user %s: %w", user.Id, err)
}
}
return nil
}
// migrateUser updates a single user's ID in both the main store and the activity store.
func migrateUser(ctx context.Context, s Server, oldID, accountID, newID string) error {
if err := s.Store().UpdateUserID(ctx, accountID, oldID, newID); err != nil {
return fmt.Errorf("failed to update user ID for user %s: %w", oldID, err)
}
if s.EventStore() == nil {
return nil
}
if err := s.EventStore().UpdateUserID(ctx, oldID, newID); err != nil {
return fmt.Errorf("failed to update activity store user ID for user %s: %w", oldID, err)
}
return nil
}
// PopulateUserInfo fetches user email and name from the external IDP and updates
// the store for users that are missing this information.
func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error {
ctx := context.Background()
users, err := s.Store().ListUsers(ctx)
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
}
// Build a map of IDP user ID -> UserData from the external IDP
allAccounts, err := idpManager.GetAllAccounts(ctx)
if err != nil {
return fmt.Errorf("failed to fetch accounts from IDP: %w", err)
}
idpUsers := make(map[string]*idp.UserData)
for _, accountUsers := range allAccounts {
for _, userData := range accountUsers {
idpUsers[userData.ID] = userData
}
}
log.Infof("fetched %d users from IDP", len(idpUsers))
var updatedCount, skippedCount, notFoundCount int
for _, user := range users {
if user.IsServiceUser {
skippedCount++
continue
}
if user.Email != "" && user.Name != "" {
skippedCount++
continue
}
// The user ID in the store may be the original IDP ID or a Dex-encoded ID.
// Try to decode the Dex format first to get the original IDP ID.
lookupID := user.Id
if originalID, _, decErr := dex.DecodeDexUserID(user.Id); decErr == nil {
lookupID = originalID
}
idpUser, found := idpUsers[lookupID]
if !found {
notFoundCount++
log.Debugf("user %s (lookup: %s) not found in IDP, skipping", user.Id, lookupID)
continue
}
email := user.Email
name := user.Name
if email == "" && idpUser.Email != "" {
email = idpUser.Email
}
if name == "" && idpUser.Name != "" {
name = idpUser.Name
}
if email == user.Email && name == user.Name {
skippedCount++
continue
}
if dryRun {
log.Infof("[DRY RUN] would update user %s: email=%q, name=%q", user.Id, email, name)
updatedCount++
continue
}
if err := s.Store().UpdateUserInfo(ctx, user.Id, email, name); err != nil {
return fmt.Errorf("failed to update user info for %s: %w", user.Id, err)
}
log.Infof("updated user %s: email=%q, name=%q", user.Id, email, name)
updatedCount++
}
if dryRun {
log.Infof("[DRY RUN] user info summary: %d would be updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount)
} else {
log.Infof("user info population complete: %d updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount)
}
return nil
}

View File

@@ -0,0 +1,828 @@
package migration
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/types"
)
// testStore is a hand-written mock for MigrationStore.
type testStore struct {
listUsersFunc func(ctx context.Context) ([]*types.User, error)
updateUserIDFunc func(ctx context.Context, accountID, oldUserID, newUserID string) error
updateUserInfoFunc func(ctx context.Context, userID, email, name string) error
checkSchemaFunc func(checks []SchemaCheck) []SchemaError
updateCalls []updateUserIDCall
updateInfoCalls []updateUserInfoCall
}
type updateUserIDCall struct {
AccountID string
OldUserID string
NewUserID string
}
type updateUserInfoCall struct {
UserID string
Email string
Name string
}
func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) {
return s.listUsersFunc(ctx)
}
func (s *testStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error {
s.updateCalls = append(s.updateCalls, updateUserIDCall{accountID, oldUserID, newUserID})
return s.updateUserIDFunc(ctx, accountID, oldUserID, newUserID)
}
func (s *testStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error {
s.updateInfoCalls = append(s.updateInfoCalls, updateUserInfoCall{userID, email, name})
if s.updateUserInfoFunc != nil {
return s.updateUserInfoFunc(ctx, userID, email, name)
}
return nil
}
func (s *testStore) CheckSchema(checks []SchemaCheck) []SchemaError {
if s.checkSchemaFunc != nil {
return s.checkSchemaFunc(checks)
}
return nil
}
type testServer struct {
store Store
eventStore EventStore
}
func (s *testServer) Store() Store { return s.store }
func (s *testServer) EventStore() EventStore { return s.eventStore }
func TestSeedConnectorFromEnv(t *testing.T) {
t.Run("returns ErrNoSeedInfo when env var is not set", func(t *testing.T) {
os.Unsetenv(idpSeedInfoKey)
conn, err := SeedConnectorFromEnv()
assert.ErrorIs(t, err, ErrNoSeedInfo)
assert.Nil(t, conn)
})
t.Run("returns ErrNoSeedInfo when env var is empty", func(t *testing.T) {
t.Setenv(idpSeedInfoKey, "")
conn, err := SeedConnectorFromEnv()
assert.ErrorIs(t, err, ErrNoSeedInfo)
assert.Nil(t, conn)
})
t.Run("returns error on invalid base64", func(t *testing.T) {
t.Setenv(idpSeedInfoKey, "not-valid-base64!!!")
conn, err := SeedConnectorFromEnv()
assert.NotErrorIs(t, err, ErrNoSeedInfo)
assert.Error(t, err)
assert.Nil(t, conn)
assert.Contains(t, err.Error(), "base64 decode")
})
t.Run("returns error on invalid JSON", func(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte("not json"))
t.Setenv(idpSeedInfoKey, encoded)
conn, err := SeedConnectorFromEnv()
assert.NotErrorIs(t, err, ErrNoSeedInfo)
assert.Error(t, err)
assert.Nil(t, conn)
assert.Contains(t, err.Error(), "json unmarshal")
})
t.Run("successfully decodes valid connector", func(t *testing.T) {
expected := dex.Connector{
Type: "oidc",
Name: "Test Provider",
ID: "test-provider",
Config: map[string]any{
"issuer": "https://example.com",
"clientID": "my-client-id",
"clientSecret": "my-secret",
},
}
data, err := json.Marshal(expected)
require.NoError(t, err)
encoded := base64.StdEncoding.EncodeToString(data)
t.Setenv(idpSeedInfoKey, encoded)
conn, err := SeedConnectorFromEnv()
assert.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, expected.Type, conn.Type)
assert.Equal(t, expected.Name, conn.Name)
assert.Equal(t, expected.ID, conn.ID)
assert.Equal(t, expected.Config["issuer"], conn.Config["issuer"])
})
}
func TestMigrateUsersToStaticConnectors(t *testing.T) {
connector := &dex.Connector{
Type: "oidc",
Name: "Test Provider",
ID: "test-connector",
}
t.Run("succeeds with no users", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil },
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil },
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
})
t.Run("returns error when ListUsers fails", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return nil, fmt.Errorf("db error")
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil },
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to list users")
})
t.Run("migrates single user with correct encoded ID", func(t *testing.T) {
user := &types.User{Id: "user-1", AccountID: "account-1"}
expectedNewID := dex.EncodeDexUserID("user-1", "test-connector")
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{user}, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
require.Len(t, ms.updateCalls, 1)
assert.Equal(t, "account-1", ms.updateCalls[0].AccountID)
assert.Equal(t, "user-1", ms.updateCalls[0].OldUserID)
assert.Equal(t, expectedNewID, ms.updateCalls[0].NewUserID)
})
t.Run("migrates multiple users", func(t *testing.T) {
users := []*types.User{
{Id: "user-1", AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
{Id: "user-3", AccountID: "account-2"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
assert.Len(t, ms.updateCalls, 3)
})
t.Run("returns error when UpdateUserID fails", func(t *testing.T) {
users := []*types.User{
{Id: "user-1", AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
}
callCount := 0
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
callCount++
if callCount == 2 {
return fmt.Errorf("update failed")
}
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to update user ID for user user-2")
})
t.Run("stops on first UpdateUserID error", func(t *testing.T) {
users := []*types.User{
{Id: "user-1", AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return fmt.Errorf("update failed")
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.Error(t, err)
assert.Len(t, ms.updateCalls, 1) // stopped after first error
})
t.Run("skips already migrated users", func(t *testing.T) {
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
users := []*types.User{
{Id: alreadyMigratedID, AccountID: "account-1"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
assert.Len(t, ms.updateCalls, 0)
})
t.Run("migrates only non-migrated users in mixed state", func(t *testing.T) {
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
users := []*types.User{
{Id: alreadyMigratedID, AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
{Id: "user-3", AccountID: "account-2"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
// Only user-2 and user-3 should be migrated
assert.Len(t, ms.updateCalls, 2)
assert.Equal(t, "user-2", ms.updateCalls[0].OldUserID)
assert.Equal(t, "user-3", ms.updateCalls[1].OldUserID)
})
t.Run("dry run does not call UpdateUserID", func(t *testing.T) {
t.Setenv(dryRunEnvKey, "true")
users := []*types.User{
{Id: "user-1", AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
t.Fatal("UpdateUserID should not be called in dry-run mode")
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
assert.Len(t, ms.updateCalls, 0)
})
t.Run("dry run skips already migrated users", func(t *testing.T) {
t.Setenv(dryRunEnvKey, "true")
alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector")
users := []*types.User{
{Id: alreadyMigratedID, AccountID: "account-1"},
{Id: "user-2", AccountID: "account-1"},
}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return users, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
t.Fatal("UpdateUserID should not be called in dry-run mode")
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
})
t.Run("dry run disabled by default", func(t *testing.T) {
user := &types.User{Id: "user-1", AccountID: "account-1"}
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{user}, nil
},
updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error {
return nil
},
}
srv := &testServer{store: ms}
err := MigrateUsersToStaticConnectors(srv, connector)
assert.NoError(t, err)
assert.Len(t, ms.updateCalls, 1) // proves it's not in dry-run
})
}
func TestPopulateUserInfo(t *testing.T) {
noopUpdateID := func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil }
t.Run("succeeds with no users", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil },
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
t.Run("returns error when ListUsers fails", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return nil, fmt.Errorf("db error")
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to list users")
})
t.Run("returns error when GetAllAccounts fails", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{{Id: "user-1", AccountID: "acc-1"}}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return nil, fmt.Errorf("idp error")
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch accounts from IDP")
})
t.Run("updates user with missing email and name", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {
{ID: "user-1", Email: "user1@example.com", Name: "User One"},
},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
require.Len(t, ms.updateInfoCalls, 1)
assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID)
assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email)
assert.Equal(t, "User One", ms.updateInfoCalls[0].Name)
})
t.Run("updates only missing email when name exists", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: "Existing Name"},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "user-1", Email: "user1@example.com", Name: "IDP Name"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
require.Len(t, ms.updateInfoCalls, 1)
assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email)
assert.Equal(t, "Existing Name", ms.updateInfoCalls[0].Name)
})
t.Run("updates only missing name when email exists", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "existing@example.com", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "user-1", Email: "idp@example.com", Name: "IDP Name"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
require.Len(t, ms.updateInfoCalls, 1)
assert.Equal(t, "existing@example.com", ms.updateInfoCalls[0].Email)
assert.Equal(t, "IDP Name", ms.updateInfoCalls[0].Name)
})
t.Run("skips users that already have both email and name", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "user1@example.com", Name: "User One"},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "user-1", Email: "different@example.com", Name: "Different Name"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
t.Run("skips service users", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "svc-1", AccountID: "acc-1", Email: "", Name: "", IsServiceUser: true},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "svc-1", Email: "svc@example.com", Name: "Service"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
t.Run("skips users not found in IDP", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "different-user", Email: "other@example.com", Name: "Other"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
t.Run("looks up dex-encoded user IDs by original ID", func(t *testing.T) {
dexEncodedID := dex.EncodeDexUserID("original-idp-id", "my-connector")
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: dexEncodedID, AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "original-idp-id", Email: "user@example.com", Name: "User"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
require.Len(t, ms.updateInfoCalls, 1)
assert.Equal(t, dexEncodedID, ms.updateInfoCalls[0].UserID)
assert.Equal(t, "user@example.com", ms.updateInfoCalls[0].Email)
assert.Equal(t, "User", ms.updateInfoCalls[0].Name)
})
t.Run("handles multiple users across multiple accounts", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
{Id: "user-2", AccountID: "acc-1", Email: "already@set.com", Name: "Already Set"},
{Id: "user-3", AccountID: "acc-2", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {
{ID: "user-1", Email: "u1@example.com", Name: "User 1"},
{ID: "user-2", Email: "u2@example.com", Name: "User 2"},
},
"acc-2": {
{ID: "user-3", Email: "u3@example.com", Name: "User 3"},
},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
require.Len(t, ms.updateInfoCalls, 2)
assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID)
assert.Equal(t, "u1@example.com", ms.updateInfoCalls[0].Email)
assert.Equal(t, "user-3", ms.updateInfoCalls[1].UserID)
assert.Equal(t, "u3@example.com", ms.updateInfoCalls[1].Email)
})
t.Run("returns error when UpdateUserInfo fails", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
return fmt.Errorf("db write error")
},
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "user-1", Email: "u1@example.com", Name: "User 1"}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to update user info for user-1")
})
t.Run("stops on first UpdateUserInfo error", func(t *testing.T) {
callCount := 0
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
{Id: "user-2", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
callCount++
return fmt.Errorf("db write error")
},
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {
{ID: "user-1", Email: "u1@example.com", Name: "U1"},
{ID: "user-2", Email: "u2@example.com", Name: "U2"},
},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.Error(t, err)
assert.Equal(t, 1, callCount)
})
t.Run("dry run does not call UpdateUserInfo", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
{Id: "user-2", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error {
t.Fatal("UpdateUserInfo should not be called in dry-run mode")
return nil
},
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {
{ID: "user-1", Email: "u1@example.com", Name: "U1"},
{ID: "user-2", Email: "u2@example.com", Name: "U2"},
},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, true)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
t.Run("skips user when IDP has empty email and name too", func(t *testing.T) {
ms := &testStore{
listUsersFunc: func(ctx context.Context) ([]*types.User, error) {
return []*types.User{
{Id: "user-1", AccountID: "acc-1", Email: "", Name: ""},
}, nil
},
updateUserIDFunc: noopUpdateID,
}
mockIDP := &idp.MockIDP{
GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) {
return map[string][]*idp.UserData{
"acc-1": {{ID: "user-1", Email: "", Name: ""}},
}, nil
},
}
srv := &testServer{store: ms}
err := PopulateUserInfo(srv, mockIDP, false)
assert.NoError(t, err)
assert.Empty(t, ms.updateInfoCalls)
})
}
func TestSchemaError_String(t *testing.T) {
t.Run("missing table", func(t *testing.T) {
e := SchemaError{Table: "jobs"}
assert.Equal(t, `table "jobs" is missing`, e.String())
})
t.Run("missing column", func(t *testing.T) {
e := SchemaError{Table: "users", Column: "email"}
assert.Equal(t, `column "email" on table "users" is missing`, e.String())
})
}
func TestRequiredSchema(t *testing.T) {
// Verify RequiredSchema covers all the tables touched by UpdateUserID and UpdateUserInfo.
expectedTables := []string{
"users",
"personal_access_tokens",
"peers",
"accounts",
"user_invites",
"proxy_access_tokens",
"jobs",
}
schemaTableNames := make([]string, len(RequiredSchema))
for i, s := range RequiredSchema {
schemaTableNames[i] = s.Table
}
for _, expected := range expectedTables {
assert.Contains(t, schemaTableNames, expected, "RequiredSchema should include table %q", expected)
}
}
func TestCheckSchema_MockStore(t *testing.T) {
t.Run("returns nil when all schema exists", func(t *testing.T) {
ms := &testStore{
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
return nil
},
}
errs := ms.CheckSchema(RequiredSchema)
assert.Empty(t, errs)
})
t.Run("returns errors for missing tables", func(t *testing.T) {
ms := &testStore{
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
return []SchemaError{
{Table: "jobs"},
{Table: "proxy_access_tokens"},
}
},
}
errs := ms.CheckSchema(RequiredSchema)
require.Len(t, errs, 2)
assert.Equal(t, "jobs", errs[0].Table)
assert.Equal(t, "", errs[0].Column)
assert.Equal(t, "proxy_access_tokens", errs[1].Table)
})
t.Run("returns errors for missing columns", func(t *testing.T) {
ms := &testStore{
checkSchemaFunc: func(checks []SchemaCheck) []SchemaError {
return []SchemaError{
{Table: "users", Column: "email"},
{Table: "users", Column: "name"},
}
},
}
errs := ms.CheckSchema(RequiredSchema)
require.Len(t, errs, 2)
assert.Equal(t, "users", errs[0].Table)
assert.Equal(t, "email", errs[0].Column)
})
}

View File

@@ -0,0 +1,82 @@
package migration
import (
"context"
"fmt"
"github.com/netbirdio/netbird/management/server/types"
)
// SchemaCheck represents a table and the columns required on it.
type SchemaCheck struct {
Table string
Columns []string
}
// RequiredSchema lists all tables and columns that the migration tool needs.
// If any are missing, the user must upgrade their management server first so
// that the automatic GORM migrations create them.
var RequiredSchema = []SchemaCheck{
{Table: "users", Columns: []string{"id", "email", "name", "account_id"}},
{Table: "personal_access_tokens", Columns: []string{"user_id", "created_by"}},
{Table: "peers", Columns: []string{"user_id"}},
{Table: "accounts", Columns: []string{"created_by"}},
{Table: "user_invites", Columns: []string{"created_by"}},
{Table: "proxy_access_tokens", Columns: []string{"created_by"}},
{Table: "jobs", Columns: []string{"triggered_by"}},
}
// SchemaError describes a single missing table or column.
type SchemaError struct {
Table string
Column string // empty when the whole table is missing
}
func (e SchemaError) String() string {
if e.Column == "" {
return fmt.Sprintf("table %q is missing", e.Table)
}
return fmt.Sprintf("column %q on table %q is missing", e.Column, e.Table)
}
// Store defines the data store operations required for IdP user migration.
// This interface is separate from the main store.Store interface because these methods
// are only used during one-time migration and should be removed once migration tooling
// is no longer needed.
//
// The SQL store implementations (SqlStore) already have these methods on their concrete
// types, so they satisfy this interface via Go's structural typing with zero code changes.
type Store interface {
// ListUsers returns all users across all accounts.
ListUsers(ctx context.Context) ([]*types.User, error)
// UpdateUserID atomically updates a user's ID and all foreign key references
// across the database (peers, groups, policies, PATs, etc.).
UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error
// UpdateUserInfo updates a user's email and name in the store.
UpdateUserInfo(ctx context.Context, userID, email, name string) error
// CheckSchema verifies that all tables and columns required by the migration
// exist in the database. Returns a list of problems; an empty slice means OK.
CheckSchema(checks []SchemaCheck) []SchemaError
}
// RequiredEventSchema lists all tables and columns that the migration tool needs
// in the activity/event store.
var RequiredEventSchema = []SchemaCheck{
{Table: "events", Columns: []string{"initiator_id", "target_id"}},
{Table: "deleted_users", Columns: []string{"id"}},
}
// EventStore defines the activity event store operations required for migration.
// Like Store, this is a temporary interface for migration tooling only.
type EventStore interface {
// CheckSchema verifies that all tables and columns required by the migration
// exist in the event database. Returns a list of problems; an empty slice means OK.
CheckSchema(checks []SchemaCheck) []SchemaError
// UpdateUserID updates all event references (initiator_id, target_id) and
// deleted_users records to use the new user ID format.
UpdateUserID(ctx context.Context, oldUserID, newUserID string) error
}

View File

@@ -0,0 +1,128 @@
package store
// This file contains migration-only methods on SqlStore.
// They satisfy the migration.Store interface via duck typing.
// Delete this file when migration tooling is no longer needed.
import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/netbirdio/netbird/management/server/idp/migration"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
func (s *SqlStore) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError {
migrator := s.db.Migrator()
var errs []migration.SchemaError
for _, check := range checks {
if !migrator.HasTable(check.Table) {
errs = append(errs, migration.SchemaError{Table: check.Table})
continue
}
for _, col := range check.Columns {
if !migrator.HasColumn(check.Table, col) {
errs = append(errs, migration.SchemaError{Table: check.Table, Column: col})
}
}
}
return errs
}
func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) {
tx := s.db
var users []*types.User
result := tx.Find(&users)
if result.Error != nil {
log.WithContext(ctx).Errorf("error when listing users from the store: %s", result.Error)
return nil, status.Errorf(status.Internal, "issue listing users from store")
}
for _, user := range users {
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
}
return users, nil
}
// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction.
// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0).
func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error {
switch s.storeEngine {
case types.PostgresStoreEngine:
return tx.Exec("SET CONSTRAINTS ALL DEFERRED").Error
case types.SqliteStoreEngine:
return tx.Exec("PRAGMA defer_foreign_keys = ON").Error
default:
return nil
}
}
func (s *SqlStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error {
user := &types.User{Email: email, Name: name}
if err := user.EncryptSensitiveData(s.fieldEncrypt); err != nil {
return fmt.Errorf("encrypt user info: %w", err)
}
result := s.db.Model(&types.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"email": user.Email,
"name": user.Name,
})
if result.Error != nil {
log.WithContext(ctx).Errorf("error updating user info for %s: %s", userID, result.Error)
return status.Errorf(status.Internal, "failed to update user info")
}
return nil
}
func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error {
type fkUpdate struct {
model any
column string
where string
}
updates := []fkUpdate{
{&types.PersonalAccessToken{}, "user_id", "user_id = ?"},
{&types.PersonalAccessToken{}, "created_by", "created_by = ?"},
{&nbpeer.Peer{}, "user_id", "user_id = ?"},
{&types.UserInviteRecord{}, "created_by", "created_by = ?"},
{&types.Account{}, "created_by", "created_by = ?"},
{&types.ProxyAccessToken{}, "created_by", "created_by = ?"},
{&types.Job{}, "triggered_by", "triggered_by = ?"},
}
err := s.transaction(func(tx *gorm.DB) error {
if err := s.txDeferFKConstraints(tx); err != nil {
return err
}
for _, u := range updates {
if err := tx.Model(u.model).Where(u.where, oldUserID).Update(u.column, newUserID).Error; err != nil {
return fmt.Errorf("update %s: %w", u.column, err)
}
}
if err := tx.Model(&types.User{}).Where(accountAndIDQueryCondition, accountID, oldUserID).Update("id", newUserID).Error; err != nil {
return fmt.Errorf("update users: %w", err)
}
return nil
})
if err != nil {
log.WithContext(ctx).Errorf("failed to update user ID in the store: %s", err)
return status.Errorf(status.Internal, "failed to update user ID in store")
}
return nil
}

View File

@@ -0,0 +1,209 @@
# IdP Migration Tool — Developer Guide
## Overview
This tool migrates NetBird deployments from an external IdP (Auth0, Zitadel, Okta, etc.) to the embedded Dex IdP introduced in v0.62.0. It does two things:
1. **DB migration** — Re-encodes every user ID from `{original_id}` to Dex's protobuf-encoded format `base64(proto{original_id, connector_id})`.
2. **Config generation** — Transforms `management.json` by replacing `IdpManagerConfig` with `EmbeddedIdP` and updating `HttpConfig` fields.
## Code Layout
```
tools/idp-migrate/
├── main.go # CLI entry point, connector resolution, config generation
├── main_test.go # 22 tests covering all exported/internal functions
├── DEVELOPMENT.md # this file
└── MIGRATION_GUIDE.md # operator-facing step-by-step guide
management/server/idp/migration/
├── migration.go # Server interface, MigrateUsersToStaticConnectors(), PopulateUserInfo(), migrateUser(), reconcileActivityStore()
├── migration_test.go # 6 top-level tests (with subtests) using hand-written mocks
└── store.go # Store, EventStore interfaces, SchemaCheck, RequiredSchema, SchemaError types
management/server/store/
└── sql_store_idp_migration.go # CheckSchema(), ListUsers(), UpdateUserInfo(), UpdateUserID(), txDeferFKConstraints() on SqlStore
management/server/activity/store/
├── sql_store_idp_migration.go # UpdateUserID() on activity Store
└── sql_store_idp_migration_test.go # 5 subtests for activity UpdateUserID
```
## Release / Distribution
The tool is included in `.goreleaser.yaml` as the `netbird-idp-migrate` build target. Each NetBird release produces pre-built archives for Linux (amd64, arm64, arm) that are uploaded to GitHub Releases. The archive naming convention is:
```
netbird-idp-migrate_<version>_linux_<arch>.tar.gz
```
The build requires `CGO_ENABLED=1` because it links the SQLite driver used by `SqlStore`. The cross-compilation setup (CC env for arm64/arm) mirrors the `netbird-mgmt` build.
## CLI Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config` | string | *(required)* | Path to management.json |
| `--datadir` | string | `""` | Override data directory from config |
| `--idp-seed-info` | string | `""` | Base64-encoded connector JSON (overrides auto-detection) |
| `--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) |
| `--skip-populate-user-info` | bool | `false` | Skip populating user info (user ID migration only) |
| `--log-level` | string | `"info"` | Log level (debug, info, warn, error) |
## Migration Flow
### Phase 0: Schema Validation
`validateSchema()` opens the store and calls `CheckSchema(RequiredSchema)` to verify that all tables and columns required by the migration exist in the database. If anything is missing, the tool exits with a descriptive error instructing the operator to start the management server (v0.62.0+) at least once so that automatic GORM migrations create the required schema.
### Phase 1: Populate User Info
Unless `--skip-populate-user-info` is set, `populateUserInfoFromIDP()` runs before connector resolution:
1. Creates an IDP manager from the existing `IdpManagerConfig` in management.json.
2. Calls `idpManager.GetAllAccounts()` to fetch email and name for all users from the external IDP.
3. Calls `migration.PopulateUserInfo()` which iterates over all store users, skipping service users and users that already have both email and name populated. For Dex-encoded user IDs, it decodes back to the original IDP ID for lookup.
4. Updates the store with any missing email/name values.
This ensures user contact info is preserved before the ID migration makes the original IDP IDs inaccessible.
### Phase 2: Connector Resolution
`resolveConnector()` uses a 3-tier priority:
1. `--idp-seed-info` flag — explicit base64-encoded connector JSON
2. `IDP_SEED_INFO` env var — same format, read via `migration.SeedConnectorFromEnv()`
3. Auto-detect from `management.json` — reads `IdpManagerConfig.ClientConfig` fields and maps `ManagerType` to a Dex connector type:
| ManagerType | Dex Connector Type | Notes |
|-------------|--------------------|----|
| `keycloak` | `keycloak` | |
| `okta` | `okta` | |
| `authentik` | `authentik` | |
| `pocketid` | `pocketid` | |
| `auth0` | `oidc` (generic) | |
| `azure` | `entra` | |
| `google` | `google` | |
| `zitadel` | **error** | Uses service account credentials — requires `--idp-seed-info` |
| `jumpcloud` | **error** | No Dex connector available |
| *(unknown)* | `oidc` (fallback) | Requires non-empty `ClientSecret` |
**Why Zitadel can't be auto-detected**: Zitadel's `IdpManagerConfig.ClientConfig` contains service account credentials (a login name like `netbird-service-account` and possibly a PAT), not OAuth client credentials. These can't be used as an OIDC connector's `clientID`/`clientSecret`. The user must create a confidential Web application in Zitadel and provide it via `--idp-seed-info`.
Additionally, `buildConnectorFromConfig` validates that `ClientSecret` is non-empty for all providers. If the secret is missing, the tool errors with instructions to use `--idp-seed-info`.
### Phase 3: DB Migration
`migrateDB()` orchestrates the database migration:
1. `openStores()` opens the main store (`SqlStore`) and activity store (non-fatal if missing).
2. Type-asserts both to `migration.Store` / `migration.EventStore`.
3. `previewUsers()` scans all users — counts pending vs already-migrated (using `DecodeDexUserID`).
4. `confirmPrompt()` asks for interactive confirmation (unless `--force` or `--dry-run`).
5. Calls `migration.MigrateUsersToStaticConnectors(srv, conn)`:
- **Reconciliation pass**: fixes activity store references for users already migrated in the main DB but whose events still reference old IDs (from a previous partial failure).
- **Main loop**: for each non-migrated user, calls `migrateUser()` which atomically updates the user ID in both the main store and activity store.
- **Dry-run**: logs what would happen, skips all writes.
`SqlStore.UpdateUserID()` atomically updates the user's primary key and all foreign key references (peers, PATs, groups, policies, jobs, etc.) in a single transaction.
### Phase 4: Config Generation
Unless `--skip-config` is set, `generateConfig()` runs:
1. **Derive domain**`deriveDomain()` priority:
1. `HttpConfig.LetsEncryptDomain` (most explicit)
2. Parse host from `HttpConfig.OIDCConfigEndpoint`
3. Parse host from `HttpConfig.AuthIssuer`
4. Parse host from `IdpManagerConfig.ClientConfig.Issuer` (last resort)
2. **Transform JSON** — reads existing config as raw JSON to preserve all fields, then:
- Removes `IdpManagerConfig`
- Adds `EmbeddedIdP` with the static connector, redirect URIs, etc.
- Overrides the connector's `redirectURI` to use the derived management domain (not the IdP issuer)
- Updates `HttpConfig`: `AuthIssuer`, `AuthAudience`, `AuthClientID`, `CLIAuthAudience`, `AuthKeysLocation`, `OIDCConfigEndpoint`, `IdpSignKeyRefreshEnabled`
- Sets `AuthUserIDClaim` to `"sub"`
- Generates `PKCEAuthorizationFlow` with Dex endpoints
3. **Write** — backs up original as `management.json.bak`, writes new config. In dry-run mode, prints to stdout instead.
## Interface Decoupling
Migration methods (`ListUsers`, `UpdateUserID`) are **not** on the core `store.Store` or `activity.Store` interfaces. Instead, they're defined in `migration/store.go`:
```go
type Store interface {
ListUsers(ctx context.Context) ([]*types.User, error)
UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error
UpdateUserInfo(ctx context.Context, userID, email, name string) error
CheckSchema(checks []SchemaCheck) []SchemaError
}
type EventStore interface {
UpdateUserID(ctx context.Context, oldUserID, newUserID string) error
}
```
A `Server` interface wraps both stores for dependency injection:
```go
type Server interface {
Store() Store
EventStore() EventStore // may return nil
}
```
The concrete `SqlStore` types already have these methods (in their respective `sql_store_idp_migration.go` files), so they satisfy the interfaces via Go's structural typing — zero changes needed on the core store interfaces. At runtime, the standalone tool type-asserts:
```go
migStore, ok := mainStore.(migration.Store)
```
This keeps migration concerns completely separate from the core store contract.
## Dex User ID Encoding
`EncodeDexUserID(userID, connectorID)` produces a manually-encoded protobuf with two string fields, then base64-encodes the result (raw, no padding). `DecodeDexUserID` reverses this. The migration loop uses `DecodeDexUserID` to detect already-migrated users (decode succeeds → skip).
See `idp/dex/provider.go` for the implementation.
## Standalone Tool
The standalone tool (`tools/idp-migrate/main.go`) is the primary migration entry point. It opens stores directly, runs schema validation, populates user info from the external IDP, migrates user IDs, and generates the new config — then exits.
Previously, the combined server (`modules.go`) had a `seedIDPConnectors()` method that ran the same migration at startup via the `IDP_SEED_INFO` env var. This combined server path has been removed; migration is now handled exclusively by the standalone tool.
## Running Tests
```bash
# Migration library
go test -v ./management/server/idp/migration/...
# Standalone tool
go test -v ./tools/idp-migrate/...
# Activity store migration tests
go test -v -run TestUpdateUserID ./management/server/activity/store/...
# Build locally
go build ./tools/idp-migrate/
```
## Clean Removal
When migration tooling is no longer needed, delete:
1. `tools/idp-migrate/` — entire directory
2. `management/server/idp/migration/` — entire directory
3. `management/server/store/sql_store_idp_migration.go` — migration methods on main SqlStore
4. `management/server/activity/store/sql_store_idp_migration.go` — migration method on activity Store
5. `management/server/activity/store/sql_store_idp_migration_test.go` — tests for the above
6. In `.goreleaser.yaml`:
- Remove the `netbird-idp-migrate` build entry
- Remove the `netbird-idp-migrate` archive entry
7. Run `go mod tidy`
No core interfaces or mocks need editing — that's the point of the decoupling.

View File

@@ -0,0 +1,477 @@
# Migrating from External IdP to Embedded IdP
This guide walks through migrating a self-hosted NetBird deployment from an external identity provider to the embedded Dex-based IdP introduced in v0.62.0.
## Overview
The migration tool does two things:
1. **Re-encodes user IDs** in the database to include the external connector ID, so Dex can route returning users to the correct external provider.
2. **Generates a new `management.json`** that replaces `IdpManagerConfig` with `EmbeddedIdP` and updates OAuth2 endpoints to the embedded Dex issuer.
After migration, existing users keep logging in through the same external provider — Dex acts as a broker in front of it. No passwords or credentials change.
---
## Before You Begin
### Prerequisites
| Requirement | Details |
|-------------|---------|
| NetBird version | `<INSERT_VERSION>` or later |
| Config access | You can read and write `management.json` |
| Server downtime | The management server **must be stopped** during migration |
| Backups | Back up your database and config before starting |
### Supported Providers
| Provider | Auto-detected | Connector type | Extra setup needed? |
|----------|:---:|----------------|---------------------|
| Auth0 | ✅ | Generic OIDC | No |
| Azure AD | ✅ | Entra | No |
| Keycloak | ✅ | Keycloak | No |
| Okta | ✅ | OIDC | No |
| Authentik | ✅ | OIDC | No |
| PocketID | ✅ | OIDC | No |
| Google | ✅ | Google | No |
| Zitadel | ❌ | Zitadel | Yes — see [Step 2](#step-2-prepare-your-provider-if-required) |
| JumpCloud | ❌ | — | No Dex connector; manual OIDC setup required |
> **Which path do I follow?**
>
> - **Auto-detected provider** → Skip Step 2 entirely. The tool reads your `management.json` and builds the connector automatically.
> - **Zitadel** → You must complete Step 2 to create an OAuth app and supply connector credentials.
> - **JumpCloud or other unsupported provider** → You must complete Step 2 to provide a custom OIDC connector.
---
## Step 1: Get the Migration Tool
**Option A — Download a pre-built binary:**
```bash
# Replace VERSION with the release tag, and adjust the architecture as needed
curl -L -o netbird-idp-migrate.tar.gz \
https://github.com/netbirdio/netbird/releases/download/VERSION/netbird-idp-migrate_VERSION_linux_amd64.tar.gz
tar xzf netbird-idp-migrate.tar.gz
chmod +x netbird-idp-migrate
```
Available architectures: `linux_amd64`, `linux_arm64`, `linux_arm`.
**Option B — Build from source** (requires Go 1.25+ and a C compiler for CGO/SQLite):
```bash
go build -o netbird-idp-migrate ./tools/idp-migrate/
```
Copy the binary to the management server host if you built it elsewhere.
---
## Step 2: Prepare Your Provider (if required)
> **Auto-detected providers (Auth0, Azure AD, Keycloak, Okta, Authentik, PocketID, Google):** Skip this step — proceed to [Step 3](#step-3-stop-the-management-server).
### Zitadel
Zitadel requires manual connector setup because the management server's service account credentials cannot be reused as OAuth client credentials for the Dex OIDC connector.
1. Open the Zitadel console at `https://<your-zitadel-domain>/ui/console`.
2. Go to **Projects** → select the NetBird project → **Applications**.
3. Click **New** and create an application:
- **Name:** `netbird-dex`
- **Type:** Web
- **Authentication Method:** Code
4. Set the redirect URI to `https://<your-management-domain>/oauth2/callback`.
5. Save and copy the **Client ID** and **Client Secret**.
6. Under **Token Settings**, enable both:
- ✅ User roles inside ID token
- ✅ User Info inside ID token
7. Create a `connector.json` file:
```json
{
"type": "zitadel",
"name": "zitadel",
"id": "zitadel",
"config": {
"issuer": "https://<your-zitadel-domain>",
"clientID": "<client-id>",
"clientSecret": "<client-secret>",
"redirectURI": "https://<your-management-domain>/oauth2/callback"
}
}
```
You will pass this file in Step 5 with the `--idp-seed-info` flag.
See also: [Zitadel setup guide](https://docs.netbird.io/selfhosted/identity-providers/zitadel).
### Custom / Unsupported Provider (JumpCloud, etc.)
For providers without built-in detection, create a generic OIDC `connector.json`:
```json
{
"type": "oidc",
"name": "My Provider",
"id": "my-provider",
"config": {
"issuer": "https://idp.example.com",
"clientID": "my-client-id",
"clientSecret": "my-client-secret",
"redirectURI": "https://<your-management-domain>/oauth2/callback"
}
}
```
You will pass this file in Step 5 with the `--idp-seed-info` flag.
---
## Step 3: Stop the Management Server
<details>
<summary><strong>Systemd / bare-metal</strong></summary>
```bash
sudo systemctl stop netbird-management
```
</details>
<details>
<summary><strong>Docker Compose</strong></summary>
```bash
docker compose stop management
```
</details>
---
## Step 4: Back Up Your Data
The tool creates `management.json.bak` automatically, but always make your own backups.
<details>
<summary><strong>Systemd / bare-metal (SQLite)</strong></summary>
```bash
cp /var/lib/netbird/store.db /var/lib/netbird/store.db.bak
cp /etc/netbird/management.json /etc/netbird/management.json.bak
```
</details>
<details>
<summary><strong>Docker Compose (SQLite in a named volume)</strong></summary>
Find the volume and its host path:
```bash
# Identify the volume name
VOLUME_NAME=$(docker volume ls --format '{{ .Name }}' | grep -i management)
echo "Volume: $VOLUME_NAME"
# Get the host path
VOLUME_PATH=$(docker volume inspect "$VOLUME_NAME" --format '{{ .Mountpoint }}')
echo "Path: $VOLUME_PATH"
# Verify store.db exists, then back up
sudo ls "$VOLUME_PATH/store.db"
sudo cp "$VOLUME_PATH/store.db" "$VOLUME_PATH/store.db.bak"
cp ~/netbird/management.json ~/netbird/management.json.bak
```
</details>
<details>
<summary><strong>PostgreSQL</strong></summary>
```bash
pg_dump -h <host> -U <user> -d <database> -f netbird-backup.sql
cp /etc/netbird/management.json /etc/netbird/management.json.bak
```
</details>
---
## Step 5: Run the Migration
### 5a. Dry run (always do this first)
This previews what will happen without writing any changes.
**Auto-detected providers:**
```bash
./netbird-idp-migrate \
--config /etc/netbird/management.json \
--dry-run
```
**Zitadel / custom providers** (pass the `connector.json` from Step 2):
```bash
./netbird-idp-migrate \
--config /etc/netbird/management.json \
--idp-seed-info "$(base64 < connector.json)" \
--dry-run
```
> **Docker users:** If your database is in a volume that doesn't match the `Datadir` in `management.json`, add `--datadir`:
>
> ```bash
> ./netbird-idp-migrate \
> --config ~/netbird/management.json \
> --datadir /var/lib/docker/volumes/<volume-name>/_data \
> --dry-run
> ```
You should see output like:
```
INFO resolved connector: type=oidc, id=auth0, name=auth0
INFO found 12 total users: 12 pending migration, 0 already migrated
INFO [DRY RUN] would migrate user abc123 -> CgZhYmMxMjMSB3ppdGFkZWw (account: acct-1)
...
INFO [DRY RUN] migration summary: 12 users would be migrated, 0 already migrated
INFO derived domain for embedded IdP: mgmt.example.com
INFO [DRY RUN] new management.json would be:
{ ... }
```
**Verify before proceeding:**
- ✅ Connector type and ID match your provider.
- ✅ User count matches what you expect.
- ✅ Generated config has the correct domain and endpoints.
### 5b. Execute the migration
Run the same command without `--dry-run`:
```bash
# Auto-detected providers
./netbird-idp-migrate --config /etc/netbird/management.json
# Zitadel / custom providers
./netbird-idp-migrate \
--config /etc/netbird/management.json \
--idp-seed-info "$(base64 < connector.json)"
```
The tool will show a summary and prompt for confirmation:
```
About to migrate 12 users. This cannot be easily undone. Continue? [y/N]
```
Type `y` and press Enter.
### 5c. Review the new config
Open `/etc/netbird/management.json` and verify:
- ✅ `IdpManagerConfig` is **removed**.
- ✅ `EmbeddedIdP` is present with `"Enabled": true` and your connector in `StaticConnectors`.
- ✅ `HttpConfig.AuthIssuer` is `https://<your-domain>/oauth2`.
- ✅ `HttpConfig.AuthClientID` is `"netbird-dashboard"`.
---
## Step 6: Post-Migration Configuration
### 6a. Update your reverse proxy
The embedded Dex IdP is served under `/oauth2/`. Your reverse proxy must route this path to the management server.
<details>
<summary><strong>Caddy</strong></summary>
Add to your `Caddyfile`, inside the site block for your management domain:
```
reverse_proxy /oauth2/* management:80
```
Place it alongside existing `/api/*` and `/management.ManagementService/*` routes, then reload:
```bash
docker compose restart caddy
# or
sudo systemctl reload caddy
```
</details>
<details>
<summary><strong>Nginx</strong></summary>
```nginx
location /oauth2/ {
proxy_pass http://management:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Reload nginx after adding the route.
</details>
<details>
<summary><strong>Traefik</strong></summary>
Add a route matching the `/oauth2/` path prefix, forwarding to the management service.
</details>
**Verify the route works:**
```bash
curl -s https://<your-domain>/oauth2/.well-known/openid-configuration | head -5
```
Expected: a JSON response with `"issuer": "https://<your-domain>/oauth2"`.
### 6b. Update dashboard environment
If your dashboard uses a separate `dashboard.env` or environment variables, update the OAuth settings:
```bash
# Before (external IdP)
AUTH_AUTHORITY=https://external-idp.example.com
AUTH_CLIENT_ID=old-client-id
AUTH_AUDIENCE=old-audience
# After (embedded Dex)
AUTH_AUTHORITY=https://<your-domain>/oauth2
AUTH_CLIENT_ID=netbird-dashboard
AUTH_AUDIENCE=netbird-dashboard
```
Restart the dashboard after updating.
---
## Step 7: Start and Verify
### Start the management server
```bash
# Systemd
sudo systemctl start netbird-management
# Docker Compose
docker compose up -d management
```
### Verify everything works
1. **OIDC discovery:** Open `https://<your-domain>/oauth2/.well-known/openid-configuration` — it should return valid JSON.
2. **Dashboard login:** Log in to the dashboard — you should be redirected through your external IdP as before.
3. **Data integrity:** Check that peers are visible and policies are intact.
> **Tip:** Use an incognito/private browser window or clear cookies for your first login. Stale tokens from the old IdP will fail validation.
---
## Command Reference
```
Usage: netbird-idp-migrate [flags]
Flags:
--config string Path to management.json (required)
--datadir string Override data directory from config
--idp-seed-info string Base64-encoded connector JSON (overrides auto-detection)
--dry-run Preview changes without writing
--force Skip confirmation prompt
--skip-config Skip config generation (DB migration only)
--log-level string Log level: debug, info, warn, error (default "info")
```
---
## Advanced Scenarios
### DB-only migration (manual config editing)
Migrate user IDs in the database but skip config generation:
```bash
./netbird-idp-migrate \
--config /etc/netbird/management.json \
--skip-config
```
### Non-interactive (CI / scripts)
```bash
./netbird-idp-migrate \
--config /etc/netbird/management.json \
--force
```
---
## Troubleshooting
### "store does not support migration operations"
The store implementation is missing the required `ListUsers`/`UpdateUserID` methods. Upgrade to v0.66.4+ binaries.
### "could not determine domain"
The tool couldn't infer your management server's domain. Either set `HttpConfig.LetsEncryptDomain` in `management.json` before running, or use `--skip-config` and configure the embedded IdP section manually.
### "could not open activity store"
This is a **warning**, not an error. If `events.db` doesn't exist (e.g., fresh install), activity event migration is skipped. User ID migration in the main database still proceeds normally.
### "no connector configuration found"
No IdP configuration was detected. Provide it explicitly with `--idp-seed-info`, set the `IDP_SEED_INFO` env var, or ensure `IdpManagerConfig` is present in `management.json`.
### "zitadel auto-detection is not supported"
Zitadel's management config uses service account credentials that aren't valid OAuth client credentials. Follow the [Zitadel setup](#zitadel) in Step 2 to create a dedicated OAuth application.
### "no client secret found"
The Dex OIDC connector requires a confidential OAuth client with a client secret. If `IdpManagerConfig.ClientConfig.ClientSecret` is empty in your config, provide the connector credentials via `--idp-seed-info`.
### "Errors.App.NotFound" from Zitadel after migration
The dashboard is still redirecting to Zitadel's `/oauth/v2/` endpoint instead of the management server's `/oauth2` endpoint. Set `AUTH_AUTHORITY=https://<your-domain>/oauth2` in your dashboard environment — see [Step 6b](#6b-update-dashboard-environment).
### OIDC discovery returns 404
The `/oauth2/` path is not being routed to the management server. Add a reverse proxy route — see [Step 6a](#6a-update-your-reverse-proxy).
### "jumpcloud does not have a supported Dex connector type"
JumpCloud has no native Dex connector. Configure a generic OIDC connector manually with `--idp-seed-info` — see [Custom providers](#custom--unsupported-provider-jumpcloud-etc) in Step 2.
### "failed to create embedded IDP service: cannot disable local authentication..."
The embedded IdP didn't support `StaticConnectors` in the config until post-`<INSERT_VERSION>`. Upgrade to a version that includes this fix.
### Partial failure / re-running
The migration is **idempotent**. Already-migrated users are detected and skipped. If the tool fails partway through, fix the underlying issue and re-run — it picks up where it left off.
---
## Rolling Back
If something goes wrong after migration:
1. **Stop** the management server.
2. **Restore the database:**
- SQLite (bare-metal): `cp /var/lib/netbird/store.db.bak /var/lib/netbird/store.db`
- SQLite (Docker volume): `sudo cp $VOLUME_PATH/store.db.bak $VOLUME_PATH/store.db`
- PostgreSQL: restore from your `pg_dump` backup
3. **Restore the config:** `cp /etc/netbird/management.json.bak /etc/netbird/management.json`
4. **Revert** any reverse proxy or dashboard env changes.
5. **Start** the management server.

613
tools/idp-migrate/main.go Normal file
View File

@@ -0,0 +1,613 @@
// Package main provides a standalone CLI tool to migrate user IDs from an
// external IdP format to the embedded Dex IdP format used by NetBird >= v0.62.0.
//
// This tool reads management.json to auto-detect the current external IdP
// configuration (issuer, clientID, clientSecret, type) and re-encodes all user
// IDs in the database to the Dex protobuf-encoded format. It works independently
// of migrate.sh and the combined server, allowing operators to migrate their
// database before switching to the combined server.
//
// Usage:
//
// netbird-idp-migrate --config /etc/netbird/management.json [--dry-run] [--force]
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"net/url"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/idp/migration"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/crypt"
)
// migrationServer implements migration.Server by wrapping the migration-specific interfaces.
type migrationServer struct {
store migration.Store
eventStore migration.EventStore
}
func (s *migrationServer) Store() migration.Store { return s.store }
func (s *migrationServer) EventStore() migration.EventStore { return s.eventStore }
func main() {
var (
configPath string
dataDir string
idpSeedInfo string
dryRun bool
force bool
skipConfig bool
skipPopulateUserInfo bool
logLevel string
)
flag.StringVar(&configPath, "config", "", "path to management.json (required)")
flag.StringVar(&dataDir, "datadir", "", "override data directory from config")
flag.StringVar(&idpSeedInfo, "idp-seed-info", "", "base64-encoded connector JSON (overrides auto-detection)")
flag.BoolVar(&dryRun, "dry-run", false, "preview changes without writing")
flag.BoolVar(&force, "force", false, "skip confirmation prompt")
flag.BoolVar(&skipConfig, "skip-config", false, "skip config generation (DB migration only)")
flag.BoolVar(&skipPopulateUserInfo, "skip-populate-user-info", false, "skip populating user info (user id migration only)")
flag.StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)")
flag.Parse()
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err)
os.Exit(1)
}
if err := run(configPath, dataDir, idpSeedInfo, dryRun, force, skipConfig, skipPopulateUserInfo); err != nil {
log.Fatalf("migration failed: %v", err)
}
}
func run(configPath, dataDirOverride, idpSeedInfo string, dryRun, force, skipConfig, skipPopulateUserInfo bool) error {
if configPath == "" {
return fmt.Errorf("--config is required")
}
cfg, err := loadConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
effectiveDataDir := cfg.Datadir
if dataDirOverride != "" {
effectiveDataDir = dataDirOverride
}
if effectiveDataDir == "" {
return fmt.Errorf("data directory not set: use --datadir or set Datadir in management.json")
}
// Validate the database schema before attempting any operations.
if err := validateSchema(cfg, effectiveDataDir); err != nil {
return err
}
if !skipPopulateUserInfo {
err := populateUserInfoFromIDP(cfg, effectiveDataDir, dryRun)
if err != nil {
return fmt.Errorf("populate user info: %w", err)
}
}
conn, err := resolveConnector(idpSeedInfo, cfg)
if err != nil && errors.Is(err, ErrNoIdpManagerConfig) {
return fmt.Errorf("no connector configuration found: provide --idp-seed-info, set IDP_SEED_INFO env var, or configure IdpManagerConfig in management.json")
}
if err != nil {
return fmt.Errorf("resolve connector: %w", err)
}
if conn.ID == "" {
return fmt.Errorf("connector ID is empty")
}
log.Infof("resolved connector: type=%s, id=%s, name=%s", conn.Type, conn.ID, conn.Name)
if err := migrateDB(cfg, effectiveDataDir, conn, dryRun, force); err != nil {
return err
}
if skipConfig {
log.Info("skipping config generation (--skip-config)")
return nil
}
return generateConfig(configPath, conn, cfg, dryRun)
}
// 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.
func validateSchema(cfg *nbconfig.Config, dataDir string) error {
ctx := context.Background()
migStore, migEventStore, cleanup, err := openStores(ctx, cfg, dataDir)
if err != nil {
return err
}
defer cleanup()
errs := migStore.CheckSchema(migration.RequiredSchema)
if len(errs) > 0 {
return fmt.Errorf("%s", formatSchemaErrors(errs))
}
if migEventStore != nil {
eventErrs := migEventStore.CheckSchema(migration.RequiredEventSchema)
if len(eventErrs) > 0 {
return fmt.Errorf("activity store schema check failed (upgrade management server first):\n%s", formatSchemaErrors(eventErrs))
}
}
log.Info("database schema check passed")
return nil
}
// formatSchemaErrors returns a user-friendly message listing all missing schema
// elements and instructing the operator to upgrade.
func formatSchemaErrors(errs []migration.SchemaError) string {
var b strings.Builder
b.WriteString("database schema is incomplete — the following tables/columns are missing:\n")
for _, e := range errs {
b.WriteString(fmt.Sprintf(" - %s\n", e.String()))
}
b.WriteString("\nPlease start the NetBird management server (v0.66.4+) at least once so that automatic database migrations create the required schema, then re-run this tool.\n")
return b.String()
}
// populateUserInfoFromIDP creates an IDP manager from the config, fetches all
// user data (email, name) from the external IDP, and updates the store for users
// that are missing this information.
func populateUserInfoFromIDP(cfg *nbconfig.Config, dataDir string, dryRun bool) error {
ctx := context.Background()
if cfg.IdpManagerConfig == nil {
return fmt.Errorf("IdpManagerConfig is not set in management.json; cannot fetch user info from IDP")
}
idpManager, err := idp.NewManager(ctx, *cfg.IdpManagerConfig, nil)
if err != nil {
return fmt.Errorf("create IDP manager: %w", err)
}
if idpManager == nil {
return fmt.Errorf("IDP manager type is 'none' or empty; cannot fetch user info")
}
log.Infof("created IDP manager (type: %s)", cfg.IdpManagerConfig.ManagerType)
migStore, _, cleanup, err := openStores(ctx, cfg, dataDir)
if err != nil {
return err
}
defer cleanup()
srv := &migrationServer{store: migStore}
return migration.PopulateUserInfo(srv, idpManager, dryRun)
}
// openStores opens the main and activity stores, returning migration-specific interfaces.
// The caller must call the returned cleanup function to close the stores.
func openStores(ctx context.Context, cfg *nbconfig.Config, dataDir string) (migration.Store, migration.EventStore, func(), error) {
engine := cfg.StoreConfig.Engine
if engine == "" {
engine = types.SqliteStoreEngine
}
mainStore, err := store.NewStore(ctx, engine, dataDir, nil, true)
if err != nil {
return nil, nil, nil, fmt.Errorf("open main store: %w", err)
}
if cfg.DataStoreEncryptionKey != "" {
fieldEncrypt, err := crypt.NewFieldEncrypt(cfg.DataStoreEncryptionKey)
if err != nil {
_ = mainStore.Close(ctx)
return nil, nil, nil, fmt.Errorf("init field encryption: %w", err)
}
mainStore.SetFieldEncrypt(fieldEncrypt)
}
migStore, ok := mainStore.(migration.Store)
if !ok {
_ = mainStore.Close(ctx)
return nil, nil, nil, fmt.Errorf("store does not support migration operations (ListUsers/UpdateUserID)")
}
cleanup := func() { _ = mainStore.Close(ctx) }
var migEventStore migration.EventStore
actStore, err := activitystore.NewSqlStore(ctx, dataDir, cfg.DataStoreEncryptionKey)
if err != nil {
log.Warnf("could not open activity store (events.db may not exist): %v", err)
} else {
migEventStore = actStore
prevCleanup := cleanup
cleanup = func() { _ = actStore.Close(ctx); prevCleanup() }
}
return migStore, migEventStore, cleanup, nil
}
// migrateDB opens the stores, previews pending users, and runs the DB migration.
func migrateDB(cfg *nbconfig.Config, dataDir string, conn *dex.Connector, dryRun, force bool) error {
ctx := context.Background()
migStore, migEventStore, cleanup, err := openStores(ctx, cfg, dataDir)
if err != nil {
return err
}
defer cleanup()
pending, err := previewUsers(ctx, migStore)
if err != nil {
return err
}
if pending == 0 {
log.Info("no users need migration — all done")
return nil
}
if dryRun {
if err := os.Setenv("NB_IDP_MIGRATION_DRY_RUN", "true"); err != nil {
return fmt.Errorf("set dry-run env: %w", err)
}
defer os.Unsetenv("NB_IDP_MIGRATION_DRY_RUN") //nolint:errcheck
}
if !dryRun && !force {
if !confirmPrompt(pending) {
log.Info("migration cancelled by user")
return nil
}
}
srv := &migrationServer{store: migStore, eventStore: migEventStore}
if err := migration.MigrateUsersToStaticConnectors(srv, conn); err != nil {
return fmt.Errorf("migrate users: %w", err)
}
if !dryRun {
log.Info("DB migration completed successfully")
}
return nil
}
// previewUsers counts pending vs already-migrated users and logs a summary.
// Returns the number of users still needing migration.
func previewUsers(ctx context.Context, migStore migration.Store) (int, error) {
users, err := migStore.ListUsers(ctx)
if err != nil {
return 0, fmt.Errorf("list users: %w", err)
}
var pending, alreadyMigrated int
for _, u := range users {
if _, _, decErr := dex.DecodeDexUserID(u.Id); decErr == nil {
alreadyMigrated++
} else {
pending++
}
}
log.Infof("found %d total users: %d pending migration, %d already migrated", len(users), pending, alreadyMigrated)
return pending, nil
}
// confirmPrompt asks the user for interactive confirmation. Returns true if they accept.
func confirmPrompt(pending int) bool {
log.Infof("About to migrate %d users. This cannot be easily undone. Continue? [y/N] ", pending)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "y" || answer == "yes"
}
// loadConfig reads management.json into the management config struct.
func loadConfig(path string) (*nbconfig.Config, error) {
cfg := &nbconfig.Config{}
if _, err := util.ReadJsonWithEnvSub(path, cfg); err != nil {
return nil, err
}
return cfg, nil
}
// resolveConnector determines the Dex connector using a three-tier priority:
// 1. --idp-seed-info flag (explicit base64-encoded JSON)
// 2. IDP_SEED_INFO env var
// 3. Auto-detect from management.json's IdpManagerConfig
func resolveConnector(flagValue string, cfg *nbconfig.Config) (*dex.Connector, error) {
// Priority 1: explicit flag
if flagValue != "" {
return decodeConnector(flagValue)
}
// Priority 2: env var
conn, err := migration.SeedConnectorFromEnv()
if err != nil && !errors.Is(err, migration.ErrNoSeedInfo) {
return nil, fmt.Errorf("reading the IDP_SEED_INFO env var: %w", err)
}
// If env var is set, return it, otherwise it was empty and we'll try auto-detect
if conn != nil {
return conn, nil
}
// Priority 3: auto-detect from config
return buildConnectorFromConfig(cfg)
}
// decodeConnector base64-decodes and JSON-unmarshals a connector.
func decodeConnector(encoded string) (*dex.Connector, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
var conn dex.Connector
if err := json.Unmarshal(decoded, &conn); err != nil {
return nil, fmt.Errorf("json unmarshal: %w", err)
}
return &conn, nil
}
var ErrNoIdpManagerConfig = errors.New("no IdpManagerConfig or IdpManagerConfig.ClientConfig found in management config file")
// buildConnectorFromConfig constructs a Dex connector from management.json's
// IdpManagerConfig fields (issuer, clientID, clientSecret, type).
//
// Some providers (Zitadel, Google Workspace) use service account credentials in
// IdpManagerConfig that are NOT valid OAuth client credentials. For these providers,
// auto-detection returns an error with instructions to use --idp-seed-info instead.
func buildConnectorFromConfig(cfg *nbconfig.Config) (*dex.Connector, error) {
idpCfg := cfg.IdpManagerConfig
if idpCfg == nil || idpCfg.ClientConfig == nil {
return nil, ErrNoIdpManagerConfig
}
managerType := strings.ToLower(idpCfg.ManagerType)
connType, err := mapManagerTypeToConnectorType(managerType)
if err != nil {
return nil, err
}
// Zitadel's IdpManagerConfig uses a service account (not an OAuth app).
// The ClientID is a service user login name (e.g., "netbird-service-account"),
// not a Zitadel OAuth application client ID. These credentials cannot be used
// for the Dex OIDC connector.
if managerType == "zitadel" {
issuer := idpCfg.ClientConfig.Issuer
if issuer == "" && cfg.HttpConfig != nil {
issuer = cfg.HttpConfig.AuthIssuer
}
return nil, fmt.Errorf(`zitadel auto-detection is not supported because the management server uses service account credentials (ClientID: %q) which are not valid OAuth client credentials for the Dex connector.
You need to create a confidential OAuth application in Zitadel:
1. Open the Zitadel console at %s/ui/console
2. Navigate to Projects → select the NetBird project → Applications
3. Create a new application:
Name: netbird-dex
Type: Web
Authentication Method: POST (sends client_id + client_secret in body)
4. Add redirect URI: https://<your-management-domain>/oauth2/callback
5. Copy the generated client ID and client secret
6. Create connector.json:
{
"type": "zitadel",
"name": "zitadel",
"id": "zitadel",
"config": {
"issuer": "%s",
"clientID": "<client-id-from-step-5>",
"clientSecret": "<client-secret-from-step-5>",
"redirectURI": "https://<your-management-domain>/oauth2/callback"
}
}
7. Run: netbird-idp-migrate --config management.json --idp-seed-info "$(base64 < connector.json)"`,
idpCfg.ClientConfig.ClientID,
strings.TrimSuffix(issuer, "/"),
issuer,
)
}
issuer := idpCfg.ClientConfig.Issuer
if issuer == "" && cfg.HttpConfig != nil {
issuer = cfg.HttpConfig.AuthIssuer
}
if issuer == "" {
return nil, fmt.Errorf("could not determine OIDC issuer from config (neither ClientConfig.Issuer nor HttpConfig.AuthIssuer set)")
}
clientSecret := idpCfg.ClientConfig.ClientSecret
if clientSecret == "" {
return nil, fmt.Errorf("no client secret found in IdpManagerConfig.ClientConfig; the Dex OIDC connector requires a confidential client with a secret — provide credentials via --idp-seed-info")
}
// Use issuer as a reasonable default for redirectURI; generateConfig() will
// override this with the correct management domain.
redirectURI := strings.TrimSuffix(issuer, "/") + "/oauth2/callback"
connID := managerType
return &dex.Connector{
Type: connType,
Name: idpCfg.ManagerType,
ID: connID,
Config: map[string]interface{}{
"issuer": issuer,
"clientID": idpCfg.ClientConfig.ClientID,
"clientSecret": clientSecret,
"redirectURI": redirectURI,
},
}, nil
}
// mapManagerTypeToConnectorType maps management.json ManagerType values to the
// connector type strings that Dex uses. These must match the types in
// idp/dex/connector.go's buildStorageConnector switch.
func mapManagerTypeToConnectorType(managerType string) (string, error) {
switch strings.ToLower(managerType) {
case "zitadel":
return "zitadel", nil
case "keycloak":
return "keycloak", nil
case "okta":
return "okta", nil
case "authentik":
return "authentik", nil
case "pocketid":
return "pocketid", nil
case "auth0":
// Auth0 uses generic OIDC in Dex (no named connector)
return "oidc", nil
case "azure":
return "entra", nil
case "google":
return "google", nil
case "jumpcloud":
return "", fmt.Errorf("jumpcloud does not have a supported Dex connector type")
default:
// Generic OIDC fallback
return "oidc", nil
}
}
// generateConfig reads the existing management.json as raw JSON, removes
// IdpManagerConfig, adds EmbeddedIdP, updates HttpConfig fields, and writes
// the result. In dry-run mode, it prints the new config to stdout instead.
func generateConfig(configPath string, conn *dex.Connector, cfg *nbconfig.Config, dryRun bool) error {
domain, err := deriveDomain(cfg)
if err != nil {
return fmt.Errorf("derive domain: %w", err)
}
log.Infof("derived domain for embedded IdP: %s", domain)
// Read existing config as raw JSON to preserve all fields
raw, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config file: %w", err)
}
var configMap map[string]interface{}
if err := json.Unmarshal(raw, &configMap); err != nil {
return fmt.Errorf("parse config JSON: %w", err)
}
// Remove old IdP config
delete(configMap, "IdpManagerConfig")
// Ensure the connector's redirectURI points to the management server (Dex callback),
// not the external IdP. The auto-detection may have used the IdP issuer URL.
connConfig := make(map[string]interface{}, len(conn.Config))
for k, v := range conn.Config {
connConfig[k] = v
}
connConfig["redirectURI"] = fmt.Sprintf("https://%s/oauth2/callback", domain)
// Add minimal EmbeddedIdP section
configMap["EmbeddedIdP"] = map[string]interface{}{
"Enabled": true,
"Issuer": fmt.Sprintf("https://%s/oauth2", domain),
"DashboardRedirectURIs": []string{
fmt.Sprintf("https://%s/nb-auth", domain),
fmt.Sprintf("https://%s/nb-silent-auth", domain),
},
"StaticConnectors": []interface{}{
map[string]interface{}{
"type": conn.Type,
"name": conn.Name,
"id": conn.ID,
"config": connConfig,
},
},
}
newJSON, err := json.MarshalIndent(configMap, "", " ")
if err != nil {
return fmt.Errorf("marshal new config: %w", err)
}
if dryRun {
log.Info("[DRY RUN] new management.json would be:")
log.Infoln(string(newJSON))
return nil
}
// Backup original
backupPath := configPath + ".bak"
if err := os.WriteFile(backupPath, raw, 0600); err != nil {
return fmt.Errorf("write backup: %w", err)
}
log.Infof("backed up original config to %s", backupPath)
// Write new config
if err := os.WriteFile(configPath, newJSON, 0600); err != nil {
return fmt.Errorf("write new config: %w", err)
}
log.Infof("wrote new config to %s", configPath)
return nil
}
// deriveDomain determines the management server domain from existing config,
// using a priority-based approach.
func deriveDomain(cfg *nbconfig.Config) (string, error) {
// Priority 1: LetsEncryptDomain (most explicit)
if cfg.HttpConfig != nil && cfg.HttpConfig.LetsEncryptDomain != "" {
return cfg.HttpConfig.LetsEncryptDomain, nil
}
// Priority 2: parse from OIDCConfigEndpoint
if cfg.HttpConfig != nil && cfg.HttpConfig.OIDCConfigEndpoint != "" {
if host := hostFromURL(cfg.HttpConfig.OIDCConfigEndpoint); host != "" {
return host, nil
}
}
// Priority 3: parse from AuthIssuer
if cfg.HttpConfig != nil && cfg.HttpConfig.AuthIssuer != "" {
if host := hostFromURL(cfg.HttpConfig.AuthIssuer); host != "" {
return host, nil
}
}
// Priority 4: parse from IdpManagerConfig.ClientConfig.Issuer
if cfg.IdpManagerConfig != nil && cfg.IdpManagerConfig.ClientConfig != nil && cfg.IdpManagerConfig.ClientConfig.Issuer != "" {
if host := hostFromURL(cfg.IdpManagerConfig.ClientConfig.Issuer); host != "" {
return host, nil
}
}
return "", fmt.Errorf("could not determine domain: set HttpConfig.LetsEncryptDomain, HttpConfig.AuthIssuer, or HttpConfig.OIDCConfigEndpoint in management.json")
}
// hostFromURL extracts the host (without port) from a URL string.
func hostFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return u.Hostname()
}
// Compile-time check that migrationServer implements migration.Server.
var _ migration.Server = (*migrationServer)(nil)

View File

@@ -0,0 +1,587 @@
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/idp/dex"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/idp/migration"
)
// TestMigrationServerInterface is a compile-time check that migrationServer
// implements the migration.Server interface.
func TestMigrationServerInterface(t *testing.T) {
var _ migration.Server = (*migrationServer)(nil)
}
func TestResolveConnector_FlagOverridesEnv(t *testing.T) {
flagConn := dex.Connector{
Type: "oidc",
Name: "from-flag",
ID: "flag-id",
Config: map[string]interface{}{
"issuer": "https://flag.example.com",
},
}
flagJSON, err := json.Marshal(flagConn)
require.NoError(t, err)
flagB64 := base64.StdEncoding.EncodeToString(flagJSON)
envConn := dex.Connector{
Type: "oidc",
Name: "from-env",
ID: "env-id",
Config: map[string]interface{}{
"issuer": "https://env.example.com",
},
}
envJSON, err := json.Marshal(envConn)
require.NoError(t, err)
envB64 := base64.StdEncoding.EncodeToString(envJSON)
t.Setenv("IDP_SEED_INFO", envB64)
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "zitadel",
ClientConfig: &idp.ClientConfig{
Issuer: "https://config.example.com",
ClientID: "config-client",
ClientSecret: "config-secret",
},
},
}
// Flag takes priority over env and config
conn, err := resolveConnector(flagB64, cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "flag-id", conn.ID)
assert.Equal(t, "from-flag", conn.Name)
// Empty flag → env takes priority over config
conn, err = resolveConnector("", cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "env-id", conn.ID)
assert.Equal(t, "from-env", conn.Name)
// Empty flag + no env → config auto-detect (uses keycloak which works with auto-detect)
t.Setenv("IDP_SEED_INFO", "")
cfgKeycloak := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "keycloak",
ClientConfig: &idp.ClientConfig{
Issuer: "https://kc.example.com/realms/test",
ClientID: "config-client",
ClientSecret: "config-secret",
},
},
}
conn, err = resolveConnector("", cfgKeycloak)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "keycloak", conn.ID)
}
func TestResolveConnector_InvalidBase64(t *testing.T) {
cfg := &nbconfig.Config{}
_, err := resolveConnector("not-valid-base64!!!", cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "base64 decode")
}
func TestResolveConnector_InvalidJSON(t *testing.T) {
cfg := &nbconfig.Config{}
encoded := base64.StdEncoding.EncodeToString([]byte("not json"))
_, err := resolveConnector(encoded, cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "json unmarshal")
}
func TestResolveConnector_EmptyConnectorID(t *testing.T) {
conn := dex.Connector{
Type: "oidc",
Name: "no-id",
ID: "",
}
data, err := json.Marshal(conn)
require.NoError(t, err)
encoded := base64.StdEncoding.EncodeToString(data)
result, err := resolveConnector(encoded, &nbconfig.Config{})
require.NoError(t, err)
// resolveConnector returns the connector; caller (run()) checks for empty ID
assert.Equal(t, "", result.ID)
}
func TestResolveConnector_NoConfigFallback(t *testing.T) {
t.Setenv("IDP_SEED_INFO", "")
cfg := &nbconfig.Config{} // no IdpManagerConfig
conn, err := resolveConnector("", cfg)
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
assert.Nil(t, conn) // no connector found
}
func TestBuildConnectorFromConfig_Zitadel(t *testing.T) {
// Zitadel uses service account credentials in IdpManagerConfig, which are
// not valid OAuth client credentials. Auto-detection should return an error
// instructing the user to use --idp-seed-info.
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "zitadel",
ClientConfig: &idp.ClientConfig{
Issuer: "https://zitadel.example.com",
ClientID: "netbird-service-account",
ClientSecret: "",
},
},
}
_, err := buildConnectorFromConfig(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "zitadel auto-detection is not supported")
assert.Contains(t, err.Error(), "--idp-seed-info")
}
func TestBuildConnectorFromConfig_EmptyClientSecret(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "okta",
ClientConfig: &idp.ClientConfig{
Issuer: "https://dev-12345.okta.com",
ClientID: "okta-id",
ClientSecret: "", // empty secret
},
},
}
_, err := buildConnectorFromConfig(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "no client secret")
}
func TestBuildConnectorFromConfig_Auth0(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "auth0",
ClientConfig: &idp.ClientConfig{
Issuer: "https://tenant.auth0.com/",
ClientID: "auth0-id",
ClientSecret: "auth0-secret",
},
},
}
conn, err := buildConnectorFromConfig(cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "oidc", conn.Type) // Auth0 maps to generic OIDC
assert.Equal(t, "auth0", conn.ID)
assert.Equal(t, "https://tenant.auth0.com/oauth2/callback", conn.Config["redirectURI"])
}
func TestBuildConnectorFromConfig_Azure(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "azure",
ClientConfig: &idp.ClientConfig{
Issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
ClientID: "azure-id",
ClientSecret: "azure-secret",
},
},
}
conn, err := buildConnectorFromConfig(cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "entra", conn.Type) // Azure maps to entra
assert.Equal(t, "azure", conn.ID)
}
func TestBuildConnectorFromConfig_Google(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "google",
ClientConfig: &idp.ClientConfig{
Issuer: "https://accounts.google.com",
ClientID: "google-id",
ClientSecret: "google-secret",
},
},
}
conn, err := buildConnectorFromConfig(cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "google", conn.Type)
assert.Equal(t, "google", conn.ID)
}
func TestBuildConnectorFromConfig_JumpCloud(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "jumpcloud",
ClientConfig: &idp.ClientConfig{
Issuer: "https://oauth.id.jumpcloud.com/",
ClientID: "jc-id",
ClientSecret: "jc-secret",
},
},
}
_, err := buildConnectorFromConfig(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "jumpcloud")
}
func TestBuildConnectorFromConfig_MissingClientConfig(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "keycloak",
// no ClientConfig
},
}
conn, err := buildConnectorFromConfig(cfg)
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
assert.Nil(t, conn) // returns nil, nil when no ClientConfig
}
func TestBuildConnectorFromConfig_MissingIdpManagerConfig(t *testing.T) {
cfg := &nbconfig.Config{}
conn, err := buildConnectorFromConfig(cfg)
require.ErrorIs(t, err, ErrNoIdpManagerConfig)
assert.Nil(t, conn)
}
func TestBuildConnectorFromConfig_IssuerFallbackToHttpConfig(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "keycloak",
ClientConfig: &idp.ClientConfig{
// Issuer empty — should fall back to HttpConfig.AuthIssuer
ClientID: "kc-id",
ClientSecret: "kc-secret",
},
},
HttpConfig: &nbconfig.HttpServerConfig{
AuthIssuer: "https://keycloak.example.com/realms/myrealm",
},
}
conn, err := buildConnectorFromConfig(cfg)
require.NoError(t, err)
require.NotNil(t, conn)
assert.Equal(t, "keycloak", conn.Type)
assert.Equal(t, "https://keycloak.example.com/realms/myrealm", conn.Config["issuer"])
}
func TestBuildConnectorFromConfig_MissingIssuer(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ManagerType: "okta",
ClientConfig: &idp.ClientConfig{
ClientID: "okta-id",
ClientSecret: "okta-secret",
// Issuer empty, no HttpConfig either
},
},
}
_, err := buildConnectorFromConfig(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "issuer")
}
func TestMapManagerTypeToConnectorType(t *testing.T) {
tests := []struct {
input string
expected string
wantErr bool
}{
{"zitadel", "zitadel", false},
{"keycloak", "keycloak", false},
{"okta", "okta", false},
{"authentik", "authentik", false},
{"pocketid", "pocketid", false},
{"auth0", "oidc", false},
{"azure", "entra", false},
{"google", "google", false},
{"jumpcloud", "", true},
{"unknown-provider", "oidc", false}, // fallback to generic OIDC
{"", "oidc", false}, // empty also falls through to default
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result, err := mapManagerTypeToConnectorType(tt.input)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestLoadConfig(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "management.json")
configJSON := `{
"Datadir": "/var/lib/netbird",
"DataStoreEncryptionKey": "example-encryption-key-0000",
"StoreConfig": {
"Engine": "sqlite"
},
"IdpManagerConfig": {
"ManagerType": "zitadel",
"ClientConfig": {
"Issuer": "https://zitadel.example.com",
"ClientID": "test-client",
"ClientSecret": "test-secret"
}
}
}`
require.NoError(t, os.WriteFile(configPath, []byte(configJSON), 0600))
cfg, err := loadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "/var/lib/netbird", cfg.Datadir)
assert.Equal(t, "example-encryption-key-0000", cfg.DataStoreEncryptionKey)
require.NotNil(t, cfg.IdpManagerConfig)
assert.Equal(t, "zitadel", cfg.IdpManagerConfig.ManagerType)
assert.Equal(t, "test-client", cfg.IdpManagerConfig.ClientConfig.ClientID)
})
t.Run("missing file", func(t *testing.T) {
_, err := loadConfig("/nonexistent/path/management.json")
require.Error(t, err)
})
t.Run("invalid json", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "bad.json")
require.NoError(t, os.WriteFile(configPath, []byte("{invalid"), 0600))
_, err := loadConfig(configPath)
require.Error(t, err)
})
}
func TestDecodeConnector(t *testing.T) {
conn := dex.Connector{
Type: "oidc",
Name: "test",
ID: "test-id",
Config: map[string]interface{}{
"issuer": "https://example.com",
"clientID": "cid",
"clientSecret": "csecret",
},
}
data, err := json.Marshal(conn)
require.NoError(t, err)
encoded := base64.StdEncoding.EncodeToString(data)
result, err := decodeConnector(encoded)
require.NoError(t, err)
assert.Equal(t, "test-id", result.ID)
assert.Equal(t, "oidc", result.Type)
assert.Equal(t, "https://example.com", result.Config["issuer"])
}
func TestDeriveDomain(t *testing.T) {
t.Run("priority 1: LetsEncryptDomain", func(t *testing.T) {
cfg := &nbconfig.Config{
HttpConfig: &nbconfig.HttpServerConfig{
LetsEncryptDomain: "mgmt.example.com",
AuthIssuer: "https://other.example.com/oauth2",
OIDCConfigEndpoint: "https://oidc.example.com/.well-known/openid-configuration",
},
}
domain, err := deriveDomain(cfg)
require.NoError(t, err)
assert.Equal(t, "mgmt.example.com", domain)
})
t.Run("priority 2: OIDCConfigEndpoint", func(t *testing.T) {
cfg := &nbconfig.Config{
HttpConfig: &nbconfig.HttpServerConfig{
OIDCConfigEndpoint: "https://oidc.example.com/.well-known/openid-configuration",
AuthIssuer: "https://issuer.example.com/oauth2",
},
}
domain, err := deriveDomain(cfg)
require.NoError(t, err)
assert.Equal(t, "oidc.example.com", domain)
})
t.Run("priority 3: AuthIssuer", func(t *testing.T) {
cfg := &nbconfig.Config{
HttpConfig: &nbconfig.HttpServerConfig{
AuthIssuer: "https://issuer.example.com/oauth2",
},
}
domain, err := deriveDomain(cfg)
require.NoError(t, err)
assert.Equal(t, "issuer.example.com", domain)
})
t.Run("priority 4: IdpManagerConfig issuer", func(t *testing.T) {
cfg := &nbconfig.Config{
IdpManagerConfig: &idp.Config{
ClientConfig: &idp.ClientConfig{
Issuer: "https://zitadel.example.com",
},
},
}
domain, err := deriveDomain(cfg)
require.NoError(t, err)
assert.Equal(t, "zitadel.example.com", domain)
})
t.Run("error when no domain found", func(t *testing.T) {
cfg := &nbconfig.Config{}
_, err := deriveDomain(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "could not determine domain")
})
}
func TestHostFromURL(t *testing.T) {
assert.Equal(t, "example.com", hostFromURL("https://example.com/path"))
assert.Equal(t, "example.com", hostFromURL("https://example.com:8080/path"))
assert.Equal(t, "", hostFromURL("not-a-url"))
}
func TestGenerateConfig(t *testing.T) {
t.Run("generates valid config", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "management.json")
originalConfig := `{
"Datadir": "/var/lib/netbird",
"HttpConfig": {
"LetsEncryptDomain": "mgmt.example.com",
"AuthIssuer": "https://zitadel.example.com/oauth2",
"AuthKeysLocation": "https://zitadel.example.com/oauth2/keys",
"OIDCConfigEndpoint": "https://zitadel.example.com/.well-known/openid-configuration",
"AuthClientID": "old-client-id",
"AuthUserIDClaim": "preferred_username"
},
"IdpManagerConfig": {
"ManagerType": "zitadel",
"ClientConfig": {
"Issuer": "https://zitadel.example.com",
"ClientID": "zit-id",
"ClientSecret": "zit-secret"
}
}
}`
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0600))
cfg := &nbconfig.Config{
HttpConfig: &nbconfig.HttpServerConfig{
LetsEncryptDomain: "mgmt.example.com",
},
}
conn := &dex.Connector{
Type: "zitadel",
Name: "zitadel",
ID: "zitadel",
Config: map[string]interface{}{
"issuer": "https://zitadel.example.com",
"clientID": "zit-id",
"clientSecret": "zit-secret",
},
}
err := generateConfig(configPath, conn, cfg, false)
require.NoError(t, err)
// Check backup was created
backupPath := configPath + ".bak"
backupData, err := os.ReadFile(backupPath)
require.NoError(t, err)
assert.Equal(t, originalConfig, string(backupData))
// Read and parse the new config
newData, err := os.ReadFile(configPath)
require.NoError(t, err)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(newData, &result))
// IdpManagerConfig should be removed
_, hasOldIdp := result["IdpManagerConfig"]
assert.False(t, hasOldIdp, "IdpManagerConfig should be removed")
// EmbeddedIdP should be present with minimal fields
embeddedIdP, ok := result["EmbeddedIdP"].(map[string]interface{})
require.True(t, ok, "EmbeddedIdP should be present")
assert.Equal(t, true, embeddedIdP["Enabled"])
assert.Equal(t, "https://mgmt.example.com/oauth2", embeddedIdP["Issuer"])
assert.Nil(t, embeddedIdP["LocalAuthDisabled"], "LocalAuthDisabled should not be set")
assert.Nil(t, embeddedIdP["SignKeyRefreshEnabled"], "SignKeyRefreshEnabled should not be set")
assert.Nil(t, embeddedIdP["CLIRedirectURIs"], "CLIRedirectURIs should not be set")
// HttpConfig should not be modified
_, hasPKCE := result["PKCEAuthorizationFlow"]
assert.False(t, hasPKCE, "PKCEAuthorizationFlow should not be added")
// Static connector's redirectURI should use the management domain
connectors := embeddedIdP["StaticConnectors"].([]interface{})
require.Len(t, connectors, 1)
firstConn := connectors[0].(map[string]interface{})
connCfg := firstConn["config"].(map[string]interface{})
assert.Equal(t, "https://mgmt.example.com/oauth2/callback", connCfg["redirectURI"],
"redirectURI should be overridden to use the management domain")
// Datadir should be preserved
assert.Equal(t, "/var/lib/netbird", result["Datadir"])
})
t.Run("dry run does not write files", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "management.json")
originalConfig := `{"HttpConfig": {"LetsEncryptDomain": "mgmt.example.com"}}`
require.NoError(t, os.WriteFile(configPath, []byte(originalConfig), 0600))
cfg := &nbconfig.Config{
HttpConfig: &nbconfig.HttpServerConfig{
LetsEncryptDomain: "mgmt.example.com",
},
}
conn := &dex.Connector{Type: "oidc", Name: "test", ID: "test"}
err := generateConfig(configPath, conn, cfg, true)
require.NoError(t, err)
// Original should be unchanged
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Equal(t, originalConfig, string(data))
// No backup should exist
_, err = os.Stat(configPath + ".bak")
assert.True(t, os.IsNotExist(err))
})
}