conflicts resolution

This commit is contained in:
crn4
2026-01-08 13:25:10 +01:00
235 changed files with 24095 additions and 2455 deletions
+59 -32
View File
@@ -243,7 +243,7 @@ func BuildManager(
am.externalCacheManager = nbcache.NewUserDataCache(cacheStore)
am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore)
if !isNil(am.idpManager) {
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
go func() {
err := am.warmupIDPCache(ctx, cacheStore)
if err != nil {
@@ -321,7 +321,8 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled ||
oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled ||
oldSettings.DNSDomain != newSettings.DNSDomain {
oldSettings.DNSDomain != newSettings.DNSDomain ||
oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion {
updateAccountPeers = true
}
@@ -332,8 +333,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
}
}
newSettings.Extra.IntegratedValidatorGroups = oldSettings.Extra.IntegratedValidatorGroups
newSettings.Extra.IntegratedValidator = oldSettings.Extra.IntegratedValidator
if newSettings.Extra == nil {
newSettings.Extra = oldSettings.Extra
}
if err = transaction.SaveAccountSettings(ctx, accountID, newSettings); err != nil {
return err
@@ -360,6 +362,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
am.handleLazyConnectionSettings(ctx, oldSettings, newSettings, userID, accountID)
am.handlePeerLoginExpirationSettings(ctx, oldSettings, newSettings, userID, accountID)
am.handleGroupsPropagationSettings(ctx, oldSettings, newSettings, userID, accountID)
am.handleAutoUpdateVersionSettings(ctx, oldSettings, newSettings, userID, accountID)
if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil {
return nil, err
}
@@ -451,6 +454,14 @@ func (am *DefaultAccountManager) handleGroupsPropagationSettings(ctx context.Con
}
}
func (am *DefaultAccountManager) handleAutoUpdateVersionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) {
if oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion {
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateVersionUpdated, map[string]any{
"version": newSettings.AutoUpdateVersion,
})
}
}
func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error {
if newSettings.PeerInactivityExpirationEnabled {
if oldSettings.PeerInactivityExpiration != newSettings.PeerInactivityExpiration {
@@ -546,7 +557,7 @@ func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx co
// newAccount creates a new Account with a generated ID and generated default setup keys.
// If ID is already in use (due to collision) we try one more time before returning error
func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain string) (*types.Account, error) {
func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain, email, name string) (*types.Account, error) {
for i := 0; i < 2; i++ {
accountId := xid.New().String()
@@ -557,7 +568,7 @@ func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain
log.WithContext(ctx).Warnf("an account with ID already exists, retrying...")
continue
case statusErr.Type() == status.NotFound:
newAccount := newAccountWithId(ctx, accountId, userID, domain, am.disableDefaultPolicy)
newAccount := newAccountWithId(ctx, accountId, userID, domain, email, name, am.disableDefaultPolicy)
am.StoreEvent(ctx, userID, newAccount.Id, accountId, activity.AccountCreated, nil)
return newAccount, nil
default:
@@ -730,23 +741,23 @@ func (am *DefaultAccountManager) AccountExists(ctx context.Context, accountID st
// If user does have an account, it returns the user's account ID.
// If the user doesn't have an account, it creates one using the provided domain.
// Returns the account ID or an error if none is found or created.
func (am *DefaultAccountManager) GetAccountIDByUserID(ctx context.Context, userID, domain string) (string, error) {
if userID == "" {
func (am *DefaultAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) {
if userAuth.UserId == "" {
return "", status.Errorf(status.NotFound, "no valid userID provided")
}
accountID, err := am.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userID)
accountID, err := am.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
if err != nil {
if s, ok := status.FromError(err); ok && s.Type() == status.NotFound {
account, err := am.GetOrCreateAccountByUser(ctx, userID, domain)
acc, err := am.GetOrCreateAccountByUser(ctx, userAuth)
if err != nil {
return "", status.Errorf(status.NotFound, "account not found or created for user id: %s", userID)
return "", status.Errorf(status.NotFound, "account not found or created for user id: %s", userAuth.UserId)
}
if err = am.addAccountIDToIDPAppMeta(ctx, userID, account.Id); err != nil {
if err = am.addAccountIDToIDPAppMeta(ctx, userAuth.UserId, acc.Id); err != nil {
return "", err
}
return account.Id, nil
return acc.Id, nil
}
return "", err
}
@@ -757,9 +768,19 @@ func isNil(i idp.Manager) bool {
return i == nil || reflect.ValueOf(i).IsNil()
}
// IsEmbeddedIdp checks if the IDP manager is an embedded IDP (data stored locally in DB).
// When true, user cache should be skipped and data fetched directly from the IDP manager.
func IsEmbeddedIdp(i idp.Manager) bool {
if isNil(i) {
return false
}
_, ok := i.(*idp.EmbeddedIdPManager)
return ok
}
// addAccountIDToIDPAppMeta update user's app metadata in idp manager
func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error {
if !isNil(am.idpManager) {
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
// user can be nil if it wasn't found (e.g., just created)
user, err := am.lookupUserInCache(ctx, userID, accountID)
if err != nil {
@@ -1005,6 +1026,9 @@ func (am *DefaultAccountManager) isCacheFresh(ctx context.Context, accountUsers
}
func (am *DefaultAccountManager) removeUserFromCache(ctx context.Context, accountID, userID string) error {
if IsEmbeddedIdp(am.idpManager) {
return nil
}
data, err := am.getAccountFromCache(ctx, accountID, false)
if err != nil {
return err
@@ -1096,7 +1120,7 @@ func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domai
lowerDomain := strings.ToLower(userAuth.Domain)
newAccount, err := am.newAccount(ctx, userAuth.UserId, lowerDomain)
newAccount, err := am.newAccount(ctx, userAuth.UserId, lowerDomain, userAuth.Email, userAuth.Name)
if err != nil {
return "", err
}
@@ -1121,7 +1145,7 @@ func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domai
}
func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context, domainAccountID string, userAuth auth.UserAuth) (string, error) {
newUser := types.NewRegularUser(userAuth.UserId)
newUser := types.NewRegularUser(userAuth.UserId, userAuth.Email, userAuth.Name)
newUser.AccountID = domainAccountID
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, domainAccountID)
@@ -1304,6 +1328,7 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
if err != nil {
// this is not really possible because we got an account by user ID
log.Errorf("failed to get user by ID %s: %v", userAuth.UserId, err)
return "", "", status.Errorf(status.NotFound, "user %s not found", userAuth.UserId)
}
@@ -1446,21 +1471,19 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
}
}
if settings.GroupsPropagationEnabled {
removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups)
if err != nil {
return err
}
removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups)
if err != nil {
return err
}
newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups)
if err != nil {
return err
}
newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups)
if err != nil {
return err
}
if removedGroupAffectsPeers || newGroupsAffectsPeers {
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId)
}
if removedGroupAffectsPeers || newGroupsAffectsPeers {
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId)
}
return nil
@@ -1503,7 +1526,7 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context
}
if userAuth.DomainCategory != types.PrivateCategory || !isDomainValid(userAuth.Domain) {
return am.GetAccountIDByUserID(ctx, userAuth.UserId, userAuth.Domain)
return am.GetAccountIDByUserID(ctx, userAuth)
}
if userAuth.AccountId != "" {
@@ -1725,7 +1748,7 @@ func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, account
}
// newAccountWithId creates a new Account with a default SetupKey (doesn't store in a Store) and provided id
func newAccountWithId(ctx context.Context, accountID, userID, domain string, disableDefaultPolicy bool) *types.Account {
func newAccountWithId(ctx context.Context, accountID, userID, domain, email, name string, disableDefaultPolicy bool) *types.Account {
log.WithContext(ctx).Debugf("creating new account")
network := types.NewNetwork()
@@ -1735,7 +1758,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis
setupKeys := map[string]*types.SetupKey{}
nameServersGroups := make(map[string]*nbdns.NameServerGroup)
owner := types.NewOwnerUser(userID)
owner := types.NewOwnerUser(userID, email, name)
owner.AccountID = accountID
users[userID] = owner
@@ -2148,3 +2171,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
return nil
}
func (am *DefaultAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) {
return am.Store.GetUserIDByPeerKey(ctx, store.LockingStrengthNone, peerKey)
}
+8 -2
View File
@@ -24,7 +24,7 @@ import (
type ExternalCacheManager nbcache.UserDataCache
type Manager interface {
GetOrCreateAccountByUser(ctx context.Context, userId, domain string) (*types.Account, error)
GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error)
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType, expiresIn time.Duration,
autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error)
@@ -44,7 +44,7 @@ type Manager interface {
GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error)
GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error)
AccountExists(ctx context.Context, accountID string) (bool, error)
GetAccountIDByUserID(ctx context.Context, userID, domain string) (string, error)
GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error)
GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error)
DeleteAccount(ctx context.Context, accountID, userID string) error
GetUserByID(ctx context.Context, id string) (*types.User, error)
@@ -123,4 +123,10 @@ type Manager interface {
UpdateToPrimaryAccount(ctx context.Context, accountId string) error
GetOwnerInfo(ctx context.Context, accountId string) (*types.UserInfo, error)
GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error)
GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error)
GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error)
GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error)
CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error
}
+28 -28
View File
@@ -382,7 +382,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
}
for _, testCase := range tt {
account := newAccountWithId(context.Background(), "account-1", userID, "netbird.io", false)
account := newAccountWithId(context.Background(), "account-1", userID, "netbird.io", "", "", false)
account.UpdateSettings(&testCase.accountSettings)
account.Network = network
account.Peers = testCase.peers
@@ -397,7 +397,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) {
}
customZone := account.GetPeersCustomZone(context.Background(), "netbird.io")
networkMap := account.GetPeerNetworkMap(context.Background(), testCase.peerID, customZone, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil)
networkMap := account.GetPeerNetworkMap(context.Background(), testCase.peerID, customZone, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
assert.Len(t, networkMap.Peers, len(testCase.expectedPeers))
assert.Len(t, networkMap.OfflinePeers, len(testCase.expectedOfflinePeers))
}
@@ -407,7 +407,7 @@ func TestNewAccount(t *testing.T) {
domain := "netbird.io"
userId := "account_creator"
accountID := "account_id"
account := newAccountWithId(context.Background(), accountID, userId, domain, false)
account := newAccountWithId(context.Background(), accountID, userId, domain, "", "", false)
verifyNewAccountHasDefaultFields(t, account, userId, domain, []string{userId})
}
@@ -418,7 +418,7 @@ func TestAccountManager_GetOrCreateAccountByUser(t *testing.T) {
return
}
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID, Domain: ""})
if err != nil {
t.Fatal(err)
}
@@ -612,7 +612,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), testCase.inputInitUserParams.UserId, testCase.inputInitUserParams.Domain)
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: testCase.inputInitUserParams.UserId, Domain: testCase.inputInitUserParams.Domain})
require.NoError(t, err, "create init user failed")
initAccount, err := manager.Store.GetAccount(context.Background(), accountID)
@@ -649,10 +649,10 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) {
func TestDefaultAccountManager_SyncUserJWTGroups(t *testing.T) {
userId := "user-id"
domain := "test.domain"
_ = newAccountWithId(context.Background(), "", userId, domain, false)
_ = newAccountWithId(context.Background(), "", userId, domain, "", "", false)
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userId, domain)
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userId, Domain: domain})
require.NoError(t, err, "create init user failed")
// as initAccount was created without account id we have to take the id after account initialization
// that happens inside the GetAccountIDByUserID where the id is getting generated
@@ -718,7 +718,7 @@ func TestAccountManager_PrivateAccount(t *testing.T) {
}
userId := "test_user"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userId, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: ""})
if err != nil {
t.Fatal(err)
}
@@ -745,7 +745,7 @@ func TestAccountManager_SetOrUpdateDomain(t *testing.T) {
userId := "test_user"
domain := "hotmail.com"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userId, domain)
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: domain})
if err != nil {
t.Fatal(err)
}
@@ -759,7 +759,7 @@ func TestAccountManager_SetOrUpdateDomain(t *testing.T) {
domain = "gmail.com"
account, err = manager.GetOrCreateAccountByUser(context.Background(), userId, domain)
account, err = manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: domain})
if err != nil {
t.Fatalf("got the following error while retrieving existing acc: %v", err)
}
@@ -782,7 +782,7 @@ func TestAccountManager_GetAccountByUserID(t *testing.T) {
userId := "test_user"
accountID, err := manager.GetAccountIDByUserID(context.Background(), userId, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userId, Domain: ""})
if err != nil {
t.Fatal(err)
}
@@ -795,14 +795,14 @@ func TestAccountManager_GetAccountByUserID(t *testing.T) {
assert.NoError(t, err)
assert.True(t, exists, "expected to get existing account after creation using userid")
_, err = manager.GetAccountIDByUserID(context.Background(), "", "")
_, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: "", Domain: ""})
if err == nil {
t.Errorf("expected an error when user ID is empty")
}
}
func createAccount(am *DefaultAccountManager, accountID, userID, domain string) (*types.Account, error) {
account := newAccountWithId(context.Background(), accountID, userID, domain, false)
account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false)
err := am.Store.SaveAccount(context.Background(), account)
if err != nil {
return nil, err
@@ -1098,7 +1098,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) {
return
}
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "netbird.cloud")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID, Domain: "netbird.cloud"})
if err != nil {
t.Fatal(err)
}
@@ -1849,7 +1849,7 @@ func TestDefaultAccountManager_DefaultAccountSettings(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
settings, err := manager.Store.GetAccountSettings(context.Background(), store.LockingStrengthNone, accountID)
@@ -1864,7 +1864,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
_, err = manager.GetAccountIDByUserID(context.Background(), userID, "")
_, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
key, err := wgtypes.GenerateKey()
@@ -1876,7 +1876,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
}, false)
require.NoError(t, err, "unable to add peer")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to get the account")
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID)
@@ -1920,7 +1920,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
key, err := wgtypes.GenerateKey()
@@ -1946,7 +1946,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
},
}
accountID, err = manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to get the account")
// when we mark peer as connected, the peer login expiration routine should trigger
@@ -1963,7 +1963,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
_, err = manager.GetAccountIDByUserID(context.Background(), userID, "")
_, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
key, err := wgtypes.GenerateKey()
@@ -1975,7 +1975,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
}, false)
require.NoError(t, err, "unable to add peer")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to get the account")
account, err := manager.Store.GetAccount(context.Background(), accountID)
@@ -2025,7 +2025,7 @@ func TestDefaultAccountManager_UpdateAccountSettings(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
updatedSettings, err := manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
@@ -3434,7 +3434,7 @@ func TestDefaultAccountManager_IsCacheCold(t *testing.T) {
assert.True(t, cold)
})
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
t.Run("should return true when account is not found in cache", func(t *testing.T) {
@@ -3462,7 +3462,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) {
initiatorId := "test-user"
domain := "example.com"
account, err := manager.GetOrCreateAccountByUser(ctx, initiatorId, domain)
account, err := manager.GetOrCreateAccountByUser(ctx, auth.UserAuth{UserId: initiatorId, Domain: domain})
require.NoError(t, err)
peer1 := &nbpeer.Peer{ID: "peer1", AccountID: account.Id, UserID: initiatorId, IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"}
@@ -3575,7 +3575,7 @@ func TestDefaultAccountManager_GetAccountOnboarding(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err)
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
t.Run("should return account onboarding when onboarding exist", func(t *testing.T) {
@@ -3607,7 +3607,7 @@ func TestDefaultAccountManager_UpdateAccountOnboarding(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err)
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
onboarding := &types.AccountOnboarding{
@@ -3646,7 +3646,7 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
key1, err := wgtypes.GenerateKey()
@@ -3717,7 +3717,7 @@ func TestAddNewUserToDomainAccountWithApproval(t *testing.T) {
// Create a domain-based account with user approval enabled
existingAccountID := "existing-account"
account := newAccountWithId(context.Background(), existingAccountID, "owner-user", "example.com", false)
account := newAccountWithId(context.Background(), existingAccountID, "owner-user", "example.com", "", "", false)
account.Settings.Extra = &types.ExtraSettings{
UserApprovalRequired: true,
}
+16 -3
View File
@@ -181,6 +181,12 @@ const (
UserRejected Activity = 90
UserCreated Activity = 91
AccountAutoUpdateVersionUpdated Activity = 92
IdentityProviderCreated Activity = 93
IdentityProviderUpdated Activity = 94
IdentityProviderDeleted Activity = 95
AccountDeleted Activity = 99999
)
@@ -287,9 +293,16 @@ var activityMap = map[Activity]Code{
AccountNetworkRangeUpdated: {"Account network range updated", "account.network.range.update"},
PeerIPUpdated: {"Peer IP updated", "peer.ip.update"},
UserApproved: {"User approved", "user.approve"},
UserRejected: {"User rejected", "user.reject"},
UserCreated: {"User created", "user.create"},
UserApproved: {"User approved", "user.approve"},
UserRejected: {"User rejected", "user.reject"},
UserCreated: {"User created", "user.create"},
AccountAutoUpdateVersionUpdated: {"Account AutoUpdate Version updated", "account.settings.auto.version.update"},
IdentityProviderCreated: {"Identity provider created", "identityprovider.create"},
IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"},
IdentityProviderDeleted: {"Identity provider deleted", "identityprovider.delete"},
}
// StringCode returns a string code of the activity
+1 -2
View File
@@ -49,8 +49,7 @@ func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim s
)
return &manager{
store: store,
store: store,
validator: jwtValidator,
extractor: claimsExtractor,
}
+1 -1
View File
@@ -277,7 +277,7 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account
domain := "example.com"
account := newAccountWithId(context.Background(), dnsAccountID, dnsAdminUserID, domain, false)
account := newAccountWithId(context.Background(), dnsAccountID, dnsAdminUserID, domain, "", "", false)
account.Users[dnsRegularUserID] = &types.User{
Id: dnsRegularUserID,
+5 -1
View File
@@ -427,7 +427,7 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
for _, groupID := range groupIDs {
group, err := transaction.GetGroupByID(ctx, store.LockingStrengthUpdate, accountID, groupID)
group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
if err != nil {
allErrors = errors.Join(allErrors, err)
continue
@@ -442,6 +442,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
deletedGroups = append(deletedGroups, group)
}
if len(groupIDsToDelete) == 0 {
return allErrors
}
if err = transaction.DeleteGroups(ctx, accountID, groupIDsToDelete); err != nil {
return err
}
+1 -1
View File
@@ -379,7 +379,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
Id: "example user",
AutoGroups: []string{groupForUsers.ID},
}
account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, false)
account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, "", "", false)
account.Routes[routeResource.ID] = routeResource
account.Routes[routePeerGroupResource.ID] = routePeerGroupResource
account.NameServerGroups[nameServerGroup.ID] = nameServerGroup
+29 -18
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/gorilla/mux"
idpmanager "github.com/netbirdio/netbird/management/server/idp"
"github.com/rs/cors"
log "github.com/sirupsen/logrus"
@@ -29,6 +30,8 @@ import (
"github.com/netbirdio/netbird/management/server/http/handlers/dns"
"github.com/netbirdio/netbird/management/server/http/handlers/events"
"github.com/netbirdio/netbird/management/server/http/handlers/groups"
"github.com/netbirdio/netbird/management/server/http/handlers/idp"
"github.com/netbirdio/netbird/management/server/http/handlers/instance"
"github.com/netbirdio/netbird/management/server/http/handlers/networks"
"github.com/netbirdio/netbird/management/server/http/handlers/peers"
"github.com/netbirdio/netbird/management/server/http/handlers/policies"
@@ -36,6 +39,8 @@ import (
"github.com/netbirdio/netbird/management/server/http/handlers/setup_keys"
"github.com/netbirdio/netbird/management/server/http/handlers/users"
"github.com/netbirdio/netbird/management/server/http/middleware"
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
nbinstance "github.com/netbirdio/netbird/management/server/instance"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
nbnetworks "github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
@@ -51,23 +56,15 @@ const (
)
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
func NewAPIHandler(
ctx context.Context,
accountManager account.Manager,
networksManager nbnetworks.Manager,
resourceManager resources.Manager,
routerManager routers.Manager,
groupsManager nbgroups.Manager,
LocationManager geolocation.Geolocation,
authManager auth.Manager,
appMetrics telemetry.AppMetrics,
integratedValidator integrated_validator.IntegratedValidator,
proxyController port_forwarding.Controller,
permissionsManager permissions.Manager,
peersManager nbpeers.Manager,
settingsManager settings.Manager,
networkMapController network_map.Controller,
) (http.Handler, error) {
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) {
// Register bypass paths for unauthenticated endpoints
if err := bypass.AddBypassPath("/api/instance"); err != nil {
return nil, fmt.Errorf("failed to add bypass path: %w", err)
}
if err := bypass.AddBypassPath("/api/setup"); err != nil {
return nil, fmt.Errorf("failed to add bypass path: %w", err)
}
var rateLimitingConfig *middleware.RateLimiterConfig
if os.Getenv(rateLimitingEnabledKey) == "true" {
@@ -122,7 +119,14 @@ func NewAPIHandler(
return nil, fmt.Errorf("register integrations endpoints: %w", err)
}
accounts.AddEndpoints(accountManager, settingsManager, router)
// Check if embedded IdP is enabled
embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager)
instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP)
if err != nil {
return nil, fmt.Errorf("failed to create instance manager: %w", err)
}
accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router)
peers.AddEndpoints(accountManager, router, networkMapController)
users.AddEndpoints(accountManager, router)
setup_keys.AddEndpoints(accountManager, router)
@@ -134,6 +138,13 @@ func NewAPIHandler(
dns.AddEndpoints(accountManager, router)
events.AddEndpoints(accountManager, router)
networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router)
idp.AddEndpoints(accountManager, router)
instance.AddEndpoints(instanceManager, router)
// Mount embedded IdP handler at /oauth2 path if configured
if embeddedIdpEnabled {
rootRouter.PathPrefix("/oauth2").Handler(corsMiddleware.Handler(embeddedIdP.Handler()))
}
return rootRouter, nil
}
@@ -3,12 +3,15 @@ package accounts
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"time"
"github.com/gorilla/mux"
goversion "github.com/hashicorp/go-version"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/settings"
@@ -26,27 +29,31 @@ const (
// MinNetworkBits is the minimum prefix length for IPv4 network ranges (e.g., /29 gives 8 addresses, /28 gives 16)
MinNetworkBitsIPv4 = 28
// MinNetworkBitsIPv6 is the minimum prefix length for IPv6 network ranges
MinNetworkBitsIPv6 = 120
MinNetworkBitsIPv6 = 120
disableAutoUpdate = "disabled"
autoUpdateLatestVersion = "latest"
)
// handler is a handler that handles the server.Account HTTP endpoints
type handler struct {
accountManager account.Manager
settingsManager settings.Manager
accountManager account.Manager
settingsManager settings.Manager
embeddedIdpEnabled bool
}
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, router *mux.Router) {
accountsHandler := newHandler(accountManager, settingsManager)
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool, router *mux.Router) {
accountsHandler := newHandler(accountManager, settingsManager, embeddedIdpEnabled)
router.HandleFunc("/accounts/{accountId}", accountsHandler.updateAccount).Methods("PUT", "OPTIONS")
router.HandleFunc("/accounts/{accountId}", accountsHandler.deleteAccount).Methods("DELETE", "OPTIONS")
router.HandleFunc("/accounts", accountsHandler.getAllAccounts).Methods("GET", "OPTIONS")
}
// newHandler creates a new handler HTTP handler
func newHandler(accountManager account.Manager, settingsManager settings.Manager) *handler {
func newHandler(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool) *handler {
return &handler{
accountManager: accountManager,
settingsManager: settingsManager,
accountManager: accountManager,
settingsManager: settingsManager,
embeddedIdpEnabled: embeddedIdpEnabled,
}
}
@@ -158,10 +165,65 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
return
}
resp := toAccountResponse(accountID, settings, meta, onboarding)
resp := toAccountResponse(accountID, settings, meta, onboarding, h.embeddedIdpEnabled)
util.WriteJSONObject(r.Context(), w, []*api.Account{resp})
}
func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJSONRequestBody) (*types.Settings, error) {
returnSettings := &types.Settings{
PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled,
PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)),
RegularUsersViewBlocked: req.Settings.RegularUsersViewBlocked,
PeerInactivityExpirationEnabled: req.Settings.PeerInactivityExpirationEnabled,
PeerInactivityExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerInactivityExpiration)),
}
if req.Settings.Extra != nil {
returnSettings.Extra = &types.ExtraSettings{
PeerApprovalEnabled: req.Settings.Extra.PeerApprovalEnabled,
UserApprovalRequired: req.Settings.Extra.UserApprovalRequired,
FlowEnabled: req.Settings.Extra.NetworkTrafficLogsEnabled,
FlowGroups: req.Settings.Extra.NetworkTrafficLogsGroups,
FlowPacketCounterEnabled: req.Settings.Extra.NetworkTrafficPacketCounterEnabled,
}
}
if req.Settings.JwtGroupsEnabled != nil {
returnSettings.JWTGroupsEnabled = *req.Settings.JwtGroupsEnabled
}
if req.Settings.GroupsPropagationEnabled != nil {
returnSettings.GroupsPropagationEnabled = *req.Settings.GroupsPropagationEnabled
}
if req.Settings.JwtGroupsClaimName != nil {
returnSettings.JWTGroupsClaimName = *req.Settings.JwtGroupsClaimName
}
if req.Settings.JwtAllowGroups != nil {
returnSettings.JWTAllowGroups = *req.Settings.JwtAllowGroups
}
if req.Settings.RoutingPeerDnsResolutionEnabled != nil {
returnSettings.RoutingPeerDNSResolutionEnabled = *req.Settings.RoutingPeerDnsResolutionEnabled
}
if req.Settings.DnsDomain != nil {
returnSettings.DNSDomain = *req.Settings.DnsDomain
}
if req.Settings.LazyConnectionEnabled != nil {
returnSettings.LazyConnectionEnabled = *req.Settings.LazyConnectionEnabled
}
if req.Settings.AutoUpdateVersion != nil {
_, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion)
if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion ||
*req.Settings.AutoUpdateVersion == disableAutoUpdate ||
err == nil {
returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion
} else if *req.Settings.AutoUpdateVersion != "" {
return nil, fmt.Errorf("invalid AutoUpdateVersion")
}
}
return returnSettings, nil
}
// updateAccount is HTTP PUT handler that updates the provided account. Updates only account settings (server.Settings)
func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
@@ -186,45 +248,10 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
return
}
settings := &types.Settings{
PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled,
PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)),
RegularUsersViewBlocked: req.Settings.RegularUsersViewBlocked,
PeerInactivityExpirationEnabled: req.Settings.PeerInactivityExpirationEnabled,
PeerInactivityExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerInactivityExpiration)),
}
if req.Settings.Extra != nil {
settings.Extra = &types.ExtraSettings{
PeerApprovalEnabled: req.Settings.Extra.PeerApprovalEnabled,
UserApprovalRequired: req.Settings.Extra.UserApprovalRequired,
FlowEnabled: req.Settings.Extra.NetworkTrafficLogsEnabled,
FlowGroups: req.Settings.Extra.NetworkTrafficLogsGroups,
FlowPacketCounterEnabled: req.Settings.Extra.NetworkTrafficPacketCounterEnabled,
}
}
if req.Settings.JwtGroupsEnabled != nil {
settings.JWTGroupsEnabled = *req.Settings.JwtGroupsEnabled
}
if req.Settings.GroupsPropagationEnabled != nil {
settings.GroupsPropagationEnabled = *req.Settings.GroupsPropagationEnabled
}
if req.Settings.JwtGroupsClaimName != nil {
settings.JWTGroupsClaimName = *req.Settings.JwtGroupsClaimName
}
if req.Settings.JwtAllowGroups != nil {
settings.JWTAllowGroups = *req.Settings.JwtAllowGroups
}
if req.Settings.RoutingPeerDnsResolutionEnabled != nil {
settings.RoutingPeerDNSResolutionEnabled = *req.Settings.RoutingPeerDnsResolutionEnabled
}
if req.Settings.DnsDomain != nil {
settings.DNSDomain = *req.Settings.DnsDomain
}
if req.Settings.LazyConnectionEnabled != nil {
settings.LazyConnectionEnabled = *req.Settings.LazyConnectionEnabled
settings, err := h.updateAccountRequestSettings(req)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" {
prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange)
@@ -265,7 +292,7 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
return
}
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding)
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding, h.embeddedIdpEnabled)
util.WriteJSONObject(r.Context(), w, &resp)
}
@@ -294,7 +321,7 @@ func (h *handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding) *api.Account {
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding, embeddedIdpEnabled bool) *api.Account {
jwtAllowGroups := settings.JWTAllowGroups
if jwtAllowGroups == nil {
jwtAllowGroups = []string{}
@@ -313,6 +340,8 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
RoutingPeerDnsResolutionEnabled: &settings.RoutingPeerDNSResolutionEnabled,
LazyConnectionEnabled: &settings.LazyConnectionEnabled,
DnsDomain: &settings.DNSDomain,
AutoUpdateVersion: &settings.AutoUpdateVersion,
EmbeddedIdpEnabled: &embeddedIdpEnabled,
}
if settings.NetworkRange.IsValid() {
@@ -33,6 +33,7 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler {
AnyTimes()
return &handler{
embeddedIdpEnabled: false,
accountManager: &mock_server.MockAccountManager{
GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
return account.Settings, nil
@@ -121,6 +122,8 @@ func TestAccounts_AccountsHandler(t *testing.T) {
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
},
expectedArray: true,
expectedID: accountID,
@@ -143,6 +146,32 @@ func TestAccounts_AccountsHandler(t *testing.T) {
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount OK with autoUpdateVersion",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"auto_update_version\": \"latest\", \"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr("latest"),
EmbeddedIdpEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -165,6 +194,8 @@ func TestAccounts_AccountsHandler(t *testing.T) {
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -187,6 +218,8 @@ func TestAccounts_AccountsHandler(t *testing.T) {
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -209,6 +242,8 @@ func TestAccounts_AccountsHandler(t *testing.T) {
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateVersion: sr(""),
EmbeddedIdpEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
@@ -0,0 +1,196 @@
package idp
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// handler handles identity provider HTTP endpoints
type handler struct {
accountManager account.Manager
}
// AddEndpoints registers identity provider endpoints
func AddEndpoints(accountManager account.Manager, router *mux.Router) {
h := newHandler(accountManager)
router.HandleFunc("/identity-providers", h.getAllIdentityProviders).Methods("GET", "OPTIONS")
router.HandleFunc("/identity-providers", h.createIdentityProvider).Methods("POST", "OPTIONS")
router.HandleFunc("/identity-providers/{idpId}", h.getIdentityProvider).Methods("GET", "OPTIONS")
router.HandleFunc("/identity-providers/{idpId}", h.updateIdentityProvider).Methods("PUT", "OPTIONS")
router.HandleFunc("/identity-providers/{idpId}", h.deleteIdentityProvider).Methods("DELETE", "OPTIONS")
}
func newHandler(accountManager account.Manager) *handler {
return &handler{
accountManager: accountManager,
}
}
// getAllIdentityProviders returns all identity providers for the account
func (h *handler) getAllIdentityProviders(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
providers, err := h.accountManager.GetIdentityProviders(r.Context(), accountID, userID)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
response := make([]api.IdentityProvider, 0, len(providers))
for _, p := range providers {
response = append(response, toAPIResponse(p))
}
util.WriteJSONObject(r.Context(), w, response)
}
// getIdentityProvider returns a specific identity provider
func (h *handler) getIdentityProvider(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
vars := mux.Vars(r)
idpID := vars["idpId"]
if idpID == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w)
return
}
provider, err := h.accountManager.GetIdentityProvider(r.Context(), accountID, idpID, userID)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(provider))
}
// createIdentityProvider creates a new identity provider
func (h *handler) createIdentityProvider(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
var req api.IdentityProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
idp := fromAPIRequest(&req)
created, err := h.accountManager.CreateIdentityProvider(r.Context(), accountID, userID, idp)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(created))
}
// updateIdentityProvider updates an existing identity provider
func (h *handler) updateIdentityProvider(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
vars := mux.Vars(r)
idpID := vars["idpId"]
if idpID == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w)
return
}
var req api.IdentityProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
idp := fromAPIRequest(&req)
updated, err := h.accountManager.UpdateIdentityProvider(r.Context(), accountID, idpID, userID, idp)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, toAPIResponse(updated))
}
// deleteIdentityProvider deletes an identity provider
func (h *handler) deleteIdentityProvider(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
vars := mux.Vars(r)
idpID := vars["idpId"]
if idpID == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w)
return
}
if err := h.accountManager.DeleteIdentityProvider(r.Context(), accountID, idpID, userID); err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
func toAPIResponse(idp *types.IdentityProvider) api.IdentityProvider {
resp := api.IdentityProvider{
Type: api.IdentityProviderType(idp.Type),
Name: idp.Name,
Issuer: idp.Issuer,
ClientId: idp.ClientID,
}
if idp.ID != "" {
resp.Id = &idp.ID
}
// Note: ClientSecret is never returned in responses for security
return resp
}
func fromAPIRequest(req *api.IdentityProviderRequest) *types.IdentityProvider {
return &types.IdentityProvider{
Type: types.IdentityProviderType(req.Type),
Name: req.Name,
Issuer: req.Issuer,
ClientID: req.ClientId,
ClientSecret: req.ClientSecret,
}
}
@@ -0,0 +1,438 @@
package idp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/status"
)
const (
testAccountID = "test-account-id"
testUserID = "test-user-id"
existingIDPID = "existing-idp-id"
newIDPID = "new-idp-id"
)
func initIDPTestData(existingIDP *types.IdentityProvider) *handler {
return &handler{
accountManager: &mock_server.MockAccountManager{
GetIdentityProvidersFunc: func(_ context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
if accountID != testAccountID {
return nil, status.Errorf(status.NotFound, "account not found")
}
if existingIDP != nil {
return []*types.IdentityProvider{existingIDP}, nil
}
return []*types.IdentityProvider{}, nil
},
GetIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
if accountID != testAccountID {
return nil, status.Errorf(status.NotFound, "account not found")
}
if existingIDP != nil && idpID == existingIDP.ID {
return existingIDP, nil
}
return nil, status.Errorf(status.NotFound, "identity provider not found")
},
CreateIdentityProviderFunc: func(_ context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) {
if accountID != testAccountID {
return nil, status.Errorf(status.NotFound, "account not found")
}
if idp.Name == "" {
return nil, status.Errorf(status.InvalidArgument, "name is required")
}
created := idp.Copy()
created.ID = newIDPID
created.AccountID = accountID
return created, nil
},
UpdateIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) {
if accountID != testAccountID {
return nil, status.Errorf(status.NotFound, "account not found")
}
if existingIDP == nil || idpID != existingIDP.ID {
return nil, status.Errorf(status.NotFound, "identity provider not found")
}
updated := idp.Copy()
updated.ID = idpID
updated.AccountID = accountID
return updated, nil
},
DeleteIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string) error {
if accountID != testAccountID {
return status.Errorf(status.NotFound, "account not found")
}
if existingIDP == nil || idpID != existingIDP.ID {
return status.Errorf(status.NotFound, "identity provider not found")
}
return nil
},
},
}
}
func TestGetAllIdentityProviders(t *testing.T) {
existingIDP := &types.IdentityProvider{
ID: existingIDPID,
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
ClientID: "client-id",
}
tt := []struct {
name string
expectedStatus int
expectedCount int
}{
{
name: "Get All Identity Providers",
expectedStatus: http.StatusOK,
expectedCount: 1,
},
}
h := initIDPTestData(existingIDP)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/identity-providers", nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
router := mux.NewRouter()
router.HandleFunc("/api/identity-providers", h.getAllIdentityProviders).Methods("GET")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
content, err := io.ReadAll(res.Body)
require.NoError(t, err)
var idps []api.IdentityProvider
err = json.Unmarshal(content, &idps)
require.NoError(t, err)
assert.Len(t, idps, tc.expectedCount)
})
}
}
func TestGetIdentityProvider(t *testing.T) {
existingIDP := &types.IdentityProvider{
ID: existingIDPID,
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
ClientID: "client-id",
}
tt := []struct {
name string
idpID string
expectedStatus int
expectedBody bool
}{
{
name: "Get Existing Identity Provider",
idpID: existingIDPID,
expectedStatus: http.StatusOK,
expectedBody: true,
},
{
name: "Get Non-Existing Identity Provider",
idpID: "non-existing-id",
expectedStatus: http.StatusNotFound,
expectedBody: false,
},
}
h := initIDPTestData(existingIDP)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
router := mux.NewRouter()
router.HandleFunc("/api/identity-providers/{idpId}", h.getIdentityProvider).Methods("GET")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
if tc.expectedBody {
content, err := io.ReadAll(res.Body)
require.NoError(t, err)
var idp api.IdentityProvider
err = json.Unmarshal(content, &idp)
require.NoError(t, err)
assert.Equal(t, existingIDPID, *idp.Id)
assert.Equal(t, existingIDP.Name, idp.Name)
}
})
}
}
func TestCreateIdentityProvider(t *testing.T) {
tt := []struct {
name string
requestBody string
expectedStatus int
expectedBody bool
}{
{
name: "Create Identity Provider",
requestBody: `{
"name": "New IDP",
"type": "oidc",
"issuer": "https://new-issuer.example.com",
"client_id": "new-client-id",
"client_secret": "new-client-secret"
}`,
expectedStatus: http.StatusOK,
expectedBody: true,
},
{
name: "Create Identity Provider with Invalid JSON",
requestBody: `{invalid json`,
expectedStatus: http.StatusBadRequest,
expectedBody: false,
},
}
h := initIDPTestData(nil)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/identity-providers", bytes.NewBufferString(tc.requestBody))
req.Header.Set("Content-Type", "application/json")
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
router := mux.NewRouter()
router.HandleFunc("/api/identity-providers", h.createIdentityProvider).Methods("POST")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
if tc.expectedBody {
content, err := io.ReadAll(res.Body)
require.NoError(t, err)
var idp api.IdentityProvider
err = json.Unmarshal(content, &idp)
require.NoError(t, err)
assert.Equal(t, newIDPID, *idp.Id)
assert.Equal(t, "New IDP", idp.Name)
assert.Equal(t, api.IdentityProviderTypeOidc, idp.Type)
}
})
}
}
func TestUpdateIdentityProvider(t *testing.T) {
existingIDP := &types.IdentityProvider{
ID: existingIDPID,
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
ClientID: "client-id",
ClientSecret: "client-secret",
}
tt := []struct {
name string
idpID string
requestBody string
expectedStatus int
expectedBody bool
}{
{
name: "Update Existing Identity Provider",
idpID: existingIDPID,
requestBody: `{
"name": "Updated IDP",
"type": "oidc",
"issuer": "https://updated-issuer.example.com",
"client_id": "updated-client-id"
}`,
expectedStatus: http.StatusOK,
expectedBody: true,
},
{
name: "Update Non-Existing Identity Provider",
idpID: "non-existing-id",
requestBody: `{
"name": "Updated IDP",
"type": "oidc",
"issuer": "https://updated-issuer.example.com",
"client_id": "updated-client-id"
}`,
expectedStatus: http.StatusNotFound,
expectedBody: false,
},
{
name: "Update Identity Provider with Invalid JSON",
idpID: existingIDPID,
requestBody: `{invalid json`,
expectedStatus: http.StatusBadRequest,
expectedBody: false,
},
}
h := initIDPTestData(existingIDP)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), bytes.NewBufferString(tc.requestBody))
req.Header.Set("Content-Type", "application/json")
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
router := mux.NewRouter()
router.HandleFunc("/api/identity-providers/{idpId}", h.updateIdentityProvider).Methods("PUT")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
if tc.expectedBody {
content, err := io.ReadAll(res.Body)
require.NoError(t, err)
var idp api.IdentityProvider
err = json.Unmarshal(content, &idp)
require.NoError(t, err)
assert.Equal(t, existingIDPID, *idp.Id)
assert.Equal(t, "Updated IDP", idp.Name)
}
})
}
}
func TestDeleteIdentityProvider(t *testing.T) {
existingIDP := &types.IdentityProvider{
ID: existingIDPID,
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
ClientID: "client-id",
}
tt := []struct {
name string
idpID string
expectedStatus int
}{
{
name: "Delete Existing Identity Provider",
idpID: existingIDPID,
expectedStatus: http.StatusOK,
},
{
name: "Delete Non-Existing Identity Provider",
idpID: "non-existing-id",
expectedStatus: http.StatusNotFound,
},
}
h := initIDPTestData(existingIDP)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,
})
router := mux.NewRouter()
router.HandleFunc("/api/identity-providers/{idpId}", h.deleteIdentityProvider).Methods("DELETE")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
})
}
}
func TestToAPIResponse(t *testing.T) {
idp := &types.IdentityProvider{
ID: "test-id",
Name: "Test IDP",
Type: types.IdentityProviderTypeGoogle,
Issuer: "https://accounts.google.com",
ClientID: "client-id",
ClientSecret: "should-not-be-returned",
}
response := toAPIResponse(idp)
assert.Equal(t, "test-id", *response.Id)
assert.Equal(t, "Test IDP", response.Name)
assert.Equal(t, api.IdentityProviderTypeGoogle, response.Type)
assert.Equal(t, "https://accounts.google.com", response.Issuer)
assert.Equal(t, "client-id", response.ClientId)
// Note: ClientSecret is not included in response type by design
}
func TestFromAPIRequest(t *testing.T) {
req := &api.IdentityProviderRequest{
Name: "New IDP",
Type: api.IdentityProviderTypeOkta,
Issuer: "https://dev-123456.okta.com",
ClientId: "okta-client-id",
ClientSecret: "okta-client-secret",
}
idp := fromAPIRequest(req)
assert.Equal(t, "New IDP", idp.Name)
assert.Equal(t, types.IdentityProviderTypeOkta, idp.Type)
assert.Equal(t, "https://dev-123456.okta.com", idp.Issuer)
assert.Equal(t, "okta-client-id", idp.ClientID)
assert.Equal(t, "okta-client-secret", idp.ClientSecret)
}
@@ -0,0 +1,67 @@
package instance
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
nbinstance "github.com/netbirdio/netbird/management/server/instance"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
)
// handler handles the instance setup HTTP endpoints
type handler struct {
instanceManager nbinstance.Manager
}
// AddEndpoints registers the instance setup endpoints.
// These endpoints bypass authentication for initial setup.
func AddEndpoints(instanceManager nbinstance.Manager, router *mux.Router) {
h := &handler{
instanceManager: instanceManager,
}
router.HandleFunc("/instance", h.getInstanceStatus).Methods("GET", "OPTIONS")
router.HandleFunc("/setup", h.setup).Methods("POST", "OPTIONS")
}
// getInstanceStatus returns the instance status including whether setup is required.
// This endpoint is unauthenticated.
func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) {
setupRequired, err := h.instanceManager.IsSetupRequired(r.Context())
if err != nil {
log.WithContext(r.Context()).Errorf("failed to check setup status: %v", err)
util.WriteErrorResponse("failed to check instance status", http.StatusInternalServerError, w)
return
}
util.WriteJSONObject(r.Context(), w, api.InstanceStatus{
SetupRequired: setupRequired,
})
}
// setup creates the initial admin user for the instance.
// This endpoint is unauthenticated but only works when setup is required.
func (h *handler) setup(w http.ResponseWriter, r *http.Request) {
var req api.SetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
return
}
userData, err := h.instanceManager.CreateOwnerUser(r.Context(), req.Email, req.Password, req.Name)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
log.WithContext(r.Context()).Infof("instance setup completed: created user %s", req.Email)
util.WriteJSONObject(r.Context(), w, api.SetupResponse{
UserId: userData.ID,
Email: userData.Email,
})
}
@@ -0,0 +1,281 @@
package instance
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/mail"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/idp"
nbinstance "github.com/netbirdio/netbird/management/server/instance"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/status"
)
// mockInstanceManager implements instance.Manager for testing
type mockInstanceManager struct {
isSetupRequired bool
isSetupRequiredFn func(ctx context.Context) (bool, error)
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
}
func (m *mockInstanceManager) IsSetupRequired(ctx context.Context) (bool, error) {
if m.isSetupRequiredFn != nil {
return m.isSetupRequiredFn(ctx)
}
return m.isSetupRequired, nil
}
func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) {
if m.createOwnerUserFn != nil {
return m.createOwnerUserFn(ctx, email, password, name)
}
// Default mock includes validation like the real manager
if !m.isSetupRequired {
return nil, status.Errorf(status.PreconditionFailed, "setup already completed")
}
if email == "" {
return nil, status.Errorf(status.InvalidArgument, "email is required")
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, status.Errorf(status.InvalidArgument, "invalid email format")
}
if name == "" {
return nil, status.Errorf(status.InvalidArgument, "name is required")
}
if password == "" {
return nil, status.Errorf(status.InvalidArgument, "password is required")
}
if len(password) < 8 {
return nil, status.Errorf(status.InvalidArgument, "password must be at least 8 characters")
}
return &idp.UserData{
ID: "test-user-id",
Email: email,
Name: name,
}, nil
}
var _ nbinstance.Manager = (*mockInstanceManager)(nil)
func setupTestRouter(manager nbinstance.Manager) *mux.Router {
router := mux.NewRouter()
AddEndpoints(manager, router)
return router
}
func TestGetInstanceStatus_SetupRequired(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response api.InstanceStatus
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.True(t, response.SetupRequired)
}
func TestGetInstanceStatus_SetupNotRequired(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: false}
router := setupTestRouter(manager)
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response api.InstanceStatus
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.False(t, response.SetupRequired)
}
func TestGetInstanceStatus_Error(t *testing.T) {
manager := &mockInstanceManager{
isSetupRequiredFn: func(ctx context.Context) (bool, error) {
return false, errors.New("database error")
},
}
router := setupTestRouter(manager)
req := httptest.NewRequest(http.MethodGet, "/instance", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestSetup_Success(t *testing.T) {
manager := &mockInstanceManager{
isSetupRequired: true,
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
assert.Equal(t, "admin@example.com", email)
assert.Equal(t, "securepassword123", password)
assert.Equal(t, "Admin User", name)
return &idp.UserData{
ID: "created-user-id",
Email: email,
Name: name,
}, nil
},
}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response api.SetupResponse
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, "created-user-id", response.UserId)
assert.Equal(t, "admin@example.com", response.Email)
}
func TestSetup_AlreadyCompleted(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: false}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "password": "securepassword123"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
}
func TestSetup_MissingEmail(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
body := `{"password": "securepassword123"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestSetup_InvalidEmail(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
body := `{"email": "not-an-email", "password": "securepassword123", "name": "User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Note: Invalid email format uses mail.ParseAddress which is treated differently
// and returns 400 Bad Request instead of 422 Unprocessable Entity
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestSetup_MissingPassword(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "name": "User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestSetup_PasswordTooShort(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "password": "short", "name": "User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
}
func TestSetup_InvalidJSON(t *testing.T) {
manager := &mockInstanceManager{isSetupRequired: true}
router := setupTestRouter(manager)
body := `{invalid json}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestSetup_CreateUserError(t *testing.T) {
manager := &mockInstanceManager{
isSetupRequired: true,
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
return nil, errors.New("user creation failed")
},
}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestSetup_ManagerError(t *testing.T) {
manager := &mockInstanceManager{
isSetupRequired: true,
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
return nil, status.Errorf(status.Internal, "database error")
},
}
router := setupTestRouter(manager)
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}`
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
@@ -299,7 +299,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
customZone := account.GetPeersCustomZone(r.Context(), dnsDomain)
netMap := account.GetPeerNetworkMap(r.Context(), peerID, customZone, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil)
netMap := account.GetPeerNetworkMap(r.Context(), peerID, customZone, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
}
@@ -369,6 +369,9 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
PortRanges: []types.RulePortRange{portRange},
}},
}
if protocol == types.PolicyRuleProtocolNetbirdSSH {
policy.Rules[0].AuthorizedUser = userAuth.UserId
}
_, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true)
if err != nil {
@@ -449,6 +452,18 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
SerialNumber: peer.Meta.SystemSerialNumber,
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
Ephemeral: peer.Ephemeral,
LocalFlags: &api.PeerLocalFlags{
BlockInbound: &peer.Meta.Flags.BlockInbound,
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
DisableDns: &peer.Meta.Flags.DisableDNS,
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
},
}
if !approved {
@@ -463,7 +478,6 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
if osVersion == "" {
osVersion = peer.Meta.Core
}
return &api.PeerBatch{
CreatedAt: peer.CreatedAt,
Id: peer.ID,
@@ -492,6 +506,18 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
SerialNumber: peer.Meta.SystemSerialNumber,
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
Ephemeral: peer.Ephemeral,
LocalFlags: &api.PeerLocalFlags{
BlockInbound: &peer.Meta.Flags.BlockInbound,
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
DisableDns: &peer.Meta.Flags.DisableDNS,
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
},
}
}
@@ -66,7 +66,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
},
}
srvUser := types.NewRegularUser(serviceUser)
srvUser := types.NewRegularUser(serviceUser, "", "")
srvUser.IsServiceUser = true
account := &types.Account{
@@ -75,7 +75,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
Peers: peersMap,
Users: map[string]*types.User{
adminUser: types.NewAdminUser(adminUser),
regularUser: types.NewRegularUser(regularUser),
regularUser: types.NewRegularUser(regularUser, "", ""),
serviceUser: srvUser,
},
Groups: map[string]*types.Group{
@@ -221,6 +221,8 @@ func (h *handler) savePolicy(w http.ResponseWriter, r *http.Request, accountID s
pr.Protocol = types.PolicyRuleProtocolUDP
case api.PolicyRuleUpdateProtocolIcmp:
pr.Protocol = types.PolicyRuleProtocolICMP
case api.PolicyRuleUpdateProtocolNetbirdSsh:
pr.Protocol = types.PolicyRuleProtocolNetbirdSSH
default:
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "unknown protocol type: %v", rule.Protocol), w)
return
@@ -254,6 +256,17 @@ func (h *handler) savePolicy(w http.ResponseWriter, r *http.Request, accountID s
}
}
if pr.Protocol == types.PolicyRuleProtocolNetbirdSSH && rule.AuthorizedGroups != nil && len(*rule.AuthorizedGroups) != 0 {
for _, sourceGroupID := range pr.Sources {
_, ok := (*rule.AuthorizedGroups)[sourceGroupID]
if !ok {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "authorized group for netbird-ssh protocol should be specified for each source group"), w)
return
}
}
pr.AuthorizedGroups = *rule.AuthorizedGroups
}
// validate policy object
if pr.Protocol == types.PolicyRuleProtocolALL || pr.Protocol == types.PolicyRuleProtocolICMP {
if len(pr.Ports) != 0 || len(pr.PortRanges) != 0 {
@@ -380,6 +393,11 @@ func toPolicyResponse(groups []*types.Group, policy *types.Policy) *api.Policy {
DestinationResource: r.DestinationResource.ToAPIResponse(),
}
if len(r.AuthorizedGroups) != 0 {
authorizedGroupsCopy := r.AuthorizedGroups
rule.AuthorizedGroups = &authorizedGroupsCopy
}
if len(r.Ports) != 0 {
portsCopy := r.Ports
rule.Ports = &portsCopy
@@ -326,6 +326,16 @@ func toUserResponse(user *types.UserInfo, currenUserID string) *api.User {
isCurrent := user.ID == currenUserID
var password *string
if user.Password != "" {
password = &user.Password
}
var idpID *string
if user.IdPID != "" {
idpID = &user.IdPID
}
return &api.User{
Id: user.ID,
Name: user.Name,
@@ -339,6 +349,8 @@ func toUserResponse(user *types.UserInfo, currenUserID string) *api.User {
LastLogin: &user.LastLogin,
Issued: &user.Issued,
PendingApproval: user.PendingApproval,
Password: password,
IdpId: idpID,
}
}
@@ -134,6 +134,9 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts []
userAuth.IsChild = ok
}
// Email is now extracted in ToUserAuth (from claims or userinfo endpoint)
// Available as userAuth.Email
// we need to call this method because if user is new, we will automatically add it to existing or create a new account
accountId, _, err := m.ensureAccount(ctx, userAuth)
if err != nil {
@@ -94,7 +94,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
groupsManagerMock := groups.NewManagerMock()
peersManager := peers.NewManager(store, permissionsManager)
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, networkMapController)
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, networkMapController, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
+234
View File
@@ -0,0 +1,234 @@
package server
import (
"context"
"errors"
"github.com/dexidp/dex/storage"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// GetIdentityProviders returns all identity providers for an account
func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
return nil, status.NewPermissionDeniedError()
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
log.Warn("identity provider management requires embedded IdP")
return []*types.IdentityProvider{}, nil
}
connectors, err := embeddedManager.ListConnectors(ctx)
if err != nil {
return nil, status.Errorf(status.Internal, "failed to list identity providers: %v", err)
}
result := make([]*types.IdentityProvider, 0, len(connectors))
for _, conn := range connectors {
result = append(result, connectorConfigToIdentityProvider(conn, accountID))
}
return result, nil
}
// GetIdentityProvider returns a specific identity provider by ID
func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
return nil, status.NewPermissionDeniedError()
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP")
}
conn, err := embeddedManager.GetConnector(ctx, idpID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return nil, status.Errorf(status.NotFound, "identity provider not found")
}
return nil, status.Errorf(status.Internal, "failed to get identity provider: %v", err)
}
return connectorConfigToIdentityProvider(conn, accountID), nil
}
// CreateIdentityProvider creates a new identity provider
func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
return nil, status.NewPermissionDeniedError()
}
if err := idpConfig.Validate(); err != nil {
return nil, status.Errorf(status.InvalidArgument, "%s", err.Error())
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP")
}
// Generate ID if not provided
if idpConfig.ID == "" {
idpConfig.ID = generateIdentityProviderID(idpConfig.Type)
}
idpConfig.AccountID = accountID
connCfg := identityProviderToConnectorConfig(idpConfig)
_, err = embeddedManager.CreateConnector(ctx, connCfg)
if err != nil {
return nil, status.Errorf(status.Internal, "failed to create identity provider: %v", err)
}
am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderCreated, idpConfig.EventMeta())
return idpConfig, nil
}
// UpdateIdentityProvider updates an existing identity provider
func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !ok {
return nil, status.NewPermissionDeniedError()
}
if err := idpConfig.Validate(); err != nil {
return nil, status.Errorf(status.InvalidArgument, "%s", err.Error())
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP")
}
idpConfig.ID = idpID
idpConfig.AccountID = accountID
connCfg := identityProviderToConnectorConfig(idpConfig)
if err := embeddedManager.UpdateConnector(ctx, connCfg); err != nil {
return nil, status.Errorf(status.Internal, "failed to update identity provider: %v", err)
}
am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderUpdated, idpConfig.EventMeta())
return idpConfig, nil
}
// DeleteIdentityProvider deletes an identity provider
func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !ok {
return status.NewPermissionDeniedError()
}
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return status.Errorf(status.Internal, "identity provider management requires embedded IdP")
}
// Get the IDP info before deleting for the activity event
conn, err := embeddedManager.GetConnector(ctx, idpID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return status.Errorf(status.NotFound, "identity provider not found")
}
return status.Errorf(status.Internal, "failed to get identity provider: %v", err)
}
idpConfig := connectorConfigToIdentityProvider(conn, accountID)
if err := embeddedManager.DeleteConnector(ctx, idpID); err != nil {
if errors.Is(err, storage.ErrNotFound) {
return status.Errorf(status.NotFound, "identity provider not found")
}
return status.Errorf(status.Internal, "failed to delete identity provider: %v", err)
}
am.StoreEvent(ctx, userID, idpID, accountID, activity.IdentityProviderDeleted, idpConfig.EventMeta())
return nil
}
// connectorConfigToIdentityProvider converts a dex.ConnectorConfig to types.IdentityProvider
func connectorConfigToIdentityProvider(conn *dex.ConnectorConfig, accountID string) *types.IdentityProvider {
return &types.IdentityProvider{
ID: conn.ID,
AccountID: accountID,
Type: types.IdentityProviderType(conn.Type),
Name: conn.Name,
Issuer: conn.Issuer,
ClientID: conn.ClientID,
ClientSecret: conn.ClientSecret,
}
}
// identityProviderToConnectorConfig converts a types.IdentityProvider to dex.ConnectorConfig
func identityProviderToConnectorConfig(idpConfig *types.IdentityProvider) *dex.ConnectorConfig {
return &dex.ConnectorConfig{
ID: idpConfig.ID,
Name: idpConfig.Name,
Type: string(idpConfig.Type),
Issuer: idpConfig.Issuer,
ClientID: idpConfig.ClientID,
ClientSecret: idpConfig.ClientSecret,
}
}
// generateIdentityProviderID generates a unique ID for an identity provider.
// For specific provider types (okta, zitadel, entra, google, pocketid, microsoft),
// the ID is prefixed with the type name. Generic OIDC providers get no prefix.
func generateIdentityProviderID(idpType types.IdentityProviderType) string {
id := xid.New().String()
switch idpType {
case types.IdentityProviderTypeOkta:
return "okta-" + id
case types.IdentityProviderTypeZitadel:
return "zitadel-" + id
case types.IdentityProviderTypeEntra:
return "entra-" + id
case types.IdentityProviderTypeGoogle:
return "google-" + id
case types.IdentityProviderTypePocketID:
return "pocketid-" + id
case types.IdentityProviderTypeMicrosoft:
return "microsoft-" + id
case types.IdentityProviderTypeAuthentik:
return "authentik-" + id
case types.IdentityProviderTypeKeycloak:
return "keycloak-" + id
default:
// Generic OIDC - no prefix
return id
}
}
+202
View File
@@ -0,0 +1,202 @@
package server
import (
"context"
"path/filepath"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/modules/peers"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
)
func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
t.Helper()
ctx := context.Background()
dataDir := t.TempDir()
testStore, cleanUp, err := store.NewTestStoreFromSQL(ctx, "", dataDir)
if err != nil {
return nil, nil, err
}
t.Cleanup(cleanUp)
// Create embedded IdP manager
embeddedConfig := &idp.EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: idp.EmbeddedStorageConfig{
Type: "sqlite3",
Config: idp.EmbeddedStorageTypeConfig{
File: filepath.Join(dataDir, "dex.db"),
},
},
}
idpManager, err := idp.NewEmbeddedIdPManager(ctx, embeddedConfig, nil)
if err != nil {
return nil, nil, err
}
t.Cleanup(func() { _ = idpManager.Stop(ctx) })
eventStore := &activity.InMemoryEventStore{}
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
if err != nil {
return nil, nil, err
}
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
settingsMockManager := settings.NewMockManager(ctrl)
settingsMockManager.EXPECT().
GetExtraSettings(gomock.Any(), gomock.Any()).
Return(&types.ExtraSettings{}, nil).
AnyTimes()
settingsMockManager.EXPECT().
UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(false, nil).
AnyTimes()
permissionsManager := permissions.NewManager(testStore)
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peers.NewManager(testStore, permissionsManager)), &config.Config{})
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
if err != nil {
return nil, nil, err
}
return manager, updateManager, nil
}
func TestDefaultAccountManager_CreateIdentityProvider_Validation(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err)
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
testCases := []struct {
name string
idp *types.IdentityProvider
expectError bool
errorMsg string
}{
{
name: "Missing Name",
idp: &types.IdentityProvider{
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
ClientID: "client-id",
},
expectError: true,
errorMsg: "name is required",
},
{
name: "Missing Type",
idp: &types.IdentityProvider{
Name: "Test IDP",
Issuer: "https://issuer.example.com",
ClientID: "client-id",
},
expectError: true,
errorMsg: "type is required",
},
{
name: "Missing Issuer",
idp: &types.IdentityProvider{
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
ClientID: "client-id",
},
expectError: true,
errorMsg: "issuer is required",
},
{
name: "Missing ClientID",
idp: &types.IdentityProvider{
Name: "Test IDP",
Type: types.IdentityProviderTypeOIDC,
Issuer: "https://issuer.example.com",
},
expectError: true,
errorMsg: "client ID is required",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := manager.CreateIdentityProvider(context.Background(), account.Id, userID, tc.idp)
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errorMsg)
}
})
}
}
func TestDefaultAccountManager_GetIdentityProviders(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err)
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
// Should return empty list (stub implementation)
providers, err := manager.GetIdentityProviders(context.Background(), account.Id, userID)
require.NoError(t, err)
assert.Empty(t, providers)
}
func TestDefaultAccountManager_GetIdentityProvider_NotFound(t *testing.T) {
manager, _, err := createManagerWithEmbeddedIdP(t)
require.NoError(t, err)
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
// Should return not found error when identity provider doesn't exist
_, err = manager.GetIdentityProvider(context.Background(), account.Id, "any-id", userID)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err)
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err)
// Should fail validation before reaching "not implemented" error
invalidIDP := &types.IdentityProvider{
Name: "", // Empty name should fail validation
}
_, err = manager.UpdateIdentityProvider(context.Background(), account.Id, "some-id", userID, invalidIDP)
require.Error(t, err)
assert.Contains(t, err.Error(), "name is required")
}
+445
View File
@@ -0,0 +1,445 @@
package idp
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/dexidp/dex/api/v2"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/management/server/telemetry"
)
// DexManager implements the Manager interface for Dex IDP.
// It uses Dex's gRPC API to manage users in the password database.
type DexManager struct {
grpcAddr string
httpClient ManagerHTTPClient
helper ManagerHelper
appMetrics telemetry.AppMetrics
mux sync.Mutex
conn *grpc.ClientConn
}
// DexClientConfig Dex manager client configuration.
type DexClientConfig struct {
// GRPCAddr is the address of Dex's gRPC API (e.g., "localhost:5557")
GRPCAddr string
// Issuer is the Dex issuer URL (e.g., "https://dex.example.com/dex")
Issuer string
}
// NewDexManager creates a new instance of DexManager.
func NewDexManager(config DexClientConfig, appMetrics telemetry.AppMetrics) (*DexManager, error) {
if config.GRPCAddr == "" {
return nil, fmt.Errorf("dex IdP configuration is incomplete, GRPCAddr is missing")
}
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConns = 5
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: httpTransport,
}
helper := JsonParser{}
return &DexManager{
grpcAddr: config.GRPCAddr,
httpClient: httpClient,
helper: helper,
appMetrics: appMetrics,
}, nil
}
// getConnection returns a gRPC connection to Dex, creating one if necessary.
// It also checks if an existing connection is still healthy and reconnects if needed.
func (dm *DexManager) getConnection(ctx context.Context) (*grpc.ClientConn, error) {
dm.mux.Lock()
defer dm.mux.Unlock()
if dm.conn != nil {
state := dm.conn.GetState()
// If connection is shutdown or in a transient failure, close and reconnect
if state == connectivity.Shutdown || state == connectivity.TransientFailure {
log.WithContext(ctx).Debugf("Dex gRPC connection in state %s, reconnecting", state)
_ = dm.conn.Close()
dm.conn = nil
} else {
return dm.conn, nil
}
}
log.WithContext(ctx).Debugf("connecting to Dex gRPC API at %s", dm.grpcAddr)
conn, err := grpc.NewClient(dm.grpcAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to Dex gRPC API: %w", err)
}
dm.conn = conn
return conn, nil
}
// getDexClient returns a Dex API client.
func (dm *DexManager) getDexClient(ctx context.Context) (api.DexClient, error) {
conn, err := dm.getConnection(ctx)
if err != nil {
return nil, err
}
return api.NewDexClient(conn), nil
}
// encodeDexUserID encodes a user ID and connector ID into Dex's composite format.
// This is the reverse of parseDexUserID - it creates the base64-encoded protobuf
// format that Dex uses in JWT tokens.
func encodeDexUserID(userID, connectorID string) string {
// Build simple protobuf structure:
// Field 1 (tag 0x0a): user ID string
// Field 2 (tag 0x12): connector ID string
buf := make([]byte, 0, 2+len(userID)+2+len(connectorID))
// Field 1: user ID
buf = append(buf, 0x0a) // tag for field 1, wire type 2 (length-delimited)
buf = append(buf, byte(len(userID))) // length
buf = append(buf, []byte(userID)...) // value
// Field 2: connector ID
buf = append(buf, 0x12) // tag for field 2, wire type 2 (length-delimited)
buf = append(buf, byte(len(connectorID))) // length
buf = append(buf, []byte(connectorID)...) // value
return base64.StdEncoding.EncodeToString(buf)
}
// parseDexUserID extracts the actual user ID from Dex's composite user ID.
// Dex encodes user IDs in JWT tokens as base64-encoded protobuf with format:
// - Field 1 (string): actual user ID
// - Field 2 (string): connector ID (e.g., "local")
// If the ID is not in this format, it returns the original ID.
func parseDexUserID(compositeID string) string {
// Try to decode as standard base64
decoded, err := base64.StdEncoding.DecodeString(compositeID)
if err != nil {
// Try URL-safe base64
decoded, err = base64.RawURLEncoding.DecodeString(compositeID)
if err != nil {
// Not base64 encoded, return as-is
return compositeID
}
}
// Parse the simple protobuf structure
// Field 1 (tag 0x0a): user ID string
// Field 2 (tag 0x12): connector ID string
if len(decoded) < 2 {
return compositeID
}
// Check for field 1 tag (0x0a = field 1, wire type 2/length-delimited)
if decoded[0] != 0x0a {
return compositeID
}
// Read the length of the user ID string
length := int(decoded[1])
if len(decoded) < 2+length {
return compositeID
}
// Extract the user ID
userID := string(decoded[2 : 2+length])
return userID
}
// UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
// Dex doesn't support app metadata, so this is a no-op.
func (dm *DexManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error {
return nil
}
// GetUserDataByID requests user data from Dex via user ID.
func (dm *DexManager) GetUserDataByID(ctx context.Context, userID string, _ AppMetadata) (*UserData, error) {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountGetUserDataByID()
}
client, err := dm.getDexClient(ctx)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, err
}
resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{})
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list passwords from Dex: %w", err)
}
// Try to parse the composite user ID from Dex JWT token
actualUserID := parseDexUserID(userID)
for _, p := range resp.Passwords {
// Match against both the raw userID and the parsed actualUserID
if p.UserId == userID || p.UserId == actualUserID {
return &UserData{
Email: p.Email,
Name: p.Username,
ID: userID, // Return the original ID for consistency
}, nil
}
}
return nil, fmt.Errorf("user with ID %s not found", userID)
}
// GetAccount returns all the users for a given account.
// Since Dex doesn't have account concepts, this returns all users.
func (dm *DexManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountGetAccount()
}
users, err := dm.getAllUsers(ctx)
if err != nil {
return nil, err
}
// Set the account ID for all users
for _, user := range users {
user.AppMetadata.WTAccountID = accountID
}
return users, nil
}
// GetAllAccounts gets all registered accounts with corresponding user data.
// Since Dex doesn't have account concepts, all users are returned under UnsetAccountID.
func (dm *DexManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountGetAllAccounts()
}
users, err := dm.getAllUsers(ctx)
if err != nil {
return nil, err
}
indexedUsers := make(map[string][]*UserData)
indexedUsers[UnsetAccountID] = users
return indexedUsers, nil
}
// CreateUser creates a new user in Dex's password database.
func (dm *DexManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountCreateUser()
}
client, err := dm.getDexClient(ctx)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, err
}
// Generate a random password for the new user
password := GeneratePassword(16, 2, 2, 2)
// Hash the password using bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate a user ID from email (Dex uses email as the key, but we need a stable ID)
userID := strings.ReplaceAll(email, "@", "-at-")
userID = strings.ReplaceAll(userID, ".", "-")
req := &api.CreatePasswordReq{
Password: &api.Password{
Email: email,
Username: name,
UserId: userID,
Hash: hashedPassword,
},
}
resp, err := client.CreatePassword(ctx, req)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to create user in Dex: %w", err)
}
if resp.AlreadyExists {
return nil, fmt.Errorf("user with email %s already exists", email)
}
log.WithContext(ctx).Debugf("created user %s in Dex", email)
return &UserData{
Email: email,
Name: name,
ID: userID,
AppMetadata: AppMetadata{
WTAccountID: accountID,
WTInvitedBy: invitedByEmail,
},
}, nil
}
// GetUserByEmail searches users with a given email.
// If no users have been found, this function returns an empty list.
func (dm *DexManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountGetUserByEmail()
}
client, err := dm.getDexClient(ctx)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, err
}
resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{})
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list passwords from Dex: %w", err)
}
users := make([]*UserData, 0)
for _, p := range resp.Passwords {
if strings.EqualFold(p.Email, email) {
// Encode the user ID in Dex's composite format to match stored IDs
encodedID := encodeDexUserID(p.UserId, "local")
users = append(users, &UserData{
Email: p.Email,
Name: p.Username,
ID: encodedID,
})
}
}
return users, nil
}
// InviteUserByID resends an invitation to a user.
// Dex doesn't support invitations, so this returns an error.
func (dm *DexManager) InviteUserByID(_ context.Context, _ string) error {
return fmt.Errorf("method InviteUserByID not implemented for Dex")
}
// DeleteUser deletes a user from Dex by user ID.
func (dm *DexManager) DeleteUser(ctx context.Context, userID string) error {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountDeleteUser()
}
client, err := dm.getDexClient(ctx)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return err
}
// First, find the user's email by ID
resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{})
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to list passwords from Dex: %w", err)
}
// Try to parse the composite user ID from Dex JWT token
actualUserID := parseDexUserID(userID)
var email string
for _, p := range resp.Passwords {
if p.UserId == userID || p.UserId == actualUserID {
email = p.Email
break
}
}
if email == "" {
return fmt.Errorf("user with ID %s not found", userID)
}
// Delete the user by email
deleteResp, err := client.DeletePassword(ctx, &api.DeletePasswordReq{
Email: email,
})
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to delete user from Dex: %w", err)
}
if deleteResp.NotFound {
return fmt.Errorf("user with email %s not found", email)
}
log.WithContext(ctx).Debugf("deleted user %s from Dex", email)
return nil
}
// getAllUsers retrieves all users from Dex's password database.
func (dm *DexManager) getAllUsers(ctx context.Context) ([]*UserData, error) {
client, err := dm.getDexClient(ctx)
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, err
}
resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{})
if err != nil {
if dm.appMetrics != nil {
dm.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list passwords from Dex: %w", err)
}
users := make([]*UserData, 0, len(resp.Passwords))
for _, p := range resp.Passwords {
// Encode the user ID in Dex's composite format (base64-encoded protobuf)
// to match how NetBird stores user IDs from Dex JWT tokens.
// The connector ID "local" is used for Dex's password database.
encodedID := encodeDexUserID(p.UserId, "local")
users = append(users, &UserData{
Email: p.Email,
Name: p.Username,
ID: encodedID,
})
}
return users, nil
}
+137
View File
@@ -0,0 +1,137 @@
package idp
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/telemetry"
)
func TestNewDexManager(t *testing.T) {
type test struct {
name string
inputConfig DexClientConfig
assertErrFunc require.ErrorAssertionFunc
assertErrFuncMessage string
}
defaultTestConfig := DexClientConfig{
GRPCAddr: "localhost:5557",
Issuer: "https://dex.example.com/dex",
}
testCase1 := test{
name: "Good Configuration",
inputConfig: defaultTestConfig,
assertErrFunc: require.NoError,
assertErrFuncMessage: "shouldn't return error",
}
testCase2Config := defaultTestConfig
testCase2Config.GRPCAddr = ""
testCase2 := test{
name: "Missing GRPCAddr Configuration",
inputConfig: testCase2Config,
assertErrFunc: require.Error,
assertErrFuncMessage: "should return error when GRPCAddr is empty",
}
// Test with empty issuer - should still work since issuer is optional for the manager
testCase3Config := defaultTestConfig
testCase3Config.Issuer = ""
testCase3 := test{
name: "Missing Issuer Configuration - OK",
inputConfig: testCase3Config,
assertErrFunc: require.NoError,
assertErrFuncMessage: "shouldn't return error when issuer is empty",
}
for _, testCase := range []test{testCase1, testCase2, testCase3} {
t.Run(testCase.name, func(t *testing.T) {
manager, err := NewDexManager(testCase.inputConfig, &telemetry.MockAppMetrics{})
testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage)
if err == nil {
require.NotNil(t, manager, "manager should not be nil")
require.Equal(t, testCase.inputConfig.GRPCAddr, manager.grpcAddr, "grpcAddr should match")
}
})
}
}
func TestDexManagerUpdateUserAppMetadata(t *testing.T) {
config := DexClientConfig{
GRPCAddr: "localhost:5557",
Issuer: "https://dex.example.com/dex",
}
manager, err := NewDexManager(config, &telemetry.MockAppMetrics{})
require.NoError(t, err, "should create manager without error")
// UpdateUserAppMetadata should be a no-op for Dex
err = manager.UpdateUserAppMetadata(context.Background(), "test-user-id", AppMetadata{
WTAccountID: "test-account",
})
require.NoError(t, err, "UpdateUserAppMetadata should not return error")
}
func TestDexManagerInviteUserByID(t *testing.T) {
config := DexClientConfig{
GRPCAddr: "localhost:5557",
Issuer: "https://dex.example.com/dex",
}
manager, err := NewDexManager(config, &telemetry.MockAppMetrics{})
require.NoError(t, err, "should create manager without error")
// InviteUserByID should return an error for Dex
err = manager.InviteUserByID(context.Background(), "test-user-id")
require.Error(t, err, "InviteUserByID should return error")
require.Contains(t, err.Error(), "not implemented", "error should mention not implemented")
}
func TestParseDexUserID(t *testing.T) {
tests := []struct {
name string
compositeID string
expectedID string
}{
{
name: "Parse base64-encoded protobuf composite ID",
// This is a real Dex composite ID: contains user ID "cf5db180-d360-484d-9b78-c5db92146420" and connector "local"
compositeID: "CiRjZjVkYjE4MC1kMzYwLTQ4NGQtOWI3OC1jNWRiOTIxNDY0MjASBWxvY2Fs",
expectedID: "cf5db180-d360-484d-9b78-c5db92146420",
},
{
name: "Return plain ID unchanged",
compositeID: "simple-user-id",
expectedID: "simple-user-id",
},
{
name: "Return UUID unchanged",
compositeID: "cf5db180-d360-484d-9b78-c5db92146420",
expectedID: "cf5db180-d360-484d-9b78-c5db92146420",
},
{
name: "Handle empty string",
compositeID: "",
expectedID: "",
},
{
name: "Handle invalid base64",
compositeID: "not-valid-base64!!!",
expectedID: "not-valid-base64!!!",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDexUserID(tt.compositeID)
require.Equal(t, tt.expectedID, result, "parsed user ID should match expected")
})
}
}
+511
View File
@@ -0,0 +1,511 @@
package idp
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/dexidp/dex/storage"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/telemetry"
)
const (
staticClientDashboard = "netbird-dashboard"
staticClientCLI = "netbird-cli"
defaultCLIRedirectURL1 = "http://localhost:53000/"
defaultCLIRedirectURL2 = "http://localhost:54000/"
defaultScopes = "openid profile email offline_access"
defaultUserIDClaim = "sub"
)
// EmbeddedIdPConfig contains configuration for the embedded Dex OIDC identity provider
type EmbeddedIdPConfig struct {
// Enabled indicates whether the embedded IDP is enabled
Enabled bool
// Issuer is the OIDC issuer URL (e.g., "http://localhost:3002/oauth2")
Issuer string
// Storage configuration for the IdP database
Storage EmbeddedStorageConfig
// DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client
DashboardRedirectURIs []string
// DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client
CLIRedirectURIs []string
// Owner is the initial owner/admin user (optional, can be nil)
Owner *OwnerConfig
// SignKeyRefreshEnabled enables automatic key rotation for signing keys
SignKeyRefreshEnabled bool
}
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
type EmbeddedStorageConfig struct {
// Type is the storage type (currently only "sqlite3" is supported)
Type string
// Config contains type-specific configuration
Config EmbeddedStorageTypeConfig
}
// EmbeddedStorageTypeConfig contains type-specific storage configuration.
type EmbeddedStorageTypeConfig struct {
// File is the path to the SQLite database file (for sqlite3 type)
File string
}
// OwnerConfig represents the initial owner/admin user for the embedded IdP.
type OwnerConfig struct {
// Email is the user's email address (required)
Email string
// Hash is the bcrypt hash of the user's password (required)
Hash string
// Username is the display name for the user (optional, defaults to email)
Username string
}
// ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig.
func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
if c.Issuer == "" {
return nil, fmt.Errorf("issuer is required")
}
if c.Storage.Type == "" {
c.Storage.Type = "sqlite3"
}
if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" {
return nil, fmt.Errorf("storage file is required for sqlite3")
}
// Build CLI redirect URIs including the device callback (both relative and absolute)
cliRedirectURIs := c.CLIRedirectURIs
cliRedirectURIs = append(cliRedirectURIs, "/device/callback")
cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback")
cfg := &dex.YAMLConfig{
Issuer: c.Issuer,
Storage: dex.Storage{
Type: c.Storage.Type,
Config: map[string]interface{}{
"file": c.Storage.Config.File,
},
},
Web: dex.Web{
AllowedOrigins: []string{"*"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
},
OAuth2: dex.OAuth2{
SkipApprovalScreen: true,
},
Frontend: dex.Frontend{
Issuer: "NetBird",
Theme: "light",
},
EnablePasswordDB: true,
StaticClients: []storage.Client{
{
ID: staticClientDashboard,
Name: "NetBird Dashboard",
Public: true,
RedirectURIs: c.DashboardRedirectURIs,
},
{
ID: staticClientCLI,
Name: "NetBird CLI",
Public: true,
RedirectURIs: cliRedirectURIs,
},
},
}
// Add owner user if provided
if c.Owner != nil && c.Owner.Email != "" && c.Owner.Hash != "" {
username := c.Owner.Username
if username == "" {
username = c.Owner.Email
}
cfg.StaticPasswords = []dex.Password{
{
Email: c.Owner.Email,
Hash: []byte(c.Owner.Hash),
Username: username,
UserID: uuid.New().String(),
},
}
}
return cfg, nil
}
// Compile-time check that EmbeddedIdPManager implements Manager interface
var _ Manager = (*EmbeddedIdPManager)(nil)
// Compile-time check that EmbeddedIdPManager implements OAuthConfigProvider interface
var _ OAuthConfigProvider = (*EmbeddedIdPManager)(nil)
// OAuthConfigProvider defines the interface for OAuth configuration needed by auth flows.
type OAuthConfigProvider interface {
GetIssuer() string
GetKeysLocation() string
GetClientIDs() []string
GetUserIDClaim() string
GetTokenEndpoint() string
GetDeviceAuthEndpoint() string
GetAuthorizationEndpoint() string
GetDefaultScopes() string
GetCLIClientID() string
GetCLIRedirectURLs() []string
}
// EmbeddedIdPManager implements the Manager interface using the embedded Dex IdP.
type EmbeddedIdPManager struct {
provider *dex.Provider
appMetrics telemetry.AppMetrics
config EmbeddedIdPConfig
}
// NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration.
// It instantiates the underlying Dex provider internally.
// Note: Storage defaults are applied in config loading (applyEmbeddedIdPConfig) based on Datadir.
func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) {
if config == nil {
return nil, fmt.Errorf("embedded IdP config is required")
}
// Apply defaults for CLI redirect URIs
if len(config.CLIRedirectURIs) == 0 {
config.CLIRedirectURIs = []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2}
}
// there are some properties create when creating YAML config (e.g., auth clients)
yamlConfig, err := config.ToYAMLConfig()
if err != nil {
return nil, err
}
provider, err := dex.NewProviderFromYAML(ctx, yamlConfig)
if err != nil {
return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err)
}
log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer)
return &EmbeddedIdPManager{
provider: provider,
appMetrics: appMetrics,
config: *config,
}, nil
}
// Handler returns the HTTP handler for serving OIDC requests.
func (m *EmbeddedIdPManager) Handler() http.Handler {
return m.provider.Handler()
}
// Stop gracefully shuts down the embedded IdP provider.
func (m *EmbeddedIdPManager) Stop(ctx context.Context) error {
return m.provider.Stop(ctx)
}
// UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
func (m *EmbeddedIdPManager) UpdateUserAppMetadata(ctx context.Context, userID string, appMetadata AppMetadata) error {
// TODO: implement
return nil
}
// GetUserDataByID requests user data from the embedded IdP via user ID.
func (m *EmbeddedIdPManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) {
user, err := m.provider.GetUserByID(ctx, userID)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to get user by ID: %w", err)
}
return &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
AppMetadata: appMetadata,
}, nil
}
// GetAccount returns all the users for a given account.
// Note: Embedded dex doesn't store account metadata, so this returns all users.
func (m *EmbeddedIdPManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) {
users, err := m.provider.ListUsers(ctx)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list users: %w", err)
}
result := make([]*UserData, 0, len(users))
for _, user := range users {
result = append(result, &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
AppMetadata: AppMetadata{
WTAccountID: accountID,
},
})
}
return result, nil
}
// GetAllAccounts gets all registered accounts with corresponding user data.
// Note: Embedded dex doesn't store account metadata, so all users are indexed under UnsetAccountID.
func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountGetAllAccounts()
}
users, err := m.provider.ListUsers(ctx)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to list users: %w", err)
}
indexedUsers := make(map[string][]*UserData)
for _, user := range users {
indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
})
}
return indexedUsers, nil
}
// CreateUser creates a new user in the embedded IdP.
func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
// Check if user already exists
_, err := m.provider.GetUser(ctx, email)
if err == nil {
return nil, fmt.Errorf("user with email %s already exists", email)
}
if !errors.Is(err, storage.ErrNotFound) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
// Generate a random password for the new user
password := GeneratePassword(16, 2, 2, 2)
// Create the user via provider (handles hashing and ID generation)
// The provider returns an encoded user ID in Dex's format (base64 protobuf with connector ID)
userID, err := m.provider.CreateUser(ctx, email, name, password)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("created user %s in embedded IdP", email)
return &UserData{
Email: email,
Name: name,
ID: userID,
Password: password,
AppMetadata: AppMetadata{
WTAccountID: accountID,
WTInvitedBy: invitedByEmail,
},
}, nil
}
// GetUserByEmail searches users with a given email.
func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) {
user, err := m.provider.GetUser(ctx, email)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return nil, nil // Return empty slice for not found
}
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to get user by email: %w", err)
}
return []*UserData{
{
Email: user.Email,
Name: user.Username,
ID: user.UserID,
},
}, nil
}
// CreateUserWithPassword creates a new user in the embedded IdP with a provided password.
// Unlike CreateUser which auto-generates a password, this method uses the provided password.
// This is useful for instance setup where the user provides their own password.
func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountCreateUser()
}
// Check if user already exists
_, err := m.provider.GetUser(ctx, email)
if err == nil {
return nil, fmt.Errorf("user with email %s already exists", email)
}
if !errors.Is(err, storage.ErrNotFound) {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
// Create the user via provider with the provided password
userID, err := m.provider.CreateUser(ctx, email, name, password)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("created user %s in embedded IdP with provided password", email)
return &UserData{
Email: email,
Name: name,
ID: userID,
}, nil
}
// InviteUserByID resends an invitation to a user.
func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error {
// TODO: implement
return fmt.Errorf("not implemented")
}
// DeleteUser deletes a user from the embedded IdP by user ID.
func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) error {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountDeleteUser()
}
// Get user by ID to retrieve email (provider.DeleteUser requires email)
user, err := m.provider.GetUserByID(ctx, userID)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to get user for deletion: %w", err)
}
err = m.provider.DeleteUser(ctx, user.Email)
if err != nil {
if m.appMetrics != nil {
m.appMetrics.IDPMetrics().CountRequestError()
}
return fmt.Errorf("failed to delete user from embedded IdP: %w", err)
}
log.WithContext(ctx).Debugf("deleted user %s from embedded IdP", user.Email)
return nil
}
// CreateConnector creates a new identity provider connector in Dex.
// Returns the created connector config with the redirect URL populated.
func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) {
return m.provider.CreateConnector(ctx, cfg)
}
// GetConnector retrieves an identity provider connector by ID.
func (m *EmbeddedIdPManager) GetConnector(ctx context.Context, id string) (*dex.ConnectorConfig, error) {
return m.provider.GetConnector(ctx, id)
}
// ListConnectors returns all identity provider connectors.
func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.ConnectorConfig, error) {
return m.provider.ListConnectors(ctx)
}
// UpdateConnector updates an existing identity provider connector.
func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error {
// Preserve existing secret if not provided in update
if cfg.ClientSecret == "" {
existing, err := m.provider.GetConnector(ctx, cfg.ID)
if err != nil {
return fmt.Errorf("failed to get existing connector: %w", err)
}
cfg.ClientSecret = existing.ClientSecret
}
return m.provider.UpdateConnector(ctx, cfg)
}
// DeleteConnector removes an identity provider connector.
func (m *EmbeddedIdPManager) DeleteConnector(ctx context.Context, id string) error {
return m.provider.DeleteConnector(ctx, id)
}
// GetIssuer returns the OIDC issuer URL.
func (m *EmbeddedIdPManager) GetIssuer() string {
return m.provider.GetIssuer()
}
// GetTokenEndpoint returns the OAuth2 token endpoint URL.
func (m *EmbeddedIdPManager) GetTokenEndpoint() string {
return m.provider.GetTokenEndpoint()
}
// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL.
func (m *EmbeddedIdPManager) GetDeviceAuthEndpoint() string {
return m.provider.GetDeviceAuthEndpoint()
}
// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL.
func (m *EmbeddedIdPManager) GetAuthorizationEndpoint() string {
return m.provider.GetAuthorizationEndpoint()
}
// GetDefaultScopes returns the default OAuth2 scopes for authentication.
func (m *EmbeddedIdPManager) GetDefaultScopes() string {
return defaultScopes
}
// GetCLIClientID returns the client ID for CLI authentication.
func (m *EmbeddedIdPManager) GetCLIClientID() string {
return staticClientCLI
}
// GetCLIRedirectURLs returns the redirect URLs configured for the CLI client.
func (m *EmbeddedIdPManager) GetCLIRedirectURLs() []string {
if len(m.config.CLIRedirectURIs) == 0 {
return []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2}
}
return m.config.CLIRedirectURIs
}
// GetKeysLocation returns the JWKS endpoint URL for token validation.
func (m *EmbeddedIdPManager) GetKeysLocation() string {
return m.provider.GetKeysLocation()
}
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
func (m *EmbeddedIdPManager) GetClientIDs() []string {
return []string{staticClientDashboard, staticClientCLI}
}
// GetUserIDClaim returns the JWT claim name used for user identification.
func (m *EmbeddedIdPManager) GetUserIDClaim() string {
return defaultUserIDClaim
}
+249
View File
@@ -0,0 +1,249 @@
package idp
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/idp/dex"
)
func TestEmbeddedIdPManager_CreateUser_EndToEnd(t *testing.T) {
ctx := context.Background()
// Create a temporary directory for the test
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create the embedded IDP config
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
// Create the embedded IDP manager
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Test data
email := "newuser@example.com"
name := "New User"
accountID := "test-account-id"
invitedByEmail := "admin@example.com"
// Create the user
userData, err := manager.CreateUser(ctx, email, name, accountID, invitedByEmail)
require.NoError(t, err)
require.NotNil(t, userData)
t.Logf("Created user: ID=%s, Email=%s, Name=%s, Password=%s",
userData.ID, userData.Email, userData.Name, userData.Password)
// Verify user data
assert.Equal(t, email, userData.Email)
assert.Equal(t, name, userData.Name)
assert.NotEmpty(t, userData.ID)
assert.NotEmpty(t, userData.Password)
assert.Equal(t, accountID, userData.AppMetadata.WTAccountID)
assert.Equal(t, invitedByEmail, userData.AppMetadata.WTInvitedBy)
// Verify the user ID is in Dex's encoded format (base64 protobuf)
rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID)
require.NoError(t, err)
assert.NotEmpty(t, rawUserID)
assert.Equal(t, "local", connectorID)
t.Logf("Decoded user ID: rawUserID=%s, connectorID=%s", rawUserID, connectorID)
// Verify we can look up the user by the encoded ID
lookedUpUser, err := manager.GetUserDataByID(ctx, userData.ID, AppMetadata{WTAccountID: accountID})
require.NoError(t, err)
assert.Equal(t, email, lookedUpUser.Email)
// Verify we can look up by email
users, err := manager.GetUserByEmail(ctx, email)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, email, users[0].Email)
// Verify creating duplicate user fails
_, err = manager.CreateUser(ctx, email, name, accountID, invitedByEmail)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
func TestEmbeddedIdPManager_GetUserDataByID_WithEncodedID(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user first
userData, err := manager.CreateUser(ctx, "test@example.com", "Test User", "account1", "admin@example.com")
require.NoError(t, err)
// The returned ID should be encoded
encodedID := userData.ID
// Lookup should work with the encoded ID
lookedUp, err := manager.GetUserDataByID(ctx, encodedID, AppMetadata{WTAccountID: "account1"})
require.NoError(t, err)
assert.Equal(t, "test@example.com", lookedUp.Email)
assert.Equal(t, "Test User", lookedUp.Name)
}
func TestEmbeddedIdPManager_DeleteUser(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user
userData, err := manager.CreateUser(ctx, "delete-me@example.com", "Delete Me", "account1", "admin@example.com")
require.NoError(t, err)
// Delete the user using the encoded ID
err = manager.DeleteUser(ctx, userData.ID)
require.NoError(t, err)
// Verify user no longer exists
_, err = manager.GetUserDataByID(ctx, userData.ID, AppMetadata{})
assert.Error(t, err)
}
func TestEmbeddedIdPManager_GetAccount(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create multiple users
_, err = manager.CreateUser(ctx, "user1@example.com", "User 1", "account1", "admin@example.com")
require.NoError(t, err)
_, err = manager.CreateUser(ctx, "user2@example.com", "User 2", "account1", "admin@example.com")
require.NoError(t, err)
// Get all users for the account
users, err := manager.GetAccount(ctx, "account1")
require.NoError(t, err)
assert.Len(t, users, 2)
emails := make([]string, len(users))
for i, u := range users {
emails[i] = u.Email
}
assert.Contains(t, emails, "user1@example.com")
assert.Contains(t, emails, "user2@example.com")
}
func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) {
// This test verifies that the user ID returned by CreateUser
// matches the format that Dex uses in JWT tokens (the 'sub' claim)
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
config := &EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: EmbeddedStorageConfig{
Type: "sqlite3",
Config: EmbeddedStorageTypeConfig{
File: filepath.Join(tmpDir, "dex.db"),
},
},
}
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
require.NoError(t, err)
defer func() { _ = manager.Stop(ctx) }()
// Create a user
userData, err := manager.CreateUser(ctx, "jwt-test@example.com", "JWT Test", "account1", "admin@example.com")
require.NoError(t, err)
// The ID should be in the format: base64(protobuf{user_id, connector_id})
// Example: CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs
// Verify it can be decoded
rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID)
require.NoError(t, err)
// Raw user ID should be a UUID
assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, rawUserID)
// Connector ID should be "local" for password-based auth
assert.Equal(t, "local", connectorID)
// Re-encoding should produce the same result
reEncoded := dex.EncodeDexUserID(rawUserID, connectorID)
assert.Equal(t, userData.ID, reEncoded)
t.Logf("User ID format verified:")
t.Logf(" Encoded ID: %s", userData.ID)
t.Logf(" Raw UUID: %s", rawUserID)
t.Logf(" Connector: %s", connectorID)
}
+16 -15
View File
@@ -72,6 +72,7 @@ type UserData struct {
Name string `json:"name"`
ID string `json:"user_id"`
AppMetadata AppMetadata `json:"app_metadata"`
Password string `json:"-"` // Plain password, only set on user creation, excluded from JSON
}
func (u *UserData) MarshalBinary() (data []byte, err error) {
@@ -173,40 +174,40 @@ func NewManager(ctx context.Context, config Config, appMetrics telemetry.AppMetr
return NewZitadelManager(*zitadelClientConfig, appMetrics)
case "authentik":
authentikConfig := AuthentikClientConfig{
return NewAuthentikManager(AuthentikClientConfig{
Issuer: config.ClientConfig.Issuer,
ClientID: config.ClientConfig.ClientID,
TokenEndpoint: config.ClientConfig.TokenEndpoint,
GrantType: config.ClientConfig.GrantType,
Username: config.ExtraConfig["Username"],
Password: config.ExtraConfig["Password"],
}
return NewAuthentikManager(authentikConfig, appMetrics)
}, appMetrics)
case "okta":
oktaClientConfig := OktaClientConfig{
return NewOktaManager(OktaClientConfig{
Issuer: config.ClientConfig.Issuer,
TokenEndpoint: config.ClientConfig.TokenEndpoint,
GrantType: config.ClientConfig.GrantType,
APIToken: config.ExtraConfig["ApiToken"],
}
return NewOktaManager(oktaClientConfig, appMetrics)
}, appMetrics)
case "google":
googleClientConfig := GoogleWorkspaceClientConfig{
return NewGoogleWorkspaceManager(ctx, GoogleWorkspaceClientConfig{
ServiceAccountKey: config.ExtraConfig["ServiceAccountKey"],
CustomerID: config.ExtraConfig["CustomerId"],
}
return NewGoogleWorkspaceManager(ctx, googleClientConfig, appMetrics)
}, appMetrics)
case "jumpcloud":
jumpcloudConfig := JumpCloudClientConfig{
return NewJumpCloudManager(JumpCloudClientConfig{
APIToken: config.ExtraConfig["ApiToken"],
}
return NewJumpCloudManager(jumpcloudConfig, appMetrics)
}, appMetrics)
case "pocketid":
pocketidConfig := PocketIdClientConfig{
return NewPocketIdManager(PocketIdClientConfig{
APIToken: config.ExtraConfig["ApiToken"],
ManagementEndpoint: config.ExtraConfig["ManagementEndpoint"],
}
return NewPocketIdManager(pocketidConfig, appMetrics)
}, appMetrics)
case "dex":
return NewDexManager(DexClientConfig{
GRPCAddr: config.ExtraConfig["GRPCAddr"],
Issuer: config.ClientConfig.Issuer,
}, appMetrics)
default:
return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType)
}
+136
View File
@@ -0,0 +1,136 @@
package instance
import (
"context"
"errors"
"fmt"
"net/mail"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
// Manager handles instance-level operations like initial setup.
type Manager interface {
// IsSetupRequired checks if instance setup is required.
// Returns true if embedded IDP is enabled and no accounts exist.
IsSetupRequired(ctx context.Context) (bool, error)
// CreateOwnerUser creates the initial owner user in the embedded IDP.
// This should only be called when IsSetupRequired returns true.
CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error)
}
// DefaultManager is the default implementation of Manager.
type DefaultManager struct {
store store.Store
embeddedIdpManager *idp.EmbeddedIdPManager
setupRequired bool
setupMu sync.RWMutex
}
// NewManager creates a new instance manager.
// If idpManager is not an EmbeddedIdPManager, setup-related operations will return appropriate defaults.
func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager) (Manager, error) {
embeddedIdp, _ := idpManager.(*idp.EmbeddedIdPManager)
m := &DefaultManager{
store: store,
embeddedIdpManager: embeddedIdp,
setupRequired: false,
}
if embeddedIdp != nil {
err := m.loadSetupRequired(ctx)
if err != nil {
return nil, err
}
}
return m, nil
}
func (m *DefaultManager) loadSetupRequired(ctx context.Context) error {
users, err := m.embeddedIdpManager.GetAllAccounts(ctx)
if err != nil {
return err
}
m.setupMu.Lock()
m.setupRequired = len(users) == 0
m.setupMu.Unlock()
return nil
}
// IsSetupRequired checks if instance setup is required.
// Setup is required when:
// 1. Embedded IDP is enabled
// 2. No accounts exist in the store
func (m *DefaultManager) IsSetupRequired(_ context.Context) (bool, error) {
if m.embeddedIdpManager == nil {
return false, nil
}
m.setupMu.RLock()
defer m.setupMu.RUnlock()
return m.setupRequired, nil
}
// CreateOwnerUser creates the initial owner user in the embedded IDP.
func (m *DefaultManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) {
if err := m.validateSetupInfo(email, password, name); err != nil {
return nil, err
}
if m.embeddedIdpManager == nil {
return nil, errors.New("embedded IDP is not enabled")
}
m.setupMu.RLock()
setupRequired := m.setupRequired
m.setupMu.RUnlock()
if !setupRequired {
return nil, status.Errorf(status.PreconditionFailed, "setup already completed")
}
userData, err := m.embeddedIdpManager.CreateUserWithPassword(ctx, email, password, name)
if err != nil {
return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err)
}
m.setupMu.Lock()
m.setupRequired = false
m.setupMu.Unlock()
log.WithContext(ctx).Infof("created owner user %s in embedded IdP", email)
return userData, nil
}
func (m *DefaultManager) validateSetupInfo(email, password, name string) error {
if email == "" {
return status.Errorf(status.InvalidArgument, "email is required")
}
if _, err := mail.ParseAddress(email); err != nil {
return status.Errorf(status.InvalidArgument, "invalid email format")
}
if name == "" {
return status.Errorf(status.InvalidArgument, "name is required")
}
if password == "" {
return status.Errorf(status.InvalidArgument, "password is required")
}
if len(password) < 8 {
return status.Errorf(status.InvalidArgument, "password must be at least 8 characters")
}
return nil
}
+268
View File
@@ -0,0 +1,268 @@
package instance
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/idp"
)
// mockStore implements a minimal store.Store for testing
type mockStore struct {
accountsCount int64
err error
}
func (m *mockStore) GetAccountsCounter(ctx context.Context) (int64, error) {
if m.err != nil {
return 0, m.err
}
return m.accountsCount, nil
}
// mockEmbeddedIdPManager wraps the real EmbeddedIdPManager for testing
type mockEmbeddedIdPManager struct {
createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error)
}
func (m *mockEmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error) {
if m.createUserFunc != nil {
return m.createUserFunc(ctx, email, password, name)
}
return &idp.UserData{
ID: "test-user-id",
Email: email,
Name: name,
}, nil
}
// testManager is a test implementation that accepts our mock types
type testManager struct {
store *mockStore
embeddedIdpManager *mockEmbeddedIdPManager
}
func (m *testManager) IsSetupRequired(ctx context.Context) (bool, error) {
if m.embeddedIdpManager == nil {
return false, nil
}
count, err := m.store.GetAccountsCounter(ctx)
if err != nil {
return false, err
}
return count == 0, nil
}
func (m *testManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) {
if m.embeddedIdpManager == nil {
return nil, errors.New("embedded IDP is not enabled")
}
return m.embeddedIdpManager.CreateUserWithPassword(ctx, email, password, name)
}
func TestIsSetupRequired_EmbeddedIdPDisabled(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 0},
embeddedIdpManager: nil, // No embedded IDP
}
required, err := manager.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.False(t, required, "setup should not be required when embedded IDP is disabled")
}
func TestIsSetupRequired_NoAccounts(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 0},
embeddedIdpManager: &mockEmbeddedIdPManager{},
}
required, err := manager.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.True(t, required, "setup should be required when no accounts exist")
}
func TestIsSetupRequired_AccountsExist(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 1},
embeddedIdpManager: &mockEmbeddedIdPManager{},
}
required, err := manager.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.False(t, required, "setup should not be required when accounts exist")
}
func TestIsSetupRequired_MultipleAccounts(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 5},
embeddedIdpManager: &mockEmbeddedIdPManager{},
}
required, err := manager.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.False(t, required, "setup should not be required when multiple accounts exist")
}
func TestIsSetupRequired_StoreError(t *testing.T) {
manager := &testManager{
store: &mockStore{err: errors.New("database error")},
embeddedIdpManager: &mockEmbeddedIdPManager{},
}
_, err := manager.IsSetupRequired(context.Background())
assert.Error(t, err, "should return error when store fails")
}
func TestCreateOwnerUser_Success(t *testing.T) {
expectedEmail := "admin@example.com"
expectedName := "Admin User"
expectedPassword := "securepassword123"
manager := &testManager{
store: &mockStore{accountsCount: 0},
embeddedIdpManager: &mockEmbeddedIdPManager{
createUserFunc: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
assert.Equal(t, expectedEmail, email)
assert.Equal(t, expectedPassword, password)
assert.Equal(t, expectedName, name)
return &idp.UserData{
ID: "created-user-id",
Email: email,
Name: name,
}, nil
},
},
}
userData, err := manager.CreateOwnerUser(context.Background(), expectedEmail, expectedPassword, expectedName)
require.NoError(t, err)
assert.Equal(t, "created-user-id", userData.ID)
assert.Equal(t, expectedEmail, userData.Email)
assert.Equal(t, expectedName, userData.Name)
}
func TestCreateOwnerUser_EmbeddedIdPDisabled(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 0},
embeddedIdpManager: nil,
}
_, err := manager.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin")
assert.Error(t, err, "should return error when embedded IDP is disabled")
assert.Contains(t, err.Error(), "embedded IDP is not enabled")
}
func TestCreateOwnerUser_IdPError(t *testing.T) {
manager := &testManager{
store: &mockStore{accountsCount: 0},
embeddedIdpManager: &mockEmbeddedIdPManager{
createUserFunc: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
return nil, errors.New("user already exists")
},
},
}
_, err := manager.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin")
assert.Error(t, err, "should return error when IDP fails")
}
func TestDefaultManager_ValidateSetupRequest(t *testing.T) {
manager := &DefaultManager{
setupRequired: true,
}
tests := []struct {
name string
email string
password string
userName string
expectError bool
errorMsg string
}{
{
name: "valid request",
email: "admin@example.com",
password: "password123",
userName: "Admin User",
expectError: false,
},
{
name: "empty email",
email: "",
password: "password123",
userName: "Admin User",
expectError: true,
errorMsg: "email is required",
},
{
name: "invalid email format",
email: "not-an-email",
password: "password123",
userName: "Admin User",
expectError: true,
errorMsg: "invalid email format",
},
{
name: "empty name",
email: "admin@example.com",
password: "password123",
userName: "",
expectError: true,
errorMsg: "name is required",
},
{
name: "empty password",
email: "admin@example.com",
password: "",
userName: "Admin User",
expectError: true,
errorMsg: "password is required",
},
{
name: "password too short",
email: "admin@example.com",
password: "short",
userName: "Admin User",
expectError: true,
errorMsg: "password must be at least 8 characters",
},
{
name: "password exactly 8 characters",
email: "admin@example.com",
password: "12345678",
userName: "Admin User",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := manager.validateSetupInfo(tt.email, tt.password, tt.userName)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
require.NoError(t, err)
}
})
}
}
func TestDefaultManager_CreateOwnerUser_SetupAlreadyCompleted(t *testing.T) {
manager := &DefaultManager{
setupRequired: false,
embeddedIdpManager: &idp.EmbeddedIdPManager{},
}
_, err := manager.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin")
require.Error(t, err)
assert.Contains(t, err.Error(), "setup already completed")
}
+1 -1
View File
@@ -381,7 +381,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
return nil, nil, "", cleanup, err
}
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController)
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
return nil, nil, "", cleanup, err
}
+1
View File
@@ -242,6 +242,7 @@ func startServer(
nil,
server.MockIntegratedValidator{},
networkMapController,
nil,
)
if err != nil {
t.Fatalf("failed creating management server: %v", err)
+16
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/hashicorp/go-version"
"github.com/netbirdio/netbird/idp/dex"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/types"
@@ -28,6 +29,7 @@ const (
defaultPushInterval = 12 * time.Hour
// requestTimeout http request timeout
requestTimeout = 45 * time.Second
EmbeddedType = "embedded"
)
type getTokenResponse struct {
@@ -206,6 +208,8 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
peerActiveVersions []string
osUIClients map[string]int
rosenpassEnabled int
localUsers int
idpUsers int
)
start := time.Now()
metricsProperties := make(properties)
@@ -266,6 +270,16 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
serviceUsers++
} else {
users++
if w.idpManager == EmbeddedType {
_, idpID, err := dex.DecodeDexUserID(user.Id)
if err == nil {
if idpID == "local" {
localUsers++
} else {
idpUsers++
}
}
}
}
pats += len(user.PATs)
}
@@ -353,6 +367,8 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
metricsProperties["idp_manager"] = w.idpManager
metricsProperties["store_engine"] = w.dataSource.GetStoreEngine()
metricsProperties["rosenpass_enabled"] = rosenpassEnabled
metricsProperties["local_users_count"] = localUsers
metricsProperties["idp_users_count"] = idpUsers
for protocol, count := range rulesProtocol {
metricsProperties["rules_protocol_"+protocol] = count
+16 -2
View File
@@ -5,6 +5,7 @@ import (
"testing"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/idp/dex"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -25,6 +26,8 @@ func (mockDatasource) GetAllConnectedPeers() map[string]struct{} {
// GetAllAccounts returns a list of *server.Account for use in tests with predefined information
func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
localUserID := dex.EncodeDexUserID("10", "local")
idpUserID := dex.EncodeDexUserID("20", "zitadel")
return []*types.Account{
{
Id: "1",
@@ -98,12 +101,14 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
},
Users: map[string]*types.User{
"1": {
Id: "1",
IsServiceUser: true,
PATs: map[string]*types.PersonalAccessToken{
"1": {},
},
},
"2": {
localUserID: {
Id: localUserID,
IsServiceUser: false,
PATs: map[string]*types.PersonalAccessToken{
"1": {},
@@ -162,12 +167,14 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
},
Users: map[string]*types.User{
"1": {
Id: "1",
IsServiceUser: true,
PATs: map[string]*types.PersonalAccessToken{
"1": {},
},
},
"2": {
idpUserID: {
Id: idpUserID,
IsServiceUser: false,
PATs: map[string]*types.PersonalAccessToken{
"1": {},
@@ -214,6 +221,7 @@ func TestGenerateProperties(t *testing.T) {
worker := Worker{
dataSource: ds,
connManager: ds,
idpManager: EmbeddedType,
}
properties := worker.generateProperties(context.Background())
@@ -327,4 +335,10 @@ func TestGenerateProperties(t *testing.T) {
t.Errorf("expected 1 active_users_last_day, got %d", properties["active_users_last_day"])
}
if properties["local_users_count"] != 1 {
t.Errorf("expected 1 local_users_count, got %d", properties["local_users_count"])
}
if properties["idp_users_count"] != 1 {
t.Errorf("expected 1 idp_users_count, got %d", properties["idp_users_count"])
}
}
+58 -7
View File
@@ -2,11 +2,12 @@ package mock_server
import (
"context"
"github.com/netbirdio/netbird/shared/auth"
"net"
"net/netip"
"time"
"github.com/netbirdio/netbird/shared/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -26,13 +27,13 @@ import (
var _ account.Manager = (*MockAccountManager)(nil)
type MockAccountManager struct {
GetOrCreateAccountByUserFunc func(ctx context.Context, userId, domain string) (*types.Account, error)
GetOrCreateAccountByUserFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error)
GetAccountFunc func(ctx context.Context, accountID string) (*types.Account, error)
CreateSetupKeyFunc func(ctx context.Context, accountId string, keyName string, keyType types.SetupKeyType,
expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error)
GetSetupKeyFunc func(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error)
AccountExistsFunc func(ctx context.Context, accountID string) (bool, error)
GetAccountIDByUserIdFunc func(ctx context.Context, userId, domain string) (string, error)
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
@@ -128,6 +129,12 @@ type MockAccountManager struct {
UpdateAccountPeersFunc func(ctx context.Context, accountID string)
BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string)
RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error
GetIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error)
GetIdentityProvidersFunc func(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error)
CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error)
DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error
}
func (am *MockAccountManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error {
@@ -236,10 +243,10 @@ func (am *MockAccountManager) DeletePeer(ctx context.Context, accountID, peerID,
// GetOrCreateAccountByUser mock implementation of GetOrCreateAccountByUser from server.AccountManager interface
func (am *MockAccountManager) GetOrCreateAccountByUser(
ctx context.Context, userId, domain string,
ctx context.Context, userAuth auth.UserAuth,
) (*types.Account, error) {
if am.GetOrCreateAccountByUserFunc != nil {
return am.GetOrCreateAccountByUserFunc(ctx, userId, domain)
return am.GetOrCreateAccountByUserFunc(ctx, userAuth)
}
return nil, status.Errorf(
codes.Unimplemented,
@@ -275,9 +282,9 @@ func (am *MockAccountManager) AccountExists(ctx context.Context, accountID strin
}
// GetAccountIDByUserID mock implementation of GetAccountIDByUserID from server.AccountManager interface
func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userId, domain string) (string, error) {
func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) {
if am.GetAccountIDByUserIdFunc != nil {
return am.GetAccountIDByUserIdFunc(ctx, userId, domain)
return am.GetAccountIDByUserIdFunc(ctx, userAuth)
}
return "", status.Errorf(
codes.Unimplemented,
@@ -988,3 +995,47 @@ func (am *MockAccountManager) RecalculateNetworkMapCache(ctx context.Context, ac
}
return nil
}
func (am *MockAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) {
return "something", nil
}
// GetIdentityProvider mocks GetIdentityProvider of the AccountManager interface
func (am *MockAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
if am.GetIdentityProviderFunc != nil {
return am.GetIdentityProviderFunc(ctx, accountID, idpID, userID)
}
return nil, status.Errorf(codes.Unimplemented, "method GetIdentityProvider is not implemented")
}
// GetIdentityProviders mocks GetIdentityProviders of the AccountManager interface
func (am *MockAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
if am.GetIdentityProvidersFunc != nil {
return am.GetIdentityProvidersFunc(ctx, accountID, userID)
}
return nil, status.Errorf(codes.Unimplemented, "method GetIdentityProviders is not implemented")
}
// CreateIdentityProvider mocks CreateIdentityProvider of the AccountManager interface
func (am *MockAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) {
if am.CreateIdentityProviderFunc != nil {
return am.CreateIdentityProviderFunc(ctx, accountID, userID, idp)
}
return nil, status.Errorf(codes.Unimplemented, "method CreateIdentityProvider is not implemented")
}
// UpdateIdentityProvider mocks UpdateIdentityProvider of the AccountManager interface
func (am *MockAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) {
if am.UpdateIdentityProviderFunc != nil {
return am.UpdateIdentityProviderFunc(ctx, accountID, idpID, userID, idp)
}
return nil, status.Errorf(codes.Unimplemented, "method UpdateIdentityProvider is not implemented")
}
// DeleteIdentityProvider mocks DeleteIdentityProvider of the AccountManager interface
func (am *MockAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
if am.DeleteIdentityProviderFunc != nil {
return am.DeleteIdentityProviderFunc(ctx, accountID, idpID, userID)
}
return status.Errorf(codes.Unimplemented, "method DeleteIdentityProvider is not implemented")
}
+1 -1
View File
@@ -865,7 +865,7 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account,
userID := testUserID
domain := "example.com"
account := newAccountWithId(context.Background(), accountID, userID, domain, false)
account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false)
account.NameServerGroups[existingNSGroup.ID] = &existingNSGroup
+33 -29
View File
@@ -91,7 +91,7 @@ func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, acc
// fetch all the peers that have access to the user's peers
for _, peer := range peers {
aclPeers, _ := account.GetPeerConnectionResources(ctx, peer, approvedPeersMap)
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, peer, approvedPeersMap, account.GetActiveGroupUsers())
for _, p := range aclPeers {
peersMap[p.ID] = p
}
@@ -269,6 +269,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
inactivityExpirationChanged = true
}
if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
return fmt.Errorf("failed to increment network serial: %w", err)
}
return transaction.SavePeer(ctx, accountID, peer)
})
if err != nil {
@@ -340,6 +344,7 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
}
var peer *nbpeer.Peer
var settings *types.Settings
var eventsToStore []func()
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -348,11 +353,16 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return err
}
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return err
}
if err = am.validatePeerDelete(ctx, transaction, accountID, peerID); err != nil {
return err
}
eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer})
eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings)
if err != nil {
return fmt.Errorf("failed to delete peer: %w", err)
}
@@ -371,7 +381,11 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
storeEvent()
}
if err := am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}); err != nil {
if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
}
if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err)
}
@@ -663,11 +677,10 @@ func getPeerIPDNSLabel(ip net.IP, peerHostName string) (string, error) {
// SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible
func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
var peer *nbpeer.Peer
var peerNotValid bool
var isStatusChanged bool
var updated, versionChanged bool
var err error
var postureChecks []*posture.Checks
var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
@@ -695,12 +708,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return status.NewPeerLoginExpiredError()
}
peerGroupIDs, err := getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
if err != nil {
return err
}
peerNotValid, isStatusChanged, err = am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
if err != nil {
return err
}
@@ -724,6 +732,11 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
return nil, nil, nil, 0, err
}
if isStatusChanged || sync.UpdateAccountPeers || (updated && (len(postureChecks) > 0 || versionChanged)) {
err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
if err != nil {
@@ -773,10 +786,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
var peer *nbpeer.Peer
var updateRemotePeers bool
var isRequiresApproval bool
var isStatusChanged bool
var isPeerUpdated bool
var postureChecks []*posture.Checks
var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
@@ -809,12 +821,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
}
peerGroupIDs, err := getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
if err != nil {
return err
}
isRequiresApproval, isStatusChanged, err = am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
if err != nil {
return err
}
@@ -852,6 +859,11 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil, nil, nil, err
}
isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
return nil, nil, nil, err
}
if updateRemotePeers || isStatusChanged || (isPeerUpdated && len(postureChecks) > 0) {
err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
if err != nil {
@@ -1057,7 +1069,7 @@ func (am *DefaultAccountManager) checkIfUserOwnsPeer(ctx context.Context, accoun
}
for _, p := range userPeers {
aclPeers, _ := account.GetPeerConnectionResources(ctx, p, approvedPeersMap)
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, p, approvedPeersMap, account.GetActiveGroupUsers())
for _, aclPeer := range aclPeers {
if aclPeer.ID == peer.ID {
return peer, nil
@@ -1229,13 +1241,9 @@ func getPeerGroupIDs(ctx context.Context, transaction store.Store, accountID str
// deletePeers deletes all specified peers and sends updates to the remote peers.
// Returns a slice of functions to save events after successful peer deletion.
func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction store.Store, accountID, userID string, peers []*nbpeer.Peer) ([]func(), error) {
func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction store.Store, accountID, userID string, peers []*nbpeer.Peer, settings *types.Settings) ([]func(), error) {
var peerDeletedEvents []func()
settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
dnsDomain := am.networkMapController.GetDNSDomain(settings)
for _, peer := range peers {
@@ -1243,10 +1251,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto
return nil, fmt.Errorf("failed to remove peer %s from groups", peer.ID)
}
if err := am.integratedPeerValidator.PeerDeleted(ctx, accountID, peer.ID, settings.Extra); err != nil {
return nil, err
}
peerPolicyRules, err := transaction.GetPolicyRulesByResourceID(ctx, store.LockingStrengthNone, accountID, peer.ID)
if err != nil {
return nil, err
+12 -12
View File
@@ -502,7 +502,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
accountID := "test_account"
adminUser := "account_creator"
someUser := "some_user"
account := newAccountWithId(context.Background(), accountID, adminUser, "", false)
account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false)
account.Users[someUser] = &types.User{
Id: someUser,
Role: types.UserRoleUser,
@@ -689,7 +689,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
accountID := "test_account"
adminUser := "account_creator"
someUser := "some_user"
account := newAccountWithId(context.Background(), accountID, adminUser, "", false)
account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false)
account.Users[someUser] = &types.User{
Id: someUser,
Role: testCase.role,
@@ -759,7 +759,7 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou
adminUser := "account_creator"
regularUser := "regular_user"
account := newAccountWithId(context.Background(), accountID, adminUser, "", false)
account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false)
account.Users[regularUser] = &types.User{
Id: regularUser,
Role: types.UserRoleUser,
@@ -2124,7 +2124,7 @@ func Test_DeletePeer(t *testing.T) {
// account with an admin and a regular user
accountID := "test_account"
adminUser := "account_creator"
account := newAccountWithId(context.Background(), accountID, adminUser, "", false)
account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false)
account.Peers = map[string]*nbpeer.Peer{
"peer1": {
ID: "peer1",
@@ -2307,12 +2307,12 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) {
}
// Create account
account := newAccountWithId(context.Background(), "test-account", "owner", "", false)
account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
// Create user pending approval
pendingUser := types.NewRegularUser("pending-user")
pendingUser := types.NewRegularUser("pending-user", "", "")
pendingUser.AccountID = account.Id
pendingUser.Blocked = true
pendingUser.PendingApproval = true
@@ -2344,12 +2344,12 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) {
}
// Create account
account := newAccountWithId(context.Background(), "test-account", "owner", "", false)
account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
// Create regular user (not pending approval)
regularUser := types.NewRegularUser("regular-user")
regularUser := types.NewRegularUser("regular-user", "", "")
regularUser.AccountID = account.Id
err = manager.Store.SaveUser(context.Background(), regularUser)
require.NoError(t, err)
@@ -2378,12 +2378,12 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) {
}
// Create account
account := newAccountWithId(context.Background(), "test-account", "owner", "", false)
account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
// Create user pending approval
pendingUser := types.NewRegularUser("pending-user")
pendingUser := types.NewRegularUser("pending-user", "", "")
pendingUser.AccountID = account.Id
pendingUser.Blocked = true
pendingUser.PendingApproval = true
@@ -2443,12 +2443,12 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) {
}
// Create account
account := newAccountWithId(context.Background(), "test-account", "owner", "", false)
account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
// Create regular user (not pending approval)
regularUser := types.NewRegularUser("regular-user")
regularUser := types.NewRegularUser("regular-user", "", "")
regularUser.AccountID = account.Id
err = manager.Store.SaveUser(context.Background(), regularUser)
require.NoError(t, err)
+28 -26
View File
@@ -3,33 +3,35 @@ package modules
type Module string
const (
Networks Module = "networks"
Peers Module = "peers"
Groups Module = "groups"
Settings Module = "settings"
Accounts Module = "accounts"
Dns Module = "dns"
Nameservers Module = "nameservers"
Events Module = "events"
Policies Module = "policies"
Routes Module = "routes"
Users Module = "users"
SetupKeys Module = "setup_keys"
Pats Module = "pats"
Networks Module = "networks"
Peers Module = "peers"
Groups Module = "groups"
Settings Module = "settings"
Accounts Module = "accounts"
Dns Module = "dns"
Nameservers Module = "nameservers"
Events Module = "events"
Policies Module = "policies"
Routes Module = "routes"
Users Module = "users"
SetupKeys Module = "setup_keys"
Pats Module = "pats"
IdentityProviders Module = "identity_providers"
)
var All = map[Module]struct{}{
Networks: {},
Peers: {},
Groups: {},
Settings: {},
Accounts: {},
Dns: {},
Nameservers: {},
Events: {},
Policies: {},
Routes: {},
Users: {},
SetupKeys: {},
Pats: {},
Networks: {},
Peers: {},
Groups: {},
Settings: {},
Accounts: {},
Dns: {},
Nameservers: {},
Events: {},
Policies: {},
Routes: {},
Users: {},
SetupKeys: {},
Pats: {},
IdentityProviders: {},
}
@@ -93,5 +93,11 @@ var NetworkAdmin = RolePermissions{
operations.Update: false,
operations.Delete: false,
},
modules.IdentityProviders: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}
+16 -16
View File
@@ -246,14 +246,14 @@ func TestAccount_getPeersByPolicy(t *testing.T) {
t.Run("check that all peers get map", func(t *testing.T) {
for _, p := range account.Peers {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), p, validatedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers())
assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present")
assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present")
}
})
t.Run("check first peer map details", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 8)
assert.Contains(t, peers, account.Peers["peerA"])
assert.Contains(t, peers, account.Peers["peerC"])
@@ -509,7 +509,7 @@ func TestAccount_getPeersByPolicy(t *testing.T) {
})
t.Run("check port ranges support for older peers", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 1)
assert.Contains(t, peers, account.Peers["peerI"])
@@ -635,7 +635,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
}
t.Run("check first peer map", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerC"])
expectedFirewallRules := []*types.FirewallRule{
@@ -665,7 +665,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
})
t.Run("check second peer map", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerB"])
expectedFirewallRules := []*types.FirewallRule{
@@ -697,7 +697,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
account.Policies[1].Rules[0].Bidirectional = false
t.Run("check first peer map directional only", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerC"])
expectedFirewallRules := []*types.FirewallRule{
@@ -719,7 +719,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
})
t.Run("check second peer map directional only", func(t *testing.T) {
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerB"])
expectedFirewallRules := []*types.FirewallRule{
@@ -917,7 +917,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
t.Run("verify peer's network map with default group peer list", func(t *testing.T) {
// peerB doesn't fulfill the NB posture check but is included in the destination group Swarm,
// will establish a connection with all source peers satisfying the NB posture check.
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"])
@@ -927,7 +927,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerC satisfy the NB posture check, should establish connection to all destination group peer's
// We expect a single permissive firewall rule which all outgoing connections
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers))
assert.Len(t, firewallRules, 7)
expectedFirewallRules := []*types.FirewallRule{
@@ -992,7 +992,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerE doesn't fulfill the NB posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"])
@@ -1002,7 +1002,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"])
@@ -1017,19 +1017,19 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's
// no connection should be established to any peer of destination group
peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers)
peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 0)
assert.Len(t, firewallRules, 0)
// peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's
// no connection should be established to any peer of destination group
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 0)
assert.Len(t, firewallRules, 0)
// peerC satisfy the NB posture check, should establish connection to all destination group peer's
// We expect a single permissive firewall rule which all outgoing connections
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers))
assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers))
@@ -1044,14 +1044,14 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerE doesn't fulfill the NB posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 3)
assert.Len(t, firewallRules, 3)
assert.Contains(t, peers, account.Peers["peerA"])
assert.Contains(t, peers, account.Peers["peerC"])
assert.Contains(t, peers, account.Peers["peerD"])
peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers)
peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 5)
// assert peers from Group Swarm
assert.Contains(t, peers, account.Peers["peerD"])
+1 -1
View File
@@ -109,7 +109,7 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er
ID: "peer1",
}
account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, false)
account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, "", "", false)
account.Users[admin.Id] = admin
account.Users[user.Id] = user
account.Peers["peer1"] = peer1
+1 -1
View File
@@ -1320,7 +1320,7 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou
accountID := "testingAcc"
domain := "example.com"
account := newAccountWithId(context.Background(), accountID, userID, domain, false)
account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false)
err := am.Store.SaveAccount(context.Background(), account)
if err != nil {
return nil, err
+5 -4
View File
@@ -15,6 +15,7 @@ import (
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
)
func TestDefaultAccountManager_SaveSetupKey(t *testing.T) {
@@ -24,7 +25,7 @@ func TestDefaultAccountManager_SaveSetupKey(t *testing.T) {
}
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
if err != nil {
t.Fatal(err)
}
@@ -99,7 +100,7 @@ func TestDefaultAccountManager_CreateSetupKey(t *testing.T) {
}
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
if err != nil {
t.Fatal(err)
}
@@ -204,7 +205,7 @@ func TestGetSetupKeys(t *testing.T) {
}
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
if err != nil {
t.Fatal(err)
}
@@ -471,7 +472,7 @@ func TestDefaultAccountManager_CreateSetupKey_ShouldNotAllowToUpdateRevokedKey(t
}
userID := "testingUser"
account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID})
if err != nil {
t.Fatal(err)
}
+6
View File
@@ -16,6 +16,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
nbutil "github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/crypt"
)
// storeFileName Store file name. Stored in the datadir
@@ -263,3 +264,8 @@ func (s *FileStore) Close(ctx context.Context) error {
func (s *FileStore) GetStoreEngine() types.Engine {
return types.FileStoreEngine
}
// SetFieldEncrypt is a no-op for FileStore as it doesn't support field encryption.
func (s *FileStore) SetFieldEncrypt(_ *crypt.FieldEncrypt) {
// no-op: FileStore stores data in plaintext JSON; encryption is not supported
}
+123 -19
View File
@@ -37,6 +37,7 @@ import (
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util/crypt"
)
const (
@@ -57,12 +58,14 @@ const (
// SqlStore represents an account storage backed by a Sql DB persisted to disk
type SqlStore struct {
db *gorm.DB
globalAccountLock sync.Mutex
metrics telemetry.AppMetrics
installationPK int
storeEngine types.Engine
pool *pgxpool.Pool
db *gorm.DB
globalAccountLock sync.Mutex
metrics telemetry.AppMetrics
installationPK int
storeEngine types.Engine
pool *pgxpool.Pool
fieldEncrypt *crypt.FieldEncrypt
transactionTimeout time.Duration
}
type installation struct {
@@ -84,6 +87,14 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
conns = runtime.NumCPU()
}
transactionTimeout := 5 * time.Minute
if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" {
if parsed, err := time.ParseDuration(v); err == nil {
transactionTimeout = parsed
}
}
log.WithContext(ctx).Infof("Setting transaction timeout to %v", transactionTimeout)
if storeEngine == types.SqliteStoreEngine {
if err == nil {
log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1")
@@ -101,7 +112,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
if skipMigration {
log.WithContext(ctx).Infof("skipping migration")
return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1}, nil
return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil
}
if err := migratePreAuto(ctx, db); err != nil {
@@ -120,7 +131,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
return nil, fmt.Errorf("migratePostAuto: %w", err)
}
return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1}, nil
return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil
}
func GetKeyQueryCondition(s *SqlStore) string {
@@ -165,6 +176,13 @@ func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) erro
generateAccountSQLTypes(account)
// Encrypt sensitive user data before saving
for i := range account.UsersG {
if err := account.UsersG[i].EncryptSensitiveData(s.fieldEncrypt); err != nil {
return fmt.Errorf("encrypt user: %w", err)
}
}
for _, group := range account.GroupsG {
group.StoreGroupPeers()
}
@@ -430,7 +448,18 @@ func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error {
return nil
}
result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&users)
usersCopy := make([]*types.User, len(users))
for i, user := range users {
userCopy := user.Copy()
userCopy.Email = user.Email
userCopy.Name = user.Name
if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
return fmt.Errorf("encrypt user: %w", err)
}
usersCopy[i] = userCopy
}
result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&usersCopy)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error)
return status.Errorf(status.Internal, "failed to save users to store")
@@ -440,7 +469,15 @@ func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error {
// SaveUser saves the given user to the database.
func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error {
result := s.db.Save(user)
userCopy := user.Copy()
userCopy.Email = user.Email
userCopy.Name = user.Name
if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
return fmt.Errorf("encrypt user: %w", err)
}
result := s.db.Save(userCopy)
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to save user to store: %s", result.Error)
return status.Errorf(status.Internal, "failed to save user to store")
@@ -590,6 +627,10 @@ func (s *SqlStore) GetUserByPATID(ctx context.Context, lockStrength LockingStren
return nil, status.NewGetUserFromStoreError()
}
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
return &user, nil
}
@@ -608,6 +649,10 @@ func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStre
return nil, status.NewGetUserFromStoreError()
}
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
return &user, nil
}
@@ -644,6 +689,12 @@ func (s *SqlStore) GetAccountUsers(ctx context.Context, lockStrength LockingStre
return nil, status.Errorf(status.Internal, "issue getting 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
}
@@ -662,6 +713,10 @@ func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStre
return nil, status.Errorf(status.Internal, "failed to get account owner from the store")
}
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
return &user, nil
}
@@ -856,6 +911,9 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
if user.AutoGroups == nil {
user.AutoGroups = []string{}
}
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
account.Users[user.Id] = &user
user.PATsG = nil
}
@@ -1131,6 +1189,9 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
account.Users = make(map[string]*types.User, len(account.UsersG))
for i := range account.UsersG {
user := &account.UsersG[i]
if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil {
return nil, fmt.Errorf("decrypt user: %w", err)
}
user.PATs = make(map[string]*types.PersonalAccessToken)
if userPats, ok := patsByUserID[user.Id]; ok {
for j := range userPats {
@@ -1535,7 +1596,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
}
func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) {
const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type FROM users WHERE account_id = $1`
const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1`
rows, err := s.pool.Query(ctx, query, accountID)
if err != nil {
return nil, err
@@ -1545,7 +1606,7 @@ func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User
var autoGroups []byte
var lastLogin, createdAt sql.NullTime
var isServiceUser, nonDeletable, blocked, pendingApproval sql.NullBool
err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType)
err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType, &u.Email, &u.Name)
if err == nil {
if lastLogin.Valid {
u.LastLogin = &lastLogin.Time
@@ -1910,16 +1971,17 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t
if len(policyIDs) == 0 {
return nil, nil
}
const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges FROM policy_rules WHERE policy_id = ANY($1)`
const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)`
rows, err := s.pool.Query(ctx, query, policyIDs)
if err != nil {
return nil, err
}
rules, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.PolicyRule, error) {
var r types.PolicyRule
var dest, destRes, sources, sourceRes, ports, portRanges []byte
var dest, destRes, sources, sourceRes, ports, portRanges, authorizedGroups []byte
var enabled, bidirectional sql.NullBool
err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges)
var authorizedUser sql.NullString
err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser)
if err == nil {
if enabled.Valid {
r.Enabled = enabled.Bool
@@ -1945,6 +2007,12 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t
if portRanges != nil {
_ = json.Unmarshal(portRanges, &r.PortRanges)
}
if authorizedGroups != nil {
_ = json.Unmarshal(authorizedGroups, &r.AuthorizedGroups)
}
if authorizedUser.Valid {
r.AuthorizedUser = authorizedUser.String
}
}
return &r, err
})
@@ -2890,8 +2958,11 @@ func (s *SqlStore) IncrementNetworkSerial(ctx context.Context, accountId string)
}
func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error {
timeoutCtx, cancel := context.WithTimeout(context.Background(), s.transactionTimeout)
defer cancel()
startTime := time.Now()
tx := s.db.Begin()
tx := s.db.WithContext(timeoutCtx).Begin()
if tx.Error != nil {
return tx.Error
}
@@ -2926,6 +2997,9 @@ func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(stor
err := operation(repo)
if err != nil {
tx.Rollback()
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
log.WithContext(ctx).Warnf("transaction exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack())
}
return err
}
@@ -2938,19 +3012,26 @@ func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(stor
}
err = tx.Commit().Error
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
log.WithContext(ctx).Warnf("transaction commit exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack())
}
return err
}
log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime))
if s.metrics != nil {
s.metrics.StoreMetrics().CountTransactionDuration(time.Since(startTime))
}
return err
return nil
}
func (s *SqlStore) withTx(tx *gorm.DB) Store {
return &SqlStore{
db: tx,
storeEngine: s.storeEngine,
db: tx,
storeEngine: s.storeEngine,
fieldEncrypt: s.fieldEncrypt,
}
}
@@ -2983,6 +3064,11 @@ func (s *SqlStore) GetDB() *gorm.DB {
return s.db
}
// SetFieldEncrypt sets the field encryptor for encrypting sensitive user data.
func (s *SqlStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) {
s.fieldEncrypt = enc
}
func (s *SqlStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.DNSSettings, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
@@ -4075,3 +4161,21 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro
return peers, nil
}
func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
var userID string
result := tx.Model(&nbpeer.Peer{}).
Select("user_id").
Take(&userID, GetKeyQueryCondition(s), peerKey)
if result.Error != nil {
return "", status.Errorf(status.Internal, "failed to get user ID by peer key")
}
return userID, nil
}
+224 -1
View File
@@ -32,6 +32,7 @@ import (
nbroute "github.com/netbirdio/netbird/route"
route2 "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util/crypt"
)
func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T, store Store)) {
@@ -2090,7 +2091,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string) *ty
setupKeys := map[string]*types.SetupKey{}
nameServersGroups := make(map[string]*nbdns.NameServerGroup)
owner := types.NewOwnerUser(userID)
owner := types.NewOwnerUser(userID, "", "")
owner.AccountID = accountID
users[userID] = owner
@@ -3114,6 +3115,138 @@ func TestSqlStore_SaveUsers(t *testing.T) {
require.Equal(t, users[1].AutoGroups, user.AutoGroups)
}
func TestSqlStore_SaveUserWithEncryption(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
// Enable encryption
key, err := crypt.GenerateKey()
require.NoError(t, err)
fieldEncrypt, err := crypt.NewFieldEncrypt(key)
require.NoError(t, err)
store.SetFieldEncrypt(fieldEncrypt)
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
// rawUser is used to read raw (potentially encrypted) data from the database
// without any gorm hooks or automatic decryption
type rawUser struct {
Id string
Email string
Name string
}
t.Run("save user with empty email and name", func(t *testing.T) {
user := &types.User{
Id: "user-empty-fields",
AccountID: accountID,
Role: types.UserRoleUser,
Email: "",
Name: "",
AutoGroups: []string{"groupA"},
}
err = store.SaveUser(context.Background(), user)
require.NoError(t, err)
// Verify using direct database query that empty strings remain empty (not encrypted)
var raw rawUser
err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error
require.NoError(t, err)
require.Equal(t, "", raw.Email, "empty email should remain empty in database")
require.Equal(t, "", raw.Name, "empty name should remain empty in database")
// Verify manual decryption returns empty strings
decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email)
require.NoError(t, err)
require.Equal(t, "", decryptedEmail)
decryptedName, err := fieldEncrypt.Decrypt(raw.Name)
require.NoError(t, err)
require.Equal(t, "", decryptedName)
})
t.Run("save user with email and name", func(t *testing.T) {
user := &types.User{
Id: "user-with-fields",
AccountID: accountID,
Role: types.UserRoleAdmin,
Email: "test@example.com",
Name: "Test User",
AutoGroups: []string{"groupB"},
}
err = store.SaveUser(context.Background(), user)
require.NoError(t, err)
// Verify using direct database query that the data is encrypted (not plaintext)
var raw rawUser
err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error
require.NoError(t, err)
require.NotEqual(t, "test@example.com", raw.Email, "email should be encrypted in database")
require.NotEqual(t, "Test User", raw.Name, "name should be encrypted in database")
// Verify manual decryption returns correct values
decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email)
require.NoError(t, err)
require.Equal(t, "test@example.com", decryptedEmail)
decryptedName, err := fieldEncrypt.Decrypt(raw.Name)
require.NoError(t, err)
require.Equal(t, "Test User", decryptedName)
})
t.Run("save multiple users with mixed fields", func(t *testing.T) {
users := []*types.User{
{
Id: "batch-user-1",
AccountID: accountID,
Email: "",
Name: "",
},
{
Id: "batch-user-2",
AccountID: accountID,
Email: "batch@example.com",
Name: "Batch User",
},
}
err = store.SaveUsers(context.Background(), users)
require.NoError(t, err)
// Verify first user (empty fields) using direct database query
var raw1 rawUser
err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-1").First(&raw1).Error
require.NoError(t, err)
require.Equal(t, "", raw1.Email, "empty email should remain empty in database")
require.Equal(t, "", raw1.Name, "empty name should remain empty in database")
// Verify second user (with fields) using direct database query
var raw2 rawUser
err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-2").First(&raw2).Error
require.NoError(t, err)
require.NotEqual(t, "batch@example.com", raw2.Email, "email should be encrypted in database")
require.NotEqual(t, "Batch User", raw2.Name, "name should be encrypted in database")
// Verify manual decryption returns empty strings for first user
decryptedEmail1, err := fieldEncrypt.Decrypt(raw1.Email)
require.NoError(t, err)
require.Equal(t, "", decryptedEmail1)
decryptedName1, err := fieldEncrypt.Decrypt(raw1.Name)
require.NoError(t, err)
require.Equal(t, "", decryptedName1)
// Verify manual decryption returns correct values for second user
decryptedEmail2, err := fieldEncrypt.Decrypt(raw2.Email)
require.NoError(t, err)
require.Equal(t, "batch@example.com", decryptedEmail2)
decryptedName2, err := fieldEncrypt.Decrypt(raw2.Name)
require.NoError(t, err)
require.Equal(t, "Batch User", decryptedName2)
})
}
func TestSqlStore_DeleteUser(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)
@@ -3718,6 +3851,69 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) {
}
}
func TestSqlStore_GetUserIDByPeerKey(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
userID := "test-user-123"
peerKey := "peer-key-abc"
peer := &nbpeer.Peer{
ID: "test-peer-1",
Key: peerKey,
AccountID: existingAccountID,
UserID: userID,
IP: net.IP{10, 0, 0, 1},
DNSLabel: "test-peer-1",
}
err = store.AddPeerToAccount(context.Background(), peer)
require.NoError(t, err)
retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey)
require.NoError(t, err)
assert.Equal(t, userID, retrievedUserID)
}
func TestSqlStore_GetUserIDByPeerKey_NotFound(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
nonExistentPeerKey := "non-existent-peer-key"
userID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, nonExistentPeerKey)
require.Error(t, err)
assert.Equal(t, "", userID)
}
func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
peerKey := "peer-key-abc"
peer := &nbpeer.Peer{
ID: "test-peer-1",
Key: peerKey,
AccountID: existingAccountID,
UserID: "",
IP: net.IP{10, 0, 0, 1},
DNSLabel: "test-peer-1",
}
err = store.AddPeerToAccount(context.Background(), peer)
require.NoError(t, err)
retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey)
require.NoError(t, err)
assert.Equal(t, "", retrievedUserID)
}
func TestSqlStore_ApproveAccountPeers(t *testing.T) {
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
accountID := "test-account"
@@ -3794,3 +3990,30 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) {
})
})
}
func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) {
if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" {
t.Skip("Skipping timeout test for MySQL")
}
t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s")
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
require.NoError(t, err)
t.Cleanup(cleanup)
sqlStore, ok := store.(*SqlStore)
require.True(t, ok)
assert.Equal(t, 1*time.Second, sqlStore.transactionTimeout)
ctx := context.Background()
err = sqlStore.ExecuteInTransaction(ctx, func(transaction Store) error {
// Sleep for 2 seconds to exceed the 1 second timeout
time.Sleep(2 * time.Second)
return nil
})
// The transaction should fail with an error (either timeout or already rolled back)
require.Error(t, err)
assert.Contains(t, err.Error(), "transaction has already been committed or rolled back", "expected transaction rolled back error, got: %v", err)
}
+11
View File
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/server/testutil"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/crypt"
"github.com/netbirdio/netbird/management/server/migration"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
@@ -204,6 +205,10 @@ type Store interface {
MarkAccountPrimary(ctx context.Context, accountID string) error
UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error
GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) ([]*types.PolicyRule, error)
// SetFieldEncrypt sets the field encryptor for encrypting sensitive user data.
SetFieldEncrypt(enc *crypt.FieldEncrypt)
GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error)
}
const (
@@ -339,6 +344,12 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
func(db *gorm.DB) error {
return migration.DropIndex[routerTypes.NetworkRouter](ctx, db, "idx_network_routers_id")
},
func(db *gorm.DB) error {
return migration.MigrateNewField[types.User](ctx, db, "name", "")
},
func(db *gorm.DB) error {
return migration.MigrateNewField[types.User](ctx, db, "email", "")
},
}
} // migratePostAuto migrates the SQLite database to the latest schema
func migratePostAuto(ctx context.Context, db *gorm.DB) error {
+113 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ssh/auth"
nbdns "github.com/netbirdio/netbird/dns"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
@@ -45,8 +46,10 @@ const (
// nativeSSHPortString defines the default port number as a string used for native SSH connections; this port is used by clients when hijacking ssh connections.
nativeSSHPortString = "22022"
nativeSSHPortNumber = 22022
// defaultSSHPortString defines the standard SSH port number as a string, commonly used for default SSH connections.
defaultSSHPortString = "22"
defaultSSHPortNumber = 22
)
type supportedFeatures struct {
@@ -275,6 +278,7 @@ func (a *Account) GetPeerNetworkMap(
resourcePolicies map[string][]*Policy,
routers map[string]map[string]*routerTypes.NetworkRouter,
metrics *telemetry.AccountManagerMetrics,
groupIDToUserIDs map[string][]string,
) *NetworkMap {
start := time.Now()
peer := a.Peers[peerID]
@@ -290,7 +294,7 @@ func (a *Account) GetPeerNetworkMap(
}
}
aclPeers, firewallRules := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap)
aclPeers, firewallRules, authorizedUsers, enableSSH := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs)
// exclude expired peers
var peersToConnect []*nbpeer.Peer
var expiredPeers []*nbpeer.Peer
@@ -338,6 +342,8 @@ func (a *Account) GetPeerNetworkMap(
OfflinePeers: expiredPeers,
FirewallRules: firewallRules,
RoutesFirewallRules: slices.Concat(networkResourcesFirewallRules, routesFirewallRules),
AuthorizedUsers: authorizedUsers,
EnableSSH: enableSSH,
}
if metrics != nil {
@@ -1122,8 +1128,10 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) map
// GetPeerConnectionResources for a given peer
//
// This function returns the list of peers and firewall rules that are applicable to a given peer.
func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, []*FirewallRule) {
func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) {
generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer)
authorizedUsers := make(map[string]map[string]struct{}) // machine user to list of userIDs
sshEnabled := false
for _, policy := range a.Policies {
if !policy.Enabled {
@@ -1166,10 +1174,58 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
if peerInDestinations {
generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
}
if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
sshEnabled = true
switch {
case len(rule.AuthorizedGroups) > 0:
for groupID, localUsers := range rule.AuthorizedGroups {
userIDs, ok := groupIDToUserIDs[groupID]
if !ok {
log.WithContext(ctx).Tracef("no user IDs found for group ID %s", groupID)
continue
}
if len(localUsers) == 0 {
localUsers = []string{auth.Wildcard}
}
for _, localUser := range localUsers {
if authorizedUsers[localUser] == nil {
authorizedUsers[localUser] = make(map[string]struct{})
}
for _, userID := range userIDs {
authorizedUsers[localUser][userID] = struct{}{}
}
}
}
case rule.AuthorizedUser != "":
if authorizedUsers[auth.Wildcard] == nil {
authorizedUsers[auth.Wildcard] = make(map[string]struct{})
}
authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{}
default:
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
}
} else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
sshEnabled = true
authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs()
}
}
}
return getAccumulatedResources()
peers, fwRules := getAccumulatedResources()
return peers, fwRules, authorizedUsers, sshEnabled
}
func (a *Account) getAllowedUserIDs() map[string]struct{} {
users := make(map[string]struct{})
for _, nbUser := range a.Users {
if !nbUser.IsBlocked() && !nbUser.IsServiceUser {
users[nbUser.Id] = struct{}{}
}
}
return users
}
// connResourcesGenerator returns generator and accumulator function which returns the result of generator calls
@@ -1194,12 +1250,17 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
peersExists[peer.ID] = struct{}{}
}
protocol := rule.Protocol
if protocol == PolicyRuleProtocolNetbirdSSH {
protocol = PolicyRuleProtocolTCP
}
fr := FirewallRule{
PolicyID: rule.ID,
PeerIP: peer.IP.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: string(rule.Protocol),
Protocol: string(protocol),
}
ruleID := rule.ID + fr.PeerIP + strconv.Itoa(direction) +
@@ -1221,6 +1282,28 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
}
}
func policyRuleImpliesLegacySSH(rule *PolicyRule) bool {
return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
}
func portRangeIncludesSSH(portRanges []RulePortRange) bool {
for _, pr := range portRanges {
if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
return true
}
}
return false
}
func portsIncludesSSH(ports []string) bool {
for _, port := range ports {
if port == defaultSSHPortString || port == nativeSSHPortString {
return true
}
}
return false
}
// getAllPeersFromGroups for given peer ID and list of groups
//
// Returns a list of peers from specified groups that pass specified posture checks
@@ -1265,7 +1348,11 @@ func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpe
return []*nbpeer.Peer{}, false
}
return []*nbpeer.Peer{peer}, resource.ID == peerID
if peer.ID == peerID {
return []*nbpeer.Peer{}, true
}
return []*nbpeer.Peer{peer}, false
}
// validatePostureChecksOnPeer validates the posture checks on a peer
@@ -1773,6 +1860,26 @@ func (a *Account) AddAllGroup(disableDefaultPolicy bool) error {
return nil
}
func (a *Account) GetActiveGroupUsers() map[string][]string {
allGroupID := ""
group, err := a.GetGroupAll()
if err != nil {
log.Errorf("failed to get group all: %v", err)
} else {
allGroupID = group.ID
}
groups := make(map[string][]string, len(a.GroupsG))
for _, user := range a.Users {
if !user.IsBlocked() && !user.IsServiceUser {
for _, groupID := range user.AutoGroups {
groups[groupID] = append(groups[groupID], user.Id)
}
groups[allGroupID] = append(groups[allGroupID], user.Id)
}
}
return groups
}
// expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules
func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
features := peerSupportedFirewallFeatures(peer.Meta.WtVersion)
@@ -1804,7 +1911,7 @@ func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer
expanded = append(expanded, &fr)
}
if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) {
if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH {
expanded = addNativeSSHRule(base, expanded)
}
+187
View File
@@ -1105,6 +1105,193 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
}
}
func Test_GetActiveGroupUsers(t *testing.T) {
tests := []struct {
name string
account *Account
expected map[string][]string
}{
{
name: "all users are active",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{"group1", "group2"},
Blocked: false,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group2", "group3"},
Blocked: false,
},
"user3": {
Id: "user3",
AutoGroups: []string{"group1"},
Blocked: false,
},
},
},
expected: map[string][]string{
"group1": {"user1", "user3"},
"group2": {"user1", "user2"},
"group3": {"user2"},
"": {"user1", "user2", "user3"},
},
},
{
name: "some users are blocked",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{"group1", "group2"},
Blocked: false,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group2", "group3"},
Blocked: true,
},
"user3": {
Id: "user3",
AutoGroups: []string{"group1", "group3"},
Blocked: false,
},
},
},
expected: map[string][]string{
"group1": {"user1", "user3"},
"group2": {"user1"},
"group3": {"user3"},
"": {"user1", "user3"},
},
},
{
name: "all users are blocked",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{"group1"},
Blocked: true,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group2"},
Blocked: true,
},
},
},
expected: map[string][]string{},
},
{
name: "user with no auto groups",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{},
Blocked: false,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group1"},
Blocked: false,
},
},
},
expected: map[string][]string{
"group1": {"user2"},
"": {"user1", "user2"},
},
},
{
name: "empty account",
account: &Account{
Users: map[string]*User{},
},
expected: map[string][]string{},
},
{
name: "multiple users in same group",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{"group1"},
Blocked: false,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group1"},
Blocked: false,
},
"user3": {
Id: "user3",
AutoGroups: []string{"group1"},
Blocked: false,
},
},
},
expected: map[string][]string{
"group1": {"user1", "user2", "user3"},
"": {"user1", "user2", "user3"},
},
},
{
name: "user in multiple groups with blocked users",
account: &Account{
Users: map[string]*User{
"user1": {
Id: "user1",
AutoGroups: []string{"group1", "group2", "group3"},
Blocked: false,
},
"user2": {
Id: "user2",
AutoGroups: []string{"group1", "group2"},
Blocked: true,
},
"user3": {
Id: "user3",
AutoGroups: []string{"group3"},
Blocked: false,
},
},
},
expected: map[string][]string{
"group1": {"user1"},
"group2": {"user1"},
"group3": {"user1", "user3"},
"": {"user1", "user3"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.account.GetActiveGroupUsers()
// Check that the number of groups matches
assert.Equal(t, len(tt.expected), len(result), "number of groups should match")
// Check each group's users
for groupID, expectedUsers := range tt.expected {
actualUsers, exists := result[groupID]
assert.True(t, exists, "group %s should exist in result", groupID)
assert.ElementsMatch(t, expectedUsers, actualUsers, "users in group %s should match", groupID)
}
// Ensure no extra groups in result
for groupID := range result {
_, exists := tt.expected[groupID]
assert.True(t, exists, "unexpected group %s in result", groupID)
}
})
}
}
func Test_FilterZoneRecordsForPeers(t *testing.T) {
tests := []struct {
name string
+6 -2
View File
@@ -25,16 +25,20 @@ func (h *Holder) GetAccount(id string) *Account {
func (h *Holder) AddAccount(account *Account) {
h.mu.Lock()
defer h.mu.Unlock()
a := h.accounts[account.Id]
if a != nil && a.Network.CurrentSerial() >= account.Network.CurrentSerial() {
return
}
h.accounts[account.Id] = account
}
func (h *Holder) LoadOrStoreFunc(id string, accGetter func(context.Context, string) (*Account, error)) (*Account, error) {
func (h *Holder) LoadOrStoreFunc(ctx context.Context, id string, accGetter func(context.Context, string) (*Account, error)) (*Account, error) {
h.mu.Lock()
defer h.mu.Unlock()
if acc, ok := h.accounts[id]; ok {
return acc, nil
}
account, err := accGetter(context.Background(), id)
account, err := accGetter(ctx, id)
if err != nil {
return nil, err
}
@@ -0,0 +1,122 @@
package types
import (
"errors"
"net/url"
)
// Identity provider validation errors
var (
ErrIdentityProviderNameRequired = errors.New("identity provider name is required")
ErrIdentityProviderTypeRequired = errors.New("identity provider type is required")
ErrIdentityProviderTypeUnsupported = errors.New("unsupported identity provider type")
ErrIdentityProviderIssuerRequired = errors.New("identity provider issuer is required")
ErrIdentityProviderIssuerInvalid = errors.New("identity provider issuer must be a valid URL")
ErrIdentityProviderClientIDRequired = errors.New("identity provider client ID is required")
)
// IdentityProviderType is the type of identity provider
type IdentityProviderType string
const (
// IdentityProviderTypeOIDC is a generic OIDC identity provider
IdentityProviderTypeOIDC IdentityProviderType = "oidc"
// IdentityProviderTypeZitadel is the Zitadel identity provider
IdentityProviderTypeZitadel IdentityProviderType = "zitadel"
// IdentityProviderTypeEntra is the Microsoft Entra (Azure AD) identity provider
IdentityProviderTypeEntra IdentityProviderType = "entra"
// IdentityProviderTypeGoogle is the Google identity provider
IdentityProviderTypeGoogle IdentityProviderType = "google"
// IdentityProviderTypeOkta is the Okta identity provider
IdentityProviderTypeOkta IdentityProviderType = "okta"
// IdentityProviderTypePocketID is the PocketID identity provider
IdentityProviderTypePocketID IdentityProviderType = "pocketid"
// IdentityProviderTypeMicrosoft is the Microsoft identity provider
IdentityProviderTypeMicrosoft IdentityProviderType = "microsoft"
// IdentityProviderTypeAuthentik is the Authentik identity provider
IdentityProviderTypeAuthentik IdentityProviderType = "authentik"
// IdentityProviderTypeKeycloak is the Keycloak identity provider
IdentityProviderTypeKeycloak IdentityProviderType = "keycloak"
)
// IdentityProvider represents an identity provider configuration
type IdentityProvider struct {
// ID is the unique identifier of the identity provider
ID string `gorm:"primaryKey"`
// AccountID is a reference to Account that this object belongs
AccountID string `json:"-" gorm:"index"`
// Type is the type of identity provider
Type IdentityProviderType
// Name is a human-readable name for the identity provider
Name string
// Issuer is the OIDC issuer URL
Issuer string
// ClientID is the OAuth2 client ID
ClientID string
// ClientSecret is the OAuth2 client secret
ClientSecret string
}
// Copy returns a copy of the IdentityProvider
func (idp *IdentityProvider) Copy() *IdentityProvider {
return &IdentityProvider{
ID: idp.ID,
AccountID: idp.AccountID,
Type: idp.Type,
Name: idp.Name,
Issuer: idp.Issuer,
ClientID: idp.ClientID,
ClientSecret: idp.ClientSecret,
}
}
// EventMeta returns a map of metadata for activity events
func (idp *IdentityProvider) EventMeta() map[string]any {
return map[string]any{
"name": idp.Name,
"type": string(idp.Type),
"issuer": idp.Issuer,
}
}
// Validate validates the identity provider configuration
func (idp *IdentityProvider) Validate() error {
if idp.Name == "" {
return ErrIdentityProviderNameRequired
}
if idp.Type == "" {
return ErrIdentityProviderTypeRequired
}
if !idp.Type.IsValid() {
return ErrIdentityProviderTypeUnsupported
}
if !idp.Type.HasBuiltInIssuer() && idp.Issuer == "" {
return ErrIdentityProviderIssuerRequired
}
if idp.Issuer != "" {
parsedURL, err := url.Parse(idp.Issuer)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return ErrIdentityProviderIssuerInvalid
}
}
if idp.ClientID == "" {
return ErrIdentityProviderClientIDRequired
}
return nil
}
// IsValid checks if the given type is a supported identity provider type
func (t IdentityProviderType) IsValid() bool {
switch t {
case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra,
IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID,
IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak:
return true
}
return false
}
// HasBuiltInIssuer returns true for types that don't require an issuer URL
func (t IdentityProviderType) HasBuiltInIssuer() bool {
return t == IdentityProviderTypeGoogle || t == IdentityProviderTypeMicrosoft
}
@@ -0,0 +1,137 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIdentityProvider_Validate(t *testing.T) {
tests := []struct {
name string
idp *IdentityProvider
expectedErr error
}{
{
name: "valid OIDC provider",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "https://example.com",
ClientID: "client-id",
},
expectedErr: nil,
},
{
name: "valid OIDC provider with path",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "https://example.com/oauth2/issuer",
ClientID: "client-id",
},
expectedErr: nil,
},
{
name: "missing name",
idp: &IdentityProvider{
Type: IdentityProviderTypeOIDC,
Issuer: "https://example.com",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderNameRequired,
},
{
name: "missing type",
idp: &IdentityProvider{
Name: "Test Provider",
Issuer: "https://example.com",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderTypeRequired,
},
{
name: "invalid type",
idp: &IdentityProvider{
Name: "Test Provider",
Type: "invalid",
Issuer: "https://example.com",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderTypeUnsupported,
},
{
name: "missing issuer for OIDC",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderIssuerRequired,
},
{
name: "invalid issuer URL - no scheme",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "example.com",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderIssuerInvalid,
},
{
name: "invalid issuer URL - no host",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "https://",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderIssuerInvalid,
},
{
name: "invalid issuer URL - just path",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "/oauth2/issuer",
ClientID: "client-id",
},
expectedErr: ErrIdentityProviderIssuerInvalid,
},
{
name: "missing client ID",
idp: &IdentityProvider{
Name: "Test Provider",
Type: IdentityProviderTypeOIDC,
Issuer: "https://example.com",
},
expectedErr: ErrIdentityProviderClientIDRequired,
},
{
name: "Google provider without issuer is valid",
idp: &IdentityProvider{
Name: "Google SSO",
Type: IdentityProviderTypeGoogle,
ClientID: "client-id",
},
expectedErr: nil,
},
{
name: "Microsoft provider without issuer is valid",
idp: &IdentityProvider{
Name: "Microsoft SSO",
Type: IdentityProviderTypeMicrosoft,
ClientID: "client-id",
},
expectedErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.idp.Validate()
assert.Equal(t, tt.expectedErr, err)
})
}
}
+2
View File
@@ -39,6 +39,8 @@ type NetworkMap struct {
FirewallRules []*FirewallRule
RoutesFirewallRules []*RouteFirewallRule
ForwardingRules []*ForwardingRule
AuthorizedUsers map[string]map[string]struct{}
EnableSSH bool
}
func (nm *NetworkMap) Merge(other *NetworkMap) {
+9 -2
View File
@@ -47,14 +47,21 @@ func (a *Account) OnPeerAddedUpdNetworkMapCache(peerId string) error {
if a.NetworkMapCache == nil {
return nil
}
return a.NetworkMapCache.OnPeerAddedIncremental(peerId)
return a.NetworkMapCache.OnPeerAddedIncremental(a, peerId)
}
func (a *Account) OnPeersAddedUpdNetworkMapCache(peerIds ...string) {
if a.NetworkMapCache == nil {
return
}
a.NetworkMapCache.EnqueuePeersForIncrementalAdd(a, peerIds...)
}
func (a *Account) OnPeerDeletedUpdNetworkMapCache(peerId string) error {
if a.NetworkMapCache == nil {
return nil
}
return a.NetworkMapCache.OnPeerDeleted(peerId)
return a.NetworkMapCache.OnPeerDeleted(a, peerId)
}
func (a *Account) UpdatePeerInNetworkMapCache(peer *nbpeer.Peer) {
+282 -385
View File
@@ -25,15 +25,12 @@ import (
"github.com/netbirdio/netbird/route"
)
// update flag is used to update the golden file.
// example: go test ./... -v -update
// var update = flag.Bool("update", false, "update golden files")
const (
numPeers = 100
devGroupID = "group-dev"
opsGroupID = "group-ops"
allGroupID = "group-all"
sshUsersGroupID = "group-ssh-users"
routeID = route.ID("route-main")
routeHA1ID = route.ID("route-ha-1")
routeHA2ID = route.ID("route-ha-2")
@@ -41,6 +38,7 @@ const (
policyIDAll = "policy-all"
policyIDPosture = "policy-posture"
policyIDDrop = "policy-drop"
policyIDSSH = "policy-ssh"
postureCheckID = "posture-check-ver"
networkResourceID = "res-database"
networkID = "net-database"
@@ -51,6 +49,9 @@ const (
offlinePeerID = "peer-99" // This peer will be completely offline.
routingPeerID = "peer-95" // This peer is used for routing, it has a route to the network.
testAccountID = "account-golden-test"
userAdminID = "user-admin"
userDevID = "user-dev"
userOpsID = "user-ops"
)
func TestGetPeerNetworkMap_Golden(t *testing.T) {
@@ -69,61 +70,34 @@ func TestGetPeerNetworkMap_Golden(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil)
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden.json")
t.Log("Update golden file...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "resulted network map from OLD method does not match golden file")
}
func TestGetPeerNetworkMap_Golden_New(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
normalizeAndSortNetworkMap(networkMap)
if string(legacyJSON) != string(newJSON) {
legacyFilePath := filepath.Join("testdata", "networkmap_golden.json")
newFilePath := filepath.Join("testdata", "networkmap_golden_new.json")
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
err = os.MkdirAll(filepath.Dir(legacyFilePath), 0755)
require.NoError(t, err)
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new.json")
err = os.WriteFile(legacyFilePath, legacyJSON, 0644)
require.NoError(t, err)
t.Logf("Saved legacy network map to %s", legacyFilePath)
t.Log("Update golden file...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
err = os.WriteFile(newFilePath, newJSON, 0644)
require.NoError(t, err)
t.Logf("Saved new network map to %s", newFilePath)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "resulted network map from NEW builder does not match golden file")
require.JSONEq(t, string(legacyJSON), string(newJSON), "network maps from legacy and new builder do not match")
}
}
func BenchmarkGetPeerNetworkMap(b *testing.B) {
@@ -141,7 +115,7 @@ func BenchmarkGetPeerNetworkMap(b *testing.B) {
b.Run("old builder", func(b *testing.B) {
for range b.N {
for _, peerID := range peerIDs {
_ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil)
_ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -169,6 +143,8 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) {
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newPeerID := "peer-new-101"
newPeerIP := net.IP{100, 64, 1, 1}
newPeer := &nbpeer.Peer{
@@ -201,92 +177,36 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil)
legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_new_peer.json")
t.Log("Update golden file with new peer...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with new peer does not match golden file")
}
func TestGetPeerNetworkMap_Golden_New_WithOnPeerAdded(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newPeerID := "peer-new-101"
newPeerIP := net.IP{100, 64, 1, 1}
newPeer := &nbpeer.Peer{
ID: newPeerID,
IP: newPeerIP,
Key: fmt.Sprintf("key-%s", newPeerID),
DNSLabel: "peernew101",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
account.Peers[newPeerID] = newPeer
if devGroup, exists := account.Groups[devGroupID]; exists {
devGroup.Peers = append(devGroup.Peers, newPeerID)
}
if allGroup, exists := account.Groups[allGroupID]; exists {
allGroup.Peers = append(allGroup.Peers, newPeerID)
}
validatedPeersMap[newPeerID] = struct{}{}
if account.Network != nil {
account.Network.Serial++
}
err := builder.OnPeerAddedIncremental(newPeerID)
err = builder.OnPeerAddedIncremental(account, newPeerID)
require.NoError(t, err, "error adding peer to cache")
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
normalizeAndSortNetworkMap(networkMap)
if string(legacyJSON) != string(newJSON) {
legacyFilePath := filepath.Join("testdata", "networkmap_golden_with_new_peer.json")
newFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded.json")
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
err = os.MkdirAll(filepath.Dir(legacyFilePath), 0755)
require.NoError(t, err)
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded.json")
t.Log("Update golden file with OnPeerAdded...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
err = os.WriteFile(legacyFilePath, legacyJSON, 0644)
require.NoError(t, err)
t.Logf("Saved legacy network map to %s", legacyFilePath)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
err = os.WriteFile(newFilePath, newJSON, 0644)
require.NoError(t, err)
t.Logf("Saved new network map to %s", newFilePath)
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerAdded does not match golden file")
require.JSONEq(t, string(legacyJSON), string(newJSON), "network maps with new peer from legacy and new builder do not match")
}
}
func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
@@ -320,7 +240,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
b.Run("old builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil)
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -328,7 +248,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) {
b.ResetTimer()
b.Run("new builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerAddedIncremental(newPeerID)
_ = builder.OnPeerAddedIncremental(account, newPeerID)
for _, testingPeerID := range peerIDs {
_ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
}
@@ -349,6 +269,8 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) {
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouter := &nbpeer.Peer{
@@ -395,106 +317,36 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil)
legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_new_router.json")
t.Log("Update golden file with new router...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with new router does not match golden file")
}
func TestGetPeerNetworkMap_Golden_New_WithOnPeerAddedRouter(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouter := &nbpeer.Peer{
ID: newRouterID,
IP: newRouterIP,
Key: fmt.Sprintf("key-%s", newRouterID),
DNSLabel: "newrouter102",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
account.Peers[newRouterID] = newRouter
if opsGroup, exists := account.Groups[opsGroupID]; exists {
opsGroup.Peers = append(opsGroup.Peers, newRouterID)
}
if allGroup, exists := account.Groups[allGroupID]; exists {
allGroup.Peers = append(allGroup.Peers, newRouterID)
}
newRoute := &route.Route{
ID: route.ID("route-new-router"),
Network: netip.MustParsePrefix("172.16.0.0/24"),
Peer: newRouter.Key,
PeerID: newRouterID,
Description: "Route from new router",
Enabled: true,
PeerGroups: []string{opsGroupID},
Groups: []string{devGroupID, opsGroupID},
AccessControlGroups: []string{devGroupID},
AccountID: account.Id,
}
account.Routes[newRoute.ID] = newRoute
validatedPeersMap[newRouterID] = struct{}{}
if account.Network != nil {
account.Network.Serial++
}
err := builder.OnPeerAddedIncremental(newRouterID)
err = builder.OnPeerAddedIncremental(account, newRouterID)
require.NoError(t, err, "error adding router to cache")
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
normalizeAndSortNetworkMap(networkMap)
if string(legacyJSON) != string(newJSON) {
legacyFilePath := filepath.Join("testdata", "networkmap_golden_with_new_router.json")
newFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded_router.json")
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
err = os.MkdirAll(filepath.Dir(legacyFilePath), 0755)
require.NoError(t, err)
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded_router.json")
err = os.WriteFile(legacyFilePath, legacyJSON, 0644)
require.NoError(t, err)
t.Logf("Saved legacy network map to %s", legacyFilePath)
t.Log("Update golden file with OnPeerAdded router...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
err = os.WriteFile(newFilePath, newJSON, 0644)
require.NoError(t, err)
t.Logf("Saved new network map to %s", newFilePath)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerAdded router does not match golden file")
require.JSONEq(t, string(legacyJSON), string(newJSON), "network maps with new router from legacy and new builder do not match")
}
}
func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
@@ -550,7 +402,7 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
b.Run("old builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil)
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -558,7 +410,7 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) {
b.ResetTimer()
b.Run("new builder after add", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerAddedIncremental(newRouterID)
_ = builder.OnPeerAddedIncremental(account, newRouterID)
for _, testingPeerID := range peerIDs {
_ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
}
@@ -579,7 +431,9 @@ func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) {
validatedPeersMap[peerID] = struct{}{}
}
deletedPeerID := "peer-25" // peer from devs group
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
deletedPeerID := "peer-25"
delete(account.Peers, deletedPeerID)
@@ -604,85 +458,36 @@ func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil)
legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_peer.json")
t.Log("Update golden file with deleted peer...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with deleted peer does not match golden file")
}
func TestGetPeerNetworkMap_Golden_New_WithOnPeerDeleted(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
deletedPeerID := "peer-25" // devs group peer
delete(account.Peers, deletedPeerID)
if devGroup, exists := account.Groups[devGroupID]; exists {
devGroup.Peers = slices.DeleteFunc(devGroup.Peers, func(id string) bool {
return id == deletedPeerID
})
}
if allGroup, exists := account.Groups[allGroupID]; exists {
allGroup.Peers = slices.DeleteFunc(allGroup.Peers, func(id string) bool {
return id == deletedPeerID
})
}
delete(validatedPeersMap, deletedPeerID)
if account.Network != nil {
account.Network.Serial++
}
err := builder.OnPeerDeleted(deletedPeerID)
err = builder.OnPeerDeleted(account, deletedPeerID)
require.NoError(t, err, "error deleting peer from cache")
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
normalizeAndSortNetworkMap(networkMap)
if string(legacyJSON) != string(newJSON) {
legacyFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_peer.json")
newFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeerdeleted.json")
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
err = os.MkdirAll(filepath.Dir(legacyFilePath), 0755)
require.NoError(t, err)
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeerdeleted.json")
t.Log("Update golden file with OnPeerDeleted...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
err = os.WriteFile(legacyFilePath, legacyJSON, 0644)
require.NoError(t, err)
t.Logf("Saved legacy network map to %s", legacyFilePath)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
err = os.WriteFile(newFilePath, newJSON, 0644)
require.NoError(t, err)
t.Logf("Saved new network map to %s", newFilePath)
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerDeleted does not match golden file")
require.JSONEq(t, string(legacyJSON), string(newJSON), "network maps with deleted peer from legacy and new builder do not match")
}
}
func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) {
@@ -698,7 +503,9 @@ func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) {
validatedPeersMap[peerID] = struct{}{}
}
deletedRouterID := "peer-75" // router peer
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
deletedRouterID := "peer-75"
var affectedRoute *route.Route
for _, r := range account.Routes {
@@ -730,93 +537,36 @@ func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil)
legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
normalizeAndSortNetworkMap(legacyNetworkMap)
legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ")
require.NoError(t, err, "error marshaling legacy network map to JSON")
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_router_peer.json")
t.Log("Update golden file with deleted peer...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with deleted peer does not match golden file")
}
func TestGetPeerNetworkMap_Golden_New_WithDeletedRouterPeer(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
deletedRouterID := "peer-75" // router peer
var affectedRoute *route.Route
for _, r := range account.Routes {
if r.PeerID == deletedRouterID {
affectedRoute = r
break
}
}
require.NotNil(t, affectedRoute, "Router peer should have a route")
for _, group := range account.Groups {
group.Peers = slices.DeleteFunc(group.Peers, func(id string) bool {
return id == deletedRouterID
})
}
for routeID, r := range account.Routes {
if r.Peer == account.Peers[deletedRouterID].Key || r.PeerID == deletedRouterID {
delete(account.Routes, routeID)
}
}
delete(account.Peers, deletedRouterID)
delete(validatedPeersMap, deletedRouterID)
if account.Network != nil {
account.Network.Serial++
}
err := builder.OnPeerDeleted(deletedRouterID)
err = builder.OnPeerDeleted(account, deletedRouterID)
require.NoError(t, err, "error deleting routing peer from cache")
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
newNetworkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(newNetworkMap)
newJSON, err := json.MarshalIndent(toNetworkMapJSON(newNetworkMap), "", " ")
require.NoError(t, err, "error marshaling new network map to JSON")
normalizeAndSortNetworkMap(networkMap)
if string(legacyJSON) != string(newJSON) {
legacyFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_router_peer.json")
newFilePath := filepath.Join("testdata", "networkmap_golden_new_with_deleted_router.json")
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err)
err = os.MkdirAll(filepath.Dir(legacyFilePath), 0755)
require.NoError(t, err)
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_deleted_router.json")
err = os.WriteFile(legacyFilePath, legacyJSON, 0644)
require.NoError(t, err)
t.Logf("Saved legacy network map to %s", legacyFilePath)
t.Log("Update golden file with deleted router...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
err = os.WriteFile(newFilePath, newJSON, 0644)
require.NoError(t, err)
t.Logf("Saved new network map to %s", newFilePath)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err)
require.JSONEq(t, string(expectedJSON), string(jsonData),
"network map after deleting router does not match golden file")
require.JSONEq(t, string(legacyJSON), string(newJSON), "network maps with deleted router from legacy and new builder do not match")
}
}
func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) {
@@ -847,7 +597,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) {
b.Run("old builder after delete", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, testingPeerID := range peerIDs {
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil)
_ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers())
}
}
})
@@ -855,7 +605,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) {
b.ResetTimer()
b.Run("new builder after delete", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = builder.OnPeerDeleted(deletedPeerID)
_ = builder.OnPeerDeleted(account, deletedPeerID)
for _, testingPeerID := range peerIDs {
_ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
}
@@ -927,6 +677,54 @@ func normalizeAndSortNetworkMap(networkMap *types.NetworkMap) {
}
}
type networkMapJSON struct {
Peers []*nbpeer.Peer `json:"Peers"`
Network *types.Network `json:"Network"`
Routes []*route.Route `json:"Routes"`
DNSConfig dns.Config `json:"DNSConfig"`
OfflinePeers []*nbpeer.Peer `json:"OfflinePeers"`
FirewallRules []*types.FirewallRule `json:"FirewallRules"`
RoutesFirewallRules []*types.RouteFirewallRule `json:"RoutesFirewallRules"`
ForwardingRules []*types.ForwardingRule `json:"ForwardingRules"`
AuthorizedUsers map[string][]string `json:"AuthorizedUsers,omitempty"`
EnableSSH bool `json:"EnableSSH"`
}
func toNetworkMapJSON(nm *types.NetworkMap) *networkMapJSON {
result := &networkMapJSON{
Peers: nm.Peers,
Network: nm.Network,
Routes: nm.Routes,
DNSConfig: nm.DNSConfig,
OfflinePeers: nm.OfflinePeers,
FirewallRules: nm.FirewallRules,
RoutesFirewallRules: nm.RoutesFirewallRules,
ForwardingRules: nm.ForwardingRules,
EnableSSH: nm.EnableSSH,
}
if len(nm.AuthorizedUsers) > 0 {
result.AuthorizedUsers = make(map[string][]string)
localUsers := make([]string, 0, len(nm.AuthorizedUsers))
for localUser := range nm.AuthorizedUsers {
localUsers = append(localUsers, localUser)
}
sort.Strings(localUsers)
for _, localUser := range localUsers {
userIDs := nm.AuthorizedUsers[localUser]
sortedUserIDs := make([]string, 0, len(userIDs))
for userID := range userIDs {
sortedUserIDs = append(sortedUserIDs, userID)
}
sort.Strings(sortedUserIDs)
result.AuthorizedUsers[localUser] = sortedUserIDs
}
}
return result
}
func createTestAccountWithEntities() *types.Account {
peers := make(map[string]*nbpeer.Peer)
devGroupPeers, opsGroupPeers, allGroupPeers := []string{}, []string{}, []string{}
@@ -962,9 +760,10 @@ func createTestAccountWithEntities() *types.Account {
}
groups := map[string]*types.Group{
allGroupID: {ID: allGroupID, Name: "All", Peers: allGroupPeers},
devGroupID: {ID: devGroupID, Name: "Developers", Peers: devGroupPeers},
opsGroupID: {ID: opsGroupID, Name: "Operations", Peers: opsGroupPeers},
allGroupID: {ID: allGroupID, Name: "All", Peers: allGroupPeers},
devGroupID: {ID: devGroupID, Name: "Developers", Peers: devGroupPeers},
opsGroupID: {ID: opsGroupID, Name: "Operations", Peers: opsGroupPeers},
sshUsersGroupID: {ID: sshUsersGroupID, Name: "SSH Users", Peers: []string{}},
}
policies := []*types.Policy{
@@ -1002,6 +801,15 @@ func createTestAccountWithEntities() *types.Account {
Sources: []string{opsGroupID}, DestinationResource: types.Resource{ID: networkResourceID},
}},
},
{
ID: policyIDSSH, Name: "SSH Access Policy", Enabled: true,
Rules: []*types.PolicyRule{{
ID: policyIDSSH, Name: "Allow SSH to Ops", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolNetbirdSSH, Bidirectional: false,
Sources: []string{devGroupID}, Destinations: []string{opsGroupID},
AuthorizedGroups: map[string][]string{sshUsersGroupID: {"root", "admin"}},
}},
},
}
routes := map[route.ID]*route.Route{
@@ -1034,8 +842,15 @@ func createTestAccountWithEntities() *types.Account {
},
}
users := map[string]*types.User{
userAdminID: {Id: userAdminID, Role: types.UserRoleAdmin, IsServiceUser: false, AccountID: testAccountID, AutoGroups: []string{allGroupID}},
userDevID: {Id: userDevID, Role: types.UserRoleUser, IsServiceUser: false, AccountID: testAccountID, AutoGroups: []string{sshUsersGroupID, devGroupID}},
userOpsID: {Id: userOpsID, Role: types.UserRoleUser, IsServiceUser: false, AccountID: testAccountID, AutoGroups: []string{sshUsersGroupID, opsGroupID}},
}
account := &types.Account{
Id: testAccountID, Peers: peers, Groups: groups, Policies: policies, Routes: routes,
Users: users,
Network: &types.Network{
Identifier: "net-golden-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)}, Serial: 1,
},
@@ -1071,6 +886,88 @@ func createTestAccountWithEntities() *types.Account {
return account
}
func TestGetPeerNetworkMap_Golden_New_WithOnPeerAddedRouter_Batched(t *testing.T) {
account := createTestAccountWithEntities()
ctx := context.Background()
validatedPeersMap := make(map[string]struct{})
for i := range numPeers {
peerID := fmt.Sprintf("peer-%d", i)
if peerID == offlinePeerID {
continue
}
validatedPeersMap[peerID] = struct{}{}
}
builder := types.NewNetworkMapBuilder(account, validatedPeersMap)
newRouterID := "peer-new-router-102"
newRouterIP := net.IP{100, 64, 1, 2}
newRouter := &nbpeer.Peer{
ID: newRouterID,
IP: newRouterIP,
Key: fmt.Sprintf("key-%s", newRouterID),
DNSLabel: "newrouter102",
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
UserID: "user-admin",
Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"},
LastLogin: func() *time.Time { t := time.Now(); return &t }(),
}
account.Peers[newRouterID] = newRouter
if opsGroup, exists := account.Groups[opsGroupID]; exists {
opsGroup.Peers = append(opsGroup.Peers, newRouterID)
}
if allGroup, exists := account.Groups[allGroupID]; exists {
allGroup.Peers = append(allGroup.Peers, newRouterID)
}
newRoute := &route.Route{
ID: route.ID("route-new-router"),
Network: netip.MustParsePrefix("172.16.0.0/24"),
Peer: newRouter.Key,
PeerID: newRouterID,
Description: "Route from new router",
Enabled: true,
PeerGroups: []string{opsGroupID},
Groups: []string{devGroupID, opsGroupID},
AccessControlGroups: []string{devGroupID},
AccountID: account.Id,
}
account.Routes[newRoute.ID] = newRoute
validatedPeersMap[newRouterID] = struct{}{}
if account.Network != nil {
account.Network.Serial++
}
builder.EnqueuePeersForIncrementalAdd(account, newRouterID)
time.Sleep(100 * time.Millisecond)
networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil)
normalizeAndSortNetworkMap(networkMap)
jsonData, err := json.MarshalIndent(networkMap, "", " ")
require.NoError(t, err, "error marshaling network map to JSON")
goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded_router.json")
t.Log("Update golden file with OnPeerAdded router...")
err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755)
require.NoError(t, err)
err = os.WriteFile(goldenFilePath, jsonData, 0644)
require.NoError(t, err)
expectedJSON, err := os.ReadFile(goldenFilePath)
require.NoError(t, err, "error reading golden file")
require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerAdded router does not match golden file")
}
func createAccountFromFile() (*types.Account, error) {
accraw := filepath.Join("testdata", "account_cnlf3j3l0ubs738o5d4g.json")
data, err := os.ReadFile(accraw)
@@ -1343,7 +1240,7 @@ func BenchmarkGetPeerNetworkMapCompactCached(b *testing.B) {
b.Run("Legacy", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = account.GetPeerNetworkMap(ctx, testingPeerID, customZone, validatedPeersMap, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil)
_ = account.GetPeerNetworkMap(ctx, testingPeerID, customZone, validatedPeersMap, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
}
})
b.Run("LegacyCompacted", func(b *testing.B) {
+445 -144
View File
@@ -7,12 +7,12 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/ssh/auth"
nbdns "github.com/netbirdio/netbird/dns"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
@@ -27,6 +27,9 @@ const (
v6AllWildcard = "::/0"
fw = "fw:"
rfw = "route-fw:"
szAddPeerBatch = 10
maxPeerAddRetries = 20
)
type NetworkMapCache struct {
@@ -47,6 +50,10 @@ type NetworkMapCache struct {
peerDNS map[string]*nbdns.Config
peerFirewallRulesCompact map[string][]*FirewallRule
peerRoutesCompact map[string][]*route.Route
peerSSH map[string]*PeerSSHView
groupIDToUserIDs map[string][]string
allowedUserIDs map[string]struct{}
resourceRouters map[string]map[string]*routerTypes.NetworkRouter
resourcePolicies map[string][]*Policy
@@ -76,41 +83,64 @@ type PeerRoutesView struct {
RouteFirewallRuleIDs []string
}
type PeerSSHView struct {
EnableSSH bool
AuthorizedUsers map[string]map[string]struct{}
}
type NetworkMapBuilder struct {
account atomic.Pointer[Account]
account *Account
cache *NetworkMapCache
validatedPeers map[string]struct{}
apb addPeerBatch
}
type addPeerBatch struct {
mu sync.Mutex
sg *sync.Cond
ids []string
la *Account
retryCount map[string]int
}
func NewNetworkMapBuilder(account *Account, validatedPeers map[string]struct{}) *NetworkMapBuilder {
builder := &NetworkMapBuilder{
cache: &NetworkMapCache{
globalRoutes: make(map[route.ID]*route.Route),
globalRules: make(map[string]*FirewallRule),
globalRouteRules: make(map[string]*RouteFirewallRule),
globalPeers: make(map[string]*nbpeer.Peer),
groupToPeers: make(map[string][]string),
peerToGroups: make(map[string][]string),
policyToRules: make(map[string][]*PolicyRule),
groupToPolicies: make(map[string][]*Policy),
groupToRoutes: make(map[string][]*route.Route),
peerToRoutes: make(map[string][]*route.Route),
globalRoutes: make(map[route.ID]*route.Route),
globalRules: make(map[string]*FirewallRule),
globalRouteRules: make(map[string]*RouteFirewallRule),
globalPeers: make(map[string]*nbpeer.Peer),
groupToPeers: make(map[string][]string),
peerToGroups: make(map[string][]string),
policyToRules: make(map[string][]*PolicyRule),
groupToPolicies: make(map[string][]*Policy),
groupToRoutes: make(map[string][]*route.Route),
peerToRoutes: make(map[string][]*route.Route),
peerACLs: make(map[string]*PeerACLView),
peerRoutes: make(map[string]*PeerRoutesView),
peerDNS: make(map[string]*nbdns.Config),
peerSSH: make(map[string]*PeerSSHView),
groupIDToUserIDs: make(map[string][]string),
allowedUserIDs: make(map[string]struct{}),
peerFirewallRulesCompact: make(map[string][]*FirewallRule),
peerRoutesCompact: make(map[string][]*route.Route),
globalResources: make(map[string]*resourceTypes.NetworkResource),
acgToRoutes: make(map[string]map[route.ID]*RouteOwnerInfo),
noACGRoutes: make(map[route.ID]*RouteOwnerInfo),
acgToRoutes: make(map[string]map[route.ID]*RouteOwnerInfo),
noACGRoutes: make(map[route.ID]*RouteOwnerInfo),
},
validatedPeers: make(map[string]struct{}),
}
builder.account.Store(account)
builder.apb.sg = sync.NewCond(&builder.apb.mu)
builder.apb.ids = make([]string, 0, szAddPeerBatch)
builder.apb.la = account
builder.apb.retryCount = make(map[string]int)
maps.Copy(builder.validatedPeers, validatedPeers)
builder.initialBuild(account)
go builder.incAddPeerLoop()
return builder
}
@@ -118,6 +148,8 @@ func (b *NetworkMapBuilder) initialBuild(account *Account) {
b.cache.mu.Lock()
defer b.cache.mu.Unlock()
b.account = account
start := time.Now()
b.buildGlobalIndexes(account)
@@ -150,9 +182,15 @@ func (b *NetworkMapBuilder) buildGlobalIndexes(account *Account) {
clear(b.cache.peerToRoutes)
clear(b.cache.acgToRoutes)
clear(b.cache.noACGRoutes)
clear(b.cache.groupIDToUserIDs)
clear(b.cache.allowedUserIDs)
clear(b.cache.peerSSH)
maps.Copy(b.cache.globalPeers, account.Peers)
b.cache.groupIDToUserIDs = account.GetActiveGroupUsers()
b.cache.allowedUserIDs = b.buildAllowedUserIDs(account)
for groupID, group := range account.Groups {
peersCopy := make([]string, len(group.Peers))
copy(peersCopy, group.Peers)
@@ -227,7 +265,7 @@ func (b *NetworkMapBuilder) buildPeerACLView(account *Account, peerID string) {
return
}
allPotentialPeers, firewallRules := b.getPeerConnectionResources(account, peer, b.validatedPeers)
allPotentialPeers, firewallRules, authorizedUsers, sshEnabled := b.getPeerConnectionResources(account, peer, b.validatedPeers)
isRouter, networkResourcesRoutes, sourcePeers := b.getNetworkResourcesForPeer(account, peer)
@@ -260,12 +298,17 @@ func (b *NetworkMapBuilder) buildPeerACLView(account *Account, peerID string) {
compactedRules := compactFirewallRules(firewallRules)
b.cache.peerFirewallRulesCompact[peerID] = compactedRules
b.cache.peerSSH[peerID] = &PeerSSHView{
EnableSSH: sshEnabled,
AuthorizedUsers: authorizedUsers,
}
}
func (b *NetworkMapBuilder) getPeerConnectionResources(account *Account, peer *nbpeer.Peer,
validatedPeersMap map[string]struct{},
) ([]*nbpeer.Peer, []*FirewallRule) {
) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) {
peerID := peer.ID
ctx := context.Background()
peerGroups := b.cache.peerToGroups[peerID]
peerGroupsMap := make(map[string]struct{}, len(peerGroups))
@@ -278,9 +321,15 @@ func (b *NetworkMapBuilder) getPeerConnectionResources(account *Account, peer *n
fwRules := make([]*FirewallRule, 0)
peers := make([]*nbpeer.Peer, 0)
authorizedUsers := make(map[string]map[string]struct{})
sshEnabled := false
for _, group := range peerGroups {
policies := b.cache.groupToPolicies[group]
for _, policy := range policies {
if isValid := account.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, peerID); !isValid {
continue
}
rules := b.cache.policyToRules[policy.ID]
for _, rule := range rules {
var sourcePeers, destinationPeers []*nbpeer.Peer
@@ -323,13 +372,13 @@ func (b *NetworkMapBuilder) getPeerConnectionResources(account *Account, peer *n
if rule.Bidirectional {
if peerInSources {
b.generateResourcescached(
account, rule, destinationPeers, FirewallRuleDirectionIN,
rule, destinationPeers, FirewallRuleDirectionIN,
peer, &peers, &fwRules, peersExists, rulesExists,
)
}
if peerInDestinations {
b.generateResourcescached(
account, rule, sourcePeers, FirewallRuleDirectionOUT,
rule, sourcePeers, FirewallRuleDirectionOUT,
peer, &peers, &fwRules, peersExists, rulesExists,
)
}
@@ -337,22 +386,58 @@ func (b *NetworkMapBuilder) getPeerConnectionResources(account *Account, peer *n
if peerInSources {
b.generateResourcescached(
account, rule, destinationPeers, FirewallRuleDirectionOUT,
rule, destinationPeers, FirewallRuleDirectionOUT,
peer, &peers, &fwRules, peersExists, rulesExists,
)
}
if peerInDestinations {
b.generateResourcescached(
account, rule, sourcePeers, FirewallRuleDirectionIN,
rule, sourcePeers, FirewallRuleDirectionIN,
peer, &peers, &fwRules, peersExists, rulesExists,
)
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
sshEnabled = true
switch {
case len(rule.AuthorizedGroups) > 0:
for groupID, localUsers := range rule.AuthorizedGroups {
userIDs, ok := b.cache.groupIDToUserIDs[groupID]
if !ok {
continue
}
if len(localUsers) == 0 {
localUsers = []string{auth.Wildcard}
}
for _, localUser := range localUsers {
if authorizedUsers[localUser] == nil {
authorizedUsers[localUser] = make(map[string]struct{})
}
for _, userID := range userIDs {
authorizedUsers[localUser][userID] = struct{}{}
}
}
}
case rule.AuthorizedUser != "":
if authorizedUsers[auth.Wildcard] == nil {
authorizedUsers[auth.Wildcard] = make(map[string]struct{})
}
authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{}
default:
authorizedUsers[auth.Wildcard] = maps.Clone(b.cache.allowedUserIDs)
}
} else if policyRuleImpliesLegacySSH(rule) && peer.SSHEnabled {
sshEnabled = true
authorizedUsers[auth.Wildcard] = maps.Clone(b.cache.allowedUserIDs)
}
}
}
}
}
return peers, fwRules
return peers, fwRules, authorizedUsers, sshEnabled
}
func (b *NetworkMapBuilder) isPeerInGroupscached(groupIDs []string, peerGroupsMap map[string]struct{}) bool {
@@ -405,14 +490,9 @@ func (b *NetworkMapBuilder) getPeersFromGroupscached(account *Account, groupIDs
}
func (b *NetworkMapBuilder) generateResourcescached(
account *Account, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, targetPeer *nbpeer.Peer,
rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, targetPeer *nbpeer.Peer,
peers *[]*nbpeer.Peer, rules *[]*FirewallRule, peersExists map[string]struct{}, rulesExists map[string]struct{},
) {
isAll := false
if allGroup, err := account.GetGroupAll(); err == nil {
isAll = (len(allGroup.Peers) - 1) == len(groupPeers)
}
for _, peer := range groupPeers {
if peer == nil {
continue
@@ -427,11 +507,7 @@ func (b *NetworkMapBuilder) generateResourcescached(
PeerIP: peer.IP.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: string(rule.Protocol),
}
if isAll {
fr.PeerIP = allPeers
Protocol: firewallRuleProtocol(rule.Protocol),
}
var s strings.Builder
@@ -942,8 +1018,29 @@ func (b *NetworkMapBuilder) getPeerNSGroups(account *Account, peerID string, che
return peerNSGroups
}
func (b *NetworkMapBuilder) UpdateAccountPointer(account *Account) {
b.account.Store(account)
func (b *NetworkMapBuilder) buildAllowedUserIDs(account *Account) map[string]struct{} {
users := make(map[string]struct{})
for _, nbUser := range account.Users {
if !nbUser.IsBlocked() && !nbUser.IsServiceUser {
users[nbUser.Id] = struct{}{}
}
}
return users
}
func firewallRuleProtocol(protocol PolicyRuleProtocolType) string {
if protocol == PolicyRuleProtocolNetbirdSSH {
return string(PolicyRuleProtocolTCP)
}
return string(protocol)
}
// lock should be held
func (b *NetworkMapBuilder) updateAccountLocked(account *Account) *Account {
if account.Network.CurrentSerial() > b.account.Network.CurrentSerial() {
b.account = account
}
return b.account
}
func (b *NetworkMapBuilder) GetPeerNetworkMap(
@@ -951,25 +1048,27 @@ func (b *NetworkMapBuilder) GetPeerNetworkMap(
validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
start := time.Now()
account := b.account.Load()
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
account := b.account
peer := account.GetPeer(peerID)
if peer == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
aclView := b.cache.peerACLs[peerID]
routesView := b.cache.peerRoutes[peerID]
dnsConfig := b.cache.peerDNS[peerID]
sshView := b.cache.peerSSH[peerID]
if aclView == nil || routesView == nil || dnsConfig == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
nm := b.assembleNetworkMap(account, peer, aclView, routesView, dnsConfig, peersCustomZone, validatedPeers)
nm := b.assembleNetworkMap(account, peer, aclView, routesView, dnsConfig, sshView, peersCustomZone, validatedPeers)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
@@ -987,7 +1086,7 @@ func (b *NetworkMapBuilder) GetPeerNetworkMap(
func (b *NetworkMapBuilder) assembleNetworkMap(
account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView,
dnsConfig *nbdns.Config, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
dnsConfig *nbdns.Config, sshView *PeerSSHView, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
) *NetworkMap {
var peersToConnect []*nbpeer.Peer
@@ -1045,7 +1144,7 @@ func (b *NetworkMapBuilder) assembleNetworkMap(
finalDNSConfig.CustomZones = zones
}
return &NetworkMap{
nm := &NetworkMap{
Peers: peersToConnect,
Network: account.Network.Copy(),
Routes: routes,
@@ -1054,6 +1153,13 @@ func (b *NetworkMapBuilder) assembleNetworkMap(
FirewallRules: firewallRules,
RoutesFirewallRules: routesFirewallRules,
}
if sshView != nil {
nm.EnableSSH = sshView.EnableSSH
nm.AuthorizedUsers = sshView.AuthorizedUsers
}
return nm
}
func (b *NetworkMapBuilder) GetPeerNetworkMapCompactCached(
@@ -1061,64 +1167,27 @@ func (b *NetworkMapBuilder) GetPeerNetworkMapCompactCached(
validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
start := time.Now()
account := b.account.Load()
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
account := b.account
peer := account.GetPeer(peerID)
if peer == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
aclView := b.cache.peerACLs[peerID]
routesView := b.cache.peerRoutes[peerID]
dnsConfig := b.cache.peerDNS[peerID]
sshView := b.cache.peerSSH[peerID]
if aclView == nil || routesView == nil || dnsConfig == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
nm := b.assembleNetworkMapCompactCached(account, peer, aclView, routesView, dnsConfig, peersCustomZone, validatedPeers)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
metrics.CountNetworkMapObjects(objectCount)
metrics.CountGetPeerNetworkMapDuration(time.Since(start))
if objectCount > 5000 {
log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from cache",
account.Id, objectCount)
}
}
return nm
}
func (b *NetworkMapBuilder) GetPeerNetworkMapCompact(
ctx context.Context, peerID string, peersCustomZone nbdns.CustomZone,
validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
start := time.Now()
account := b.account.Load()
peer := account.GetPeer(peerID)
if peer == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
aclView := b.cache.peerACLs[peerID]
routesView := b.cache.peerRoutes[peerID]
dnsConfig := b.cache.peerDNS[peerID]
if aclView == nil || routesView == nil || dnsConfig == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
nm := b.assembleNetworkMapCompact(account, peer, aclView, routesView, dnsConfig, peersCustomZone, validatedPeers)
nm := b.assembleNetworkMapCompactCached(account, peer, aclView, routesView, dnsConfig, sshView, peersCustomZone, validatedPeers)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
@@ -1136,7 +1205,7 @@ func (b *NetworkMapBuilder) GetPeerNetworkMapCompact(
func (b *NetworkMapBuilder) assembleNetworkMapCompactCached(
account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView,
dnsConfig *nbdns.Config, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
dnsConfig *nbdns.Config, sshView *PeerSSHView, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
) *NetworkMap {
var peersToConnect []*nbpeer.Peer
@@ -1182,7 +1251,7 @@ func (b *NetworkMapBuilder) assembleNetworkMapCompactCached(
finalDNSConfig.CustomZones = zones
}
return &NetworkMap{
nm := &NetworkMap{
Peers: peersToConnect,
Network: account.Network.Copy(),
Routes: routes,
@@ -1191,11 +1260,59 @@ func (b *NetworkMapBuilder) assembleNetworkMapCompactCached(
FirewallRules: firewallRules,
RoutesFirewallRules: routesFirewallRules,
}
if sshView != nil {
nm.EnableSSH = sshView.EnableSSH
nm.AuthorizedUsers = sshView.AuthorizedUsers
}
return nm
}
func (b *NetworkMapBuilder) GetPeerNetworkMapCompact(
ctx context.Context, peerID string, peersCustomZone nbdns.CustomZone,
validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics,
) *NetworkMap {
start := time.Now()
b.cache.mu.RLock()
defer b.cache.mu.RUnlock()
account := b.account
peer := account.GetPeer(peerID)
if peer == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
aclView := b.cache.peerACLs[peerID]
routesView := b.cache.peerRoutes[peerID]
dnsConfig := b.cache.peerDNS[peerID]
sshView := b.cache.peerSSH[peerID]
if aclView == nil || routesView == nil || dnsConfig == nil {
return &NetworkMap{Network: account.Network.Copy()}
}
nm := b.assembleNetworkMapCompact(account, peer, aclView, routesView, dnsConfig, sshView, peersCustomZone, validatedPeers)
if metrics != nil {
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
metrics.CountNetworkMapObjects(objectCount)
metrics.CountGetPeerNetworkMapDuration(time.Since(start))
if objectCount > 5000 {
log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from cache",
account.Id, objectCount)
}
}
return nm
}
func (b *NetworkMapBuilder) assembleNetworkMapCompact(
account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView,
dnsConfig *nbdns.Config, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
dnsConfig *nbdns.Config, sshView *PeerSSHView, customZone nbdns.CustomZone, validatedPeers map[string]struct{},
) *NetworkMap {
var peersToConnect []*nbpeer.Peer
@@ -1279,7 +1396,7 @@ func (b *NetworkMapBuilder) assembleNetworkMapCompact(
finalDNSConfig.CustomZones = zones
}
return &NetworkMap{
nm := &NetworkMap{
Peers: peersToConnect,
Network: account.Network.Copy(),
Routes: routes,
@@ -1288,14 +1405,13 @@ func (b *NetworkMapBuilder) assembleNetworkMapCompact(
FirewallRules: firewallRules,
RoutesFirewallRules: routesFirewallRules,
}
}
func splitRouteAndPeer(r *route.Route) (string, string) {
parts := strings.Split(string(r.ID), ":")
if len(parts) < 2 {
return string(r.ID), ""
if sshView != nil {
nm.EnableSSH = sshView.EnableSSH
nm.AuthorizedUsers = sshView.AuthorizedUsers
}
return parts[0], parts[1]
return nm
}
func (b *NetworkMapBuilder) compactRoutesForPeer(peerID string, ownRouteIDs []route.ID, otherRouteIDs []route.ID) []*route.Route {
@@ -1427,6 +1543,14 @@ func compactFirewallRules(expandedRules []*FirewallRule) []*FirewallRule {
return compactRules
}
func splitRouteAndPeer(r *route.Route) (string, string) {
parts := strings.Split(string(r.ID), ":")
if len(parts) < 2 {
return string(r.ID), ""
}
return parts[0], parts[1]
}
func (b *NetworkMapBuilder) generateFirewallRuleID(rule *FirewallRule) string {
var s strings.Builder
s.WriteString(fw)
@@ -1501,6 +1625,106 @@ func (b *NetworkMapBuilder) isPeerRouter(account *Account, peerID string) bool {
return false
}
func (b *NetworkMapBuilder) incAddPeerLoop() {
for {
b.apb.mu.Lock()
if len(b.apb.ids) == 0 {
b.apb.sg.Wait()
}
b.addPeersIncrementally()
b.apb.mu.Unlock()
}
}
// lock on b.apb level should be held
func (b *NetworkMapBuilder) addPeersIncrementally() {
peers := slices.Clone(b.apb.ids)
clear(b.apb.ids)
b.apb.ids = b.apb.ids[:0]
latestAcc := b.apb.la
b.apb.mu.Unlock()
tt := time.Now()
b.cache.mu.Lock()
defer b.cache.mu.Unlock()
account := b.updateAccountLocked(latestAcc)
log.Debugf("NetworkMapBuilder: Starting incremental add of %d peers", len(peers))
allUpdates := make(map[string]*PeerUpdateDelta)
for _, peerID := range peers {
peer := account.GetPeer(peerID)
if peer == nil {
b.apb.mu.Lock()
retries := b.apb.retryCount[peerID]
b.apb.mu.Unlock()
if retries >= maxPeerAddRetries {
log.Errorf("NetworkMapBuilder: peer %s not found in account %s after %d retries, giving up", peerID, account.Id, retries)
b.apb.mu.Lock()
delete(b.apb.retryCount, peerID)
b.apb.mu.Unlock()
continue
}
log.Warnf("NetworkMapBuilder: peer %s not found in account %s, retry %d/%d", peerID, account.Id, retries+1, maxPeerAddRetries)
b.apb.mu.Lock()
b.apb.retryCount[peerID] = retries + 1
b.apb.mu.Unlock()
b.enqueuePeersForIncrementalAdd(latestAcc, peerID)
continue
}
b.apb.mu.Lock()
delete(b.apb.retryCount, peerID)
b.apb.mu.Unlock()
b.validatedPeers[peerID] = struct{}{}
b.cache.globalPeers[peerID] = peer
peerGroups := b.updateIndexesForNewPeer(account, peerID)
b.buildPeerACLView(account, peerID)
b.buildPeerRoutesView(account, peerID)
b.buildPeerDNSView(account, peerID)
peerDeltas := b.collectDeltasForNewPeer(account, peerID, peerGroups)
for affectedPeerID, delta := range peerDeltas {
if existing, ok := allUpdates[affectedPeerID]; ok {
existing.mergeFrom(delta)
continue
}
allUpdates[affectedPeerID] = delta
}
}
for affectedPeerID, delta := range allUpdates {
b.applyDeltaToPeer(account, affectedPeerID, delta)
}
log.Debugf("NetworkMapBuilder: Added %d peers to cache, affected %d peers, took %s", len(peers), len(allUpdates), time.Since(tt))
b.apb.mu.Lock()
if len(b.apb.ids) > 0 {
b.apb.sg.Signal()
}
}
func (b *NetworkMapBuilder) enqueuePeersForIncrementalAdd(acc *Account, peerIDs ...string) {
b.apb.mu.Lock()
b.apb.ids = append(b.apb.ids, peerIDs...)
if b.apb.la != nil && acc.Network.CurrentSerial() > b.apb.la.Network.CurrentSerial() {
b.apb.la = acc
}
b.apb.sg.Signal()
b.apb.mu.Unlock()
}
func (b *NetworkMapBuilder) EnqueuePeersForIncrementalAdd(acc *Account, peerIDs ...string) {
b.enqueuePeersForIncrementalAdd(acc, peerIDs...)
}
type ViewDelta struct {
AddedPeerIDs []string
RemovedPeerIDs []string
@@ -1508,17 +1732,18 @@ type ViewDelta struct {
RemovedRuleIDs []string
}
func (b *NetworkMapBuilder) OnPeerAddedIncremental(peerID string) error {
func (b *NetworkMapBuilder) OnPeerAddedIncremental(acc *Account, peerID string) error {
tt := time.Now()
account := b.account.Load()
peer := account.GetPeer(peerID)
peer := acc.GetPeer(peerID)
if peer == nil {
return fmt.Errorf("peer %s not found in account", peerID)
return fmt.Errorf("NetworkMapBuilder: peer %s not found in account", peerID)
}
b.cache.mu.Lock()
defer b.cache.mu.Unlock()
account := b.updateAccountLocked(acc)
log.Debugf("NetworkMapBuilder: Adding peer %s (IP: %s) to cache", peerID, peer.IP.String())
b.validatedPeers[peerID] = struct{}{}
@@ -1577,6 +1802,13 @@ func (b *NetworkMapBuilder) updateIndexesForNewPeer(account *Account, peerID str
}
func (b *NetworkMapBuilder) incrementalUpdateAffectedPeers(account *Account, newPeerID string, peerGroups []string) {
updates := b.collectDeltasForNewPeer(account, newPeerID, peerGroups)
for affectedPeerID, delta := range updates {
b.applyDeltaToPeer(account, affectedPeerID, delta)
}
}
func (b *NetworkMapBuilder) collectDeltasForNewPeer(account *Account, newPeerID string, peerGroups []string) map[string]*PeerUpdateDelta {
updates := b.calculateIncrementalUpdates(account, newPeerID, peerGroups)
if b.isPeerRouter(account, newPeerID) {
@@ -1596,9 +1828,7 @@ func (b *NetworkMapBuilder) incrementalUpdateAffectedPeers(account *Account, new
}
}
for affectedPeerID, delta := range updates {
b.applyDeltaToPeer(account, affectedPeerID, delta)
}
return updates
}
func (b *NetworkMapBuilder) findPeersAffectedByNewRouter(account *Account, newRouterID string, routerGroups []string) map[string]struct{} {
@@ -1792,8 +2022,8 @@ func (b *NetworkMapBuilder) calculateNewRouterNetworkResourceUpdates(
updates[peerID] = delta
}
if delta.AddConnectedPeer == "" {
delta.AddConnectedPeer = newPeerID
if !slices.Contains(delta.AddConnectedPeers, newPeerID) {
delta.AddConnectedPeers = append(delta.AddConnectedPeers, newPeerID)
}
delta.RebuildRoutesView = true
@@ -1922,8 +2152,8 @@ func (b *NetworkMapBuilder) calculateNetworkResourceFirewallUpdates(
updates[routerPeerID] = delta
}
if delta.AddConnectedPeer == "" {
delta.AddConnectedPeer = newPeerID
if !slices.Contains(delta.AddConnectedPeers, newPeerID) {
delta.AddConnectedPeers = append(delta.AddConnectedPeers, newPeerID)
}
delta.RebuildRoutesView = true
@@ -1933,13 +2163,63 @@ func (b *NetworkMapBuilder) calculateNetworkResourceFirewallUpdates(
type PeerUpdateDelta struct {
PeerID string
AddConnectedPeer string
AddConnectedPeers []string
AddFirewallRules []*FirewallRuleDelta
AddRoutes []route.ID
UpdateRouteFirewallRules []*RouteFirewallRuleUpdate
UpdateDNS bool
RebuildRoutesView bool
}
func (d *PeerUpdateDelta) mergeFrom(other *PeerUpdateDelta) {
for _, peerID := range other.AddConnectedPeers {
if !slices.Contains(d.AddConnectedPeers, peerID) {
d.AddConnectedPeers = append(d.AddConnectedPeers, peerID)
}
}
existingRuleIDs := make(map[string]struct{}, len(d.AddFirewallRules))
for _, rule := range d.AddFirewallRules {
existingRuleIDs[rule.RuleID] = struct{}{}
}
for _, rule := range other.AddFirewallRules {
if _, exists := existingRuleIDs[rule.RuleID]; !exists {
d.AddFirewallRules = append(d.AddFirewallRules, rule)
existingRuleIDs[rule.RuleID] = struct{}{}
}
}
for _, routeID := range other.AddRoutes {
if !slices.Contains(d.AddRoutes, routeID) {
d.AddRoutes = append(d.AddRoutes, routeID)
}
}
existingRouteUpdates := make(map[string]map[string]struct{})
for _, update := range d.UpdateRouteFirewallRules {
if existingRouteUpdates[update.RuleID] == nil {
existingRouteUpdates[update.RuleID] = make(map[string]struct{})
}
existingRouteUpdates[update.RuleID][update.AddSourceIP] = struct{}{}
}
for _, update := range other.UpdateRouteFirewallRules {
if existingRouteUpdates[update.RuleID] == nil {
existingRouteUpdates[update.RuleID] = make(map[string]struct{})
}
if _, exists := existingRouteUpdates[update.RuleID][update.AddSourceIP]; !exists {
d.UpdateRouteFirewallRules = append(d.UpdateRouteFirewallRules, update)
existingRouteUpdates[update.RuleID][update.AddSourceIP] = struct{}{}
}
}
if other.UpdateDNS {
d.UpdateDNS = true
}
if other.RebuildRoutesView {
d.RebuildRoutesView = true
}
}
type FirewallRuleDelta struct {
Rule *FirewallRule
RuleID string
@@ -1977,7 +2257,7 @@ func (b *NetworkMapBuilder) addUpdateForPeersInGroups(
PeerIP: newPeer.IP.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: string(rule.Protocol),
Protocol: firewallRuleProtocol(rule.Protocol),
}
for _, peerID := range peers {
if peerID == newPeerID {
@@ -2028,7 +2308,7 @@ func (b *NetworkMapBuilder) addUpdateForDirectPeerResource(
PeerIP: newPeer.IP.String(),
Direction: direction,
Action: string(rule.Action),
Protocol: string(rule.Protocol),
Protocol: firewallRuleProtocol(rule.Protocol),
}
b.addOrUpdateFirewallRuleInDelta(updates, targetPeerID, newPeerID, rule, direction, fr, fr.PeerIP, targetPeer)
@@ -2041,11 +2321,13 @@ func (b *NetworkMapBuilder) addOrUpdateFirewallRuleInDelta(
delta := updates[targetPeerID]
if delta == nil {
delta = &PeerUpdateDelta{
PeerID: targetPeerID,
AddConnectedPeer: newPeerID,
AddFirewallRules: make([]*FirewallRuleDelta, 0),
PeerID: targetPeerID,
AddConnectedPeers: []string{newPeerID},
AddFirewallRules: make([]*FirewallRuleDelta, 0),
}
updates[targetPeerID] = delta
} else if !slices.Contains(delta.AddConnectedPeers, newPeerID) {
delta.AddConnectedPeers = append(delta.AddConnectedPeers, newPeerID)
}
baseRule.PeerIP = peerIP
@@ -2071,10 +2353,12 @@ func (b *NetworkMapBuilder) addOrUpdateFirewallRuleInDelta(
}
func (b *NetworkMapBuilder) applyDeltaToPeer(account *Account, peerID string, delta *PeerUpdateDelta) {
if delta.AddConnectedPeer != "" || len(delta.AddFirewallRules) > 0 {
if len(delta.AddConnectedPeers) > 0 || len(delta.AddFirewallRules) > 0 {
if aclView := b.cache.peerACLs[peerID]; aclView != nil {
if delta.AddConnectedPeer != "" && !slices.Contains(aclView.ConnectedPeerIDs, delta.AddConnectedPeer) {
aclView.ConnectedPeerIDs = append(aclView.ConnectedPeerIDs, delta.AddConnectedPeer)
for _, connectedPeerID := range delta.AddConnectedPeers {
if !slices.Contains(aclView.ConnectedPeerIDs, connectedPeerID) {
aclView.ConnectedPeerIDs = append(aclView.ConnectedPeerIDs, connectedPeerID)
}
}
for _, ruleDelta := range delta.AddFirewallRules {
@@ -2130,11 +2414,11 @@ func (b *NetworkMapBuilder) updateRouteFirewallRules(routesView *PeerRoutesView,
}
}
func (b *NetworkMapBuilder) OnPeerDeleted(peerID string) error {
func (b *NetworkMapBuilder) OnPeerDeleted(acc *Account, peerID string) error {
b.cache.mu.Lock()
defer b.cache.mu.Unlock()
account := b.account.Load()
account := b.updateAccountLocked(acc)
deletedPeer := b.cache.globalPeers[peerID]
if deletedPeer == nil {
@@ -2190,6 +2474,7 @@ func (b *NetworkMapBuilder) OnPeerDeleted(peerID string) error {
delete(b.cache.peerACLs, peerID)
delete(b.cache.peerRoutes, peerID)
delete(b.cache.peerDNS, peerID)
delete(b.cache.peerSSH, peerID)
delete(b.cache.globalPeers, peerID)
@@ -2240,11 +2525,16 @@ func (b *NetworkMapBuilder) OnPeerDeleted(peerID string) error {
b.buildPeerRoutesView(account, affectedPeerID)
}
peerDeletionUpdates := b.findPeersAffectedByDeletedPeerACL(peerID, peerIP)
peersToRebuildACL := make(map[string]struct{})
peerDeletionUpdates := b.findPeersAffectedByDeletedPeerACL(peerID, peerIP, peerGroups, peersToRebuildACL)
for affectedPeerID, updates := range peerDeletionUpdates {
b.applyDeletionUpdates(affectedPeerID, updates)
}
for affectedPeerID := range peersToRebuildACL {
b.buildPeerACLView(account, affectedPeerID)
}
b.cleanupUnusedRules()
log.Debugf("NetworkMapBuilder: Deleted peer %s, affected %d other peers", peerID, len(affectedPeers))
@@ -2255,6 +2545,8 @@ func (b *NetworkMapBuilder) OnPeerDeleted(peerID string) error {
func (b *NetworkMapBuilder) findPeersAffectedByDeletedPeerACL(
deletedPeerID string,
peerIP string,
peerGroups []string,
peersToRebuildACL map[string]struct{},
) map[string]*PeerDeletionUpdate {
affected := make(map[string]*PeerDeletionUpdate)
@@ -2264,26 +2556,47 @@ func (b *NetworkMapBuilder) findPeersAffectedByDeletedPeerACL(
continue
}
if !slices.Contains(aclView.ConnectedPeerIDs, deletedPeerID) {
continue
}
if affected[peerID] == nil {
affected[peerID] = &PeerDeletionUpdate{
RemovePeerID: deletedPeerID,
PeerIP: peerIP,
if slices.Contains(aclView.ConnectedPeerIDs, deletedPeerID) {
peersToRebuildACL[peerID] = struct{}{}
if affected[peerID] == nil {
affected[peerID] = &PeerDeletionUpdate{
RemovePeerID: deletedPeerID,
PeerIP: peerIP,
}
}
}
}
for _, ruleID := range aclView.FirewallRuleIDs {
if rule := b.cache.globalRules[ruleID]; rule != nil && rule.PeerIP == peerIP {
affected[peerID].RemoveFirewallRuleIDs = append(
affected[peerID].RemoveFirewallRuleIDs,
ruleID,
)
affectedRouteOwners := make(map[string]struct{})
for _, groupID := range peerGroups {
if routeMap, ok := b.cache.acgToRoutes[groupID]; ok {
for _, info := range routeMap {
if info.PeerID != deletedPeerID {
affectedRouteOwners[info.PeerID] = struct{}{}
}
}
}
}
for _, info := range b.cache.noACGRoutes {
if info.PeerID != deletedPeerID {
affectedRouteOwners[info.PeerID] = struct{}{}
}
}
for ownerPeerID := range affectedRouteOwners {
if affected[ownerPeerID] == nil {
affected[ownerPeerID] = &PeerDeletionUpdate{
RemovePeerID: deletedPeerID,
PeerIP: peerIP,
RemoveFromSourceRanges: true,
}
} else {
affected[ownerPeerID].RemoveFromSourceRanges = true
}
}
return affected
}
@@ -2296,18 +2609,6 @@ type PeerDeletionUpdate struct {
}
func (b *NetworkMapBuilder) applyDeletionUpdates(peerID string, updates *PeerDeletionUpdate) {
if aclView := b.cache.peerACLs[peerID]; aclView != nil {
aclView.ConnectedPeerIDs = slices.DeleteFunc(aclView.ConnectedPeerIDs, func(id string) bool {
return id == updates.RemovePeerID
})
if len(updates.RemoveFirewallRuleIDs) > 0 {
aclView.FirewallRuleIDs = slices.DeleteFunc(aclView.FirewallRuleIDs, func(ruleID string) bool {
return slices.Contains(updates.RemoveFirewallRuleIDs, ruleID)
})
}
}
if routesView := b.cache.peerRoutes[peerID]; routesView != nil {
if len(updates.RemoveRouteIDs) > 0 {
routesView.NetworkResourceIDs = slices.DeleteFunc(routesView.NetworkResourceIDs, func(routeID route.ID) bool {
+4
View File
@@ -23,6 +23,8 @@ const (
PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
// PolicyRuleProtocolICMP type of traffic
PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
// PolicyRuleProtocolNetbirdSSH type of traffic
PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
)
const (
@@ -167,6 +169,8 @@ func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error)
protocol = PolicyRuleProtocolUDP
case "icmp":
return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
case "netbird-ssh":
return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
default:
return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
}
+12
View File
@@ -80,6 +80,12 @@ type PolicyRule struct {
// PortRanges a list of port ranges.
PortRanges []RulePortRange `gorm:"serializer:json"`
// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
AuthorizedGroups map[string][]string `gorm:"serializer:json"`
// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
AuthorizedUser string
}
// Copy returns a copy of a policy rule
@@ -99,10 +105,16 @@ func (pm *PolicyRule) Copy() *PolicyRule {
Protocol: pm.Protocol,
Ports: make([]string, len(pm.Ports)),
PortRanges: make([]RulePortRange, len(pm.PortRanges)),
AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)),
AuthorizedUser: pm.AuthorizedUser,
}
copy(rule.Destinations, pm.Destinations)
copy(rule.Sources, pm.Sources)
copy(rule.Ports, pm.Ports)
copy(rule.PortRanges, pm.PortRanges)
for k, v := range pm.AuthorizedGroups {
rule.AuthorizedGroups[k] = make([]string, len(v))
copy(rule.AuthorizedGroups[k], v)
}
return rule
}
+4
View File
@@ -52,6 +52,9 @@ type Settings struct {
// LazyConnectionEnabled indicates if the experimental feature is enabled or disabled
LazyConnectionEnabled bool `gorm:"default:false"`
// AutoUpdateVersion client auto-update version
AutoUpdateVersion string `gorm:"default:'disabled'"`
}
// Copy copies the Settings struct
@@ -72,6 +75,7 @@ func (s *Settings) Copy() *Settings {
LazyConnectionEnabled: s.LazyConnectionEnabled,
DNSDomain: s.DNSDomain,
NetworkRange: s.NetworkRange,
AutoUpdateVersion: s.AutoUpdateVersion,
}
if s.Extra != nil {
settings.Extra = s.Extra.Copy()
+75 -8
View File
@@ -7,6 +7,7 @@ import (
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integration_reference"
"github.com/netbirdio/netbird/util/crypt"
)
const (
@@ -65,7 +66,11 @@ type UserInfo struct {
LastLogin time.Time `json:"last_login"`
Issued string `json:"issued"`
PendingApproval bool `json:"pending_approval"`
Password string `json:"password"`
IntegrationReference integration_reference.IntegrationReference `json:"-"`
// IdPID is the identity provider ID (connector ID) extracted from the Dex-encoded user ID.
// This field is only populated when the user ID can be decoded from Dex's format.
IdPID string `json:"idp_id,omitempty"`
}
// User represents a user of the system
@@ -96,6 +101,9 @@ type User struct {
Issued string `gorm:"default:api"`
IntegrationReference integration_reference.IntegrationReference `gorm:"embedded;embeddedPrefix:integration_ref_"`
Name string `gorm:"default:''"`
Email string `gorm:"default:''"`
}
// IsBlocked returns true if the user is blocked, false otherwise
@@ -143,10 +151,16 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
}
if userData == nil {
name := u.Name
if u.IsServiceUser {
name = u.ServiceUserName
}
return &UserInfo{
ID: u.Id,
Email: "",
Name: u.ServiceUserName,
Email: u.Email,
Name: name,
Role: string(u.Role),
AutoGroups: u.AutoGroups,
Status: string(UserStatusActive),
@@ -178,6 +192,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
LastLogin: u.GetLastLogin(),
Issued: u.Issued,
PendingApproval: u.PendingApproval,
Password: userData.Password,
}, nil
}
@@ -204,11 +219,13 @@ func (u *User) Copy() *User {
CreatedAt: u.CreatedAt,
Issued: u.Issued,
IntegrationReference: u.IntegrationReference,
Email: u.Email,
Name: u.Name,
}
}
// NewUser creates a new user
func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, serviceUserName string, autoGroups []string, issued string) *User {
func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, serviceUserName string, autoGroups []string, issued string, email string, name string) *User {
return &User{
Id: id,
Role: role,
@@ -218,20 +235,70 @@ func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, se
AutoGroups: autoGroups,
Issued: issued,
CreatedAt: time.Now().UTC(),
Name: name,
Email: email,
}
}
// NewRegularUser creates a new user with role UserRoleUser
func NewRegularUser(id string) *User {
return NewUser(id, UserRoleUser, false, false, "", []string{}, UserIssuedAPI)
func NewRegularUser(id, email, name string) *User {
return NewUser(id, UserRoleUser, false, false, "", []string{}, UserIssuedAPI, email, name)
}
// NewAdminUser creates a new user with role UserRoleAdmin
func NewAdminUser(id string) *User {
return NewUser(id, UserRoleAdmin, false, false, "", []string{}, UserIssuedAPI)
return NewUser(id, UserRoleAdmin, false, false, "", []string{}, UserIssuedAPI, "", "")
}
// NewOwnerUser creates a new user with role UserRoleOwner
func NewOwnerUser(id string) *User {
return NewUser(id, UserRoleOwner, false, false, "", []string{}, UserIssuedAPI)
func NewOwnerUser(id string, email string, name string) *User {
return NewUser(id, UserRoleOwner, false, false, "", []string{}, UserIssuedAPI, email, name)
}
// EncryptSensitiveData encrypts the user's sensitive fields (Email and Name) in place.
func (u *User) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
if enc == nil {
return nil
}
var err error
if u.Email != "" {
u.Email, err = enc.Encrypt(u.Email)
if err != nil {
return fmt.Errorf("encrypt email: %w", err)
}
}
if u.Name != "" {
u.Name, err = enc.Encrypt(u.Name)
if err != nil {
return fmt.Errorf("encrypt name: %w", err)
}
}
return nil
}
// DecryptSensitiveData decrypts the user's sensitive fields (Email and Name) in place.
func (u *User) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
if enc == nil {
return nil
}
var err error
if u.Email != "" {
u.Email, err = enc.Decrypt(u.Email)
if err != nil {
return fmt.Errorf("decrypt email: %w", err)
}
}
if u.Name != "" {
u.Name, err = enc.Decrypt(u.Name)
if err != nil {
return fmt.Errorf("decrypt name: %w", err)
}
}
return nil
}
+298
View File
@@ -0,0 +1,298 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/util/crypt"
)
func TestUser_EncryptSensitiveData(t *testing.T) {
key, err := crypt.GenerateKey()
require.NoError(t, err)
fieldEncrypt, err := crypt.NewFieldEncrypt(key)
require.NoError(t, err)
t.Run("encrypt email and name", func(t *testing.T) {
user := &User{
Id: "user-1",
Email: "test@example.com",
Name: "Test User",
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.NotEqual(t, "test@example.com", user.Email, "email should be encrypted")
assert.NotEqual(t, "Test User", user.Name, "name should be encrypted")
assert.NotEmpty(t, user.Email, "encrypted email should not be empty")
assert.NotEmpty(t, user.Name, "encrypted name should not be empty")
})
t.Run("encrypt empty email and name", func(t *testing.T) {
user := &User{
Id: "user-2",
Email: "",
Name: "",
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, "", user.Email, "empty email should remain empty")
assert.Equal(t, "", user.Name, "empty name should remain empty")
})
t.Run("encrypt only email", func(t *testing.T) {
user := &User{
Id: "user-3",
Email: "test@example.com",
Name: "",
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.NotEqual(t, "test@example.com", user.Email, "email should be encrypted")
assert.NotEmpty(t, user.Email, "encrypted email should not be empty")
assert.Equal(t, "", user.Name, "empty name should remain empty")
})
t.Run("encrypt only name", func(t *testing.T) {
user := &User{
Id: "user-4",
Email: "",
Name: "Test User",
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, "", user.Email, "empty email should remain empty")
assert.NotEqual(t, "Test User", user.Name, "name should be encrypted")
assert.NotEmpty(t, user.Name, "encrypted name should not be empty")
})
t.Run("nil encryptor returns no error", func(t *testing.T) {
user := &User{
Id: "user-5",
Email: "test@example.com",
Name: "Test User",
}
err := user.EncryptSensitiveData(nil)
require.NoError(t, err)
assert.Equal(t, "test@example.com", user.Email, "email should remain unchanged with nil encryptor")
assert.Equal(t, "Test User", user.Name, "name should remain unchanged with nil encryptor")
})
}
func TestUser_DecryptSensitiveData(t *testing.T) {
key, err := crypt.GenerateKey()
require.NoError(t, err)
fieldEncrypt, err := crypt.NewFieldEncrypt(key)
require.NoError(t, err)
t.Run("decrypt email and name", func(t *testing.T) {
originalEmail := "test@example.com"
originalName := "Test User"
user := &User{
Id: "user-1",
Email: originalEmail,
Name: originalName,
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
err = user.DecryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, originalEmail, user.Email, "decrypted email should match original")
assert.Equal(t, originalName, user.Name, "decrypted name should match original")
})
t.Run("decrypt empty email and name", func(t *testing.T) {
user := &User{
Id: "user-2",
Email: "",
Name: "",
}
err := user.DecryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, "", user.Email, "empty email should remain empty")
assert.Equal(t, "", user.Name, "empty name should remain empty")
})
t.Run("decrypt only email", func(t *testing.T) {
originalEmail := "test@example.com"
user := &User{
Id: "user-3",
Email: originalEmail,
Name: "",
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
err = user.DecryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, originalEmail, user.Email, "decrypted email should match original")
assert.Equal(t, "", user.Name, "empty name should remain empty")
})
t.Run("decrypt only name", func(t *testing.T) {
originalName := "Test User"
user := &User{
Id: "user-4",
Email: "",
Name: originalName,
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
err = user.DecryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, "", user.Email, "empty email should remain empty")
assert.Equal(t, originalName, user.Name, "decrypted name should match original")
})
t.Run("nil encryptor returns no error", func(t *testing.T) {
user := &User{
Id: "user-5",
Email: "test@example.com",
Name: "Test User",
}
err := user.DecryptSensitiveData(nil)
require.NoError(t, err)
assert.Equal(t, "test@example.com", user.Email, "email should remain unchanged with nil encryptor")
assert.Equal(t, "Test User", user.Name, "name should remain unchanged with nil encryptor")
})
t.Run("decrypt with invalid ciphertext returns error", func(t *testing.T) {
user := &User{
Id: "user-6",
Email: "not-valid-base64-ciphertext!!!",
Name: "Test User",
}
err := user.DecryptSensitiveData(fieldEncrypt)
require.Error(t, err)
assert.Contains(t, err.Error(), "decrypt email")
})
t.Run("decrypt with wrong key returns error", func(t *testing.T) {
originalEmail := "test@example.com"
originalName := "Test User"
user := &User{
Id: "user-7",
Email: originalEmail,
Name: originalName,
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
differentKey, err := crypt.GenerateKey()
require.NoError(t, err)
differentEncrypt, err := crypt.NewFieldEncrypt(differentKey)
require.NoError(t, err)
err = user.DecryptSensitiveData(differentEncrypt)
require.Error(t, err)
assert.Contains(t, err.Error(), "decrypt email")
})
}
func TestUser_EncryptDecryptRoundTrip(t *testing.T) {
key, err := crypt.GenerateKey()
require.NoError(t, err)
fieldEncrypt, err := crypt.NewFieldEncrypt(key)
require.NoError(t, err)
testCases := []struct {
name string
email string
uname string
}{
{
name: "standard email and name",
email: "user@example.com",
uname: "John Doe",
},
{
name: "email with special characters",
email: "user+tag@sub.example.com",
uname: "O'Brien, Mary-Jane",
},
{
name: "unicode characters",
email: "user@example.com",
uname: "Jean-Pierre Müller 日本語",
},
{
name: "long values",
email: "very.long.email.address.that.is.quite.extended@subdomain.example.organization.com",
uname: "A Very Long Name That Contains Many Words And Is Quite Extended For Testing Purposes",
},
{
name: "empty email only",
email: "",
uname: "Name Only",
},
{
name: "empty name only",
email: "email@only.com",
uname: "",
},
{
name: "both empty",
email: "",
uname: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
user := &User{
Id: "test-user",
Email: tc.email,
Name: tc.uname,
}
err := user.EncryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
if tc.email != "" {
assert.NotEqual(t, tc.email, user.Email, "email should be encrypted")
}
if tc.uname != "" {
assert.NotEqual(t, tc.uname, user.Name, "name should be encrypted")
}
err = user.DecryptSensitiveData(fieldEncrypt)
require.NoError(t, err)
assert.Equal(t, tc.email, user.Email, "decrypted email should match original")
assert.Equal(t, tc.uname, user.Name, "decrypted name should match original")
})
}
}
+85 -19
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
@@ -40,7 +41,7 @@ func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountI
}
newUserID := uuid.New().String()
newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI)
newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI, "", "")
newUser.AccountID = accountID
log.WithContext(ctx).Debugf("New User: %v", newUser)
@@ -104,7 +105,12 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
inviterID = createdBy
}
idpUser, err := am.createNewIdpUser(ctx, accountID, inviterID, invite)
var idpUser *idp.UserData
if IsEmbeddedIdp(am.idpManager) {
idpUser, err = am.createEmbeddedIdpUser(ctx, accountID, inviterID, invite)
} else {
idpUser, err = am.createNewIdpUser(ctx, accountID, inviterID, invite)
}
if err != nil {
return nil, err
}
@@ -117,18 +123,26 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
Issued: invite.Issued,
IntegrationReference: invite.IntegrationReference,
CreatedAt: time.Now().UTC(),
Email: invite.Email,
Name: invite.Name,
}
if err = am.Store.SaveUser(ctx, newUser); err != nil {
return nil, err
}
_, err = am.refreshCache(ctx, accountID)
if err != nil {
return nil, err
if !IsEmbeddedIdp(am.idpManager) {
_, err = am.refreshCache(ctx, accountID)
if err != nil {
return nil, err
}
}
am.StoreEvent(ctx, userID, newUser.Id, accountID, activity.UserInvited, nil)
eventType := activity.UserInvited
if IsEmbeddedIdp(am.idpManager) {
eventType = activity.UserCreated
}
am.StoreEvent(ctx, userID, newUser.Id, accountID, eventType, nil)
return newUser.ToUserInfo(idpUser)
}
@@ -172,6 +186,34 @@ func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID
return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviterUser.Email)
}
// createEmbeddedIdpUser validates the invite and creates a new user in the embedded IdP.
// Unlike createNewIdpUser, this method fetches user data directly from the database
// since the embedded IdP usage ensures the username and email are stored locally in the User table.
func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) {
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID)
if err != nil {
return nil, fmt.Errorf("failed to get inviter user: %w", err)
}
if inviter == nil {
return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist", inviterID)
}
// check if the user is already registered with this email => reject
existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
for _, user := range existingUsers {
if strings.EqualFold(user.Email, invite.Email) {
return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account")
}
}
return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviter.Email)
}
func (am *DefaultAccountManager) GetUserByID(ctx context.Context, id string) (*types.User, error) {
return am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, id)
}
@@ -523,16 +565,14 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
userHadPeers, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
_, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
)
if err != nil {
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
}
if userHadPeers {
updateAccountPeers = true
}
updateAccountPeers = true
err = transaction.SaveUser(ctx, updatedUser)
if err != nil {
@@ -579,9 +619,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
return nil, err
}
}
if settings.GroupsPropagationEnabled && updateAccountPeers {
} else if updateAccountPeers {
if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
return nil, fmt.Errorf("failed to increment network serial: %w", err)
}
@@ -761,7 +799,7 @@ func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initi
// If the AccountManager has a non-nil idpManager and the User is not a service user,
// it will attempt to look up the UserData from the cache.
func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.User, accountID string) (*types.UserInfo, error) {
if !isNil(am.idpManager) && !user.IsServiceUser {
if !isNil(am.idpManager) && !user.IsServiceUser && !IsEmbeddedIdp(am.idpManager) {
userData, err := am.lookupUserInCache(ctx, user.Id, accountID)
if err != nil {
return nil, err
@@ -812,7 +850,10 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse
}
// GetOrCreateAccountByUser returns an existing account for a given user id or creates a new one if doesn't exist
func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, userID, domain string) (*types.Account, error) {
func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) {
userID := userAuth.UserId
domain := userAuth.Domain
start := time.Now()
unlock := am.Store.AcquireGlobalLock(ctx)
defer unlock()
@@ -823,7 +864,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u
account, err := am.Store.GetAccountByUser(ctx, userID)
if err != nil {
if s, ok := status.FromError(err); ok && s.Type() == status.NotFound {
account, err = am.newAccount(ctx, userID, lowerDomain)
account, err = am.newAccount(ctx, userID, lowerDomain, userAuth.Email, userAuth.Name)
if err != nil {
return nil, err
}
@@ -888,7 +929,8 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
var queriedUsers []*idp.UserData
var err error
if !isNil(am.idpManager) {
// embedded IdP ensures that we have user data (email and name) stored in the database.
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
users := make(map[string]userLoggedInOnce, len(accountUsers))
usersFromIntegration := make([]*idp.UserData, 0)
for _, user := range accountUsers {
@@ -925,6 +967,10 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
if err != nil {
return nil, err
}
// Try to decode Dex user ID to extract the IdP ID (connector ID)
if _, connectorID, decodeErr := dex.DecodeDexUserID(accountUser.Id); decodeErr == nil && connectorID != "" {
info.IdPID = connectorID
}
userInfosMap[accountUser.Id] = info
}
@@ -946,7 +992,7 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
info = &types.UserInfo{
ID: localUser.Id,
Email: "",
Email: localUser.Email,
Name: name,
Role: string(localUser.Role),
AutoGroups: localUser.AutoGroups,
@@ -955,6 +1001,10 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
NonDeletable: localUser.NonDeletable,
}
}
// Try to decode Dex user ID to extract the IdP ID (connector ID)
if _, connectorID, decodeErr := dex.DecodeDexUserID(localUser.Id); decodeErr == nil && connectorID != "" {
info.IdPID = connectorID
}
userInfosMap[info.ID] = info
}
@@ -996,6 +1046,12 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
)
}
if len(peerIDs) != 0 {
if err := am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
return err
}
}
err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
@@ -1111,6 +1167,7 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
var updateAccountPeers bool
var userPeers []*nbpeer.Peer
var targetUser *types.User
var settings *types.Settings
var err error
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -1119,6 +1176,11 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
return fmt.Errorf("failed to get user to delete: %w", err)
}
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return fmt.Errorf("failed to get account settings: %w", err)
}
userPeers, err = transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, targetUserInfo.ID)
if err != nil {
return fmt.Errorf("failed to get user peers: %w", err)
@@ -1126,7 +1188,7 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
if len(userPeers) > 0 {
updateAccountPeers = true
addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers)
addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings)
if err != nil {
return fmt.Errorf("failed to delete user peers: %w", err)
}
@@ -1145,6 +1207,9 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
var peerIDs []string
for _, peer := range userPeers {
peerIDs = append(peerIDs, peer.ID)
if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peer.ID, settings.Extra); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err)
}
}
if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err)
@@ -1153,6 +1218,7 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
for _, addPeerRemovedEvent := range addPeerRemovedEvents {
addPeerRemovedEvent()
}
meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt}
am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.UserDeleted, meta)
+192 -42
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"fmt"
"os"
"reflect"
"testing"
"time"
@@ -29,6 +30,7 @@ import (
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/integration_reference"
@@ -58,7 +60,7 @@ func TestUser_CreatePAT_ForSameUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = s.SaveAccount(context.Background(), account)
if err != nil {
@@ -105,7 +107,7 @@ func TestUser_CreatePAT_ForDifferentUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockTargetUserId] = &types.User{
Id: mockTargetUserId,
IsServiceUser: false,
@@ -133,7 +135,7 @@ func TestUser_CreatePAT_ForServiceUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockTargetUserId] = &types.User{
Id: mockTargetUserId,
IsServiceUser: true,
@@ -165,7 +167,7 @@ func TestUser_CreatePAT_WithWrongExpiration(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -190,7 +192,7 @@ func TestUser_CreatePAT_WithEmptyName(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -215,7 +217,7 @@ func TestUser_DeletePAT(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockUserID] = &types.User{
Id: mockUserID,
PATs: map[string]*types.PersonalAccessToken{
@@ -258,7 +260,7 @@ func TestUser_GetPAT(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockUserID] = &types.User{
Id: mockUserID,
AccountID: mockAccountID,
@@ -298,7 +300,7 @@ func TestUser_GetAllPATs(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockUserID] = &types.User{
Id: mockUserID,
AccountID: mockAccountID,
@@ -362,6 +364,8 @@ func TestUser_Copy(t *testing.T) {
ID: 0,
IntegrationType: "test",
},
Email: "whatever@gmail.com",
Name: "John Doe",
}
err := validateStruct(user)
@@ -408,7 +412,7 @@ func TestUser_CreateServiceUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -455,7 +459,7 @@ func TestUser_CreateUser_ServiceUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -503,7 +507,7 @@ func TestUser_CreateUser_RegularUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -534,7 +538,7 @@ func TestUser_InviteNewUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -641,7 +645,7 @@ func TestUser_DeleteUser_ServiceUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockServiceUserID] = tt.serviceUser
err = store.SaveAccount(context.Background(), account)
@@ -680,7 +684,7 @@ func TestUser_DeleteUser_SelfDelete(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -707,7 +711,7 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
targetId := "user2"
account.Users[targetId] = &types.User{
@@ -801,7 +805,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
targetId := "user2"
account.Users[targetId] = &types.User{
@@ -969,7 +973,7 @@ func TestDefaultAccountManager_GetUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -1005,9 +1009,9 @@ func TestDefaultAccountManager_ListUsers(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account.Users["normal_user1"] = types.NewRegularUser("normal_user1")
account.Users["normal_user2"] = types.NewRegularUser("normal_user2")
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users["normal_user1"] = types.NewRegularUser("normal_user1", "", "")
account.Users["normal_user2"] = types.NewRegularUser("normal_user2", "", "")
err = store.SaveAccount(context.Background(), account)
if err != nil {
@@ -1047,7 +1051,7 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
externalUser := &types.User{
Id: "externalUser",
Role: types.UserRoleUser,
@@ -1104,7 +1108,7 @@ func TestUser_IsAdmin(t *testing.T) {
user := types.NewAdminUser(mockUserID)
assert.True(t, user.HasAdminPower())
user = types.NewRegularUser(mockUserID)
user = types.NewRegularUser(mockUserID, "", "")
assert.False(t, user.HasAdminPower())
}
@@ -1115,7 +1119,7 @@ func TestUser_GetUsersFromAccount_ForAdmin(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockServiceUserID] = &types.User{
Id: mockServiceUserID,
Role: "user",
@@ -1149,7 +1153,7 @@ func TestUser_GetUsersFromAccount_ForUser(t *testing.T) {
}
t.Cleanup(cleanup)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false)
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
account.Users[mockServiceUserID] = &types.User{
Id: mockServiceUserID,
Role: "user",
@@ -1320,13 +1324,13 @@ func TestDefaultAccountManager_SaveUser(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// create an account and an admin user
account, err := manager.GetOrCreateAccountByUser(context.Background(), ownerUserID, "netbird.io")
account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: ownerUserID, Domain: "netbird.io"})
if err != nil {
t.Fatal(err)
}
// create other users
account.Users[regularUserID] = types.NewRegularUser(regularUserID)
account.Users[regularUserID] = types.NewRegularUser(regularUserID, "", "")
account.Users[adminUserID] = types.NewAdminUser(adminUserID)
account.Users[serviceUserID] = &types.User{IsServiceUser: true, Id: serviceUserID, Role: types.UserRoleAdmin, ServiceUserName: "service"}
err = manager.Store.SaveAccount(context.Background(), account)
@@ -1379,11 +1383,11 @@ func TestUserAccountPeersUpdate(t *testing.T) {
updateManager.CloseChannel(context.Background(), peer1.ID)
})
// Creating a new regular user should not update account peers and not send peer update
// Creating a new regular user should send peer update (as users are not filtered yet)
t.Run("creating new regular user with no groups", func(t *testing.T) {
done := make(chan struct{})
go func() {
peerShouldNotReceiveUpdate(t, updMsg)
peerShouldReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1402,11 +1406,11 @@ func TestUserAccountPeersUpdate(t *testing.T) {
}
})
// updating user with no linked peers should not update account peers and not send peer update
// updating user with no linked peers should update account peers and send peer update (as users are not filtered yet)
t.Run("updating user with no linked peers", func(t *testing.T) {
done := make(chan struct{})
go func() {
peerShouldNotReceiveUpdate(t, updMsg)
peerShouldReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1516,7 +1520,7 @@ func TestSaveOrAddUser_PreventAccountSwitch(t *testing.T) {
}
t.Cleanup(cleanup)
account1 := newAccountWithId(context.Background(), "account1", "ownerAccount1", "", false)
account1 := newAccountWithId(context.Background(), "account1", "ownerAccount1", "", "", "", false)
targetId := "user2"
account1.Users[targetId] = &types.User{
Id: targetId,
@@ -1525,7 +1529,7 @@ func TestSaveOrAddUser_PreventAccountSwitch(t *testing.T) {
}
require.NoError(t, s.SaveAccount(context.Background(), account1))
account2 := newAccountWithId(context.Background(), "account2", "ownerAccount2", "", false)
account2 := newAccountWithId(context.Background(), "account2", "ownerAccount2", "", "", "", false)
require.NoError(t, s.SaveAccount(context.Background(), account2))
permissionsManager := permissions.NewManager(s)
@@ -1552,7 +1556,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) {
}
t.Cleanup(cleanup)
account1 := newAccountWithId(context.Background(), "account1", "account1Owner", "", false)
account1 := newAccountWithId(context.Background(), "account1", "account1Owner", "", "", "", false)
account1.Settings.RegularUsersViewBlocked = false
account1.Users["blocked-user"] = &types.User{
Id: "blocked-user",
@@ -1574,7 +1578,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) {
}
require.NoError(t, store.SaveAccount(context.Background(), account1))
account2 := newAccountWithId(context.Background(), "account2", "account2Owner", "", false)
account2 := newAccountWithId(context.Background(), "account2", "account2Owner", "", "", "", false)
account2.Users["settings-blocked-user"] = &types.User{
Id: "settings-blocked-user",
Role: types.UserRoleUser,
@@ -1771,7 +1775,7 @@ func TestApproveUser(t *testing.T) {
}
// Create account with admin and pending approval user
account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", false)
account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
@@ -1782,7 +1786,7 @@ func TestApproveUser(t *testing.T) {
require.NoError(t, err)
// Create user pending approval
pendingUser := types.NewRegularUser("pending-user")
pendingUser := types.NewRegularUser("pending-user", "", "")
pendingUser.AccountID = account.Id
pendingUser.Blocked = true
pendingUser.PendingApproval = true
@@ -1807,12 +1811,12 @@ func TestApproveUser(t *testing.T) {
assert.Contains(t, err.Error(), "not pending approval")
// Test approval by non-admin should fail
regularUser := types.NewRegularUser("regular-user")
regularUser := types.NewRegularUser("regular-user", "", "")
regularUser.AccountID = account.Id
err = manager.Store.SaveUser(context.Background(), regularUser)
require.NoError(t, err)
pendingUser2 := types.NewRegularUser("pending-user-2")
pendingUser2 := types.NewRegularUser("pending-user-2", "", "")
pendingUser2.AccountID = account.Id
pendingUser2.Blocked = true
pendingUser2.PendingApproval = true
@@ -1830,7 +1834,7 @@ func TestRejectUser(t *testing.T) {
}
// Create account with admin and pending approval user
account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", false)
account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", "", "", false)
err = manager.Store.SaveAccount(context.Background(), account)
require.NoError(t, err)
@@ -1841,7 +1845,7 @@ func TestRejectUser(t *testing.T) {
require.NoError(t, err)
// Create user pending approval
pendingUser := types.NewRegularUser("pending-user")
pendingUser := types.NewRegularUser("pending-user", "", "")
pendingUser.AccountID = account.Id
pendingUser.Blocked = true
pendingUser.PendingApproval = true
@@ -1857,7 +1861,7 @@ func TestRejectUser(t *testing.T) {
require.Error(t, err)
// Test rejection of non-pending user should fail
regularUser := types.NewRegularUser("regular-user")
regularUser := types.NewRegularUser("regular-user", "", "")
regularUser.AccountID = account.Id
err = manager.Store.SaveUser(context.Background(), regularUser)
require.NoError(t, err)
@@ -1867,7 +1871,7 @@ func TestRejectUser(t *testing.T) {
assert.Contains(t, err.Error(), "not pending approval")
// Test rejection by non-admin should fail
pendingUser2 := types.NewRegularUser("pending-user-2")
pendingUser2 := types.NewRegularUser("pending-user-2", "", "")
pendingUser2.AccountID = account.Id
pendingUser2.Blocked = true
pendingUser2.PendingApproval = true
@@ -1877,3 +1881,149 @@ func TestRejectUser(t *testing.T) {
err = manager.RejectUser(context.Background(), account.Id, regularUser.Id, pendingUser2.Id)
require.Error(t, err)
}
func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
ctx := context.Background()
// Create temporary directory for Dex
tmpDir := t.TempDir()
dexDataDir := tmpDir + "/dex"
require.NoError(t, os.MkdirAll(dexDataDir, 0700))
// Create embedded IDP config
embeddedIdPConfig := &idp.EmbeddedIdPConfig{
Enabled: true,
Issuer: "http://localhost:5556/dex",
Storage: idp.EmbeddedStorageConfig{
Type: "sqlite3",
Config: idp.EmbeddedStorageTypeConfig{
File: dexDataDir + "/dex.db",
},
},
}
// Create embedded IDP manager
embeddedIdp, err := idp.NewEmbeddedIdPManager(ctx, embeddedIdPConfig, nil)
require.NoError(t, err)
defer func() { _ = embeddedIdp.Stop(ctx) }()
// Create test store
testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", tmpDir)
require.NoError(t, err)
defer cleanup()
// Create account with owner user
account := newAccountWithId(ctx, mockAccountID, mockUserID, "", "owner@test.com", "Owner User", false)
require.NoError(t, testStore.SaveAccount(ctx, account))
// Create mock network map controller
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
// Create account manager with embedded IDP
permissionsManager := permissions.NewManager(testStore)
am := DefaultAccountManager{
Store: testStore,
eventStore: &activity.InMemoryEventStore{},
permissionsManager: permissionsManager,
idpManager: embeddedIdp,
cacheLoading: map[string]chan struct{}{},
networkMapController: networkMapControllerMock,
}
// Initialize cache manager
cacheStore, err := nbcache.NewStore(ctx, nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval, nbcache.DefaultIDPCacheOpenConn)
require.NoError(t, err)
am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore)
am.externalCacheManager = nbcache.NewUserDataCache(cacheStore)
t.Run("create regular user returns password", func(t *testing.T) {
userInfo, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{
Email: "newuser@test.com",
Name: "New User",
Role: "user",
AutoGroups: []string{},
IsServiceUser: false,
})
require.NoError(t, err)
require.NotNil(t, userInfo)
// Verify user data
assert.Equal(t, "newuser@test.com", userInfo.Email)
assert.Equal(t, "New User", userInfo.Name)
assert.Equal(t, "user", userInfo.Role)
assert.NotEmpty(t, userInfo.ID)
// IMPORTANT: Password should be returned for embedded IDP
assert.NotEmpty(t, userInfo.Password, "Password should be returned for embedded IDP user")
t.Logf("Created user: ID=%s, Email=%s, Password=%s", userInfo.ID, userInfo.Email, userInfo.Password)
// Verify user ID is in Dex encoded format
rawUserID, connectorID, err := dex.DecodeDexUserID(userInfo.ID)
require.NoError(t, err)
assert.NotEmpty(t, rawUserID)
assert.Equal(t, "local", connectorID)
t.Logf("Decoded user ID: rawUserID=%s, connectorID=%s", rawUserID, connectorID)
// Verify user exists in database with correct data
dbUser, err := testStore.GetUserByUserID(ctx, store.LockingStrengthNone, userInfo.ID)
require.NoError(t, err)
assert.Equal(t, "newuser@test.com", dbUser.Email)
assert.Equal(t, "New User", dbUser.Name)
// Store user ID for delete test
createdUserID := userInfo.ID
t.Run("delete user works", func(t *testing.T) {
err := am.DeleteUser(ctx, mockAccountID, mockUserID, createdUserID)
require.NoError(t, err)
// Verify user is deleted from database
_, err = testStore.GetUserByUserID(ctx, store.LockingStrengthNone, createdUserID)
assert.Error(t, err, "User should be deleted from database")
})
})
t.Run("create service user does not return password", func(t *testing.T) {
userInfo, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{
Name: "Service User",
Role: "user",
AutoGroups: []string{},
IsServiceUser: true,
})
require.NoError(t, err)
require.NotNil(t, userInfo)
assert.True(t, userInfo.IsServiceUser)
assert.Equal(t, "Service User", userInfo.Name)
// Service users don't have passwords
assert.Empty(t, userInfo.Password, "Service users should not have passwords")
})
t.Run("duplicate email fails", func(t *testing.T) {
// Create first user
_, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{
Email: "duplicate@test.com",
Name: "First User",
Role: "user",
AutoGroups: []string{},
IsServiceUser: false,
})
require.NoError(t, err)
// Try to create second user with same email
_, err = am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{
Email: "duplicate@test.com",
Name: "Second User",
Role: "user",
AutoGroups: []string{},
IsServiceUser: false,
})
assert.Error(t, err, "Creating user with duplicate email should fail")
t.Logf("Duplicate email error: %v", err)
})
}