Compare commits
21 Commits
main
...
combined-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5efb8d19 | ||
|
|
86b148dcd8 | ||
|
|
e4e6294ea1 | ||
|
|
527bdcfa1d | ||
|
|
7237c9ac1b | ||
|
|
30db103331 | ||
|
|
287748932b | ||
|
|
a4945fb644 | ||
|
|
f60d86862c | ||
|
|
c4836c03e3 | ||
|
|
0d8ffd177c | ||
|
|
90d1a7bc0b | ||
|
|
b716049e24 | ||
|
|
0188034d43 | ||
|
|
dbe83c57c8 | ||
|
|
1775067a15 | ||
|
|
7ff7690eb4 | ||
|
|
51ddd69a1b | ||
|
|
a1d8c275ca | ||
|
|
9769397347 | ||
|
|
7a10fa61c7 |
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
61
management/server/activity/store/sql_store_idp_migration.go
Normal file
61
management/server/activity/store/sql_store_idp_migration.go
Normal 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
|
||||
})
|
||||
}
|
||||
161
management/server/activity/store/sql_store_idp_migration_test.go
Normal file
161
management/server/activity/store/sql_store_idp_migration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
235
management/server/idp/migration/migration.go
Normal file
235
management/server/idp/migration/migration.go
Normal 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
|
||||
}
|
||||
828
management/server/idp/migration/migration_test.go
Normal file
828
management/server/idp/migration/migration_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
82
management/server/idp/migration/store.go
Normal file
82
management/server/idp/migration/store.go
Normal 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
|
||||
}
|
||||
128
management/server/store/sql_store_idp_migration.go
Normal file
128
management/server/store/sql_store_idp_migration.go
Normal 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
|
||||
}
|
||||
209
tools/idp-migrate/DEVELOPMENT.md
Normal file
209
tools/idp-migrate/DEVELOPMENT.md
Normal 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.
|
||||
477
tools/idp-migrate/MIGRATION_GUIDE.md
Normal file
477
tools/idp-migrate/MIGRATION_GUIDE.md
Normal 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
613
tools/idp-migrate/main.go
Normal 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)
|
||||
587
tools/idp-migrate/main_test.go
Normal file
587
tools/idp-migrate/main_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user