diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 963fb7c44..1aabfb2dd 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -43,7 +43,7 @@ type migrationServer struct { eventStore migration.MigrationEventStore } -func (m *migrationServer) Store() migration.MigrationStore { return m.store } +func (m *migrationServer) Store() migration.MigrationStore { return m.store } func (m *migrationServer) EventStore() migration.MigrationEventStore { return m.eventStore } func (s *BaseServer) GeoLocationManager() geolocation.Geolocation { @@ -123,37 +123,6 @@ func (s *BaseServer) AccountManager() account.Manager { }) } -func (s *BaseServer) seedIDPConnectors() error { - if !migration.IsSeedInfoPresent() { - return nil - } - - conn, err := migration.SeedConnectorFromEnv() - if err != nil { - log.Fatalf("failed to parse IDP_SEED_INFO: %v", err) - } - - log.Infof("seeding IDP connector from environment: id=%s type=%s", conn.ID, conn.Type) - s.Config.EmbeddedIdP.StaticConnectors = append(s.Config.EmbeddedIdP.StaticConnectors, *conn) - - s.AfterInit(func(s *BaseServer) { - ms, ok := s.Store().(migration.MigrationStore) - if !ok { - log.Fatalf("store does not support migration operations") - } - var mes migration.MigrationEventStore - if es, ok := s.EventStore().(migration.MigrationEventStore); ok { - mes = es - } - srv := &migrationServer{store: ms, eventStore: mes} - if err := migration.MigrateUsersToStaticConnectors(srv, conn); err != nil { - log.Fatalf("failed to migrate users to static connectors: %v", err) - } - }) - - return nil -} - func (s *BaseServer) IdpManager() idp.Manager { return Create(s, func() idp.Manager { var idpManager idp.Manager @@ -163,11 +132,6 @@ func (s *BaseServer) IdpManager() idp.Manager { // Legacy IdpManager won't be used anymore even if configured. embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled if embeddedEnabled { - err := s.seedIDPConnectors() - if err != nil { - log.Fatalf("failed to seed IDP connectors: %v", err) - } - idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics()) if err != nil { log.Fatalf("failed to create embedded IDP service: %v", err) diff --git a/management/server/idp/migration/migration.go b/management/server/idp/migration/migration.go index 818df35ae..c0a91d482 100644 --- a/management/server/idp/migration/migration.go +++ b/management/server/idp/migration/migration.go @@ -13,6 +13,7 @@ import ( 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" ) @@ -147,3 +148,92 @@ func migrateUser(ctx context.Context, s Server, oldID, accountID, newID string) 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 +} diff --git a/management/server/idp/migration/migration_test.go b/management/server/idp/migration/migration_test.go index 657bb4f33..d7387737f 100644 --- a/management/server/idp/migration/migration_test.go +++ b/management/server/idp/migration/migration_test.go @@ -12,14 +12,17 @@ import ( "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 - updateCalls []updateUserIDCall + 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 + updateCalls []updateUserIDCall + updateInfoCalls []updateUserInfoCall } type updateUserIDCall struct { @@ -28,6 +31,12 @@ type updateUserIDCall struct { 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) } @@ -37,6 +46,14 @@ func (s *testStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newU 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 +} + type testServer struct { store MigrationStore eventStore MigrationEventStore @@ -365,3 +382,380 @@ func TestMigrateUsersToStaticConnectors(t *testing.T) { 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) + }) +} diff --git a/management/server/idp/migration/store.go b/management/server/idp/migration/store.go index fd5d4f997..3a9a95ac6 100644 --- a/management/server/idp/migration/store.go +++ b/management/server/idp/migration/store.go @@ -20,6 +20,9 @@ type MigrationStore interface { // 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 } // MigrationEventStore defines the activity event store operations required for migration. diff --git a/management/server/store/sql_store_idp_migration.go b/management/server/store/sql_store_idp_migration.go index b5e7bf3a8..34f3359af 100644 --- a/management/server/store/sql_store_idp_migration.go +++ b/management/server/store/sql_store_idp_migration.go @@ -47,6 +47,24 @@ func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { } } +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 diff --git a/tools/idp-migrate/main.go b/tools/idp-migrate/main.go index 93d6bccfe..d2834ef6d 100644 --- a/tools/idp-migrate/main.go +++ b/tools/idp-migrate/main.go @@ -26,8 +26,9 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/idp/dex" - activitystore "github.com/netbirdio/netbird/management/server/activity/store" 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" @@ -41,18 +42,19 @@ type migrationServer struct { eventStore migration.MigrationEventStore } -func (s *migrationServer) Store() migration.MigrationStore { return s.store } +func (s *migrationServer) Store() migration.MigrationStore { return s.store } func (s *migrationServer) EventStore() migration.MigrationEventStore { return s.eventStore } func main() { var ( - configPath string - dataDir string - idpSeedInfo string - dryRun bool - force bool - skipConfig bool - logLevel string + 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)") @@ -61,6 +63,7 @@ func main() { 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() @@ -69,12 +72,12 @@ func main() { os.Exit(1) } - if err := run(configPath, dataDir, idpSeedInfo, dryRun, force, skipConfig); err != nil { + 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 bool) error { +func run(configPath, dataDirOverride, idpSeedInfo string, dryRun, force, skipConfig, skipPopulateUserInfo bool) error { if configPath == "" { return fmt.Errorf("--config is required") } @@ -92,6 +95,13 @@ func run(configPath, dataDirOverride, idpSeedInfo string, dryRun, force, skipCon return fmt.Errorf("data directory not set: use --datadir or set Datadir in management.json") } + 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 { return fmt.Errorf("resolve connector: %w", err) @@ -117,6 +127,36 @@ func run(configPath, dataDirOverride, idpSeedInfo string, dryRun, force, skipCon return generateConfig(configPath, conn, cfg, dryRun) } +// 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.MigrationStore, migration.MigrationEventStore, func(), error) { @@ -474,9 +514,9 @@ func generateConfig(configPath string, conn *dex.Connector, cfg *nbconfig.Config configMap["PKCEAuthorizationFlow"] = map[string]interface{}{ "ProviderConfig": map[string]interface{}{ "Audience": "netbird-cli", - "ClientID": "netbird-cli", - "ClientSecret": "", - "Scope": "openid profile email offline_access", + "ClientID": "netbird-cli", + "ClientSecret": "", + "Scope": "openid profile email offline_access", "AuthorizationEndpoint": dexIssuer + "/auth", "TokenEndpoint": dexIssuer + "/token", "DeviceAuthEndpoint": dexIssuer + "/device/code",