mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 05:39:07 +02:00
conflicts resolution
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,8 +49,7 @@ func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim s
|
||||
)
|
||||
|
||||
return &manager{
|
||||
store: store,
|
||||
|
||||
store: store,
|
||||
validator: jwtValidator,
|
||||
extractor: claimsExtractor,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ func startServer(
|
||||
nil,
|
||||
server.MockIntegratedValidator{},
|
||||
networkMapController,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed creating management server: %v", err)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user