mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 13:59:08 +01:00
Merge remote-tracking branch 'origin/main' into feature/native-grpc
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -15,10 +14,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
|
||||
cacheStore "github.com/eko/gocache/lib/v4/store"
|
||||
"github.com/eko/gocache/store/redis/v4"
|
||||
"github.com/rs/xid"
|
||||
@@ -30,15 +25,18 @@ import (
|
||||
"github.com/netbirdio/netbird/formatter/hook"
|
||||
"github.com/netbirdio/netbird/idp/dex"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
@@ -50,7 +48,9 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ const (
|
||||
type userLoggedInOnce bool
|
||||
|
||||
func cacheEntryExpiration() time.Duration {
|
||||
r := rand.Intn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
r := util.RandIntn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
return time.Duration(r) * time.Millisecond
|
||||
}
|
||||
|
||||
@@ -237,6 +237,10 @@ func BuildManager(
|
||||
log.WithContext(ctx).Error(err)
|
||||
}
|
||||
|
||||
if IsEmbeddedIdp(idpManager) && accountsCounter > 1 {
|
||||
log.WithContext(ctx).Warnf("embedded IdP requires a single account, found %d", accountsCounter)
|
||||
}
|
||||
|
||||
// enable single account mode only if configured by user and number of existing accounts is not grater than 1
|
||||
am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1
|
||||
if am.singleAccountMode {
|
||||
@@ -715,8 +719,10 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context
|
||||
log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID)
|
||||
return
|
||||
}
|
||||
// The job outlives the request that arms it, so it must not inherit the request's cancellation.
|
||||
jobCtx := context.WithoutCancel(ctx)
|
||||
if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok {
|
||||
go am.peerLoginExpiry.Schedule(ctx, nextRun, accountID, am.peerLoginExpirationJob(ctx, accountID))
|
||||
go am.peerLoginExpiry.Schedule(jobCtx, nextRun, accountID, am.peerLoginExpirationJob(jobCtx, accountID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,8 +754,9 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context
|
||||
// checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions
|
||||
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
|
||||
am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
|
||||
jobCtx := context.WithoutCancel(ctx)
|
||||
if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok {
|
||||
go am.peerInactivityExpiry.Schedule(ctx, nextRun, accountID, am.peerInactivityExpirationJob(ctx, accountID))
|
||||
go am.peerInactivityExpiry.Schedule(jobCtx, nextRun, accountID, am.peerInactivityExpirationJob(jobCtx, accountID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1591,7 +1598,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userAuth.Domain = domain
|
||||
// Keep the configured single account domain when the existing account has none
|
||||
if domain != "" {
|
||||
userAuth.Domain = domain
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled")
|
||||
return nil
|
||||
@@ -1626,6 +1636,8 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
var removeOldGroups []string
|
||||
var hasChanges bool
|
||||
var user *types.User
|
||||
var change affectedpeers.Change
|
||||
var snap *affectedpeers.Snapshot
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
|
||||
if err != nil {
|
||||
@@ -1648,6 +1660,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, g := range newGroupsToCreate {
|
||||
g.PublicID = xid.New().String()
|
||||
}
|
||||
|
||||
if err = transaction.CreateGroups(ctx, userAuth.AccountId, newGroupsToCreate); err != nil {
|
||||
return fmt.Errorf("error saving groups: %w", err)
|
||||
}
|
||||
@@ -1660,14 +1676,25 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
return fmt.Errorf("error saving user: %w", err)
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
// The user's auto-groups changed, so the SSH rules authorizing them ship a new
|
||||
// group -> user mapping even when no peer moves between groups.
|
||||
change.UserGroupIDs = allGroupChanges
|
||||
|
||||
// The user's peers are the changed entity in every scenario the sync can
|
||||
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
|
||||
// together with every peer they can connect to, like on a regular peer update.
|
||||
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
for _, peer := range userPeers {
|
||||
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
|
||||
}
|
||||
|
||||
// Propagate changes to peers if group propagation is enabled
|
||||
if settings.GroupsPropagationEnabled {
|
||||
peers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
for _, peer := range userPeers {
|
||||
for _, g := range addNewGroups {
|
||||
if err := transaction.AddPeerToGroup(ctx, userAuth.AccountId, peer.ID, g); err != nil {
|
||||
return fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, g, err)
|
||||
@@ -1680,7 +1707,8 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
change.LinkGroups = allGroupChanges
|
||||
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
||||
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
@@ -1690,6 +1718,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1726,20 +1758,17 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if removedGroupAffectsPeers || newGroupsAffectsPeers {
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
|
||||
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
||||
}
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
affectedPeerIDs := snap.Expand(bgCtx, userAuth.AccountId, change)
|
||||
if len(affectedPeerIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if err := am.networkMapController.BufferUpdateAffectedPeers(bgCtx, userAuth.AccountId, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}); err != nil {
|
||||
log.WithContext(bgCtx).Errorf("failed to update affected peers after JWT group sync for account %s: %v", userAuth.AccountId, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1817,6 +1846,7 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context
|
||||
|
||||
return am.addNewPrivateAccount(ctx, domainAccountID, userAuth)
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) getPrivateDomainWithGlobalLock(ctx context.Context, domain string) (string, context.CancelFunc, error) {
|
||||
domainAccountID, err := am.Store.GetAccountIDByPrivateDomain(ctx, store.LockingStrengthNone, domain)
|
||||
if handleNotFound(err) != nil {
|
||||
@@ -1900,7 +1930,7 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu
|
||||
// derived from syncTime (the moment the gRPC stream opened). Any
|
||||
// concurrent stream that started earlier loses the optimistic-lock race
|
||||
// in MarkPeerConnected and bails without writing.
|
||||
func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
|
||||
@@ -2422,30 +2452,24 @@ func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Contex
|
||||
return fmt.Errorf("get account settings: %w", err)
|
||||
}
|
||||
|
||||
if len(settings.IPv6EnabledGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
|
||||
for _, gid := range settings.IPv6EnabledGroups {
|
||||
enabledSet[gid] = struct{}{}
|
||||
}
|
||||
|
||||
affected := false
|
||||
for _, gid := range groupIDs {
|
||||
if _, ok := enabledSet[gid]; ok {
|
||||
affected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !affected {
|
||||
if !ipv6ReconcileNeeded(settings, groupIDs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||
}
|
||||
|
||||
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
|
||||
// reconciliation.
|
||||
func ipv6ReconcileNeeded(settings *types.Settings, groupIDs []string) bool {
|
||||
for _, groupID := range groupIDs {
|
||||
if slices.Contains(settings.IPv6EnabledGroups, groupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error {
|
||||
if settings.NetworkRangeV6.IsValid() {
|
||||
network.NetV6 = net.IPNet{
|
||||
@@ -2455,8 +2479,7 @@ func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transacti
|
||||
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
|
||||
}
|
||||
if network.NetV6.IP == nil {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
network.NetV6 = types.AllocateIPv6Subnet(r)
|
||||
network.NetV6 = types.AllocateIPv6Subnet()
|
||||
|
||||
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
|
||||
ones, _ := network.NetV6.Mask.Size()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package account
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
type ExternalCacheManager nbcache.UserDataCache
|
||||
@@ -70,7 +71,7 @@ type Manager interface {
|
||||
UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
|
||||
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
|
||||
GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
|
||||
AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
|
||||
AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
|
||||
CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
|
||||
DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error
|
||||
GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error)
|
||||
@@ -109,9 +110,9 @@ type Manager interface {
|
||||
GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
|
||||
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
|
||||
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
|
||||
LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API
|
||||
ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
|
||||
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
|
||||
LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) // used by peer gRPC API
|
||||
ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
|
||||
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) // used by peer gRPC API
|
||||
GetExternalCacheManager() ExternalCacheManager
|
||||
GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
|
||||
SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error)
|
||||
@@ -121,7 +122,7 @@ type Manager interface {
|
||||
UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error
|
||||
GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error)
|
||||
GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error)
|
||||
SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error
|
||||
SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error
|
||||
FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
|
||||
|
||||
type RequestBuffer interface {
|
||||
GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./request_buffer.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
|
||||
//
|
||||
|
||||
// Package account is a generated GoMock package.
|
||||
package account
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRequestBuffer is a mock of RequestBuffer interface.
|
||||
type MockRequestBuffer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRequestBufferMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRequestBufferMockRecorder is the mock recorder for MockRequestBuffer.
|
||||
type MockRequestBufferMockRecorder struct {
|
||||
mock *MockRequestBuffer
|
||||
}
|
||||
|
||||
// NewMockRequestBuffer creates a new mock instance.
|
||||
func NewMockRequestBuffer(ctrl *gomock.Controller) *MockRequestBuffer {
|
||||
mock := &MockRequestBuffer{ctrl: ctrl}
|
||||
mock.recorder = &MockRequestBufferMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRequestBuffer) EXPECT() *MockRequestBufferMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetAccountWithBackpressure mocks base method.
|
||||
func (m *MockRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountWithBackpressure", ctx, accountID)
|
||||
ret0, _ := ret[0].(*types.Account)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountWithBackpressure indicates an expected call of GetAccountWithBackpressure.
|
||||
func (mr *MockRequestBufferMockRecorder) GetAccountWithBackpressure(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountWithBackpressure", reflect.TypeOf((*MockRequestBuffer)(nil).GetAccountWithBackpressure), ctx, accountID)
|
||||
}
|
||||
@@ -2,117 +2,38 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// AccountRequest holds the result channel to return the requested account.
|
||||
type AccountRequest struct {
|
||||
AccountID string
|
||||
ResultChan chan *AccountResult
|
||||
}
|
||||
|
||||
// AccountResult holds the account data or an error.
|
||||
type AccountResult struct {
|
||||
Account *types.Account
|
||||
Err error
|
||||
}
|
||||
const defaultAccountBufferInterval = 100 * time.Millisecond
|
||||
|
||||
type AccountRequestBuffer struct {
|
||||
store store.Store
|
||||
getAccountRequests map[string][]*AccountRequest
|
||||
mu sync.Mutex
|
||||
getAccountRequestCh chan *AccountRequest
|
||||
bufferInterval time.Duration
|
||||
buffer *requestbuffer.Buffer[*types.Account]
|
||||
}
|
||||
|
||||
func NewAccountRequestBuffer(ctx context.Context, store store.Store) *AccountRequestBuffer {
|
||||
bufferIntervalStr := os.Getenv("NB_GET_ACCOUNT_BUFFER_INTERVAL")
|
||||
bufferInterval, err := time.ParseDuration(bufferIntervalStr)
|
||||
if err != nil {
|
||||
if bufferIntervalStr != "" {
|
||||
log.WithContext(ctx).Warnf("failed to parse account request buffer interval: %s", err)
|
||||
}
|
||||
bufferInterval = 100 * time.Millisecond
|
||||
interval := requestbuffer.Interval(ctx, "NB_GET_ACCOUNT_BUFFER_INTERVAL", defaultAccountBufferInterval)
|
||||
log.WithContext(ctx).Infof("set account request buffer interval to %s", interval)
|
||||
|
||||
return &AccountRequestBuffer{
|
||||
buffer: requestbuffer.New(ctx, "account request buffer", interval, store.GetAccount),
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("set account request buffer interval to %s", bufferInterval)
|
||||
|
||||
ac := AccountRequestBuffer{
|
||||
store: store,
|
||||
getAccountRequests: make(map[string][]*AccountRequest),
|
||||
getAccountRequestCh: make(chan *AccountRequest),
|
||||
bufferInterval: bufferInterval,
|
||||
}
|
||||
|
||||
go ac.processGetAccountRequests(ctx)
|
||||
|
||||
return &ac
|
||||
}
|
||||
|
||||
func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
|
||||
req := &AccountRequest{
|
||||
AccountID: accountID,
|
||||
ResultChan: make(chan *AccountResult, 1),
|
||||
account, err := ac.buffer.Get(ctx, accountID)
|
||||
if err != nil || account == nil {
|
||||
return account, err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID)
|
||||
startTime := time.Now()
|
||||
ac.getAccountRequestCh <- req
|
||||
|
||||
result := <-req.ResultChan
|
||||
log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
|
||||
return result.Account, result.Err
|
||||
}
|
||||
|
||||
func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) {
|
||||
ac.mu.Lock()
|
||||
requests := ac.getAccountRequests[accountID]
|
||||
delete(ac.getAccountRequests, accountID)
|
||||
ac.mu.Unlock()
|
||||
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
account, err := ac.store.GetAccount(ctx, accountID)
|
||||
log.WithContext(ctx).Tracef("getting account %s in batch took %s", accountID, time.Since(startTime))
|
||||
result := &AccountResult{Account: account, Err: err}
|
||||
|
||||
for _, req := range requests {
|
||||
if account != nil {
|
||||
// Shallow copy the account so each goroutine gets its own struct value.
|
||||
// This prevents data races when callers mutate fields like Policies.
|
||||
accountCopy := *account
|
||||
req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err}
|
||||
} else {
|
||||
req.ResultChan <- result
|
||||
}
|
||||
close(req.ResultChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *AccountRequestBuffer) processGetAccountRequests(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case req := <-ac.getAccountRequestCh:
|
||||
ac.mu.Lock()
|
||||
ac.getAccountRequests[req.AccountID] = append(ac.getAccountRequests[req.AccountID], req)
|
||||
if len(ac.getAccountRequests[req.AccountID]) == 1 {
|
||||
go func(ctx context.Context, accountID string) {
|
||||
time.Sleep(ac.bufferInterval)
|
||||
ac.processGetAccountBatch(ctx, accountID)
|
||||
}(ctx, req.AccountID)
|
||||
}
|
||||
ac.mu.Unlock()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
// Shallow copy the account so each caller gets its own struct value.
|
||||
// This prevents data races when callers mutate fields like Policies.
|
||||
accountCopy := *account
|
||||
return &accountCopy, nil
|
||||
}
|
||||
|
||||
@@ -10,16 +10,17 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/prometheus/client_golang/prometheus/push"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
@@ -37,6 +38,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
nbAccount "github.com/netbirdio/netbird/management/server/account"
|
||||
@@ -1757,6 +1760,7 @@ func TestAccount_Copy(t *testing.T) {
|
||||
AccountID: "account1",
|
||||
},
|
||||
},
|
||||
PostureValidation: map[string]map[string]bool{"1": {"1": true}},
|
||||
}
|
||||
err := hasNilField(account)
|
||||
if err != nil {
|
||||
@@ -1916,6 +1920,154 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_SchedulePeerLoginExpiration_IncludesOfflinePeers(t *testing.T) {
|
||||
manager, updateManager, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
|
||||
connectedKey, offlineKey := addExpiringPeers(t, manager)
|
||||
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
|
||||
PeerLoginExpiration: time.Hour,
|
||||
PeerLoginExpirationEnabled: true,
|
||||
Extra: &types.ExtraSettings{},
|
||||
})
|
||||
require.NoError(t, err, "expecting to update account settings successfully but got error")
|
||||
manager.peerLoginExpiry.CancelAll(context.Background())
|
||||
|
||||
// The connected peer logged in just now, so a job computed from connected peers alone
|
||||
// would be armed for an hour. The offline peer's login expires in two seconds; a
|
||||
// reconnect of that peer must not have to wait for the connected peer's tick.
|
||||
now := time.Now().UTC()
|
||||
setPeerLogin(t, manager, accountID, connectedKey, true, now)
|
||||
setPeerLogin(t, manager, accountID, offlineKey, false, now.Add(-time.Hour+2*time.Second))
|
||||
|
||||
offlinePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
|
||||
require.NoError(t, err)
|
||||
updateManager.CreateChannel(context.Background(), offlinePeer.ID)
|
||||
|
||||
manager.peerLoginExpiry = NewDefaultScheduler()
|
||||
t.Cleanup(func() { manager.peerLoginExpiry.CancelAll(context.Background()) })
|
||||
manager.schedulePeerLoginExpiration(context.Background(), accountID)
|
||||
|
||||
// The flag is committed per peer before the disconnect fans out, so wait for both.
|
||||
require.Eventually(t, func() bool {
|
||||
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
|
||||
return err == nil && peer.Status.LoginExpired && !updateManager.HasChannel(offlinePeer.ID)
|
||||
}, 10*time.Second, 100*time.Millisecond, "offline peer should be expired and disconnected at its own deadline")
|
||||
|
||||
connectedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, connectedKey)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, connectedPeer.Status.LoginExpired, "connected peer with a fresh login must not expire")
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_SchedulePeerLoginExpiration_DetachesRequestContext(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
connectedKey, _ := addExpiringPeers(t, manager)
|
||||
setPeerLogin(t, manager, accountID, connectedKey, true, time.Now().UTC())
|
||||
|
||||
scheduled := make(chan context.Context, 1)
|
||||
manager.peerLoginExpiry = &MockScheduler{
|
||||
IsSchedulerRunningFunc: func(string) bool { return false },
|
||||
ScheduleFunc: func(ctx context.Context, _ time.Duration, _ string, _ func() (time.Duration, bool)) {
|
||||
scheduled <- ctx
|
||||
},
|
||||
}
|
||||
|
||||
requestCtx, cancel := context.WithCancel(context.Background())
|
||||
manager.schedulePeerLoginExpiration(requestCtx, accountID)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case jobCtx := <-scheduled:
|
||||
assert.NoError(t, jobCtx.Err(), "the expiration job must outlive the request that armed it")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout while waiting for the job to be scheduled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_ExpireAndUpdatePeers_SkipsPeerThatLoggedInAgain(t *testing.T) {
|
||||
manager, updateManager, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
|
||||
reloggedKey, staleKey := addExpiringPeers(t, manager)
|
||||
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
|
||||
PeerLoginExpiration: time.Hour,
|
||||
PeerLoginExpirationEnabled: true,
|
||||
Extra: &types.ExtraSettings{},
|
||||
})
|
||||
require.NoError(t, err, "expecting to update account settings successfully but got error")
|
||||
manager.peerLoginExpiry.CancelAll(context.Background())
|
||||
|
||||
expiredLogin := time.Now().UTC().Add(-2 * time.Hour)
|
||||
setPeerLogin(t, manager, accountID, reloggedKey, true, expiredLogin)
|
||||
setPeerLogin(t, manager, accountID, staleKey, true, expiredLogin)
|
||||
|
||||
expiredPeers, err := manager.getExpiredPeers(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, expiredPeers, 2, "both peers should be due for expiration")
|
||||
|
||||
// The job holds the candidate list while one peer completes a fresh login, which
|
||||
// moves its deadline into the future and must win over the stale candidate entry.
|
||||
setPeerLogin(t, manager, accountID, reloggedKey, true, time.Now().UTC())
|
||||
|
||||
reloggedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
|
||||
require.NoError(t, err)
|
||||
stalePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
|
||||
require.NoError(t, err)
|
||||
updateManager.CreateChannel(context.Background(), reloggedPeer.ID)
|
||||
updateManager.CreateChannel(context.Background(), stalePeer.ID)
|
||||
|
||||
err = manager.expireAndUpdatePeers(context.Background(), accountID, expiredPeers, peerExpirationSessionExpired)
|
||||
require.NoError(t, err)
|
||||
|
||||
reloggedPeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, reloggedPeer.Status.LoginExpired, "a peer that logged in again must not be flagged from the stale candidate list")
|
||||
assert.True(t, reloggedPeer.Status.Connected, "the re-logged peer must keep its connected status")
|
||||
assert.True(t, updateManager.HasChannel(reloggedPeer.ID), "the re-logged peer's update channel must stay open")
|
||||
|
||||
stalePeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stalePeer.Status.LoginExpired, "a peer that is still due must be flagged")
|
||||
assert.False(t, updateManager.HasChannel(stalePeer.ID), "the expired peer's update channel must be closed")
|
||||
}
|
||||
|
||||
// addExpiringPeers registers two SSO peers with login expiration enabled and returns their public keys.
|
||||
func addExpiringPeers(t *testing.T, manager *DefaultAccountManager) (string, string) {
|
||||
t.Helper()
|
||||
keys := make([]string, 0, 2)
|
||||
for _, hostname := range []string{"connected-peer", "offline-peer"} {
|
||||
key, err := wgtypes.GenerateKey()
|
||||
require.NoError(t, err, "unable to generate WireGuard key")
|
||||
_, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: hostname},
|
||||
LoginExpirationEnabled: true,
|
||||
}, false)
|
||||
require.NoError(t, err, "unable to add peer")
|
||||
keys = append(keys, key.PublicKey().String())
|
||||
}
|
||||
return keys[0], keys[1]
|
||||
}
|
||||
|
||||
func setPeerLogin(t *testing.T, manager *DefaultAccountManager, accountID, peerKey string, connected bool, lastLogin time.Time) {
|
||||
t.Helper()
|
||||
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerKey)
|
||||
require.NoError(t, err)
|
||||
peer.Status.Connected = connected
|
||||
peer.LastLogin = &lastLogin
|
||||
require.NoError(t, manager.Store.SavePeer(context.Background(), accountID, peer))
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
@@ -2698,7 +2850,7 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
||||
expectedNextExpiration: time.Duration(0),
|
||||
},
|
||||
{
|
||||
name: "No connected peers, no expiration",
|
||||
name: "Offline peer with expiration, return expiration",
|
||||
peers: map[string]*nbpeer.Peer{
|
||||
"peer-1": {
|
||||
Status: &nbpeer.PeerStatus{
|
||||
@@ -2717,8 +2869,33 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
||||
},
|
||||
expiration: time.Second,
|
||||
expirationEnabled: false,
|
||||
expectedNextRun: false,
|
||||
expectedNextExpiration: time.Duration(0),
|
||||
expectedNextRun: true,
|
||||
expectedNextExpiration: time.Second,
|
||||
},
|
||||
{
|
||||
name: "Offline peer with the earliest deadline defines the next run",
|
||||
peers: map[string]*nbpeer.Peer{
|
||||
"peer-1": {
|
||||
Status: &nbpeer.PeerStatus{
|
||||
Connected: true,
|
||||
},
|
||||
LoginExpirationEnabled: true,
|
||||
LastLogin: util.ToPtr(time.Now().UTC()),
|
||||
UserID: userID,
|
||||
},
|
||||
"peer-2": {
|
||||
Status: &nbpeer.PeerStatus{
|
||||
Connected: false,
|
||||
},
|
||||
LoginExpirationEnabled: true,
|
||||
LastLogin: util.ToPtr(time.Now().UTC().Add(-50 * time.Minute)),
|
||||
UserID: userID,
|
||||
},
|
||||
},
|
||||
expiration: time.Hour,
|
||||
expirationEnabled: true,
|
||||
expectedNextRun: true,
|
||||
expectedNextExpiration: 10 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "Connected peers with disabled expiration, no expiration",
|
||||
@@ -3170,6 +3347,16 @@ func TestAccount_SetJWTGroups(t *testing.T) {
|
||||
user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, "user2")
|
||||
assert.NoError(t, err, "unable to get user")
|
||||
assert.Len(t, user.AutoGroups, 1, "new group should be added")
|
||||
|
||||
var newJWTGroup *types.Group
|
||||
for _, g := range groups {
|
||||
if g.Name == "group3" {
|
||||
newJWTGroup = g
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, newJWTGroup, "JIT-created JWT group not found")
|
||||
assert.NotEqual(t, "", newJWTGroup.PublicID, "JIT-created JWT group must have a non-empty PublicID")
|
||||
})
|
||||
|
||||
t.Run("remove all JWT groups when list is empty", func(t *testing.T) {
|
||||
@@ -3282,13 +3469,33 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
return buildTestManager(t, store, nil)
|
||||
}
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
// createManagerWithNetworkMapStore builds a manager whose network map controller
|
||||
// reads the twin (nmdata) store, the production path on sqlite and postgres.
|
||||
func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager) {
|
||||
t.Helper()
|
||||
|
||||
if engine := os.Getenv("NETBIRD_STORE_ENGINE"); engine != "" && !strings.EqualFold(engine, string(types.SqliteStoreEngine)) {
|
||||
t.Skipf("network map store test needs the sqlite engine, got %s", engine)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
store, err := createStoreAt(t, dataDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
manager, updateManager, err := buildTestManager(t, store, nmdataStore)
|
||||
require.NoError(t, err)
|
||||
return manager, updateManager
|
||||
}
|
||||
|
||||
func newSettingsMockManager(t testing.TB) *settings.MockManager {
|
||||
t.Helper()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
t.Cleanup(ctrl.Finish)
|
||||
|
||||
@@ -3301,6 +3508,29 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(false, nil).
|
||||
AnyTimes()
|
||||
return settingsMockManager
|
||||
}
|
||||
|
||||
func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||
t.Helper()
|
||||
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
|
||||
// Everything built here watches this context; cancelling it on cleanup stops
|
||||
// the metrics flushers, caches and controllers instead of leaking them for
|
||||
// the rest of the package run.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
t.Cleanup(ctrl.Finish)
|
||||
|
||||
settingsMockManager := newSettingsMockManager(t)
|
||||
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
@@ -3311,8 +3541,6 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
Return(nil).
|
||||
AnyTimes()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -3320,7 +3548,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -3338,7 +3566,11 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
|
||||
|
||||
func createStore(t testing.TB) (store.Store, error) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
return createStoreAt(t, t.TempDir())
|
||||
}
|
||||
|
||||
func createStoreAt(t testing.TB, dataDir string) (store.Store, error) {
|
||||
t.Helper()
|
||||
store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -4269,7 +4501,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
|
||||
}
|
||||
|
||||
// Sanity: an actually different range still triggers reallocation.
|
||||
newRange := netip.MustParsePrefix("100.99.0.0/16")
|
||||
newRange := netip.MustParsePrefix("100.60.0.0/16")
|
||||
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
|
||||
PeerLoginExpirationEnabled: true,
|
||||
PeerLoginExpiration: types.DefaultPeerLoginExpiration,
|
||||
|
||||
@@ -281,6 +281,12 @@ const (
|
||||
// AccountMetricsPushDisabled indicates that a user disabled metrics push for the account
|
||||
AccountMetricsPushDisabled Activity = 141
|
||||
|
||||
// AgentNetworkSettingsDeleted indicates that a user deleted the Agent Network account settings, releasing the endpoint
|
||||
AgentNetworkSettingsDeleted Activity = 142
|
||||
|
||||
// CustomDomainValidationExpired indicates that an unvalidated domain registration expired.
|
||||
CustomDomainValidationExpired Activity = 143
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -453,13 +459,15 @@ var activityMap = map[Activity]Code{
|
||||
AgentNetworkBudgetRuleDeleted: {"Agent Network budget rule deleted", "agent_network.budget_rule.delete"},
|
||||
|
||||
AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"},
|
||||
AgentNetworkSettingsDeleted: {"Agent Network settings deleted", "agent_network.settings.delete"},
|
||||
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
CustomDomainValidationExpired: {"Unvalidated domain registration expired", "domain.validation.expire"},
|
||||
}
|
||||
|
||||
// StringCode returns a string code of the activity
|
||||
|
||||
@@ -165,16 +165,16 @@ func (store *Store) Get(ctx context.Context, accountID string, offset, limit int
|
||||
return store.processResult(ctx, events)
|
||||
}
|
||||
|
||||
// Save an event in the SQLite events table end encrypt the "email" element in meta map
|
||||
func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Event, error) {
|
||||
// Save persists an activity event and encrypts deleted user details using the caller's context.
|
||||
func (store *Store) Save(ctx context.Context, event *activity.Event) (*activity.Event, error) {
|
||||
eventCopy := event.Copy()
|
||||
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(eventCopy)
|
||||
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(ctx, eventCopy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventCopy.Meta = meta
|
||||
|
||||
if err = store.db.Create(eventCopy).Error; err != nil {
|
||||
if err = store.db.WithContext(ctx).Create(eventCopy).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Ev
|
||||
|
||||
// saveDeletedUserEmailAndNameInEncrypted if the meta contains email and name then store it in encrypted way and delete
|
||||
// this item from meta map
|
||||
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event) (map[string]any, error) {
|
||||
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(ctx context.Context, event *activity.Event) (map[string]any, error) {
|
||||
email, ok := event.Meta["email"]
|
||||
if !ok {
|
||||
return event.Meta, nil
|
||||
@@ -211,7 +211,7 @@ func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event
|
||||
}
|
||||
deletedUser.Name = encryptedName
|
||||
|
||||
err = store.db.Clauses(clause.OnConflict{
|
||||
err = store.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"email", "name"}),
|
||||
}).Create(deletedUser).Error
|
||||
|
||||
@@ -7,11 +7,49 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestSave_CancellationWhileWaitingForConnection(t *testing.T) {
|
||||
t.Setenv(storeEngineEnv, "sqlite")
|
||||
key, err := crypt.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
store, err := NewSqlStore(context.Background(), t.TempDir(), key)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, store.Close(context.Background())) })
|
||||
db, err := store.db.DB()
|
||||
require.NoError(t, err)
|
||||
conn, err := db.Conn(context.Background())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := store.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(), Activity: activity.CustomDomainValidationExpired,
|
||||
AccountID: "account-id", TargetID: "domain-id", InitiatorID: activity.SystemInitiator,
|
||||
})
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.NoError(t, conn.Close())
|
||||
case <-time.After(time.Second):
|
||||
// Release the connection so a regression cannot leave the writer running.
|
||||
require.NoError(t, conn.Close())
|
||||
assert.ErrorIs(t, <-result, context.DeadlineExceeded)
|
||||
t.Error("activity writes must stop waiting when their deadline expires")
|
||||
}
|
||||
events, err := store.Get(context.Background(), "account-id", 0, 10, true)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, events, "a timed-out write must not persist after the connection is released")
|
||||
}
|
||||
|
||||
func TestNewSqlStore(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
key, _ := crypt.GenerateKey()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
)
|
||||
|
||||
// A user's auto-group change refreshes the destinations of the SSH rules authorizing
|
||||
// that group — they carry the group -> user mapping — even though no peer moved
|
||||
// between groups.
|
||||
func TestAffectedPeers_UserGroupChange_RefreshesSSHAuthorizedDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[3]: {"root"}},
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[3]}})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the SSH rule's destination peers carry the changed group -> user mapping")
|
||||
|
||||
result = resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[4]}})
|
||||
assert.Empty(t, result, "a group no SSH rule authorizes affects nobody")
|
||||
}
|
||||
|
||||
// Creating, blocking or unblocking a user changes the account's allowed-user set, which
|
||||
// reaches only the destinations of the SSH rules that ship it.
|
||||
func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Ships the allowed-user set: an SSH rule naming no groups and no user.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Does not ship it: an SSH rule that authorizes a specific group.
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[2]},
|
||||
Destinations: []string{groupIDs[3]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[0]: {"root"}},
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{AllowedUsersChanged: true})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the destinations of the rule shipping the allowed-user set refresh")
|
||||
}
|
||||
|
||||
// TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated verifies that a JWT
|
||||
// auto-group change updates only the user's peers and the peers linked to the changed
|
||||
// group through policies, instead of fanning out to the whole account.
|
||||
func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "groups"
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"jwt-grp"},
|
||||
Destinations: []string{"jwt-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updUser := updateManager.CreateChannel(ctx, userPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, userPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Groups: []string{"jwt-linked"},
|
||||
}
|
||||
|
||||
t.Run("adding JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
|
||||
t.Run("removing JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
userAuth.Groups = nil
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
}
|
||||
@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
account.InjectProxyPolicies(ctx)
|
||||
|
||||
validated := make(map[string]struct{}, len(account.Peers))
|
||||
for id := range account.Peers {
|
||||
validated[id] = struct{}{}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
@@ -145,7 +146,7 @@ func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) {
|
||||
assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
|
||||
}
|
||||
|
||||
func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string {
|
||||
func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context, policy *types.Policy) string {
|
||||
t.Helper()
|
||||
|
||||
check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
|
||||
@@ -156,7 +157,6 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
|
||||
policy.SourcePostureChecks = []string{check.ID}
|
||||
_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
|
||||
require.NoError(t, err)
|
||||
@@ -168,7 +168,7 @@ func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
|
||||
s := setupRouterScenario(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
checkID := s.createPostureCheckGatedPolicy(t, ctx)
|
||||
checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
|
||||
|
||||
srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
@@ -338,3 +338,61 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T)
|
||||
assert.NotContains(t, affected, second.routerPeerID,
|
||||
"a router in an unrelated network must not be affected by a source-peer change for another resource")
|
||||
}
|
||||
|
||||
// TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer drives the customer path
|
||||
// on the twin store: the source peer's metadata flips a posture verdict on sync,
|
||||
// and the routing peer serving the gated resource must be refreshed in both
|
||||
// directions. Without the flip detection the deny direction takes the nmap
|
||||
// shortcut (the denied peer's map holds no router) and the allow direction
|
||||
// depends on which meta field moved, leaving the routers with a stale map.
|
||||
func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer is the same
|
||||
// scenario with the source peer named directly in the rule: it must receive its posture
|
||||
// checks and have its flips detected exactly like a group member.
|
||||
func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) {
|
||||
runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
|
||||
return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID)
|
||||
})
|
||||
}
|
||||
|
||||
func runPostureFlipRefreshesRoutingPeer(t *testing.T, policyFor func(s *routerScenario) *types.Policy) {
|
||||
t.Helper()
|
||||
|
||||
manager, updateManager := createManagerWithNetworkMapStore(t)
|
||||
s := buildRouterScenario(t, manager, updateManager, true)
|
||||
ctx := context.Background()
|
||||
|
||||
s.createPostureCheckGatedPolicy(t, ctx, policyFor(s))
|
||||
|
||||
source, err := s.manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, s.accountID, s.sourcePeerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
syncWithVersion := func(version string) {
|
||||
meta := source.Meta
|
||||
meta.WtVersion = version
|
||||
_, _, _, _, err := s.manager.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: source.Key, Meta: meta}, s.accountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
syncWithVersion("0.31.0")
|
||||
|
||||
routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
|
||||
unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
|
||||
t.Cleanup(func() {
|
||||
s.updateManager.CloseChannel(ctx, s.routerPeerID)
|
||||
s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
|
||||
})
|
||||
settleAffectedUpdates(routerCh, unrelatedCh)
|
||||
|
||||
syncWithVersion("0.29.0")
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
|
||||
syncWithVersion("0.31.0")
|
||||
peerShouldReceiveUpdate(t, routerCh)
|
||||
peerShouldNotReceiveUpdate(t, unrelatedCh)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario {
|
||||
manager, updateManager, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
return buildRouterScenario(t, manager, updateManager, directRouterPeer)
|
||||
}
|
||||
|
||||
func buildRouterScenario(t *testing.T, manager *DefaultAccountManager, updateManager *update_channel.PeersUpdateManager, directRouterPeer bool) *routerScenario {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
account, err := createAccount(manager, "router_scenario", userID, "")
|
||||
@@ -167,6 +173,23 @@ func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.P
|
||||
}
|
||||
}
|
||||
|
||||
// peerToResourcePolicyByPeer builds a policy naming the source peer directly via
|
||||
// SourceResource rather than through a group.
|
||||
func peerToResourcePolicyByPeer(sourcePeerID, resourceGroupID string) *types.Policy {
|
||||
return &types.Policy{
|
||||
Enabled: true,
|
||||
Name: "peer-to-resource-by-peer",
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
SourceResource: types.Resource{ID: sourcePeerID, Type: types.ResourceTypePeer},
|
||||
Destinations: []string{resourceGroupID},
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// peerToResourcePolicyByResource builds a policy referencing the resource
|
||||
// directly via DestinationResource rather than its group.
|
||||
func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// A user update refreshes only the peers its auto-group change reaches, and a user
|
||||
// update that changes no group membership refreshes nobody.
|
||||
func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
const targetUserID = "target-user"
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
}))
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updTarget := updateManager.CreateChannel(ctx, targetPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, targetPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
t.Run("auto group change updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("update without group changes refreshes nobody", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldNotReceiveUpdate(t, updTarget)
|
||||
peerShouldNotReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", user.Name)
|
||||
})
|
||||
|
||||
t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) {
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.IPv6EnabledGroups = []string{"ug-v6"}
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The reassigned peer refreshes with everyone it can reach: peer2 via the
|
||||
// policy, but not peer3, which shares no group or policy with it.
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) {
|
||||
// An SSH rule that authorizes no group of its own ships the account's
|
||||
// allowed-user set to its destinations, so those are the peers an unblock
|
||||
// reaches — not the whole account.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
blocked.Blocked = true
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, blocked))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
// Same auto-groups as the previous subtest left them, so no group change and
|
||||
// no IPv6 reconciliation interferes: the unblock alone drives the refresh.
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
@@ -83,7 +84,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun
|
||||
hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0
|
||||
hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
|
||||
// the resource<->router bridge can fire for any of these
|
||||
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
|
||||
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject || len(c.UserGroupIDs) > 0 || c.AllowedUsersChanged
|
||||
|
||||
if needsRoutersResources {
|
||||
if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
|
||||
@@ -219,6 +220,18 @@ type Change struct {
|
||||
// (correct when the peer's own attributes changed, e.g. IP/status).
|
||||
OutputPeerIDs []string
|
||||
|
||||
// UserGroupIDs are groups whose USER membership changed (a user's auto-groups),
|
||||
// as opposed to their peer membership. Peers ship the group -> user mapping only
|
||||
// for the groups an SSH rule authorizes, so these refresh the destinations of the
|
||||
// SSH rules authorizing them — independently of any peer moving between groups.
|
||||
UserGroupIDs []string
|
||||
|
||||
// AllowedUsersChanged marks a change to the set of users allowed to open SSH
|
||||
// sessions — a user was created, blocked or unblocked. That set is account-wide,
|
||||
// and peers receive it through the SSH rules that name no group or user of their
|
||||
// own, so those rules' destinations refresh.
|
||||
AllowedUsersChanged bool
|
||||
|
||||
// LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
|
||||
// OPPOSITE side — they are never expanded to their own members. Use this when a
|
||||
// peer's group membership changed: pass the peer in ChangedPeerIDs and its
|
||||
@@ -240,6 +253,8 @@ func (c Change) isEmpty() bool {
|
||||
len(c.Resources) == 0 &&
|
||||
len(c.Networks) == 0 &&
|
||||
len(c.PostureCheckIDs) == 0 &&
|
||||
len(c.UserGroupIDs) == 0 &&
|
||||
!c.AllowedUsersChanged &&
|
||||
len(c.DistributionGroupIDs) == 0 &&
|
||||
len(c.RemovedPeersByGroup) == 0 &&
|
||||
len(c.LinkGroups) == 0 &&
|
||||
@@ -359,6 +374,9 @@ func (r *resolver) walk() {
|
||||
r.collectFromProxyServices()
|
||||
}
|
||||
|
||||
r.collectFromSSHAuthorizedGroups()
|
||||
r.collectFromAllowedUsers()
|
||||
|
||||
r.collectFromChangedRoutes(r.change.Routes)
|
||||
r.collectFromChangedRouters(r.change.Routers)
|
||||
r.collectFromChangedResources(r.change.Resources)
|
||||
@@ -811,6 +829,59 @@ func (r *resolver) collectFromNameServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that
|
||||
// authorize a group whose user membership changed. Those destination peers carry the
|
||||
// group -> user mapping for the groups they authorize, so they refresh even when no
|
||||
// peer moved between groups.
|
||||
func (r *resolver) collectFromSSHAuthorizedGroups() {
|
||||
if len(r.change.UserGroupIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
changed := toSet(r.change.UserGroupIDs)
|
||||
for _, policy := range r.policies() {
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled || rule.Protocol != types.PolicyRuleProtocolNetbirdSSH {
|
||||
continue
|
||||
}
|
||||
if !anyInSet(maps.Keys(rule.AuthorizedGroups), changed) {
|
||||
continue
|
||||
}
|
||||
log.WithContext(r.ctx).Tracef("collectFromSSHAuthorizedGroups: rule %s authorizes a changed user group -> folding its destinations", rule.ID)
|
||||
r.foldPolicySideForRule(policy, rule, sideDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectFromAllowedUsers folds the destinations of the rules that make a peer carry
|
||||
// the account's allowed-user set, for a change to who is in that set.
|
||||
func (r *resolver) collectFromAllowedUsers() {
|
||||
if !r.change.AllowedUsersChanged {
|
||||
return
|
||||
}
|
||||
|
||||
for _, policy := range r.policies() {
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled || !ruleShipsAllowedUsers(rule) {
|
||||
continue
|
||||
}
|
||||
log.WithContext(r.ctx).Tracef("collectFromAllowedUsers: rule %s ships the allowed-user set -> folding its destinations", rule.ID)
|
||||
r.foldPolicySideForRule(policy, rule, sideDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ruleShipsAllowedUsers reports whether a rule makes its destination peers carry the
|
||||
// account's allowed-user set. It mirrors the network map's SSH requirements except for
|
||||
// the destination peer's own SSH flag, which the snapshot does not hold — so it folds a
|
||||
// superset and never misses a peer.
|
||||
func ruleShipsAllowedUsers(rule *types.PolicyRule) bool {
|
||||
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
|
||||
return len(rule.AuthorizedGroups) == 0 && rule.AuthorizedUser == ""
|
||||
}
|
||||
return types.PolicyRuleImpliesLegacySSH(rule)
|
||||
}
|
||||
|
||||
func (r *resolver) collectFromDNSSettings() {
|
||||
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
|
||||
return
|
||||
|
||||
@@ -85,6 +85,8 @@ func TestChangeIsEmpty(t *testing.T) {
|
||||
assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
|
||||
assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
|
||||
assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
|
||||
assert.False(t, Change{UserGroupIDs: []string{"g"}}.isEmpty())
|
||||
assert.False(t, Change{AllowedUsersChanged: true}.isEmpty())
|
||||
}
|
||||
|
||||
func TestPolicyReferencesPostureChecks(t *testing.T) {
|
||||
|
||||
@@ -68,7 +68,10 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection is the
|
||||
// GC-1 guard for UpdateSettings: it must apply the collection toggles while
|
||||
// preserving the immutable Cluster/Subdomain pinned at bootstrap.
|
||||
// preserving the immutable Domain/ProxyAddress assigned at bootstrap. The
|
||||
// request echoes the identity fields back — the PUT convention every other
|
||||
// endpoint follows — and a request echoing anything else is rejected outright
|
||||
// rather than quietly ignored.
|
||||
func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err, "createManager must succeed")
|
||||
@@ -84,43 +87,85 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
|
||||
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
// Creating a provider bootstraps the settings row (cluster + subdomain).
|
||||
// Bootstrap is an explicit settings create; providers have no settings
|
||||
// side effects anymore.
|
||||
seedPrivateProxyCluster(t, am.Store, clusterAddr)
|
||||
before, err := mgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the row")
|
||||
require.Equal(t, clusterAddr, before.ProxyAddress, "proxy address pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Domain, "endpoint allocated at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "openai",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "openai",
|
||||
// A private address: the save-time credential check leaves it
|
||||
// unchecked rather than spending a dummy key against the real
|
||||
// api.openai.com, which the vendor refuses and which would make
|
||||
// this test depend on the runner having egress.
|
||||
UpstreamURL: "https://10.255.255.1",
|
||||
APIKey: "sk-test",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
require.NoError(t, err, "CreateProvider must bootstrap settings")
|
||||
})
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
before, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed after bootstrap")
|
||||
require.Equal(t, clusterAddr, before.Cluster, "cluster pinned at bootstrap")
|
||||
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||
|
||||
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
|
||||
// immutable fields must be ignored.
|
||||
// Flipping the toggles works when the request echoes the assigned
|
||||
// identity. Retention is echoed too: UpdateSettings takes it verbatim, so
|
||||
// omitting it would zero the account's retention.
|
||||
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: "attacker.cluster",
|
||||
Subdomain: "evil",
|
||||
Domain: before.Domain,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
})
|
||||
require.NoError(t, err, "UpdateSettings must succeed")
|
||||
assert.Equal(t, before.Cluster, updated.Cluster, "cluster is immutable and must be preserved")
|
||||
assert.Equal(t, before.Subdomain, updated.Subdomain, "subdomain is immutable and must be preserved")
|
||||
assert.Equal(t, before.Domain, updated.Domain, "domain is immutable and must be preserved")
|
||||
assert.Equal(t, before.ProxyAddress, updated.ProxyAddress, "proxy address is immutable and must be preserved")
|
||||
assert.True(t, updated.EnableLogCollection, "log collection toggle must apply")
|
||||
assert.True(t, updated.EnablePromptCollection, "prompt collection toggle must apply")
|
||||
assert.True(t, updated.RedactPii, "redact toggle must apply")
|
||||
assert.Equal(t, before.AccessLogRetentionDays, updated.AccessLogRetentionDays, "echoed retention must survive")
|
||||
|
||||
// Neither identity field can be smuggled into the row: a hand-rolled
|
||||
// Settings value carrying a different endpoint or proxy address is
|
||||
// rejected, not silently ignored.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
domain string
|
||||
proxyAddress string
|
||||
}{
|
||||
{name: "foreign endpoint", domain: "evil.example.com", proxyAddress: before.ProxyAddress},
|
||||
{name: "foreign proxy address", domain: before.Domain, proxyAddress: "attacker.cluster"},
|
||||
{name: "empty identity echo", domain: "", proxyAddress: ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||
AccountID: accountID,
|
||||
Domain: tc.domain,
|
||||
ProxyAddress: tc.proxyAddress,
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: false,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
})
|
||||
assert.Error(t, err, "a mismatched identity echo must be rejected")
|
||||
assert.ErrorContains(t, err, "immutable", "the rejection must name the immutability rule")
|
||||
})
|
||||
}
|
||||
|
||||
// The rejected updates left the row exactly as the accepted one wrote it.
|
||||
afterRejects, err := mgr.GetSettings(ctx, accountID, adminUserID)
|
||||
require.NoError(t, err, "GetSettings must succeed")
|
||||
assert.True(t, afterRejects.EnablePromptCollection, "a rejected update must not roll back the accepted toggles")
|
||||
|
||||
reloaded, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Cluster, reloaded.Cluster, "persisted cluster unchanged")
|
||||
assert.Equal(t, before.Domain, reloaded.Domain, "persisted domain unchanged")
|
||||
assert.Equal(t, before.ProxyAddress, reloaded.ProxyAddress, "persisted proxy address unchanged")
|
||||
assert.True(t, reloaded.EnablePromptCollection, "persisted prompt collection toggled on")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
networkmap "github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -92,15 +93,28 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
seedPrivateProxyCluster(t, am.Store, clusterAddr)
|
||||
_, err = agentMgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the endpoint")
|
||||
// The bootstrap itself reconciles and queues updates on both channels;
|
||||
// drain them so the fan-out assertions below can only be satisfied by the
|
||||
// operation under test, not by this leftover.
|
||||
drain(clientCh)
|
||||
drain(proxyCh)
|
||||
|
||||
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "openai-test",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
AccountID: accountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "openai-test",
|
||||
// A private address: the save-time credential check leaves it
|
||||
// unchecked rather than spending a dummy key against the real
|
||||
// api.openai.com, which the vendor refuses and which would make
|
||||
// this test depend on the runner having egress.
|
||||
UpstreamURL: "https://10.255.255.1",
|
||||
APIKey: "sk-test-key",
|
||||
Enabled: true,
|
||||
Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}},
|
||||
}, clusterAddr)
|
||||
})
|
||||
require.NoError(t, err, "CreateProvider must succeed")
|
||||
|
||||
policy, err := agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{
|
||||
@@ -210,3 +224,22 @@ func synthZoneRData(sync *nbproto.SyncResponse, clusterAddr, fqdn string) string
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// seedPrivateProxyCluster registers a connected proxy with private capabilities in a
|
||||
// netbird client for clusterAddr, matching what a real deployment looks like
|
||||
// when the account bootstraps: the agent-network gateway service is always
|
||||
// private, so its cluster has to be one that can serve private services.
|
||||
func seedPrivateProxyCluster(t *testing.T, st store.Store, clusterAddr string) {
|
||||
t.Helper()
|
||||
private := true
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, st.SaveProxy(context.Background(), &rpproxy.Proxy{
|
||||
ID: "agent-net-proxy-" + clusterAddr,
|
||||
SessionID: "agent-net-session",
|
||||
ClusterAddress: clusterAddr,
|
||||
LastSeen: now,
|
||||
ConnectedAt: &now,
|
||||
Status: rpproxy.StatusConnected,
|
||||
Capabilities: rpproxy.Capabilities{Private: &private},
|
||||
}), "seeding the proxy cluster must succeed")
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/cache"
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,12 +19,17 @@ var (
|
||||
ErrTokenExpired = errors.New("JWT expired")
|
||||
)
|
||||
|
||||
type SessionStore struct {
|
||||
cache *cache.Cache[string]
|
||||
// TokenCache atomically records used JWTs until their expiration.
|
||||
type TokenCache interface {
|
||||
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
func NewSessionStore(cacheStore store.StoreInterface) *SessionStore {
|
||||
return &SessionStore{cache: cache.New[string](cacheStore)}
|
||||
type SessionStore struct {
|
||||
cache TokenCache
|
||||
}
|
||||
|
||||
func NewSessionStore(cacheStore TokenCache) *SessionStore {
|
||||
return &SessionStore{cache: cacheStore}
|
||||
}
|
||||
|
||||
// RegisterToken records a JWT until its exp time and rejects reuse.
|
||||
@@ -38,20 +40,14 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA
|
||||
}
|
||||
|
||||
key := usedTokenKeyPrefix + hashToken(token)
|
||||
_, err := s.cache.Get(ctx, key)
|
||||
if err == nil {
|
||||
created, err := s.cache.SetNX(ctx, key, usedTokenMarker, ttl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store used token entry: %w", err)
|
||||
}
|
||||
if !created {
|
||||
return ErrTokenAlreadyUsed
|
||||
}
|
||||
|
||||
var notFound *store.NotFound
|
||||
if !errors.As(err, ¬Found) {
|
||||
return fmt.Errorf("failed to lookup used token entry: %w", err)
|
||||
}
|
||||
|
||||
if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil {
|
||||
return fmt.Errorf("failed to store used token entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,39 @@ func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
|
||||
func TestSessionStore_ConcurrentRegistrationAllowsOneCaller(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
const attempts = 100
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, attempts)
|
||||
for range attempts {
|
||||
go func() {
|
||||
<-start
|
||||
results <- s.RegisterToken(ctx, "token", time.Now().Add(time.Hour))
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
succeeded := 0
|
||||
alreadyUsed := 0
|
||||
for range attempts {
|
||||
err := <-results
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case errors.Is(err, ErrTokenAlreadyUsed):
|
||||
alreadyUsed++
|
||||
default:
|
||||
require.NoError(t, err, "concurrent registration returned an unexpected error")
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, succeeded, "exactly one concurrent caller should register the token")
|
||||
assert.Equal(t, attempts-1, alreadyUsed, "every other caller should be rejected as already used")
|
||||
}
|
||||
|
||||
func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -72,6 +106,23 @@ func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) {
|
||||
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour)))
|
||||
}
|
||||
|
||||
type failingTokenCache struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failingTokenCache) SetNX(context.Context, string, string, time.Duration) (bool, error) {
|
||||
return false, f.err
|
||||
}
|
||||
|
||||
func TestSessionStore_CacheErrorIsReturned(t *testing.T) {
|
||||
cacheErr := errors.New("cache unavailable")
|
||||
s := NewSessionStore(failingTokenCache{err: cacheErr})
|
||||
|
||||
err := s.RegisterToken(context.Background(), "token", time.Now().Add(time.Hour))
|
||||
require.Error(t, err, "cache failure should be surfaced to the caller")
|
||||
assert.ErrorIs(t, err, cacheErr, "cache error should be wrapped, not replaced")
|
||||
}
|
||||
|
||||
func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
|
||||
a := hashToken("tokenA")
|
||||
b := hashToken("tokenB")
|
||||
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
gocachestore "github.com/eko/gocache/store/go_cache/v4"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
type goCacheStore struct {
|
||||
store.StoreInterface
|
||||
client *gocache.Cache
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newMemoryStore(maxTimeout, cleanupInterval time.Duration) Store {
|
||||
client := gocache.New(maxTimeout, cleanupInterval)
|
||||
return &goCacheStore{
|
||||
StoreInterface: gocachestore.NewGoCache(client),
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *goCacheStore) SetNX(_ context.Context, key, value string, ttl time.Duration) (bool, error) {
|
||||
// Add only returns an error when a non-expired entry already exists.
|
||||
if err := s.client.Add(key, value, ttl); err != nil {
|
||||
return false, nil //nolint:nilerr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetDel reads the value under key and removes it. go-cache has no native read-and-delete
|
||||
// and releases its own lock between the two calls, so mu holds the pair together and no
|
||||
// value is consumed twice.
|
||||
//
|
||||
// Writes do not take mu: a Set landing mid-pair is lost, since GetDel returns the prior
|
||||
// value and deletes the new one. Callers must write a consumed key only once.
|
||||
func (s *goCacheStore) GetDel(_ context.Context, key string) (string, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
value, found := s.client.Get(key)
|
||||
if !found {
|
||||
return "", false, nil
|
||||
}
|
||||
s.client.Delete(key)
|
||||
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("cached value is %T, not a string", value)
|
||||
}
|
||||
return str, true, nil
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
func TestMemoryStore(t *testing.T) {
|
||||
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
require.NoError(t, err, "couldn't create memory store")
|
||||
|
||||
ctx := context.Background()
|
||||
key, value := "testing", "tested"
|
||||
err = memStore.Set(ctx, key, value)
|
||||
assert.NoError(t, err, "couldn't set testing data")
|
||||
|
||||
result, err := memStore.Get(ctx, key)
|
||||
assert.NoError(t, err, "couldn't get testing data")
|
||||
assert.Equal(t, value, result, "value returned doesn't match testing data")
|
||||
|
||||
created, err := memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
|
||||
require.NoError(t, err, "couldn't conditionally set testing data")
|
||||
require.True(t, created, "first conditional set should create the entry")
|
||||
|
||||
created, err = memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
|
||||
require.NoError(t, err, "couldn't conditionally check testing data")
|
||||
require.False(t, created, "second conditional set should not replace the entry")
|
||||
|
||||
// test expiration
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, err = memStore.Get(ctx, key)
|
||||
assert.Error(t, err, "value should not be found")
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetDel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
newStore := func(t *testing.T) cache.Store {
|
||||
t.Helper()
|
||||
memStore, err := cache.NewStore(ctx, time.Minute, time.Minute, 100)
|
||||
require.NoError(t, err, "couldn't create memory store")
|
||||
|
||||
return memStore
|
||||
}
|
||||
|
||||
const (
|
||||
key = "consume"
|
||||
value = "verifier"
|
||||
)
|
||||
|
||||
t.Run("exactly one concurrent caller consumes the key", func(t *testing.T) {
|
||||
memStore := newStore(t)
|
||||
require.NoError(t, memStore.Set(ctx, key, value), "couldn't set testing data")
|
||||
|
||||
assertGetDelConsumedOnce(ctx, t, []cache.Store{memStore}, key, value)
|
||||
assertGetDelMisses(ctx, t, memStore, key)
|
||||
})
|
||||
|
||||
t.Run("missing key is not an error", func(t *testing.T) {
|
||||
assertGetDelMisses(ctx, t, newStore(t), "never-set")
|
||||
})
|
||||
|
||||
t.Run("expired key is not found", func(t *testing.T) {
|
||||
memStore := newStore(t)
|
||||
_, err := memStore.SetNX(ctx, key, value, 50*time.Millisecond)
|
||||
require.NoError(t, err, "couldn't set testing data")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assertGetDelMisses(ctx, t, memStore, key)
|
||||
})
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
redisstore "github.com/eko/gocache/store/redis/v4"
|
||||
"github.com/redis/go-redis/v9"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type redisStore struct {
|
||||
store.StoreInterface
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store, error) {
|
||||
options, err := redis.ParseURL(redisEnvAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing redis cache url: %s", err)
|
||||
}
|
||||
|
||||
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
|
||||
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
|
||||
options.MaxActiveConns = maxConn
|
||||
options.ConnMaxIdleTime = 30 * time.Minute
|
||||
options.ConnMaxLifetime = 0
|
||||
options.PoolTimeout = 10 * time.Second
|
||||
redisClient := redis.NewClient(options)
|
||||
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = redisClient.Ping(subCtx).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
|
||||
|
||||
return &redisStore{
|
||||
StoreInterface: redisstore.NewRedis(redisClient),
|
||||
client: redisClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
|
||||
return s.client.SetNX(ctx, key, value, ttl).Result()
|
||||
}
|
||||
|
||||
func (s *redisStore) GetDel(ctx context.Context, key string) (string, bool, error) {
|
||||
value, err := s.client.GetDel(ctx, key).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
func startRedis(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
|
||||
require.NoError(t, err, "couldn't start redis container")
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := redisContainer.Terminate(ctx); err != nil {
|
||||
t.Logf("failed to terminate container: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
redisURL, err := redisContainer.ConnectionString(ctx)
|
||||
require.NoError(t, err, "couldn't get connection string")
|
||||
|
||||
t.Setenv(cache.RedisStoreEnvVar, redisURL)
|
||||
return redisURL
|
||||
}
|
||||
|
||||
func newRedisStore(t *testing.T) cache.Store {
|
||||
t.Helper()
|
||||
|
||||
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
return redisStore
|
||||
}
|
||||
|
||||
func TestRedisStoreConnectionFailure(t *testing.T) {
|
||||
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
|
||||
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
|
||||
require.Error(t, err, "getting redis cache store should return error")
|
||||
}
|
||||
|
||||
func TestRedisStoreConnectionSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
redisURL := startRedis(t)
|
||||
redisStore := newRedisStore(t)
|
||||
|
||||
key, value := "testing", "tested"
|
||||
err := redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
|
||||
assert.NoError(t, err, "couldn't set testing data")
|
||||
|
||||
result, err := redisStore.Get(ctx, key)
|
||||
assert.NoError(t, err, "couldn't get testing data")
|
||||
assert.Equal(t, value, result, "value returned doesn't match testing data")
|
||||
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
require.NoError(t, err, "parsing redis cache url")
|
||||
|
||||
redisClient := redis.NewClient(options)
|
||||
r, err := redisClient.Get(ctx, key).Result()
|
||||
assert.NoError(t, err, "couldn't get testing data from redis")
|
||||
assert.Equal(t, value, r, "value returned from redis doesn't match testing data")
|
||||
|
||||
// test expiration
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, err = redisStore.Get(ctx, key)
|
||||
assert.Error(t, err, "value should not be found")
|
||||
}
|
||||
|
||||
func TestRedisStoreSetNX(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
redisURL := startRedis(t)
|
||||
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
|
||||
|
||||
const (
|
||||
key = "conditional"
|
||||
value = "tested"
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
type setResult struct {
|
||||
created bool
|
||||
err error
|
||||
}
|
||||
results := make(chan setResult, 2)
|
||||
for _, cacheStore := range []cache.Store{redisStore, secondRedisStore} {
|
||||
go func() {
|
||||
<-start
|
||||
created, err := cacheStore.SetNX(ctx, key, value, time.Minute)
|
||||
results <- setResult{created: created, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
created := 0
|
||||
for range 2 {
|
||||
result := <-results
|
||||
require.NoError(t, result.err, "conditional redis set failed")
|
||||
if result.created {
|
||||
created++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, created, "expected exactly one redis client to create the entry")
|
||||
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
require.NoError(t, err, "parsing redis cache url")
|
||||
|
||||
ttl, err := redis.NewClient(options).PTTL(ctx, key).Result()
|
||||
require.NoError(t, err, "couldn't read entry TTL")
|
||||
require.Positive(t, ttl, "created entry should have a positive TTL")
|
||||
}
|
||||
|
||||
func TestRedisStoreGetDel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
startRedis(t)
|
||||
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
|
||||
|
||||
const (
|
||||
key = "consume"
|
||||
value = "verifier"
|
||||
)
|
||||
|
||||
t.Run("exactly one caller across independent clients consumes the key", func(t *testing.T) {
|
||||
// A generous TTL: the key is consumed explicitly, so expiry racing the
|
||||
// concurrent callers would only make the test flaky on a loaded runner.
|
||||
err := redisStore.Set(ctx, key, value, store.WithExpiration(time.Minute))
|
||||
require.NoError(t, err, "couldn't set value to consume")
|
||||
|
||||
assertGetDelConsumedOnce(ctx, t, []cache.Store{redisStore, secondRedisStore}, key, value)
|
||||
assertGetDelMisses(ctx, t, secondRedisStore, key)
|
||||
})
|
||||
|
||||
t.Run("missing key is not an error", func(t *testing.T) {
|
||||
assertGetDelMisses(ctx, t, redisStore, "never-set")
|
||||
})
|
||||
|
||||
t.Run("expired key is not found", func(t *testing.T) {
|
||||
err := redisStore.Set(ctx, key, value, store.WithExpiration(50*time.Millisecond))
|
||||
require.NoError(t, err, "couldn't set value to consume")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assertGetDelMisses(ctx, t, redisStore, key)
|
||||
})
|
||||
}
|
||||
Vendored
+11
-36
@@ -2,17 +2,10 @@ package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
gocache_store "github.com/eko/gocache/store/go_cache/v4"
|
||||
redis_store "github.com/eko/gocache/store/redis/v4"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
"github.com/redis/go-redis/v9"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// RedisStoreEnvVar is the environment variable that determines if a redis store should be used.
|
||||
@@ -31,15 +24,23 @@ const (
|
||||
DefaultStoreMaxConn = 1000
|
||||
)
|
||||
|
||||
// Store extends the shared cache interface with conditional and consuming operations.
|
||||
type Store interface {
|
||||
store.StoreInterface
|
||||
// SetNX stores a value with a TTL only when the key does not exist.
|
||||
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
|
||||
// GetDel reads a value and removes it, so only one caller can consume a key.
|
||||
GetDel(ctx context.Context, key string) (value string, found bool, err error)
|
||||
}
|
||||
|
||||
// NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar
|
||||
// to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store.
|
||||
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (store.StoreInterface, error) {
|
||||
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (Store, error) {
|
||||
redisAddr := GetAddrFromEnv()
|
||||
if redisAddr != "" {
|
||||
return getRedisStore(ctx, redisAddr, maxConn)
|
||||
}
|
||||
goc := gocache.New(maxTimeout, cleanupInterval)
|
||||
return gocache_store.NewGoCache(goc), nil
|
||||
return newMemoryStore(maxTimeout, cleanupInterval), nil
|
||||
}
|
||||
|
||||
// GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart.
|
||||
@@ -50,29 +51,3 @@ func GetAddrFromEnv() string {
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (store.StoreInterface, error) {
|
||||
options, err := redis.ParseURL(redisEnvAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing redis cache url: %s", err)
|
||||
}
|
||||
|
||||
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
|
||||
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
|
||||
options.MaxActiveConns = maxConn
|
||||
options.ConnMaxIdleTime = 30 * time.Minute
|
||||
options.ConnMaxLifetime = 0
|
||||
options.PoolTimeout = 10 * time.Second
|
||||
redisClient := redis.NewClient(options)
|
||||
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = redisClient.Ping(subCtx).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
|
||||
|
||||
return redis_store.NewRedis(redisClient), nil
|
||||
}
|
||||
|
||||
+39
-87
@@ -3,101 +3,53 @@ package cache_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
func TestMemoryStore(t *testing.T) {
|
||||
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't create memory store: %s", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key, value := "testing", "tested"
|
||||
err = memStore.Set(ctx, key, value)
|
||||
if err != nil {
|
||||
t.Errorf("couldn't set testing data: %s", err)
|
||||
}
|
||||
result, err := memStore.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("couldn't get testing data: %s", err)
|
||||
}
|
||||
if value != result.(string) {
|
||||
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
|
||||
}
|
||||
// test expiration
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, err = memStore.Get(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("value should not be found")
|
||||
}
|
||||
}
|
||||
func assertGetDelConsumedOnce(ctx context.Context, t *testing.T, stores []cache.Store, key, value string) {
|
||||
t.Helper()
|
||||
|
||||
func TestRedisStoreConnectionFailure(t *testing.T) {
|
||||
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
|
||||
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
|
||||
if err == nil {
|
||||
t.Fatal("getting redis cache store should return error")
|
||||
}
|
||||
}
|
||||
const getDelAttempts = 64
|
||||
|
||||
func TestRedisStoreConnectionSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't start redis container: %s", err)
|
||||
type getDelResult struct {
|
||||
value string
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
defer func() {
|
||||
if err := redisContainer.Terminate(ctx); err != nil {
|
||||
t.Logf("failed to terminate container: %s", err)
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan getDelResult, getDelAttempts)
|
||||
for i := range getDelAttempts {
|
||||
cacheStore := stores[i%len(stores)]
|
||||
go func() {
|
||||
<-start
|
||||
value, found, err := cacheStore.GetDel(ctx, key)
|
||||
results <- getDelResult{value: value, found: found, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
consumers := 0
|
||||
for range getDelAttempts {
|
||||
result := <-results
|
||||
require.NoError(t, result.err, "concurrent GetDel failed")
|
||||
if !result.found {
|
||||
continue
|
||||
}
|
||||
}()
|
||||
redisURL, err := redisContainer.ConnectionString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't get connection string: %s", err)
|
||||
}
|
||||
|
||||
t.Setenv(cache.RedisStoreEnvVar, redisURL)
|
||||
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't create redis store: %s", err)
|
||||
}
|
||||
|
||||
key, value := "testing", "tested"
|
||||
err = redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
|
||||
if err != nil {
|
||||
t.Errorf("couldn't set testing data: %s", err)
|
||||
}
|
||||
result, err := redisStore.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("couldn't get testing data: %s", err)
|
||||
}
|
||||
if value != result.(string) {
|
||||
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
|
||||
}
|
||||
|
||||
options, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
t.Errorf("parsing redis cache url: %s", err)
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(options)
|
||||
r, e := redisClient.Get(ctx, key).Result()
|
||||
if e != nil {
|
||||
t.Errorf("couldn't get testing data from redis: %s", e)
|
||||
}
|
||||
if value != r {
|
||||
t.Errorf("value returned from redis doesn't match testing data, got %s, expected %s", r, value)
|
||||
}
|
||||
// test expiration
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, err = redisStore.Get(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("value should not be found")
|
||||
consumers++
|
||||
require.Equal(t, value, result.value, "consumed value doesn't match testing data")
|
||||
}
|
||||
require.Equal(t, 1, consumers, "expected exactly one consumer")
|
||||
}
|
||||
|
||||
func assertGetDelMisses(ctx context.Context, t *testing.T, cacheStore cache.Store, key string) {
|
||||
t.Helper()
|
||||
|
||||
value, found, err := cacheStore.GetDel(ctx, key)
|
||||
require.NoError(t, err, "GetDel on a missing key should not error")
|
||||
require.False(t, found, "GetDel should not find key %q, got value %q", key, value)
|
||||
require.Empty(t, value, "GetDel should return an empty value when not found")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -234,7 +234,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
}
|
||||
|
||||
+26
-15
@@ -61,23 +61,34 @@ func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userI
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// StoreEvent records an activity, waiting for expiration events before cleanup can stop.
|
||||
func (am *DefaultAccountManager) StoreEvent(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
|
||||
if isEnabled() {
|
||||
go func() {
|
||||
_, err := am.eventStore.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activityID.(activity.Activity),
|
||||
InitiatorID: initiatorID,
|
||||
TargetID: targetID,
|
||||
AccountID: accountID,
|
||||
Meta: meta,
|
||||
})
|
||||
if err != nil {
|
||||
// todo add metric
|
||||
log.WithContext(ctx).Errorf("received an error while storing an activity event, error: %s", err)
|
||||
}
|
||||
}()
|
||||
if !isEnabled() {
|
||||
return
|
||||
}
|
||||
eventStore := am.eventStore
|
||||
save := func(ctx context.Context) {
|
||||
_, err := eventStore.Save(ctx, &activity.Event{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Activity: activityID.(activity.Activity),
|
||||
InitiatorID: initiatorID,
|
||||
TargetID: targetID,
|
||||
AccountID: accountID,
|
||||
Meta: meta,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("received an error while storing an activity event, error: %s", err)
|
||||
}
|
||||
}
|
||||
if activityID == activity.CustomDomainValidationExpired {
|
||||
// The domain is already deleted; shutdown must allow its audit write to finish.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
save(ctx)
|
||||
return
|
||||
}
|
||||
// Request cancellation must not discard the audit record of a completed operation.
|
||||
go save(context.WithoutCancel(ctx))
|
||||
}
|
||||
|
||||
type eventUserInfo struct {
|
||||
|
||||
@@ -6,10 +6,52 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
func TestStoreEvent_CanceledContext(t *testing.T) {
|
||||
t.Setenv("NB_EVENT_ACTIVITY_LOG_ENABLED", "true")
|
||||
t.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", "sqlite")
|
||||
for _, code := range []activity.Activity{activity.CustomDomainValidationExpired, activity.DomainAdded} {
|
||||
t.Run(code.StringCode(), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key, err := crypt.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
eventStore, err := activitystore.NewSqlStore(context.Background(), dir, key)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, eventStore.Close(context.Background())) })
|
||||
manager := &DefaultAccountManager{eventStore: eventStore}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
// The operation already succeeded when shutdown or the request cancels its context.
|
||||
manager.StoreEvent(ctx, activity.SystemInitiator, "domain-id", "account-id",
|
||||
code, map[string]any{"domain": "expired.example.com"})
|
||||
if code != activity.CustomDomainValidationExpired {
|
||||
require.Eventually(t, func() bool {
|
||||
events, err := eventStore.Get(context.Background(), "account-id", 0, 10, true)
|
||||
return err == nil && len(events) == 1
|
||||
}, time.Second, time.Millisecond, "asynchronous events must survive request cancellation")
|
||||
}
|
||||
require.NoError(t, eventStore.Close(context.Background()))
|
||||
|
||||
reopened, err := activitystore.NewSqlStore(context.Background(), dir, key)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, reopened.Close(context.Background())) })
|
||||
events, err := reopened.Get(context.Background(), "account-id", 0, 10, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1, "the event must be persisted before shutdown closes the store")
|
||||
assert.Equal(t, code, events[0].Activity, "persist the requested activity")
|
||||
assert.Equal(t, "domain-id", events[0].TargetID, "retain the registration ID")
|
||||
assert.Equal(t, "expired.example.com", events[0].Meta["domain"], "retain the domain name")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateAndStoreEvents(t *testing.T, manager *DefaultAccountManager, typ activity.Activity, initiatorID, targetID,
|
||||
accountID string, count int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -3,6 +3,7 @@ package geolocation
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
@@ -21,6 +22,8 @@ const (
|
||||
geoLiteCitySha256ZipURL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City-CSV/download?suffix=zip.sha256"
|
||||
geoLiteCityMMDB = "GeoLite2-City.mmdb"
|
||||
geoLiteCityCSV = "GeoLite2-City-Locations-en.csv"
|
||||
|
||||
geonamesCsvFields = 14
|
||||
)
|
||||
|
||||
// loadGeolocationDatabases loads the MaxMind databases.
|
||||
@@ -160,6 +163,10 @@ func loadGeonamesCsv(filepath string) ([]GeoNames, error) {
|
||||
if index == 0 {
|
||||
continue
|
||||
}
|
||||
if len(record) < geonamesCsvFields {
|
||||
return nil, fmt.Errorf("geonames csv record %d has %d fields, want at least %d", index, len(record), geonamesCsvFields)
|
||||
}
|
||||
|
||||
geoNameID, err := strconv.Atoi(record[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -242,7 +242,11 @@ func getDatabaseFilename(ctx context.Context, databaseURL string, filenamePatter
|
||||
// strip suffixes that may be nested, such as .tar.gz
|
||||
basename := strings.SplitN(filename, ".", 2)[0]
|
||||
// get date version from basename
|
||||
date := strings.SplitN(basename, "_", 2)[1]
|
||||
parts := strings.SplitN(basename, "_", 2)
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
return "", fmt.Errorf("unexpected database filename %q: missing date suffix", filename)
|
||||
}
|
||||
date := parts[1]
|
||||
// format db as "GeoLite2-Cities-{maxmind|geonames}_{DATE}.{mmdb|db}"
|
||||
databaseFilename := filepath.Base(strings.Replace(filenamePattern, "*", date, 1))
|
||||
|
||||
|
||||
@@ -184,7 +184,12 @@ func getFilenameFromURL(url string) (string, error) {
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, params, err := mime.ParseMediaType(resp.Header["Content-Disposition"][0])
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return "", fmt.Errorf("no Content-Disposition header in response from %s", url)
|
||||
}
|
||||
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+114
-6
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -93,14 +95,14 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
|
||||
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
eventsToStore = append(eventsToStore, events...)
|
||||
|
||||
newGroup.PublicID = xid.New().String()
|
||||
|
||||
if err := transaction.CreateGroup(ctx, newGroup); err != nil {
|
||||
return status.Errorf(status.Internal, "failed to create group: %v", err)
|
||||
}
|
||||
|
||||
for _, peerID := range newGroup.Peers {
|
||||
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil {
|
||||
return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err)
|
||||
}
|
||||
if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, newGroup.Peers, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
|
||||
@@ -158,6 +160,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
|
||||
return err
|
||||
}
|
||||
|
||||
newGroup.PublicID = oldGroup.PublicID
|
||||
|
||||
if err = transaction.UpdateGroup(ctx, newGroup); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -194,6 +198,9 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
|
||||
|
||||
// syncGroupMembership applies the peer membership delta for a group within a transaction.
|
||||
func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error {
|
||||
if err := validateGroupPeers(ctx, transaction, accountID, peersToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, peerID := range peersToAdd {
|
||||
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
|
||||
return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err)
|
||||
@@ -207,6 +214,25 @@ func syncGroupMembership(ctx context.Context, transaction store.Store, accountID
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGroupPeers(ctx context.Context, transaction store.Store, accountID string, peerIDs []string) error {
|
||||
if len(peerIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
peers, err := transaction.GetPeersByIDs(ctx, store.LockingStrengthNone, accountID, peerIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, peerID := range peerIDs {
|
||||
if _, ok := peers[peerID]; !ok {
|
||||
return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateGroups adds new groups to the account.
|
||||
// Note: This function does not acquire the global lock.
|
||||
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
|
||||
@@ -235,6 +261,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
newGroup.AccountID = accountID
|
||||
newGroup.PublicID = xid.New().String()
|
||||
|
||||
if err = transaction.CreateGroup(ctx, newGroup); err != nil {
|
||||
return err
|
||||
@@ -327,6 +354,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
|
||||
|
||||
newGroup.AccountID = accountID
|
||||
|
||||
oldGroup, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, newGroup.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newGroup.PublicID = oldGroup.PublicID
|
||||
|
||||
if err := transaction.UpdateGroup(ctx, newGroup); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -341,7 +374,6 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
|
||||
|
||||
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
|
||||
|
||||
var err error
|
||||
snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
|
||||
return err
|
||||
})
|
||||
@@ -528,7 +560,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
|
||||
change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}}
|
||||
|
||||
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
|
||||
if err := syncGroupMembership(ctx, transaction, accountID, groupID, []string{peerID}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -734,6 +766,22 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty
|
||||
return &GroupLinkError{"network router", linkedRouter.ID}
|
||||
}
|
||||
|
||||
if isLinked, linkedService := isGroupLinkedToReverseProxyService(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||
return &GroupLinkError{"reverse proxy service", linkedService.Domain}
|
||||
}
|
||||
|
||||
if isLinked, linkedPolicy := isGroupLinkedToAgentNetworkPolicy(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||
return &GroupLinkError{"agent network policy", linkedPolicy.Name}
|
||||
}
|
||||
|
||||
isLinked, linkedRule, err := isGroupLinkedToAgentNetworkBudgetRule(ctx, transaction, group.AccountID, group.ID)
|
||||
if err != nil {
|
||||
return status.Errorf(status.Internal, "failed to check agent network budget rules")
|
||||
}
|
||||
if isLinked {
|
||||
return &GroupLinkError{"agent network budget rule", linkedRule.Name}
|
||||
}
|
||||
|
||||
return checkGroupLinkedToSettings(ctx, transaction, group)
|
||||
}
|
||||
|
||||
@@ -865,6 +913,66 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isGroupLinkedToReverseProxyService checks if a group is used as an access group
|
||||
// of a private reverse proxy service or as a bearer-auth distribution group.
|
||||
func isGroupLinkedToReverseProxyService(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *service.Service) {
|
||||
services, err := transaction.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("error retrieving reverse proxy services while checking group linkage: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
if svc.Private && slices.Contains(svc.AccessGroups, groupID) {
|
||||
return true, svc
|
||||
}
|
||||
if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled && slices.Contains(svc.Auth.BearerAuth.DistributionGroups, groupID) {
|
||||
return true, svc
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isGroupLinkedToAgentNetworkPolicy checks if a group is used as a source group by any
|
||||
// agent network policy in the account.
|
||||
func isGroupLinkedToAgentNetworkPolicy(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.Policy) {
|
||||
policies, err := transaction.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("error retrieving agent network policies while checking group linkage: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
if policy == nil {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(policy.SourceGroups, groupID) {
|
||||
return true, policy
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isGroupLinkedToAgentNetworkBudgetRule checks if a group is a target of any
|
||||
// account-level agent network budget rule.
|
||||
func isGroupLinkedToAgentNetworkBudgetRule(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.AccountBudgetRule, error) {
|
||||
rules, err := transaction.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("error retrieving agent network budget rules while checking group linkage: %v", err)
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(rule.TargetGroups, groupID) {
|
||||
return true, rule, nil
|
||||
}
|
||||
}
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
|
||||
// It fetches each collection once and checks all groupIDs against them in memory.
|
||||
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +27,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Allocate IPv6 subnet for the account
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet()
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account))
|
||||
|
||||
// Create setup key
|
||||
|
||||
@@ -11,13 +11,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/networks"
|
||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||
@@ -125,6 +127,26 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
|
||||
"grp-for-integration",
|
||||
"only service users with admin power can delete integration group",
|
||||
},
|
||||
{
|
||||
"agent network policy",
|
||||
"grp-for-agent-network-policy",
|
||||
"agent network policy",
|
||||
},
|
||||
{
|
||||
"agent network budget rule",
|
||||
"grp-for-agent-network-budget-rule",
|
||||
"agent network budget rule",
|
||||
},
|
||||
{
|
||||
"reverse proxy private service access group",
|
||||
"grp-for-rp-private",
|
||||
"reverse proxy service",
|
||||
},
|
||||
{
|
||||
"reverse proxy bearer distribution group",
|
||||
"grp-for-rp-bearer",
|
||||
"reverse proxy service",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -135,6 +157,16 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
group, getErr := am.GetGroup(context.Background(), account.Id, testCase.groupID, groupAdminUserID)
|
||||
if getErr != nil {
|
||||
t.Errorf("group %s should still exist after failed deletion: %s", testCase.groupID, getErr)
|
||||
return
|
||||
}
|
||||
if group == nil {
|
||||
t.Errorf("group %s was deleted despite the failed deletion", testCase.groupID)
|
||||
return
|
||||
}
|
||||
|
||||
var sErr *status.Error
|
||||
if errors.As(err, &sErr) {
|
||||
if sErr.Message != testCase.expectedReason {
|
||||
@@ -218,6 +250,23 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
||||
groupIDs: []string{"grp-for-integration"},
|
||||
expectedReasons: []string{"only service users with admin power can delete integration group"},
|
||||
},
|
||||
{
|
||||
name: "agent network policy",
|
||||
groupIDs: []string{"grp-for-agent-network-policy"},
|
||||
expectedReasons: []string{"agent network policy"},
|
||||
},
|
||||
{
|
||||
name: "agent network budget rule",
|
||||
groupIDs: []string{"grp-for-agent-network-budget-rule"},
|
||||
expectedReasons: []string{"agent network budget rule"},
|
||||
expectedNotDeleted: []string{"grp-for-agent-network-budget-rule"},
|
||||
},
|
||||
{
|
||||
name: "reverse proxy services",
|
||||
groupIDs: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||
expectedReasons: []string{"reverse proxy service", "reverse proxy service"},
|
||||
expectedNotDeleted: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||
},
|
||||
{
|
||||
name: "successfully delete multiple groups",
|
||||
groupIDs: []string{"group-1", "group-2"},
|
||||
@@ -285,6 +334,65 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_DeleteGroupUnlinkedFromReverseProxyService(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err, "Failed to create account manager")
|
||||
|
||||
_, account, err := initTestGroupAccount(am)
|
||||
require.NoError(t, err, "Failed to init testing account")
|
||||
|
||||
deletableGroups := []*types.Group{
|
||||
{
|
||||
ID: "grp-rp-bearer-disabled",
|
||||
AccountID: account.Id,
|
||||
Name: "Group only in a disabled bearer auth",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
},
|
||||
{
|
||||
ID: "grp-rp-nonprivate-access",
|
||||
AccountID: account.Id,
|
||||
Name: "Group only in a non-private service's access groups",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
},
|
||||
}
|
||||
for _, group := range deletableGroups {
|
||||
require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, group))
|
||||
}
|
||||
|
||||
// Disabled bearer auth and stale access groups on a non-private service
|
||||
// are inert configuration and must not block group deletion.
|
||||
services := []*rpservice.Service{
|
||||
{
|
||||
ID: "rp-svc-bearer-disabled",
|
||||
AccountID: account.Id,
|
||||
Domain: "bearer-disabled.services.example.com",
|
||||
Auth: rpservice.AuthConfig{
|
||||
BearerAuth: &rpservice.BearerAuthConfig{
|
||||
Enabled: false,
|
||||
DistributionGroups: []string{"grp-rp-bearer-disabled"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "rp-svc-nonprivate-access",
|
||||
AccountID: account.Id,
|
||||
Domain: "nonprivate.services.example.com",
|
||||
Private: false,
|
||||
AccessGroups: []string{"grp-rp-nonprivate-access"},
|
||||
},
|
||||
}
|
||||
for _, svc := range services {
|
||||
require.NoError(t, am.Store.CreateService(context.Background(), svc))
|
||||
}
|
||||
|
||||
for _, group := range deletableGroups {
|
||||
err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, group.ID)
|
||||
assert.NoError(t, err, "group %s is not referenced by an active reverse proxy gate and should be deletable", group.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
|
||||
am, _, err := createManager(t)
|
||||
require.NoError(t, err)
|
||||
@@ -406,6 +514,38 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
||||
Peers: make([]string, 0),
|
||||
}
|
||||
|
||||
groupForAgentNetworkPolicy := &types.Group{
|
||||
ID: "grp-for-agent-network-policy",
|
||||
AccountID: "account-id",
|
||||
Name: "Group for agent network policies",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
}
|
||||
|
||||
groupForAgentNetworkBudgetRule := &types.Group{
|
||||
ID: "grp-for-agent-network-budget-rule",
|
||||
AccountID: "account-id",
|
||||
Name: "Group for agent network budget rules",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
}
|
||||
|
||||
groupForRPPrivate := &types.Group{
|
||||
ID: "grp-for-rp-private",
|
||||
AccountID: "account-id",
|
||||
Name: "Group for private reverse proxy service",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
}
|
||||
|
||||
groupForRPBearer := &types.Group{
|
||||
ID: "grp-for-rp-bearer",
|
||||
AccountID: "account-id",
|
||||
Name: "Group for bearer reverse proxy service",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: make([]string, 0),
|
||||
}
|
||||
|
||||
routeResource := &route.Route{
|
||||
ID: "example route",
|
||||
Groups: []string{groupForRoute.ID},
|
||||
@@ -461,6 +601,81 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkPolicy)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkBudgetRule)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPPrivate)
|
||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPBearer)
|
||||
|
||||
agentNetworkPolicy := &agentNetworkTypes.Policy{
|
||||
ID: "example agent network policy",
|
||||
AccountID: accountID,
|
||||
Name: "Example agent network policy",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{groupForAgentNetworkPolicy.ID},
|
||||
}
|
||||
if err := am.Store.SaveAgentNetworkPolicy(context.Background(), agentNetworkPolicy); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
budgetRuleDecoy := agentNetworkTypes.NewAccountBudgetRule(accountID)
|
||||
budgetRuleDecoy.Name = "Unrelated agent network budget rule"
|
||||
budgetRuleDecoy.TargetGroups = []string{"unrelated-group"}
|
||||
if err := am.Store.SaveAgentNetworkBudgetRule(context.Background(), budgetRuleDecoy); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
budgetRule := agentNetworkTypes.NewAccountBudgetRule(accountID)
|
||||
budgetRule.Name = "Example agent network budget rule"
|
||||
budgetRule.TargetGroups = []string{groupForAgentNetworkBudgetRule.ID}
|
||||
if err := am.Store.SaveAgentNetworkBudgetRule(context.Background(), budgetRule); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// The decoy services are created first so the linkage check has to scan
|
||||
// past services that do not reference the groups under test.
|
||||
rpServices := []*rpservice.Service{
|
||||
{
|
||||
ID: "rp-svc-private-decoy",
|
||||
AccountID: accountID,
|
||||
Domain: "private-decoy.services.example.com",
|
||||
Private: true,
|
||||
AccessGroups: []string{"unrelated-group"},
|
||||
},
|
||||
{
|
||||
ID: "rp-svc-bearer-decoy",
|
||||
AccountID: accountID,
|
||||
Domain: "bearer-decoy.services.example.com",
|
||||
Auth: rpservice.AuthConfig{
|
||||
BearerAuth: &rpservice.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{"unrelated-group"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "rp-svc-private",
|
||||
AccountID: accountID,
|
||||
Domain: "private.services.example.com",
|
||||
Private: true,
|
||||
AccessGroups: []string{groupForRPPrivate.ID},
|
||||
},
|
||||
{
|
||||
ID: "rp-svc-bearer",
|
||||
AccountID: accountID,
|
||||
Domain: "bearer.services.example.com",
|
||||
Auth: rpservice.AuthConfig{
|
||||
BearerAuth: &rpservice.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{groupForRPBearer.ID},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, svc := range rpServices {
|
||||
if err := am.Store.CreateService(context.Background(), svc); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
acc, err := am.Store.GetAccount(context.Background(), account.Id)
|
||||
if err != nil {
|
||||
@@ -1065,3 +1280,82 @@ func Test_IncrementNetworkSerial(t *testing.T) {
|
||||
|
||||
assert.Equal(t, totalPeers, int(account.Network.Serial), "Expected %d serial increases in account %s, got %d", totalPeers, accountID, account.Network.Serial)
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) {
|
||||
manager, _, account, peer1, _, _ := setupNetworkMapTest(t)
|
||||
|
||||
otherAccount, err := createAccount(manager, "other_account", "other_user", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
foreignPeer := &peer2.Peer{
|
||||
ID: "foreign-peer",
|
||||
AccountID: otherAccount.Id,
|
||||
Key: "foreign-key",
|
||||
DNSLabel: "foreign-peer",
|
||||
IP: uint32ToIP(1),
|
||||
}
|
||||
require.NoError(t, manager.Store.AddPeerToAccount(context.Background(), foreignPeer))
|
||||
|
||||
assertRejected := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
require.Error(t, err)
|
||||
s, ok := status.FromError(err)
|
||||
require.True(t, ok, "expected status error, got %v", err)
|
||||
assert.Equal(t, status.InvalidArgument, s.Type(), "peer outside the account should be rejected as invalid argument")
|
||||
}
|
||||
|
||||
t.Run("create rejects foreign peer", func(t *testing.T) {
|
||||
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
|
||||
Name: "foreign",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Peers: []string{peer1.ID, foreignPeer.ID},
|
||||
})
|
||||
assertRejected(t, err)
|
||||
|
||||
_, err = manager.Store.GetGroupByName(context.Background(), store.LockingStrengthNone, account.Id, "foreign")
|
||||
assert.Error(t, err, "rejected create must not persist the group")
|
||||
})
|
||||
|
||||
t.Run("update rejects foreign and unknown peers", func(t *testing.T) {
|
||||
group := &types.Group{ID: "own", Name: "own", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
|
||||
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
|
||||
|
||||
group.Peers = []string{peer1.ID, foreignPeer.ID}
|
||||
assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
|
||||
|
||||
group.Peers = []string{peer1.ID, "does-not-exist"}
|
||||
assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
|
||||
|
||||
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected updates must not change membership")
|
||||
})
|
||||
|
||||
t.Run("update tolerates and drops pre-existing dangling members", func(t *testing.T) {
|
||||
group := &types.Group{ID: "polluted", Name: "polluted", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
|
||||
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
|
||||
require.NoError(t, manager.Store.AddPeerToGroup(context.Background(), account.Id, foreignPeer.ID, group.ID))
|
||||
|
||||
group.Peers = []string{peer1.ID, foreignPeer.ID}
|
||||
assert.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group), "keeping an existing member must not be rejected")
|
||||
|
||||
group.Peers = []string{peer1.ID}
|
||||
require.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
|
||||
|
||||
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{peer1.ID}, stored.Peers, "dangling member should be removed once omitted")
|
||||
})
|
||||
|
||||
t.Run("direct add rejects foreign and unknown peers", func(t *testing.T) {
|
||||
group := &types.Group{ID: "direct", Name: "direct", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
|
||||
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
|
||||
|
||||
assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, foreignPeer.ID))
|
||||
assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, "does-not-exist"))
|
||||
|
||||
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
|
||||
}
|
||||
|
||||
event := func() {
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource))
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
|
||||
}
|
||||
|
||||
return event, nil
|
||||
@@ -133,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
|
||||
}
|
||||
|
||||
event := func() {
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource))
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
|
||||
}
|
||||
|
||||
return event, nil
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
||||
@@ -148,13 +148,10 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
peers = *req.Peers
|
||||
}
|
||||
|
||||
resources := make([]types.Resource, 0)
|
||||
if req.Resources != nil {
|
||||
for _, res := range *req.Resources {
|
||||
resource := types.Resource{}
|
||||
resource.FromAPIRequest(&res)
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
resources, err := resourcesFromAPIRequest(req.Resources)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
group := types.Group{
|
||||
@@ -210,13 +207,10 @@ func (h *handler) createGroup(w http.ResponseWriter, r *http.Request) {
|
||||
peers = *req.Peers
|
||||
}
|
||||
|
||||
resources := make([]types.Resource, 0)
|
||||
if req.Resources != nil {
|
||||
for _, res := range *req.Resources {
|
||||
resource := types.Resource{}
|
||||
resource.FromAPIRequest(&res)
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
resources, err := resourcesFromAPIRequest(req.Resources)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
group := types.Group{
|
||||
@@ -335,11 +329,30 @@ func toGroupResponse(peers []*nbpeer.Peer, group *types.Group) *api.Group {
|
||||
gr.PeersCount = len(gr.Peers)
|
||||
|
||||
for _, res := range group.Resources {
|
||||
resResp := res.ToAPIResponse()
|
||||
gr.Resources = append(gr.Resources, *resResp)
|
||||
if resResp := res.ToAPIResponse(); resResp != nil {
|
||||
gr.Resources = append(gr.Resources, *resResp)
|
||||
}
|
||||
}
|
||||
|
||||
gr.ResourcesCount = len(gr.Resources)
|
||||
|
||||
return &gr
|
||||
}
|
||||
|
||||
func resourcesFromAPIRequest(req *[]api.Resource) ([]types.Resource, error) {
|
||||
resources := make([]types.Resource, 0)
|
||||
if req == nil {
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
for _, res := range *req {
|
||||
if res.Id == "" || !types.ResourceType(res.Type).Valid() {
|
||||
return nil, status.Errorf(status.InvalidArgument, "resource id shouldn't be empty and type must be one of: peer, domain, host, subnet")
|
||||
}
|
||||
resource := types.Resource{}
|
||||
resource.FromAPIRequest(&res)
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -208,6 +208,33 @@ func TestWriteGroup(t *testing.T) {
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Write Group POST Empty Resource",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/groups",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(`{"name":"With Resource","resources":[{}]}`)),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Write Group PUT Empty Resource",
|
||||
requestType: http.MethodPut,
|
||||
requestPath: "/api/groups/id-existed",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(`{"name":"With Resource","resources":[{"id":"","type":"host"}]}`)),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Write Group POST Unknown Resource Type",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/groups",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(`{"name":"With Resource","resources":[{"id":"res-1","type":"banana"}]}`)),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Write Group PUT OK",
|
||||
requestType: http.MethodPut,
|
||||
@@ -376,6 +403,20 @@ func TestGetAllGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToGroupResponseSkipsEmptyResource(t *testing.T) {
|
||||
group := &types.Group{
|
||||
ID: "id-resources",
|
||||
Name: "Resources",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
Resources: []types.Resource{{}, {ID: "res-1", Type: types.ResourceTypeHost}},
|
||||
}
|
||||
|
||||
got := toGroupResponse(nil, group)
|
||||
|
||||
assert.Equal(t, 1, got.ResourcesCount)
|
||||
assert.Equal(t, []api.Resource{{Id: "res-1", Type: api.ResourceType(types.ResourceTypeHost)}}, got.Resources)
|
||||
}
|
||||
|
||||
func TestDeleteGroup(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
|
||||
|
||||
util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain))
|
||||
util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -534,14 +534,21 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
|
||||
util.WriteJSONObject(r.Context(), w, resp)
|
||||
}
|
||||
|
||||
func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
|
||||
// toAccessiblePeers resolves the twin peers in netMap back to the full account
|
||||
// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the
|
||||
// slim netmap twins intentionally don't carry.
|
||||
func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
|
||||
accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers))
|
||||
appendByID := func(id string) {
|
||||
if p, ok := accountPeers[id]; ok && p != nil {
|
||||
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
|
||||
}
|
||||
}
|
||||
for _, p := range netMap.Peers {
|
||||
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
|
||||
appendByID(p.ID)
|
||||
}
|
||||
|
||||
for _, p := range netMap.OfflinePeers {
|
||||
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
|
||||
appendByID(p.ID)
|
||||
}
|
||||
|
||||
return accessiblePeers
|
||||
@@ -610,6 +617,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -665,6 +673,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
ugomock "go.uber.org/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
@@ -106,7 +105,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := ugomock.NewController(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
networkMapController := network_map.NewMockController(ctrl)
|
||||
networkMapController.EXPECT().
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -99,16 +100,17 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
// Group validation is performed by the proxy via ValidateSession gRPC call.
|
||||
// This allows the proxy to show 403 pages directly without redirect dance.
|
||||
|
||||
// GenerateSessionToken applies the service's group and account-status gates,
|
||||
// so a user without access never receives a token. The proxy re-checks the
|
||||
// installed cookie against the service's allowed groups, and renders the
|
||||
// denial page from the error carried back in the redirect.
|
||||
sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to create session token")
|
||||
redirectURL.Scheme = "https"
|
||||
query := redirectURL.Query()
|
||||
query.Set("error", "access_denied")
|
||||
query.Set("error_description", "Service configuration error")
|
||||
query.Set("error_description", sessionTokenErrorDescription(err))
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
return
|
||||
@@ -124,6 +126,23 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// sessionTokenErrorDescription maps a session token failure to the text the
|
||||
// proxy renders on its access denied page. Account status denials get a message
|
||||
// the user can act on, while everything else stays generic so a lookup or
|
||||
// signing failure does not describe management internals to the browser.
|
||||
func sessionTokenErrorDescription(err error) string {
|
||||
if errors.Is(err, nbgrpc.ErrUserPendingApproval) {
|
||||
return "Your account is pending approval by an administrator"
|
||||
}
|
||||
if errors.Is(err, nbgrpc.ErrUserBlocked) {
|
||||
return "Your account is blocked"
|
||||
}
|
||||
if errors.Is(err, nbgrpc.ErrUserNotInGroup) {
|
||||
return "You are not authorized to access this service"
|
||||
}
|
||||
return "Service configuration error"
|
||||
}
|
||||
|
||||
func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
activitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
|
||||
nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
@@ -221,6 +222,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
)
|
||||
|
||||
proxyService.SetServiceManager(&testServiceManager{store: testStore})
|
||||
proxyService.SetActivityManager(activitymanager.NewManager(testStore))
|
||||
|
||||
handler := NewAuthCallbackHandler(proxyService, nil)
|
||||
|
||||
@@ -360,6 +362,51 @@ func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore sto
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, allowedUser))
|
||||
|
||||
// A second tenant, whose users must never be issued a token signed with
|
||||
// the first tenant's service session key.
|
||||
otherAccount := &types.Account{
|
||||
Id: "otherAccountId",
|
||||
Domain: "other.com",
|
||||
DomainCategory: "private",
|
||||
IsDomainPrimaryAccount: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
require.NoError(t, testStore.SaveAccount(ctx, otherAccount))
|
||||
|
||||
otherAccountUser := &types.User{
|
||||
Id: "otherAccountUserId",
|
||||
AccountID: "otherAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, otherAccountUser))
|
||||
|
||||
// A user awaiting approval is stored as blocked and pending approval, and
|
||||
// carries the same group membership as the approved one.
|
||||
pendingUser := &types.User{
|
||||
Id: "pendingUserId",
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, pendingUser))
|
||||
|
||||
blockedUser := &types.User{
|
||||
Id: "blockedUserId",
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
CreatedAt: time.Now(),
|
||||
Issued: "api",
|
||||
}
|
||||
require.NoError(t, testStore.SaveUser(ctx, blockedUser))
|
||||
}
|
||||
|
||||
// testServiceManager is a minimal implementation for testing.
|
||||
@@ -490,6 +537,113 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
|
||||
require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter")
|
||||
}
|
||||
|
||||
// TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account
|
||||
// is pending approval or blocked never receives a session token from the OIDC
|
||||
// callback, and that the redirect carries a description the proxy can render.
|
||||
// TestAuthCallback_RecordsUserLogin drives the real OIDC callback and asserts
|
||||
// the login lands on the user row. That timestamp is what activity accounting
|
||||
// reads, and it is the only signal that can ever count someone who reaches
|
||||
// proxy-protected services from a browser and never opens the dashboard.
|
||||
func TestAuthCallback_RecordsUserLogin(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, before.LastLogin, "fixture user starts with no login on record")
|
||||
|
||||
setup.oidcServer.tokenSubject = "allowedUserId"
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.LastLogin, "a completed proxy SSO login must be recorded on the user")
|
||||
require.WithinDuration(t, time.Now().UTC(), after.LastLogin.UTC(), time.Minute, "login should be stamped at sign-in time")
|
||||
}
|
||||
|
||||
// TestAuthCallback_DeniedUserLoginNotRecorded keeps the write on the granted
|
||||
// path: a refused sign-in is not a login.
|
||||
func TestAuthCallback_DeniedUserLoginNotRecorded(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
setup.oidcServer.tokenSubject = "blockedUserId"
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "blockedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, after.LastLogin, "a denied user must not be recorded as having logged in")
|
||||
}
|
||||
|
||||
func TestAuthCallback_UserDeniedByAccountStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subject string
|
||||
expectErrorDesc string
|
||||
}{
|
||||
{
|
||||
name: "pending approval",
|
||||
subject: "pendingUserId",
|
||||
expectErrorDesc: "Your account is pending approval by an administrator",
|
||||
},
|
||||
{
|
||||
name: "blocked",
|
||||
subject: "blockedUserId",
|
||||
expectErrorDesc: "Your account is blocked",
|
||||
},
|
||||
{
|
||||
name: "unknown to management",
|
||||
subject: "userMissingFromStoreId",
|
||||
expectErrorDesc: "Service configuration error",
|
||||
},
|
||||
{
|
||||
// The account topology stays out of the browser-visible message.
|
||||
name: "belongs to another account",
|
||||
subject: "otherAccountUserId",
|
||||
expectErrorDesc: "Service configuration error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
setup.oidcServer.tokenSubject = tt.subject
|
||||
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
parsedLocation, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, parsedLocation.Query().Get("session_token"), "Denied user must not receive a session token")
|
||||
require.Equal(t, "access_denied", parsedLocation.Query().Get("error"))
|
||||
require.Equal(t, tt.expectErrorDesc, parsedLocation.Query().Get("error_description"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallback_ProxyNotFound(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
@@ -64,6 +64,19 @@ func (h *handler) createSetupKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A one-off key can be used once, and GenerateSetupKey pins its usage limit
|
||||
// at 1 whatever the request says. Silently overriding a caller that asked
|
||||
// for a different number leaves them holding a key that does not do what
|
||||
// they configured, and no way to find out except by using it. Only values
|
||||
// above 1 are refused: usage_limit is a required field with no null, so 0
|
||||
// cannot be told apart from a caller that has nothing to say about it.
|
||||
if types.SetupKeyType(req.Type) == types.SetupKeyOneOff && req.UsageLimit > 1 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument,
|
||||
"usage_limit %d is not valid for a one-off setup key, which can be used once; use type reusable for a key that can be used more than once",
|
||||
req.UsageLimit), w)
|
||||
return
|
||||
}
|
||||
|
||||
expiresIn := time.Duration(req.ExpiresIn) * time.Second
|
||||
|
||||
if expiresIn < 0 {
|
||||
|
||||
@@ -134,6 +134,40 @@ func TestSetupKeysHandlers(t *testing.T) {
|
||||
expectedBody: true,
|
||||
expectedSetupKey: expectedNewKey,
|
||||
},
|
||||
{
|
||||
// A one-off key is used once. Asking for more used to be accepted
|
||||
// and then quietly reduced to 1.
|
||||
name: "Create One-Off Setup Key With Conflicting Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
// 0 is what a caller sends when it has nothing to say about the
|
||||
// usage limit, since the field is required and has no null, so it
|
||||
// has to keep working.
|
||||
name: "Create One-Off Setup Key Without Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"one-off\",\"expires_in\":86400,\"usage_limit\":0}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
// Only one-off keys are constrained; a reusable key means what it
|
||||
// says.
|
||||
name: "Create Reusable Setup Key With Usage Limit",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: bytes.NewBuffer(
|
||||
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"reusable\",\"expires_in\":86400,\"usage_limit\":5}", newSetupKeyName))),
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: false,
|
||||
},
|
||||
{
|
||||
name: "Update Setup Key",
|
||||
requestType: http.MethodPut,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools"
|
||||
"github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel"
|
||||
@@ -34,7 +36,7 @@ func Test_Events_GetAll(t *testing.T) {
|
||||
|
||||
for _, user := range users {
|
||||
t.Run(user.name+" - Get all events", func(t *testing.T) {
|
||||
apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false)
|
||||
apiHandler, accountManager, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false)
|
||||
|
||||
// First, perform a mutation to generate an event (create a group as admin)
|
||||
groupBody, err := json.Marshal(&api.GroupRequest{Name: "eventTestGroup"})
|
||||
@@ -44,7 +46,14 @@ func Test_Events_GetAll(t *testing.T) {
|
||||
createReq := testing_tools.BuildRequest(t, groupBody, http.MethodPost, "/api/groups", testing_tools.TestAdminId)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
apiHandler.ServeHTTP(createRecorder, createReq)
|
||||
assert.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event")
|
||||
require.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event")
|
||||
|
||||
// Group creation returns before its asynchronous audit write finishes.
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
events, err := accountManager.GetEvents(context.Background(), testing_tools.TestAccountId, testing_tools.TestAdminId)
|
||||
assert.NoError(c, err)
|
||||
assert.NotEmpty(c, events, "wait for the group creation event before checking permissions")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
// Now query events
|
||||
req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", user.userId)
|
||||
|
||||
@@ -136,7 +136,10 @@ func Test_SetupKeys_Create(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Create Setup Key as on-off with more than one usage",
|
||||
// The key used to be created anyway, with its usage limit quietly
|
||||
// reduced to 1, so the caller was told a key they had not asked for
|
||||
// was what they asked for.
|
||||
name: "Create Setup Key as one-off with more than one usage",
|
||||
requestType: http.MethodPost,
|
||||
requestPath: "/api/setup-keys",
|
||||
requestBody: &api.CreateSetupKeyRequest{
|
||||
@@ -146,23 +149,7 @@ func Test_SetupKeys_Create(t *testing.T) {
|
||||
Type: "one-off",
|
||||
UsageLimit: 3,
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedResponse: &api.SetupKey{
|
||||
AutoGroups: []string{},
|
||||
Ephemeral: false,
|
||||
Expires: time.Time{},
|
||||
Id: "",
|
||||
Key: "",
|
||||
LastUsed: time.Time{},
|
||||
Name: testing_tools.NewKeyName,
|
||||
Revoked: false,
|
||||
State: "valid",
|
||||
Type: "one-off",
|
||||
UpdatedAt: time.Now(),
|
||||
UsageLimit: 1,
|
||||
UsedTimes: 0,
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
},
|
||||
{
|
||||
name: "Create Setup Key with expiration in the past",
|
||||
|
||||
@@ -59,13 +59,21 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
done := make(chan struct{})
|
||||
if validateUpdate {
|
||||
go func() {
|
||||
@@ -88,15 +96,13 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
}
|
||||
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
@@ -111,6 +117,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -137,7 +147,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -200,13 +210,21 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
|
||||
geoMock := &geolocation.Mock{}
|
||||
validatorMock := server.MockIntegratedValidator{}
|
||||
@@ -218,15 +236,13 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
}
|
||||
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
|
||||
am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
@@ -241,6 +257,10 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -267,7 +287,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// maxDiscoveryDocumentSize caps the discovery document read at 1 MiB. Providers serve a few kilobytes.
|
||||
const maxDiscoveryDocumentSize = 1 << 20
|
||||
|
||||
// oidcProviderJSON represents the OpenID Connect discovery document
|
||||
type oidcProviderJSON struct {
|
||||
Issuer string `json:"issuer"`
|
||||
@@ -35,6 +38,10 @@ func validateOIDCIssuer(ctx context.Context, issuer string) error {
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
// An issuer that redirects its own discovery document is misconfigured.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
|
||||
@@ -48,22 +55,22 @@ func validateOIDCIssuer(ctx context.Context, issuer string) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: unable to read response body: %v", types.ErrIdentityProviderIssuerUnreachable, err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %s", types.ErrIdentityProviderIssuerUnreachable, resp.Status)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %s: %s", types.ErrIdentityProviderIssuerUnreachable, resp.Status, body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryDocumentSize+1))
|
||||
if err != nil || len(body) > maxDiscoveryDocumentSize {
|
||||
return fmt.Errorf("%w: failed to decode provider discovery object", types.ErrIdentityProviderIssuerUnreachable)
|
||||
}
|
||||
|
||||
var p oidcProviderJSON
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return fmt.Errorf("%w: failed to decode provider discovery object: %v", types.ErrIdentityProviderIssuerUnreachable, err)
|
||||
return fmt.Errorf("%w: failed to decode provider discovery object", types.ErrIdentityProviderIssuerUnreachable)
|
||||
}
|
||||
|
||||
if p.Issuer != issuer {
|
||||
return fmt.Errorf("%w: expected %q got %q", types.ErrIdentityProviderIssuerMismatch, issuer, p.Issuer)
|
||||
return fmt.Errorf("%w: %q", types.ErrIdentityProviderIssuerMismatch, issuer)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -151,15 +158,15 @@ func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, acc
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP")
|
||||
}
|
||||
|
||||
if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate ID if not provided
|
||||
if idpConfig.ID == "" {
|
||||
idpConfig.ID = generateIdentityProviderID(idpConfig.Type)
|
||||
@@ -188,15 +195,15 @@ func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, acc
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP")
|
||||
}
|
||||
|
||||
if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idpConfig.ID = idpID
|
||||
idpConfig.AccountID = accountID
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
@@ -34,6 +35,20 @@ import (
|
||||
|
||||
func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||
t.Helper()
|
||||
return createManagerWithEmbeddedIdPMode(t, "netbird.selfhosted")
|
||||
}
|
||||
|
||||
func createManagerWithEmbeddedIdPMode(t testing.TB, singleAccountModeDomain string) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||
t.Helper()
|
||||
return createManagerWithEmbeddedIdPModeAndSetup(t, singleAccountModeDomain, nil)
|
||||
}
|
||||
|
||||
func createManagerWithEmbeddedIdPModeAndSetup(
|
||||
t testing.TB,
|
||||
singleAccountModeDomain string,
|
||||
setupStore func(context.Context, store.Store) error,
|
||||
) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -43,6 +58,11 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
||||
return nil, nil, err
|
||||
}
|
||||
t.Cleanup(cleanUp)
|
||||
if setupStore != nil {
|
||||
if err := setupStore(ctx, testStore); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create embedded IdP manager
|
||||
embeddedConfig := &idp.EmbeddedIdPConfig{
|
||||
@@ -92,8 +112,8 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
||||
|
||||
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, peersManager), &config.Config{})
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
|
||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -102,7 +122,7 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_CreateIdentityProvider_Validation(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
manager, _, err := createManagerWithEmbeddedIdP(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
userID := "testingUser"
|
||||
@@ -196,8 +216,25 @@ func TestDefaultAccountManager_GetIdentityProvider_NotFound(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestUpdateUserAuthWithSingleModeKeepsConfiguredDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager, _, err := createManagerWithEmbeddedIdPModeAndSetup(t, "netbird.selfhosted", func(ctx context.Context, testStore store.Store) error {
|
||||
// An account with no domain, as left behind by an IdP that emitted no domain claims.
|
||||
return testStore.SaveAccount(ctx, newAccountWithId(ctx, "account-1", "user-1", "", "", "", false))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, manager.singleAccountMode)
|
||||
|
||||
userAuth := auth.UserAuth{UserId: "user-2"}
|
||||
require.NoError(t, manager.updateUserAuthWithSingleMode(ctx, &userAuth))
|
||||
|
||||
assert.Equal(t, "netbird.selfhosted", userAuth.Domain,
|
||||
"An empty account domain must not clear the configured single account domain")
|
||||
assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory)
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
manager, _, err := createManagerWithEmbeddedIdP(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
userID := "testingUser"
|
||||
@@ -319,3 +356,45 @@ func TestValidateOIDCIssuer_TrailingSlash(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, types.ErrIdentityProviderIssuerMismatch))
|
||||
}
|
||||
|
||||
func TestValidateOIDCIssuer_DoesNotFollowRedirects(t *testing.T) {
|
||||
var reached bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
t.Cleanup(target.Close)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/redirect-target", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
err := validateOIDCIssuer(context.Background(), srv.URL)
|
||||
require.Error(t, err)
|
||||
assert.False(t, reached, "Redirects are not followed")
|
||||
}
|
||||
|
||||
func TestValidateOIDCIssuer_BoundsResponseSize(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"issuer":"` + strings.Repeat("a", maxDiscoveryDocumentSize) + `"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
err := validateOIDCIssuer(context.Background(), srv.URL)
|
||||
require.ErrorIs(t, err, types.ErrIdentityProviderIssuerUnreachable)
|
||||
assert.NotErrorIs(t, err, types.ErrIdentityProviderIssuerMismatch)
|
||||
}
|
||||
|
||||
func TestValidateOIDCIssuer_RejectsTrailingContent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"issuer":"http://` + r.Host + `"} {"issuer":"second"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
err := validateOIDCIssuer(context.Background(), srv.URL)
|
||||
require.ErrorIs(t, err, types.ErrIdentityProviderIssuerUnreachable,
|
||||
"Content after the first object is not a valid discovery document")
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
staticClientDashboard = "netbird-dashboard"
|
||||
staticClientCLI = "netbird-cli"
|
||||
StaticClientDashboard = "netbird-dashboard"
|
||||
StaticClientCLI = "netbird-cli"
|
||||
DefaultTOTPAuthenticatorID = "default-totp"
|
||||
LocalConnectorID = dex.LocalConnectorID
|
||||
|
||||
defaultCLIRedirectURL1 = "http://localhost:53000/"
|
||||
defaultCLIRedirectURL2 = "http://localhost:54000/"
|
||||
defaultScopes = "openid profile email groups"
|
||||
@@ -76,6 +79,9 @@ type EmbeddedIdPConfig struct {
|
||||
DashboardPostLogoutRedirectURIs []string
|
||||
// StaticConnectors are additional connectors to seed during initialization
|
||||
StaticConnectors []dex.Connector
|
||||
// GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the
|
||||
// device_code grant to disable the device flow; keep authorization_code and refresh_token.
|
||||
GrantTypes []string
|
||||
}
|
||||
|
||||
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
||||
@@ -175,6 +181,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
},
|
||||
OAuth2: dex.OAuth2{
|
||||
SkipApprovalScreen: true,
|
||||
GrantTypes: c.GrantTypes,
|
||||
},
|
||||
Frontend: dex.Frontend{
|
||||
Issuer: "NetBird",
|
||||
@@ -185,14 +192,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
EnablePasswordDB: true,
|
||||
StaticClients: []storage.Client{
|
||||
{
|
||||
ID: staticClientDashboard,
|
||||
ID: StaticClientDashboard,
|
||||
Name: "NetBird Dashboard",
|
||||
Public: true,
|
||||
RedirectURIs: redirectURIs,
|
||||
PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs),
|
||||
},
|
||||
{
|
||||
ID: staticClientCLI,
|
||||
ID: StaticClientCLI,
|
||||
Name: "NetBird CLI",
|
||||
Public: true,
|
||||
RedirectURIs: redirectURIs,
|
||||
@@ -254,13 +261,13 @@ func sanitizePostLogoutRedirectURIs(uris []string) []string {
|
||||
|
||||
func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string, rememberMe bool, sessionCookieEncryptionKey string) error {
|
||||
cfg.MFA.Authenticators = []dex.MFAAuthenticator{{
|
||||
ID: "default-totp",
|
||||
ID: DefaultTOTPAuthenticatorID,
|
||||
// Has to be caps otherwise it will fail
|
||||
Type: "TOTP",
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "NetBird",
|
||||
},
|
||||
ConnectorTypes: []string{"local"},
|
||||
ConnectorTypes: []string{LocalConnectorID},
|
||||
}}
|
||||
|
||||
if sessionMaxLifetime == "" {
|
||||
@@ -736,7 +743,7 @@ func (m *EmbeddedIdPManager) GetDefaultScopes() string {
|
||||
|
||||
// GetCLIClientID returns the client ID for CLI authentication.
|
||||
func (m *EmbeddedIdPManager) GetCLIClientID() string {
|
||||
return staticClientCLI
|
||||
return StaticClientCLI
|
||||
}
|
||||
|
||||
// GetCLIRedirectURLs returns the redirect URLs configured for the CLI client.
|
||||
@@ -775,7 +782,7 @@ func (m *EmbeddedIdPManager) GetLocalKeysLocation() string {
|
||||
|
||||
// GetClientIDs returns the OAuth2 client IDs configured for this provider.
|
||||
func (m *EmbeddedIdPManager) GetClientIDs() []string {
|
||||
return []string{staticClientDashboard, staticClientCLI}
|
||||
return []string{StaticClientDashboard, StaticClientCLI}
|
||||
}
|
||||
|
||||
// GetUserIDClaim returns the JWT claim name used for user identification.
|
||||
@@ -792,11 +799,11 @@ func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool {
|
||||
func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error {
|
||||
var mfaChain []string
|
||||
if enabled {
|
||||
mfaChain = []string{"default-totp"}
|
||||
mfaChain = []string{DefaultTOTPAuthenticatorID}
|
||||
}
|
||||
if err := m.provider.SetClientsMFAChain(ctx, []string{
|
||||
staticClientCLI,
|
||||
staticClientDashboard,
|
||||
StaticClientCLI,
|
||||
StaticClientDashboard,
|
||||
}, mfaChain); err != nil {
|
||||
return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err)
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestEmbeddedIdPConfig_ToYAMLConfig_IncludesDeviceCallbackRedirectURI(t *tes
|
||||
|
||||
var cliRedirectURIs []string
|
||||
for _, client := range yamlConfig.StaticClients {
|
||||
if client.ID == staticClientCLI {
|
||||
if client.ID == StaticClientCLI {
|
||||
cliRedirectURIs = client.RedirectURIs
|
||||
break
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -25,8 +27,10 @@ type Server interface {
|
||||
EventStore() EventStore // may return nil
|
||||
}
|
||||
|
||||
const idpSeedInfoKey = "IDP_SEED_INFO"
|
||||
const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
|
||||
const (
|
||||
idpSeedInfoKey = "IDP_SEED_INFO"
|
||||
dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
|
||||
)
|
||||
|
||||
func isDryRun() bool {
|
||||
return os.Getenv(dryRunEnvKey) == "true"
|
||||
@@ -233,3 +237,163 @@ func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const DefaultSingleAccountDomain = "netbird.selfhosted"
|
||||
|
||||
var (
|
||||
ErrMultipleAccounts = errors.New("the embedded IdP supports a single account only")
|
||||
ErrUnusableDomain = errors.New("domain cannot be resolved in single account mode")
|
||||
ErrDomainConflict = errors.New("requested domain conflicts with the account domain")
|
||||
)
|
||||
|
||||
var resolvableDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
|
||||
|
||||
// RequireSingleAccount refuses to migrate an instance that holds more than one account.
|
||||
func RequireSingleAccount(s Server) error {
|
||||
accountsCounter, err := s.Store().GetAccountsCounter(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count accounts: %w", err)
|
||||
}
|
||||
|
||||
if accountsCounter > 1 {
|
||||
return errMultipleAccounts(accountsCounter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func errMultipleAccounts(accountsCounter int64) error {
|
||||
return fmt.Errorf("%w: this instance has %d accounts. Identity provider connectors are stored without "+
|
||||
"an account scope, so every account would share and be able to manage the same connectors. "+
|
||||
"Consolidate this instance to a single account, or keep using an external IdP, before migrating",
|
||||
ErrMultipleAccounts, accountsCounter)
|
||||
}
|
||||
|
||||
func NormalizeSingleAccountDomain(singleAccountDomain string) (string, error) {
|
||||
if singleAccountDomain == "" {
|
||||
singleAccountDomain = DefaultSingleAccountDomain
|
||||
}
|
||||
|
||||
singleAccountDomain = strings.ToLower(singleAccountDomain)
|
||||
if !resolvableDomainRegexp.MatchString(singleAccountDomain) {
|
||||
return "", fmt.Errorf("%w: %q must contain at least one dot and only lowercase letters, digits and "+
|
||||
"hyphens, otherwise users cannot join the existing account", ErrUnusableDomain, singleAccountDomain)
|
||||
}
|
||||
|
||||
return singleAccountDomain, nil
|
||||
}
|
||||
|
||||
// resolveAccountDomain picks the domain the account should end up with. The account keeps a usable
|
||||
// domain of its own, the configured one only fills a blank. Anything else is a conflict to report.
|
||||
func resolveAccountDomain(accountID, accountDomain, singleAccountDomain string, requested bool) (string, error) {
|
||||
accountDomain = strings.ToLower(accountDomain)
|
||||
|
||||
if accountDomain == "" {
|
||||
return singleAccountDomain, nil
|
||||
}
|
||||
|
||||
if !resolvableDomainRegexp.MatchString(accountDomain) {
|
||||
return "", fmt.Errorf("%w: account %s has domain %q, which must contain at least one dot and only "+
|
||||
"lowercase letters, digits and hyphens. Correct the account domain before migrating",
|
||||
ErrUnusableDomain, accountID, accountDomain)
|
||||
}
|
||||
|
||||
if requested && accountDomain != singleAccountDomain {
|
||||
return "", fmt.Errorf("%w: account %s already uses domain %q but %q was requested. Re-run without "+
|
||||
"--single-account-mode-domain to keep %q, or correct the account domain first",
|
||||
ErrDomainConflict, accountID, accountDomain, singleAccountDomain, accountDomain)
|
||||
}
|
||||
|
||||
return accountDomain, nil
|
||||
}
|
||||
|
||||
// EnsureSingleAccountDomain gives the remaining account the domain attributes single account mode
|
||||
// resolves against, so users can still join it after the migration.
|
||||
func EnsureSingleAccountDomain(s Server, singleAccountDomain string) error {
|
||||
plan, err := planSingleAccountDomain(s, singleAccountDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plan.skip {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isDryRun() {
|
||||
log.Infof("[DRY RUN] would set account %s domain to %q, category to %q and mark it as the primary domain account "+
|
||||
"(currently domain=%q primary=%v)", plan.accountID, plan.domain, types.PrivateCategory,
|
||||
plan.currentDomain, plan.isPrimary)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.Store().UpdateAccountDomainAttributes(context.Background(), plan.accountID, plan.domain,
|
||||
types.PrivateCategory, true); err != nil {
|
||||
return fmt.Errorf("failed to update domain attributes of account %s: %w", plan.accountID, err)
|
||||
}
|
||||
|
||||
log.Infof("account %s now resolves in single account mode with domain %q", plan.accountID, plan.domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSingleAccountDomain reports whether EnsureSingleAccountDomain would succeed, without writing.
|
||||
func CheckSingleAccountDomain(s Server, singleAccountDomain string) error {
|
||||
_, err := planSingleAccountDomain(s, singleAccountDomain)
|
||||
return err
|
||||
}
|
||||
|
||||
type singleAccountDomainPlan struct {
|
||||
accountID string
|
||||
domain string
|
||||
currentDomain string
|
||||
isPrimary bool
|
||||
skip bool
|
||||
}
|
||||
|
||||
// planSingleAccountDomain decides what the account's domain attributes should become. It reads
|
||||
// only, so it can run both as a preflight and as the first half of the update.
|
||||
func planSingleAccountDomain(s Server, singleAccountDomain string) (singleAccountDomainPlan, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// An empty value means the operator did not pick a domain, so the default is only a fallback.
|
||||
requested := singleAccountDomain != ""
|
||||
|
||||
singleAccountDomain, err := NormalizeSingleAccountDomain(singleAccountDomain)
|
||||
if err != nil {
|
||||
return singleAccountDomainPlan{}, err
|
||||
}
|
||||
|
||||
accountsCounter, err := s.Store().GetAccountsCounter(ctx)
|
||||
if err != nil {
|
||||
return singleAccountDomainPlan{}, fmt.Errorf("failed to count accounts: %w", err)
|
||||
}
|
||||
// The count is checked again here: it is read long after RequireSingleAccount, and marking an
|
||||
// arbitrary account as the primary one for the domain would be wrong.
|
||||
switch {
|
||||
case accountsCounter == 0:
|
||||
log.Info("no accounts yet, nothing to prepare for single account mode")
|
||||
return singleAccountDomainPlan{skip: true}, nil
|
||||
case accountsCounter > 1:
|
||||
return singleAccountDomainPlan{}, errMultipleAccounts(accountsCounter)
|
||||
}
|
||||
|
||||
accountID, err := s.Store().GetAnyAccountID(ctx)
|
||||
if err != nil {
|
||||
return singleAccountDomainPlan{}, fmt.Errorf("failed to get the existing account: %w", err)
|
||||
}
|
||||
|
||||
isPrimary, accountDomain, err := s.Store().IsPrimaryAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return singleAccountDomainPlan{}, fmt.Errorf("failed to read domain attributes of account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
domain, err := resolveAccountDomain(accountID, accountDomain, singleAccountDomain, requested)
|
||||
if err != nil {
|
||||
return singleAccountDomainPlan{}, err
|
||||
}
|
||||
|
||||
return singleAccountDomainPlan{
|
||||
accountID: accountID,
|
||||
domain: domain,
|
||||
currentDomain: accountDomain,
|
||||
isPrimary: isPrimary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ type testStore struct {
|
||||
checkSchemaFunc func(checks []SchemaCheck) []SchemaError
|
||||
updateCalls []updateUserIDCall
|
||||
updateInfoCalls []updateUserInfoCall
|
||||
|
||||
accountsCounter int64
|
||||
accounts map[string]*types.Account
|
||||
domainAttrCalls []domainAttrCall
|
||||
}
|
||||
|
||||
type domainAttrCall struct {
|
||||
AccountID string
|
||||
Domain string
|
||||
Category string
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
type updateUserIDCall struct {
|
||||
@@ -38,6 +49,35 @@ type updateUserInfoCall struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *testStore) GetAccountsCounter(context.Context) (int64, error) {
|
||||
return s.accountsCounter, nil
|
||||
}
|
||||
|
||||
func (s *testStore) GetAnyAccountID(context.Context) (string, error) {
|
||||
for id := range s.accounts {
|
||||
return id, nil
|
||||
}
|
||||
return "", fmt.Errorf("no accounts")
|
||||
}
|
||||
|
||||
func (s *testStore) IsPrimaryAccount(_ context.Context, accountID string) (bool, string, error) {
|
||||
account, ok := s.accounts[accountID]
|
||||
if !ok {
|
||||
return false, "", fmt.Errorf("account %s not found", accountID)
|
||||
}
|
||||
return account.IsDomainPrimaryAccount, account.Domain, nil
|
||||
}
|
||||
|
||||
func (s *testStore) UpdateAccountDomainAttributes(_ context.Context, accountID, domain, category string, isPrimaryDomain bool) error {
|
||||
s.domainAttrCalls = append(s.domainAttrCalls, domainAttrCall{accountID, domain, category, isPrimaryDomain})
|
||||
if account, ok := s.accounts[accountID]; ok {
|
||||
account.Domain = domain
|
||||
account.DomainCategory = category
|
||||
account.IsDomainPrimaryAccount = isPrimaryDomain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
||||
return s.listUsersFunc(ctx)
|
||||
}
|
||||
@@ -826,3 +866,212 @@ func TestCheckSchema_MockStore(t *testing.T) {
|
||||
assert.Equal(t, "email", errs[0].Column)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequireSingleAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accounts int64
|
||||
expectErr bool
|
||||
}{
|
||||
{name: "fresh install", accounts: 0},
|
||||
{name: "single account", accounts: 1},
|
||||
{name: "multiple accounts", accounts: 3, expectErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := &testServer{store: &testStore{accountsCounter: tt.accounts}}
|
||||
|
||||
err := RequireSingleAccount(srv)
|
||||
if !tt.expectErr {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrMultipleAccounts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSingleAccountDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *types.Account
|
||||
requestedDomain string
|
||||
expectedDomain string
|
||||
}{
|
||||
{
|
||||
name: "account migrated from an IdP without domain claims",
|
||||
account: &types.Account{Id: "account-1"},
|
||||
expectedDomain: DefaultSingleAccountDomain,
|
||||
},
|
||||
{
|
||||
name: "requested domain is applied to an account without one",
|
||||
account: &types.Account{Id: "account-1"},
|
||||
requestedDomain: "corp.example.com",
|
||||
expectedDomain: "corp.example.com",
|
||||
},
|
||||
{
|
||||
name: "account keeps its own domain",
|
||||
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||
expectedDomain: "acme.com",
|
||||
},
|
||||
{
|
||||
name: "requesting the domain the account already has is not a conflict",
|
||||
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||
requestedDomain: "acme.com",
|
||||
expectedDomain: "acme.com",
|
||||
},
|
||||
{
|
||||
name: "already resolvable account is rewritten with the same values",
|
||||
account: &types.Account{
|
||||
Id: "account-1",
|
||||
Domain: "acme.com",
|
||||
DomainCategory: types.PrivateCategory,
|
||||
IsDomainPrimaryAccount: true,
|
||||
},
|
||||
expectedDomain: "acme.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{tt.account.Id: tt.account},
|
||||
}
|
||||
|
||||
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, tt.requestedDomain))
|
||||
|
||||
require.Len(t, store.domainAttrCalls, 1)
|
||||
assert.Equal(t, domainAttrCall{
|
||||
AccountID: tt.account.Id,
|
||||
Domain: tt.expectedDomain,
|
||||
Category: types.PrivateCategory,
|
||||
IsPrimary: true,
|
||||
}, store.domainAttrCalls[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSingleAccountDomainDryRun(t *testing.T) {
|
||||
t.Setenv(dryRunEnvKey, "true")
|
||||
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{"account-1": {Id: "account-1"}},
|
||||
}
|
||||
|
||||
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, ""))
|
||||
assert.Empty(t, store.domainAttrCalls, "Dry run must not write anything")
|
||||
}
|
||||
|
||||
func TestEnsureSingleAccountDomainRejectsUnresolvableDomains(t *testing.T) {
|
||||
t.Run("account domain that cannot resolve is reported", func(t *testing.T) {
|
||||
account := &types.Account{Id: "account-1", Domain: "corp"}
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{account.Id: account},
|
||||
}
|
||||
|
||||
err := EnsureSingleAccountDomain(&testServer{store: store}, "")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrUnusableDomain)
|
||||
assert.Empty(t, store.domainAttrCalls, "A broken account domain must not be replaced silently")
|
||||
})
|
||||
|
||||
t.Run("requested domain conflicting with the account domain is reported", func(t *testing.T) {
|
||||
account := &types.Account{Id: "account-1", Domain: "acme.com"}
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{account.Id: account},
|
||||
}
|
||||
|
||||
err := EnsureSingleAccountDomain(&testServer{store: store}, "corp.example.com")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrDomainConflict)
|
||||
assert.Empty(t, store.domainAttrCalls, "A conflict must not overwrite the account domain")
|
||||
})
|
||||
|
||||
t.Run("configured domain that cannot resolve is rejected", func(t *testing.T) {
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{"account-1": {Id: "account-1"}},
|
||||
}
|
||||
|
||||
err := EnsureSingleAccountDomain(&testServer{store: store}, "corp")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrUnusableDomain)
|
||||
assert.Empty(t, store.domainAttrCalls)
|
||||
})
|
||||
|
||||
t.Run("account appearing after the preflight is rejected", func(t *testing.T) {
|
||||
store := &testStore{
|
||||
accountsCounter: 2,
|
||||
accounts: map[string]*types.Account{
|
||||
"account-1": {Id: "account-1"},
|
||||
"account-2": {Id: "account-2"},
|
||||
},
|
||||
}
|
||||
|
||||
err := EnsureSingleAccountDomain(&testServer{store: store}, "")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrMultipleAccounts)
|
||||
assert.Empty(t, store.domainAttrCalls, "No account may be marked primary when several exist")
|
||||
})
|
||||
|
||||
t.Run("fresh install with no accounts is a no-op", func(t *testing.T) {
|
||||
store := &testStore{accountsCounter: 0, accounts: map[string]*types.Account{}}
|
||||
|
||||
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, ""))
|
||||
assert.Empty(t, store.domainAttrCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckSingleAccountDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *types.Account
|
||||
requested string
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "usable account domain passes",
|
||||
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||
},
|
||||
{
|
||||
name: "empty account domain passes",
|
||||
account: &types.Account{Id: "account-1"},
|
||||
},
|
||||
{
|
||||
name: "unresolvable account domain fails",
|
||||
account: &types.Account{Id: "account-1", Domain: "corp"},
|
||||
expectErr: ErrUnusableDomain,
|
||||
},
|
||||
{
|
||||
name: "conflicting request fails",
|
||||
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||
requested: "corp.example.com",
|
||||
expectErr: ErrDomainConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &testStore{
|
||||
accountsCounter: 1,
|
||||
accounts: map[string]*types.Account{tt.account.Id: tt.account},
|
||||
}
|
||||
|
||||
err := CheckSingleAccountDomain(&testServer{store: store}, tt.requested)
|
||||
if tt.expectErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorIs(t, err, tt.expectErr)
|
||||
}
|
||||
|
||||
assert.Empty(t, store.domainAttrCalls, "The preflight must not write anything")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ type Store interface {
|
||||
// CheckSchema verifies that all tables and columns required by the migration
|
||||
// exist in the database. Returns a list of problems; an empty slice means OK.
|
||||
CheckSchema(checks []SchemaCheck) []SchemaError
|
||||
|
||||
// GetAccountsCounter returns the total number of accounts in the store.
|
||||
GetAccountsCounter(ctx context.Context) (int64, error)
|
||||
|
||||
// GetAnyAccountID returns the ID of one of the existing accounts.
|
||||
GetAnyAccountID(ctx context.Context) (string, error)
|
||||
|
||||
// IsPrimaryAccount returns whether the account is the primary account for its domain,
|
||||
// along with that domain.
|
||||
IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error)
|
||||
|
||||
// UpdateAccountDomainAttributes sets the domain, domain category and primary
|
||||
// domain flag of an account.
|
||||
UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error
|
||||
}
|
||||
|
||||
// RequiredEventSchema lists all tables and columns that the migration tool needs
|
||||
|
||||
@@ -2,11 +2,12 @@ package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,31 +34,32 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int)
|
||||
|
||||
//Set special character
|
||||
for i := 0; i < minSpecialChar; i++ {
|
||||
random := rand.Intn(len(specialCharSet))
|
||||
random := util.RandIntn(len(specialCharSet))
|
||||
password.WriteString(string(specialCharSet[random]))
|
||||
}
|
||||
|
||||
//Set numeric
|
||||
for i := 0; i < minNum; i++ {
|
||||
random := rand.Intn(len(numberSet))
|
||||
random := util.RandIntn(len(numberSet))
|
||||
password.WriteString(string(numberSet[random]))
|
||||
}
|
||||
|
||||
//Set uppercase
|
||||
for i := 0; i < minUpperCase; i++ {
|
||||
random := rand.Intn(len(upperCharSet))
|
||||
random := util.RandIntn(len(upperCharSet))
|
||||
password.WriteString(string(upperCharSet[random]))
|
||||
}
|
||||
|
||||
remainingLength := passwordLength - minSpecialChar - minNum - minUpperCase
|
||||
for i := 0; i < remainingLength; i++ {
|
||||
random := rand.Intn(len(allCharSet))
|
||||
random := util.RandIntn(len(allCharSet))
|
||||
password.WriteString(string(allCharSet[random]))
|
||||
}
|
||||
inRune := []rune(password.String())
|
||||
rand.Shuffle(len(inRune), func(i, j int) {
|
||||
for i := len(inRune) - 1; i > 0; i-- {
|
||||
j := util.RandIntn(i + 1)
|
||||
inRune[i], inRune[j] = inRune[j], inRune[i]
|
||||
})
|
||||
}
|
||||
return string(inRune)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// UpdateIntegratedValidator updates the integrated validator groups for a specified account.
|
||||
@@ -109,7 +110,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, groups, peers, settings.Extra)
|
||||
validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, types.TwinGroups(groups), types.TwinPeers(peers), settings.Extra)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -138,7 +139,7 @@ func (a MockIntegratedValidator) ValidatePeer(_ context.Context, update *nbpeer.
|
||||
return update, false, nil
|
||||
}
|
||||
|
||||
func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
|
||||
func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
|
||||
validatedPeers := make(map[string]struct{})
|
||||
for _, peer := range peers {
|
||||
validatedPeers[peer.ID] = struct{}{}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package integration_reference
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IntegrationReference holds the reference to a particular integration
|
||||
type IntegrationReference struct {
|
||||
ID int
|
||||
IntegrationType string
|
||||
}
|
||||
|
||||
func (ir IntegrationReference) String() string {
|
||||
return fmt.Sprintf("%s:%d", ir.IntegrationType, ir.ID)
|
||||
}
|
||||
|
||||
func (ir IntegrationReference) CacheKey(path ...string) string {
|
||||
if len(path) == 0 {
|
||||
return ir.String()
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", ir.String(), strings.Join(path, ":"))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./interface.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
|
||||
//
|
||||
|
||||
// Package integrated_validator is a generated GoMock package.
|
||||
package integrated_validator
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
peer "github.com/netbirdio/netbird/management/server/peer"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
proto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockIntegratedValidator is a mock of IntegratedValidator interface.
|
||||
type MockIntegratedValidator struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockIntegratedValidatorMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockIntegratedValidatorMockRecorder is the mock recorder for MockIntegratedValidator.
|
||||
type MockIntegratedValidatorMockRecorder struct {
|
||||
mock *MockIntegratedValidator
|
||||
}
|
||||
|
||||
// NewMockIntegratedValidator creates a new mock instance.
|
||||
func NewMockIntegratedValidator(ctrl *gomock.Controller) *MockIntegratedValidator {
|
||||
mock := &MockIntegratedValidator{ctrl: ctrl}
|
||||
mock.recorder = &MockIntegratedValidatorMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockIntegratedValidator) EXPECT() *MockIntegratedValidatorMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetInvalidPeers mocks base method.
|
||||
func (m *MockIntegratedValidator) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetInvalidPeers", ctx, accountID, extraSettings)
|
||||
ret0, _ := ret[0].(map[string]string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetInvalidPeers indicates an expected call of GetInvalidPeers.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) GetInvalidPeers(ctx, accountID, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvalidPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetInvalidPeers), ctx, accountID, extraSettings)
|
||||
}
|
||||
|
||||
// GetValidatedPeers mocks base method.
|
||||
func (m *MockIntegratedValidator) GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID, groups, peers, extraSettings)
|
||||
ret0, _ := ret[0].(map[string]struct{})
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetValidatedPeers indicates an expected call of GetValidatedPeers.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) GetValidatedPeers(ctx, accountID, groups, peers, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetValidatedPeers), ctx, accountID, groups, peers, extraSettings)
|
||||
}
|
||||
|
||||
// IsNotValidPeer mocks base method.
|
||||
func (m *MockIntegratedValidator) IsNotValidPeer(ctx context.Context, accountID string, arg2 *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsNotValidPeer", ctx, accountID, arg2, peersGroup, extraSettings)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(bool)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// IsNotValidPeer indicates an expected call of IsNotValidPeer.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) IsNotValidPeer(ctx, accountID, arg2, peersGroup, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotValidPeer", reflect.TypeOf((*MockIntegratedValidator)(nil).IsNotValidPeer), ctx, accountID, arg2, peersGroup, extraSettings)
|
||||
}
|
||||
|
||||
// PeerDeleted mocks base method.
|
||||
func (m *MockIntegratedValidator) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PeerDeleted", ctx, accountID, peerID, extraSettings)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PeerDeleted indicates an expected call of PeerDeleted.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) PeerDeleted(ctx, accountID, peerID, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PeerDeleted", reflect.TypeOf((*MockIntegratedValidator)(nil).PeerDeleted), ctx, accountID, peerID, extraSettings)
|
||||
}
|
||||
|
||||
// PreparePeer mocks base method.
|
||||
func (m *MockIntegratedValidator) PreparePeer(ctx context.Context, accountID string, p *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *peer.Peer {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PreparePeer", ctx, accountID, p, peersGroup, extraSettings, temporary)
|
||||
ret0, _ := ret[0].(*peer.Peer)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PreparePeer indicates an expected call of PreparePeer.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) PreparePeer(ctx, accountID, p, peersGroup, extraSettings, temporary any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).PreparePeer), ctx, accountID, p, peersGroup, extraSettings, temporary)
|
||||
}
|
||||
|
||||
// SetPeerInvalidationListener mocks base method.
|
||||
func (m *MockIntegratedValidator) SetPeerInvalidationListener(fn func(string, []string)) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetPeerInvalidationListener", fn)
|
||||
}
|
||||
|
||||
// SetPeerInvalidationListener indicates an expected call of SetPeerInvalidationListener.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) SetPeerInvalidationListener(fn any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerInvalidationListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerInvalidationListener), fn)
|
||||
}
|
||||
|
||||
// Stop mocks base method.
|
||||
func (m *MockIntegratedValidator) Stop(ctx context.Context) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Stop", ctx)
|
||||
}
|
||||
|
||||
// Stop indicates an expected call of Stop.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) Stop(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockIntegratedValidator)(nil).Stop), ctx)
|
||||
}
|
||||
|
||||
// ValidateExtraSettings mocks base method.
|
||||
func (m *MockIntegratedValidator) ValidateExtraSettings(ctx context.Context, newExtraSettings, oldExtraSettings *types.ExtraSettings, userID, accountID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ValidateExtraSettings", ctx, newExtraSettings, oldExtraSettings, userID, accountID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ValidateExtraSettings indicates an expected call of ValidateExtraSettings.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) ValidateExtraSettings(ctx, newExtraSettings, oldExtraSettings, userID, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateExtraSettings", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateExtraSettings), ctx, newExtraSettings, oldExtraSettings, userID, accountID)
|
||||
}
|
||||
|
||||
// ValidateFlowResponse mocks base method.
|
||||
func (m *MockIntegratedValidator) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ValidateFlowResponse", ctx, peerKey, flowResponse)
|
||||
ret0, _ := ret[0].(*proto.PKCEAuthorizationFlow)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ValidateFlowResponse indicates an expected call of ValidateFlowResponse.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) ValidateFlowResponse(ctx, peerKey, flowResponse any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateFlowResponse", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateFlowResponse), ctx, peerKey, flowResponse)
|
||||
}
|
||||
|
||||
// ValidatePeer mocks base method.
|
||||
func (m *MockIntegratedValidator) ValidatePeer(ctx context.Context, update, p *peer.Peer, userID, accountID, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*peer.Peer, bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ValidatePeer", ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
|
||||
ret0, _ := ret[0].(*peer.Peer)
|
||||
ret1, _ := ret[1].(bool)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// ValidatePeer indicates an expected call of ValidatePeer.
|
||||
func (mr *MockIntegratedValidatorMockRecorder) ValidatePeer(ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidatePeer), ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
|
||||
}
|
||||
@@ -5,16 +5,19 @@ import (
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
|
||||
|
||||
// IntegratedValidator interface exists to avoid the circle dependencies
|
||||
type IntegratedValidator interface {
|
||||
ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error
|
||||
ValidatePeer(ctx context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
|
||||
PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
|
||||
ValidatePeer(ctx context.Context, update *nbpeer.Peer, p *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
|
||||
PreparePeer(ctx context.Context, accountID string, p *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
|
||||
IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error)
|
||||
GetValidatedPeers(ctx context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
|
||||
GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
|
||||
GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error)
|
||||
PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error
|
||||
SetPeerInvalidationListener(fn func(accountID string, peerIDs []string))
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,7 @@ func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
|
||||
func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*nmdata.Group, peers []*nmdata.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
|
||||
validatedPeers := make(map[string]struct{})
|
||||
for _, p := range peers {
|
||||
validatedPeers[p.ID] = struct{}{}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
@@ -376,7 +376,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
|
||||
return nil, nil, "", cleanup, err
|
||||
}
|
||||
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil)
|
||||
accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
|
||||
eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
pb "github.com/golang/protobuf/proto" //nolint
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -216,7 +216,7 @@ func startServer(
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := server.NewAccountRequestBuffer(ctx, str)
|
||||
networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config)
|
||||
networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
|
||||
|
||||
accountManager, err := server.BuildManager(
|
||||
context.Background(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -635,3 +636,128 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error {
|
||||
var model T
|
||||
|
||||
if !db.Migrator().HasTable(&model) {
|
||||
log.WithContext(ctx).Debugf("Table for %T does not exist, no backfill needed", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
err := stmt.Parse(&model)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse model: %w", err)
|
||||
}
|
||||
tableName := stmt.Schema.Table
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&model, "public_id") {
|
||||
log.WithContext(ctx).Infof("Column public_id does not exist in table %s, adding it", tableName)
|
||||
if err := tx.Migrator().AddColumn(&model, "public_id"); err != nil {
|
||||
return fmt.Errorf("add column public_id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var rows []map[string]any
|
||||
if err := tx.Table(tableName).Select("id", "public_id").Where("public_id IS NULL").Or("public_id = ''").Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("failed to find rows with empty public_id: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
log.WithContext(ctx).Infof("No rows with empty public_id found in table %s, no migration needed", tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if err := tx.Table(tableName).Where("id = ?", row["id"]).Update("public_id", xid.New().String()).Error; err != nil {
|
||||
return fmt.Errorf("failed to update row with id %v: %w", row["id"], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old
|
||||
// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket
|
||||
// breakdown, where the total and cache portion are derived on read instead.
|
||||
//
|
||||
// The fold preserves both aggregates exactly for historical rows: the cache
|
||||
// total moves into cached_input_cost_usd and the remainder into
|
||||
// input_cost_usd, so a row's derived total and cache cost still match what it
|
||||
// reported before the upgrade. The finer split is genuinely unknown for those
|
||||
// rows — the old schema never recorded a read/write or input/output division —
|
||||
// so it is lumped rather than guessed; only rows written after the upgrade
|
||||
// carry a true four-way split.
|
||||
//
|
||||
// Dropping the columns before folding would zero every historical row's cost,
|
||||
// so the update runs first and the drop only happens once it succeeds. A table
|
||||
// with no cost_usd column has already been migrated (or was created fresh) and
|
||||
// is skipped.
|
||||
func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error {
|
||||
var model T
|
||||
|
||||
if !db.Migrator().HasTable(&model) {
|
||||
log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model)
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model, "cost_usd") {
|
||||
log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(&model); err != nil {
|
||||
return fmt.Errorf("parse model schema: %w", err)
|
||||
}
|
||||
tableName := stmt.Schema.Table
|
||||
|
||||
// COALESCE guards rows whose new columns were added as NULL by an earlier
|
||||
// AutoMigrate run that predates the NOT NULL default.
|
||||
hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd")
|
||||
cacheExpr := "0"
|
||||
if hasCacheColumn {
|
||||
cacheExpr = "COALESCE(cache_cost_usd, 0)"
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Only touch rows that carry a legacy total and no breakdown yet, so
|
||||
// the migration is idempotent and never overwrites a true split.
|
||||
update := fmt.Sprintf(`UPDATE %s
|
||||
SET input_cost_usd = COALESCE(cost_usd, 0) - %s,
|
||||
cached_input_cost_usd = %s,
|
||||
cache_creation_cost_usd = 0,
|
||||
output_cost_usd = 0
|
||||
WHERE COALESCE(cost_usd, 0) <> 0
|
||||
AND COALESCE(input_cost_usd, 0) = 0
|
||||
AND COALESCE(cached_input_cost_usd, 0) = 0
|
||||
AND COALESCE(cache_creation_cost_usd, 0) = 0
|
||||
AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr)
|
||||
res := tx.Exec(update)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error)
|
||||
}
|
||||
log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName)
|
||||
|
||||
if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil {
|
||||
return fmt.Errorf("drop cost_usd from %s: %w", tableName, err)
|
||||
}
|
||||
if hasCacheColumn {
|
||||
if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil {
|
||||
return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// agentNetworkSettingsMigration is a local view of the agent_network_settings
|
||||
// table spanning both the legacy identity columns (cluster, subdomain) and
|
||||
// their replacement (domain, proxy_address), so the migrator can address all
|
||||
// four during the reshape without importing the current model.
|
||||
type agentNetworkSettingsMigration struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (agentNetworkSettingsMigration) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// MigrateAgentNetworkSettingsToDomain reshapes agent_network_settings from the
|
||||
// legacy (cluster, subdomain) identity columns to (domain, proxy_address):
|
||||
// domain becomes `<subdomain>.<cluster>` — the endpoint hostname the old
|
||||
// columns derived — and proxy_address becomes the cluster address, preserving
|
||||
// which proxy serves the account. Runs before AutoMigrate, which then creates
|
||||
// the unique index on the freshly backfilled domain column.
|
||||
//
|
||||
// A legacy row missing either half cannot be given an endpoint; the old
|
||||
// bootstrap always wrote both, so such a row indicates corruption and the
|
||||
// migration fails loudly rather than leaving an empty domain to collide with
|
||||
// the unique index confusingly.
|
||||
//
|
||||
// The transaction is real only on sqlite and postgres, where DDL is
|
||||
// transactional. MySQL implicitly commits around every ALTER TABLE, so there
|
||||
// each step stands alone; what makes an interrupted run resumable on MySQL is
|
||||
// that every step is guarded by the schema state it changes — the entry check
|
||||
// fires while either legacy column remains, the adds skip existing columns,
|
||||
// the backfill and its loud-failure check run only while the legacy cluster
|
||||
// column exists (they provably completed before any drop), and each drop
|
||||
// skips what is already gone.
|
||||
func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error {
|
||||
model := &agentNetworkSettingsMigration{}
|
||||
migrator := db.Migrator()
|
||||
|
||||
if !migrator.HasTable(model) {
|
||||
return nil
|
||||
}
|
||||
hasCluster := migrator.HasColumn(model, "cluster")
|
||||
if !hasCluster && !migrator.HasColumn(model, "subdomain") {
|
||||
// Fresh schema or already migrated — nothing to reshape.
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
txMigrator := tx.Migrator()
|
||||
for _, field := range []string{"Domain", "ProxyAddress"} {
|
||||
if !txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.AddColumn(model, field); err != nil {
|
||||
return fmt.Errorf("add %s column to agent_network_settings: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasCluster {
|
||||
concat := "subdomain || '.' || cluster"
|
||||
if tx.Name() == "mysql" {
|
||||
concat = "CONCAT(subdomain, '.', cluster)"
|
||||
}
|
||||
res := tx.Exec(fmt.Sprintf(
|
||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
concat,
|
||||
))
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error)
|
||||
}
|
||||
|
||||
var unmigratable int64
|
||||
if err := tx.Model(model).Where("domain IS NULL OR domain = ''").Count(&unmigratable).Error; err != nil {
|
||||
return fmt.Errorf("count unmigratable agent_network_settings rows: %w", err)
|
||||
}
|
||||
if unmigratable > 0 {
|
||||
return fmt.Errorf(
|
||||
"%d agent_network_settings row(s) have no cluster/subdomain to derive an endpoint from; resolve them manually before upgrading",
|
||||
unmigratable,
|
||||
)
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
log.WithContext(ctx).Infof("migrated %d agent_network_settings row(s) to domain/proxy_address", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
if txMigrator.HasIndex(model, "idx_agent_network_settings_cluster_subdomain") {
|
||||
if err := txMigrator.DropIndex(model, "idx_agent_network_settings_cluster_subdomain"); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings index: %w", err)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"Cluster", "Subdomain"} {
|
||||
if txMigrator.HasColumn(model, field) {
|
||||
if err := txMigrator.DropColumn(model, field); err != nil {
|
||||
return fmt.Errorf("drop legacy agent_network_settings column %s: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
)
|
||||
|
||||
// MigrateCustomDomainValidationExpiry gives existing pending registrations a validation window.
|
||||
func MigrateCustomDomainValidationExpiry(ctx context.Context, db *gorm.DB) error {
|
||||
result := db.WithContext(ctx).Model(&domain.Domain{}).
|
||||
Where("validated = ? AND validation_expires_at IS NULL", false).
|
||||
Update("validation_expires_at", time.Now().UTC().Add(domain.ValidationTTL))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("backfill custom domain validation expiry: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package migration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
)
|
||||
|
||||
func TestMigrateCustomDomainValidationExpiry(t *testing.T) {
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.AutoMigrate(&domain.Domain{}))
|
||||
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&domain.Domain{})) })
|
||||
ctx := context.Background()
|
||||
existingDeadline := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
rows := []domain.Domain{
|
||||
{ID: "legacy", Domain: "legacy.example.com"},
|
||||
{ID: "validated", Domain: "validated.example.com", Validated: true},
|
||||
{ID: "pending", Domain: "pending.example.com", ValidationExpiresAt: &existingDeadline},
|
||||
}
|
||||
require.NoError(t, db.Create(&rows).Error)
|
||||
before := time.Now().UTC()
|
||||
require.NoError(t, migration.MigrateCustomDomainValidationExpiry(ctx, db))
|
||||
after := time.Now().UTC()
|
||||
var migrated domain.Domain
|
||||
require.NoError(t, db.First(&migrated, "id = ?", "legacy").Error)
|
||||
require.NotNil(t, migrated.ValidationExpiresAt)
|
||||
assert.WithinRange(t, *migrated.ValidationExpiresAt, before.Truncate(time.Millisecond).Add(48*time.Hour), after.Add(48*time.Hour+time.Millisecond), "legacy pending registrations get a full window")
|
||||
deadline := *migrated.ValidationExpiresAt
|
||||
require.NoError(t, migration.MigrateCustomDomainValidationExpiry(ctx, db))
|
||||
require.NoError(t, db.First(&migrated, "id = ?", "legacy").Error)
|
||||
assert.Equal(t, deadline, *migrated.ValidationExpiresAt, "repeated migration must not extend the deadline")
|
||||
var validated, pending domain.Domain
|
||||
require.NoError(t, db.First(&validated, "id = ?", "validated").Error)
|
||||
require.NoError(t, db.First(&pending, "id = ?", "pending").Error)
|
||||
assert.Nil(t, validated.ValidationExpiresAt, "validated domains do not acquire an expiry")
|
||||
require.NotNil(t, pending.ValidationExpiresAt)
|
||||
assert.WithinDuration(t, existingDeadline, *pending.ValidationExpiresAt, 0, "existing deadlines must be preserved")
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/testutil"
|
||||
@@ -639,3 +640,221 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
||||
db.Model(&testChildWithFK{}).Count(&count)
|
||||
assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists")
|
||||
}
|
||||
|
||||
// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored
|
||||
// as a total plus a cache portion, with no per-bucket columns. Used to build a
|
||||
// realistic pre-upgrade table for the fold migration to run against.
|
||||
type legacyCostRow struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string
|
||||
Model string
|
||||
CostUSD float64
|
||||
CacheCostUSD float64
|
||||
}
|
||||
|
||||
func (legacyCostRow) TableName() string { return "agent_network_request_usage" }
|
||||
|
||||
// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade
|
||||
// path: a table written under the old schema must come out with its per-row
|
||||
// total and cache cost unchanged, because dropping cost_usd without folding it
|
||||
// forward would silently zero every historical row's spend.
|
||||
func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
// setupDatabase hands back a process-shared database, so start from a clean
|
||||
// table rather than inheriting rows from another test.
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created")
|
||||
require.NoError(t, db.Create(&legacyCostRow{
|
||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyCostRow{
|
||||
ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0,
|
||||
}).Error)
|
||||
// A zero-cost row (denied / unpriced request) must stay zero, not be touched.
|
||||
require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error)
|
||||
|
||||
// AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly
|
||||
// as a real upgrade does before the post-auto migrations run.
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added")
|
||||
|
||||
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db))
|
||||
|
||||
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"),
|
||||
"legacy cost_usd column must be dropped once folded")
|
||||
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"),
|
||||
"legacy cache_cost_usd column must be dropped once folded")
|
||||
|
||||
var rows []*agentNetworkTypes.AgentNetworkUsage
|
||||
require.NoError(t, db.Order("id").Find(&rows).Error)
|
||||
require.Len(t, rows, 3)
|
||||
|
||||
// u1: total and cache portion both preserved; the read/write and
|
||||
// input/output splits are unknowable for a legacy row, so the cache total
|
||||
// lands on cached_input and the remainder on input.
|
||||
assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold")
|
||||
assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold")
|
||||
assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input")
|
||||
assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input")
|
||||
assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover")
|
||||
assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover")
|
||||
|
||||
// u2: no cache spend — the whole total is the non-cache remainder.
|
||||
assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive")
|
||||
assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free")
|
||||
|
||||
// u3: zero stays zero rather than being rewritten.
|
||||
assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced")
|
||||
}
|
||||
|
||||
// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is
|
||||
// safe to re-run: with no legacy column present it is a no-op that leaves a
|
||||
// true four-way split untouched.
|
||||
func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
// Timestamp must be set explicitly: a zero time.Time serialises as
|
||||
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
|
||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
||||
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
|
||||
InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db),
|
||||
"running against an already-migrated table must be a no-op, not an error")
|
||||
|
||||
var row agentNetworkTypes.AgentNetworkUsage
|
||||
require.NoError(t, db.First(&row, "id = ?", "u1").Error)
|
||||
assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten")
|
||||
assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
||||
}
|
||||
|
||||
// legacyAgentNetworkSettings is the pre-reshape schema: identity carried as
|
||||
// (cluster, subdomain) instead of (domain, proxy_address).
|
||||
type legacyAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Cluster string
|
||||
Subdomain string
|
||||
EnableLogCollection bool
|
||||
}
|
||||
|
||||
func (legacyAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns pins
|
||||
// the reshape: domain becomes `<subdomain>.<cluster>`, proxy_address becomes
|
||||
// the cluster, the legacy columns are dropped, and non-identity fields ride
|
||||
// through untouched.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-1", Cluster: "eu.proxy.netbird.io", Subdomain: "violet", EnableLogCollection: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-2", Cluster: "us.proxy.netbird.io", Subdomain: "violet",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}),
|
||||
"AutoMigrate must create the domain unique index over the backfilled values")
|
||||
|
||||
var one, two agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&one, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", one.Domain, "domain must combine subdomain and cluster")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", one.ProxyAddress, "proxy address must carry the cluster")
|
||||
assert.True(t, one.EnableLogCollection, "non-identity fields must ride through")
|
||||
require.NoError(t, db.First(&two, "account_id = ?", "acct-2").Error)
|
||||
assert.Equal(t, "violet.us.proxy.netbird.io", two.Domain,
|
||||
"duplicate labels on different clusters are distinct hostnames and must both survive")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "cluster"), "legacy cluster column must be dropped")
|
||||
assert.False(t, migrator.HasColumn(&legacyAgentNetworkSettings{}, "subdomain"), "legacy subdomain column must be dropped")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated proves the
|
||||
// migration is safe to re-run: with no legacy column present it is a no-op.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_SkipsAlreadyMigrated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-1", Domain: "gw.example.com", ProxyAddress: "gw.example.com",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"running against an already-migrated table must be a no-op, not an error")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "gw.example.com", row.Domain, "migrated rows must be untouched")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow pins the
|
||||
// loud-failure contract: a legacy row missing its identity halves cannot be
|
||||
// given an endpoint, and silently leaving an empty domain would collide with
|
||||
// the unique index confusingly later.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-broken", Cluster: "", Subdomain: "",
|
||||
}).Error)
|
||||
|
||||
err := migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
require.Error(t, err, "a row with no identity to derive an endpoint from must fail the migration")
|
||||
assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do")
|
||||
}
|
||||
|
||||
// partialAgentNetworkSettings models the one non-atomic state a MySQL run can
|
||||
// be interrupted in: DDL auto-commits there, so a crash between the two legacy
|
||||
// column drops leaves subdomain behind while cluster (and the completed
|
||||
// backfill) are already committed.
|
||||
type partialAgentNetworkSettings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Subdomain string
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL
|
||||
// resumability: a rerun over the interrupted state must remove the leftover
|
||||
// subdomain column without re-running the backfill (the cluster column that
|
||||
// feeds it is gone) and without touching the migrated values.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&partialAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&partialAgentNetworkSettings{
|
||||
AccountID: "acct-1", Subdomain: "violet",
|
||||
Domain: "violet.eu.proxy.netbird.io", ProxyAddress: "eu.proxy.netbird.io",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.MigrateAgentNetworkSettingsToDomain(ctx, db),
|
||||
"a rerun over a partially-dropped schema must resume, not error")
|
||||
|
||||
migrator := db.Migrator()
|
||||
assert.False(t, migrator.HasColumn(&partialAgentNetworkSettings{}, "subdomain"),
|
||||
"the leftover legacy column must be dropped on resume")
|
||||
|
||||
var row agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&row, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", row.Domain, "migrated values must be untouched")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", row.ProxyAddress, "migrated values must be untouched")
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
var _ account.Manager = (*MockAccountManager)(nil)
|
||||
@@ -41,11 +42,11 @@ type MockAccountManager struct {
|
||||
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
|
||||
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
|
||||
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
|
||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error
|
||||
GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
|
||||
GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error)
|
||||
AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
|
||||
AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
|
||||
GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error)
|
||||
GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error)
|
||||
GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error)
|
||||
@@ -98,9 +99,9 @@ type MockAccountManager struct {
|
||||
SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error
|
||||
GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
|
||||
UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
|
||||
LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
|
||||
LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
|
||||
ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
|
||||
SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
|
||||
ApproveUserFunc func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
|
||||
RejectUserFunc func(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
|
||||
@@ -230,7 +231,7 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use
|
||||
return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
if am.SyncAndMarkPeerFunc != nil {
|
||||
return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime)
|
||||
}
|
||||
@@ -424,7 +425,7 @@ func (am *MockAccountManager) AddPeer(
|
||||
userId string,
|
||||
peer *nbpeer.Peer,
|
||||
temporary bool,
|
||||
) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
|
||||
) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
if am.AddPeerFunc != nil {
|
||||
return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary)
|
||||
}
|
||||
@@ -862,7 +863,7 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account
|
||||
}
|
||||
|
||||
// LoginPeer mocks LoginPeer of the AccountManager interface
|
||||
func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
|
||||
func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
if am.LoginPeerFunc != nil {
|
||||
return am.LoginPeerFunc(ctx, login)
|
||||
}
|
||||
@@ -878,7 +879,7 @@ func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey,
|
||||
}
|
||||
|
||||
// SyncPeer mocks SyncPeer of the AccountManager interface
|
||||
func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
if am.SyncPeerFunc != nil {
|
||||
return am.SyncPeerFunc(ctx, sync, accountID)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ type ManagementServiceServerMock struct {
|
||||
proto.UnimplementedManagementServiceServer
|
||||
|
||||
LoginFunc func(context.Context, *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
||||
SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer)
|
||||
SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer) error
|
||||
GetServerKeyFunc func(context.Context, *proto.Empty) (*proto.ServerKeyResponse, error)
|
||||
IsHealthyFunc func(context.Context, *proto.Empty) (*proto.Empty, error)
|
||||
GetDeviceAuthorizationFlowFunc func(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
||||
@@ -30,7 +30,7 @@ func (m ManagementServiceServerMock) Login(ctx context.Context, req *proto.Encry
|
||||
|
||||
func (m ManagementServiceServerMock) Sync(msg *proto.EncryptedMessage, sync proto.ManagementService_SyncServer) error {
|
||||
if m.SyncFunc != nil {
|
||||
return m.Sync(msg, sync)
|
||||
return m.SyncFunc(msg, sync)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method Sync not implemented")
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
|
||||
return err
|
||||
}
|
||||
|
||||
newNSGroup.PublicID = xid.New().String()
|
||||
|
||||
if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,6 +118,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
|
||||
return err
|
||||
}
|
||||
|
||||
nsGroupToSave.PublicID = oldNSGroup.PublicID
|
||||
|
||||
if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -803,7 +803,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,16 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network
|
||||
|
||||
network.ID = xid.New().String()
|
||||
|
||||
err = m.store.SaveNetwork(ctx, network)
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
network.PublicID = xid.New().String()
|
||||
|
||||
if err := transaction.SaveNetwork(ctx, network); err != nil {
|
||||
return fmt.Errorf("failed to save network: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save network: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkCreated, network.EventMeta())
|
||||
@@ -102,14 +109,25 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
_, err = m.store.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID)
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
existing, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get network: %w", err)
|
||||
}
|
||||
network.PublicID = existing.PublicID
|
||||
|
||||
if err := transaction.SaveNetwork(ctx, network); err != nil {
|
||||
return fmt.Errorf("failed to save network: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get network: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkUpdated, network.EventMeta())
|
||||
|
||||
return network, m.store.SaveNetwork(ctx, network)
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error {
|
||||
|
||||
@@ -255,3 +255,73 @@ func Test_UpdateNetworkFailsWithPermissionDenied(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, updatedNetwork)
|
||||
}
|
||||
|
||||
// Test_CreateNetworkAllocatesSeqID verifies that CreateNetwork sets a
|
||||
// non-zero AccountSeqID on the persisted network (allocated through the
|
||||
// account_seq_counters table).
|
||||
func Test_CreateNetworkSetsPublicId(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "testAccountId"
|
||||
const userID = "testAdminId"
|
||||
|
||||
s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
am := mock_server.MockAccountManager{}
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
groupsManager := groups.NewManagerMock()
|
||||
routerManager := routers.NewManagerMock()
|
||||
resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil)
|
||||
manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am)
|
||||
|
||||
created, err := manager.CreateNetwork(ctx, userID, &types.Network{
|
||||
AccountID: accountID,
|
||||
Name: "seq-allocation-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, "", created.PublicID, "CreateNetwork must allocate a non-zero AccountSeqID")
|
||||
}
|
||||
|
||||
// Test_UpdateNetworkPreservesSeqID verifies UpdateNetwork does not reset
|
||||
// AccountSeqID even when the caller passes a zero value (the shape REST
|
||||
// handlers produce because the field is `json:"-"`).
|
||||
func Test_UpdateNetworkPreservesPublicId(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const accountID = "testAccountId"
|
||||
const userID = "testAdminId"
|
||||
|
||||
s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
am := mock_server.MockAccountManager{}
|
||||
permissionsManager := permissions.NewManager(s)
|
||||
groupsManager := groups.NewManagerMock()
|
||||
routerManager := routers.NewManagerMock()
|
||||
resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil)
|
||||
manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am)
|
||||
|
||||
created, err := manager.CreateNetwork(ctx, userID, &types.Network{
|
||||
AccountID: accountID,
|
||||
Name: "seq-preserve-original",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
originalPublicId := created.PublicID
|
||||
require.NotZero(t, originalPublicId)
|
||||
|
||||
update := &types.Network{
|
||||
AccountID: accountID,
|
||||
ID: created.ID,
|
||||
Name: "seq-preserve-renamed",
|
||||
}
|
||||
require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape")
|
||||
|
||||
_, err = manager.UpdateNetwork(ctx, userID, update)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := manager.GetNetwork(ctx, accountID, userID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalPublicId, got.PublicID, "PublicID must survive UpdateNetwork")
|
||||
require.Equal(t, "seq-preserve-renamed", got.Name)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
@@ -146,6 +147,8 @@ func (m *managerImpl) createResourceInTransaction(ctx context.Context, transacti
|
||||
return nil, nil, fmt.Errorf("failed to get network: %w", err)
|
||||
}
|
||||
|
||||
resource.PublicID = xid.New().String()
|
||||
|
||||
if err = transaction.SaveNetworkResource(ctx, resource); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to save network resource: %w", err)
|
||||
}
|
||||
@@ -245,6 +248,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get network resource: %w", err)
|
||||
}
|
||||
resource.PublicID = oldResource.PublicID
|
||||
|
||||
oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
reverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
|
||||
@@ -32,6 +32,7 @@ type NetworkResource struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
NetworkID string `gorm:"index"`
|
||||
AccountID string `gorm:"index"`
|
||||
PublicID string `json:"-" gorm:"index"`
|
||||
Name string
|
||||
Description string
|
||||
Type NetworkResourceType
|
||||
@@ -96,6 +97,7 @@ func (n *NetworkResource) Copy() *NetworkResource {
|
||||
ID: n.ID,
|
||||
AccountID: n.AccountID,
|
||||
NetworkID: n.NetworkID,
|
||||
PublicID: n.PublicID,
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
Type: n.Type,
|
||||
|
||||
@@ -104,6 +104,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
|
||||
|
||||
router.ID = xid.New().String()
|
||||
|
||||
router.PublicID = xid.New().String()
|
||||
|
||||
err = transaction.CreateNetworkRouter(ctx, router)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create network router: %w", err)
|
||||
@@ -199,6 +201,11 @@ func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction
|
||||
return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
|
||||
}
|
||||
|
||||
// Preserve PublicID from the existing router so the upstream
|
||||
// UpdateNetworkRouter (which does Updates(router) with Select("*"))
|
||||
// doesn't clobber it with the request's zero value.
|
||||
router.PublicID = existing.PublicID
|
||||
|
||||
if err = transaction.UpdateNetworkRouter(ctx, router); err != nil {
|
||||
return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type NetworkRouter struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
NetworkID string `gorm:"index"`
|
||||
AccountID string `gorm:"index"`
|
||||
PublicID string `json:"-"`
|
||||
Peer string
|
||||
PeerGroups []string `gorm:"serializer:json"`
|
||||
Masquerade bool
|
||||
@@ -81,6 +82,7 @@ func (n *NetworkRouter) Copy() *NetworkRouter {
|
||||
ID: n.ID,
|
||||
NetworkID: n.NetworkID,
|
||||
AccountID: n.AccountID,
|
||||
PublicID: n.PublicID,
|
||||
Peer: n.Peer,
|
||||
PeerGroups: n.PeerGroups,
|
||||
Masquerade: n.Masquerade,
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
|
||||
PublicID string `json:"-"`
|
||||
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
@@ -41,11 +44,12 @@ func (n *Network) FromAPIRequest(req *api.NetworkRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a copy of a posture checks.
|
||||
// Copy returns a copy of a network.
|
||||
func (n *Network) Copy() *Network {
|
||||
return &Network{
|
||||
ID: n.ID,
|
||||
AccountID: n.AccountID,
|
||||
PublicID: n.PublicID,
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
}
|
||||
|
||||
+37
-21
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
|
||||
@@ -405,7 +405,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
|
||||
return status.NewPeerNotPartOfAccountError()
|
||||
}
|
||||
|
||||
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
|
||||
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
|
||||
}
|
||||
@@ -740,7 +740,7 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en
|
||||
// to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied
|
||||
// Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
|
||||
// The peer property is just a placeholder for the Peer properties to pass further
|
||||
func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
|
||||
func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded {
|
||||
// no auth method provided => reject access
|
||||
return nil, nil, nil, false, status.ErrNoAuthMethodProvided
|
||||
@@ -1000,7 +1000,7 @@ func getPeerIPDNSLabel(ip netip.Addr, 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) {
|
||||
func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
var peer *nbpeer.Peer
|
||||
var ipv6CapabilityChanged bool
|
||||
var metaDiff nbpeer.MetaDiff
|
||||
@@ -1064,7 +1064,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks)
|
||||
metaDiffAffectsPosture := metaDiffAffectsPosture(&metaDiff, resPostureChecks)
|
||||
if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) {
|
||||
changedPeerIDs := []string{peer.ID}
|
||||
affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture)
|
||||
@@ -1076,6 +1076,14 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
|
||||
return peer, nmap, resPostureChecks, dnsFwdPort, nil
|
||||
}
|
||||
|
||||
// metaDiffAffectsPosture reports whether the meta change flips the verdict of any of
|
||||
// the peer's posture checks, replaying them against the old and new state.
|
||||
func metaDiffAffectsPosture(diff *nbpeer.MetaDiff, checks []*nmdata.PostureChecks) bool {
|
||||
oldPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation})
|
||||
newPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation})
|
||||
return nmdata.PostureVerdictChanged(checks, oldPeer, newPeer)
|
||||
}
|
||||
|
||||
func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool {
|
||||
var reason string
|
||||
switch {
|
||||
@@ -1127,7 +1135,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context,
|
||||
return affectedPeerIDsFromNetworkMap(nmap, peerID)
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
|
||||
func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound {
|
||||
// we couldn't find this peer by its public key which can mean that peer hasn't been registered yet.
|
||||
// Try registering it.
|
||||
@@ -1148,7 +1156,7 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo
|
||||
|
||||
// LoginPeer logs in or registers a peer.
|
||||
// If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so.
|
||||
func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
|
||||
func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey)
|
||||
if err != nil {
|
||||
return am.handlePeerLoginNotFound(ctx, login, err)
|
||||
@@ -1321,7 +1329,7 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
|
||||
|
||||
// getPeerLoginInfo computes the login/register response data (network, posture
|
||||
// checks, SSH) from the store without building the peer's full network map.
|
||||
func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) {
|
||||
func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*nmdata.PostureChecks, bool, error) {
|
||||
network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("get account network: %w", err)
|
||||
@@ -1341,7 +1349,7 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
|
||||
postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID, peerGroupIDs, policies)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
@@ -1363,7 +1371,7 @@ func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.
|
||||
}
|
||||
|
||||
// getPeerPostureChecks returns the posture checks for the peer.
|
||||
func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) {
|
||||
func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string, peerGroupIDs []string, policies []*types.Policy) ([]*nmdata.PostureChecks, error) {
|
||||
if len(policies) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1375,7 +1383,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
|
||||
continue
|
||||
}
|
||||
|
||||
postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs)
|
||||
postureChecksIDs := processPeerPostureChecks(policy, peerID, peerGroupIDs)
|
||||
peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...)
|
||||
}
|
||||
|
||||
@@ -1384,16 +1392,20 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return maps.Values(peerPostureChecks), nil
|
||||
return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
|
||||
}
|
||||
|
||||
// processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks.
|
||||
func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string {
|
||||
// processPeerPostureChecks returns the policy's posture checks when the peer is a source of the policy, directly or through a source group.
|
||||
func processPeerPostureChecks(policy *types.Policy, peerID string, peerGroupIDs []string) []string {
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
|
||||
return policy.SourcePostureChecks
|
||||
}
|
||||
|
||||
for _, sourceGroup := range rule.Sources {
|
||||
if slices.Contains(peerGroupIDs, sourceGroup) {
|
||||
return policy.SourcePostureChecks
|
||||
@@ -1482,9 +1494,12 @@ func checkAuth(ctx context.Context, loginUserID string, peer *nbpeer.Peer) error
|
||||
|
||||
func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Settings) bool {
|
||||
expired, expiresIn := peer.LoginExpired(settings.PeerLoginExpiration)
|
||||
expired = settings.PeerLoginExpirationEnabled && expired
|
||||
if expired || peer.Status.LoginExpired {
|
||||
log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, expiresIn)
|
||||
if settings.PeerLoginExpirationEnabled && expired {
|
||||
log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, -expiresIn)
|
||||
return true
|
||||
}
|
||||
if peer.Status.LoginExpired {
|
||||
log.WithContext(ctx).Debugf("peer's %s login is marked as expired", peer.ID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1588,7 +1603,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
|
||||
}
|
||||
seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
|
||||
ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
|
||||
add := func(peers []*nbpeer.Peer) {
|
||||
add := func(peers []*nmdata.Peer) {
|
||||
for _, p := range peers {
|
||||
if p == nil || p.ID == "" || p.ID == selfPeerID {
|
||||
continue
|
||||
@@ -1631,7 +1646,9 @@ func (am *DefaultAccountManager) UpdateAccountPeer(ctx context.Context, accountI
|
||||
|
||||
// getNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found.
|
||||
// If there is no peer that expires this function returns false and a duration of 0.
|
||||
// This function only considers peers that haven't been expired yet and that are connected.
|
||||
// This function only considers peers that haven't been expired yet. Offline peers count too:
|
||||
// a running job is never re-armed on connect, so a peer that reconnects with an old login
|
||||
// must already be part of the scheduled run.
|
||||
func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, accountID string) (time.Duration, bool) {
|
||||
peersWithExpiry, err := am.Store.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
@@ -1651,8 +1668,7 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco
|
||||
|
||||
var nextExpiry *time.Duration
|
||||
for _, peer := range peersWithExpiry {
|
||||
// consider only connected peers because others will require login on connecting to the management server
|
||||
if peer.Status.LoginExpired || !peer.Status.Connected {
|
||||
if peer.Status.LoginExpired {
|
||||
continue
|
||||
}
|
||||
_, duration := peer.LoginExpired(settings.PeerLoginExpiration)
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// Peer capability constants mirror the proto enum values.
|
||||
const (
|
||||
PeerCapabilitySourcePrefixes int32 = 1
|
||||
PeerCapabilityIPv6Overlay int32 = 2
|
||||
PeerCapabilitySourcePrefixes = nmdata.PeerCapabilitySourcePrefixes
|
||||
PeerCapabilityIPv6Overlay = nmdata.PeerCapabilityIPv6Overlay
|
||||
PeerCapabilityComponentNetworkMap = nmdata.PeerCapabilityComponentNetworkMap
|
||||
)
|
||||
|
||||
// Peer represents a machine connected to the network.
|
||||
@@ -140,6 +142,7 @@ type Flags struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -172,6 +175,7 @@ type PeerSystemMeta struct { //nolint:revive
|
||||
Flags Flags `gorm:"serializer:json"`
|
||||
Files []File `gorm:"serializer:json"`
|
||||
Capabilities []int32 `gorm:"serializer:json"`
|
||||
SyncMessageVersion int
|
||||
}
|
||||
|
||||
func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool {
|
||||
@@ -218,6 +222,14 @@ func (p *Peer) SupportsSourcePrefixes() bool {
|
||||
return p.HasCapability(PeerCapabilitySourcePrefixes)
|
||||
}
|
||||
|
||||
// SupportsComponentNetworkMap reports whether the peer assembles its
|
||||
// NetworkMap from server-shipped components instead of consuming a fully
|
||||
// expanded NetworkMap. Determines whether the network_map controller skips
|
||||
// Calculate() server-side and emits the components envelope.
|
||||
func (p *Peer) SupportsComponentNetworkMap() bool {
|
||||
return p.HasCapability(PeerCapabilityComponentNetworkMap)
|
||||
}
|
||||
|
||||
func capabilitiesEqual(a, b []int32) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
@@ -406,6 +418,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
|
||||
if !sameMultiset(oldMeta.Files, newMeta.Files) {
|
||||
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
|
||||
}
|
||||
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
|
||||
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
|
||||
}
|
||||
|
||||
if !oldLocation.equal(newLocation) {
|
||||
add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP)
|
||||
@@ -559,6 +574,7 @@ func (f Flags) isEqual(other Flags) bool {
|
||||
return f.RosenpassEnabled == other.RosenpassEnabled &&
|
||||
f.RosenpassPermissive == other.RosenpassPermissive &&
|
||||
f.ServerSSHAllowed == other.ServerSSHAllowed &&
|
||||
f.RemoteJobsAllowed == other.RemoteJobsAllowed &&
|
||||
f.DisableClientRoutes == other.DisableClientRoutes &&
|
||||
f.DisableServerRoutes == other.DisableServerRoutes &&
|
||||
f.DisableDNS == other.DisableDNS &&
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
|
||||
return &nbpeer.MetaDiff{
|
||||
OldMeta: oldMeta,
|
||||
NewMeta: newMeta,
|
||||
OldLocation: oldLoc,
|
||||
NewLocation: newLoc,
|
||||
}
|
||||
}
|
||||
|
||||
func postureBundle(def nmdata.ChecksDefinition) []*nmdata.PostureChecks {
|
||||
return []*nmdata.PostureChecks{{Checks: def}}
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_NBVersion(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "1.2.0"}})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oldVer, newVer string
|
||||
want bool
|
||||
}{
|
||||
{"both above min, no flip", "1.3.0", "1.4.0", false},
|
||||
{"both below min, no flip", "1.0.0", "1.1.0", false},
|
||||
{"crosses up below->above", "1.1.0", "1.3.0", true},
|
||||
{"crosses down above->below", "1.3.0", "1.1.0", true},
|
||||
{"unparsable old only -> flip", "garbage", "1.3.0", true},
|
||||
{"unparsable both -> no flip", "garbage", "junk", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
|
||||
nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.Equal(t, tt.want, metaDiffAffectsPosture(diff, c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
|
||||
Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
|
||||
}})
|
||||
|
||||
withinMin := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(withinMin, c))
|
||||
|
||||
crossesDown := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, metaDiffAffectsPosture(crossesDown, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
|
||||
Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
|
||||
}})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "freebsd"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, metaDiffAffectsPosture(diff, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
|
||||
Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
|
||||
}})
|
||||
|
||||
files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
|
||||
nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, metaDiffAffectsPosture(diff, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
|
||||
Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
|
||||
}})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
|
||||
{Path: "/usr/bin/foo", ProcessIsRunning: true},
|
||||
}},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
|
||||
{Path: "/usr/bin/foo", ProcessIsRunning: true},
|
||||
{Path: "/usr/bin/bar", ProcessIsRunning: true},
|
||||
}},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(diff, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_GeoLocation(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{GeoLocationCheck: &nmdata.GeoLocationCheck{
|
||||
Action: posture.CheckActionAllow,
|
||||
Locations: []nmdata.GeoLocation{{CountryCode: "DE"}},
|
||||
}})
|
||||
|
||||
stayAllowed := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
|
||||
nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(stayAllowed, c))
|
||||
|
||||
moveOut := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{CountryCode: "DE"},
|
||||
nbpeer.Location{CountryCode: "FR"},
|
||||
)
|
||||
assert.True(t, metaDiffAffectsPosture(moveOut, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{
|
||||
Action: posture.CheckActionAllow,
|
||||
Ranges: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
}})
|
||||
|
||||
movesOutOfRange := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
|
||||
)
|
||||
assert.True(t, metaDiffAffectsPosture(movesOutOfRange, c))
|
||||
|
||||
staysInRange := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(staysInRange, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_IrrelevantFieldChange(t *testing.T) {
|
||||
c := postureBundle(nmdata.ChecksDefinition{
|
||||
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "1.0.0"},
|
||||
GeoLocationCheck: &nmdata.GeoLocationCheck{Action: posture.CheckActionAllow, Locations: []nmdata.GeoLocation{{CountryCode: "DE"}}},
|
||||
})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
|
||||
nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
|
||||
nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(diff, c))
|
||||
}
|
||||
|
||||
func TestMetaDiffAffectsPosture_NoChecks(t *testing.T) {
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
|
||||
nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, metaDiffAffectsPosture(diff, nil))
|
||||
}
|
||||
|
||||
func TestProcessPeerPostureChecks(t *testing.T) {
|
||||
policy := &types.Policy{
|
||||
Enabled: true,
|
||||
SourcePostureChecks: []string{"pc1"},
|
||||
Rules: []*types.PolicyRule{
|
||||
{Enabled: false, Sources: []string{"g-disabled"}, SourceResource: types.Resource{ID: "peer-disabled", Type: types.ResourceTypePeer}},
|
||||
{Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}},
|
||||
{Enabled: true, SourceResource: types.Resource{ID: "peer-direct", Type: types.ResourceTypePeer}, Destinations: []string{"g-dst"}},
|
||||
{Enabled: true, SourceResource: types.Resource{ID: "peer-as-host", Type: types.ResourceTypeHost}, Destinations: []string{"g-dst"}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-in-group", []string{"g-src"}), "source group member")
|
||||
assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-direct", nil), "direct source peer")
|
||||
assert.Empty(t, processPeerPostureChecks(policy, "peer-elsewhere", []string{"g-dst"}), "destination-only peer")
|
||||
assert.Empty(t, processPeerPostureChecks(policy, "peer-disabled", []string{"g-disabled"}), "disabled rule")
|
||||
assert.Empty(t, processPeerPostureChecks(policy, "peer-as-host", nil), "source resource of a non-peer type")
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -57,6 +57,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
|
||||
Signature: "turn-pass",
|
||||
}
|
||||
networkMap := &types.NetworkMap{
|
||||
Network: &types.Network{Net: *ipnet, Serial: 1000},
|
||||
Peers: []*nbpeer.Peer{{
|
||||
Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
|
||||
Peers: []*nmdata.Peer{{
|
||||
IP: netip.MustParseAddr("192.168.1.2"),
|
||||
IPv6: netip.MustParseAddr("fd00::2"),
|
||||
Key: "peer2-key",
|
||||
DNSLabel: "peer2",
|
||||
SSHEnabled: true,
|
||||
SSHKey: "peer2-ssh-key"}},
|
||||
OfflinePeers: []*nbpeer.Peer{{
|
||||
OfflinePeers: []*nmdata.Peer{{
|
||||
IP: netip.MustParseAddr("192.168.1.3"),
|
||||
IPv6: netip.MustParseAddr("fd00::3"),
|
||||
Key: "peer3-key",
|
||||
DNSLabel: "peer3",
|
||||
SSHEnabled: true,
|
||||
SSHKey: "peer3-ssh-key"}},
|
||||
Routes: []*nbroute.Route{
|
||||
Routes: []*nmdata.Route{
|
||||
{
|
||||
ID: "route1",
|
||||
Network: netip.MustParsePrefix("10.0.0.0/24"),
|
||||
@@ -1169,18 +1170,18 @@ func TestToSyncResponse(t *testing.T) {
|
||||
},
|
||||
}
|
||||
dnsName := "example.com"
|
||||
checks := []*posture.Checks{
|
||||
checks := []*nmdata.PostureChecks{
|
||||
{
|
||||
Checks: posture.ChecksDefinition{
|
||||
ProcessCheck: &posture.ProcessCheck{
|
||||
Processes: []posture.Process{{LinuxPath: "/usr/bin/netbird"}},
|
||||
Checks: nmdata.ChecksDefinition{
|
||||
ProcessCheck: &nmdata.ProcessCheck{
|
||||
Processes: []nmdata.Process{{LinuxPath: "/usr/bin/netbird"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
dnsCache := &cache.DNSConfigCache{}
|
||||
accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true}
|
||||
response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort))
|
||||
response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, types.TwinAccountSettings(accountSettings), nil, []string{}, int64(dnsForwarderPort))
|
||||
|
||||
assert.NotNil(t, response)
|
||||
// assert peer config
|
||||
@@ -1300,7 +1301,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
assert.NoError(t, err)
|
||||
@@ -1391,7 +1392,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
assert.NoError(t, err)
|
||||
@@ -1550,7 +1551,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
assert.NoError(t, err)
|
||||
@@ -1635,7 +1636,7 @@ func Test_LoginPeer(t *testing.T) {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := NewAccountRequestBuffer(ctx, s)
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
|
||||
networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
|
||||
|
||||
am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
var allOps = []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
|
||||
|
||||
// TestAgentNetworkAdminRole pins the delegated-admin contract: full control
|
||||
// over the whole agent_network area (parent grant cascades to every
|
||||
// submodule), read-only on the account objects needed to build policies,
|
||||
// and nothing else in the account.
|
||||
func TestAgentNetworkAdminRole(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleAgentNetworkAdmin]
|
||||
require.True(t, ok, "agent_network_admin must exist in RolesMap")
|
||||
|
||||
agentNetworkModules := []modules.Module{
|
||||
modules.AgentNetwork,
|
||||
modules.AgentNetworkProviders,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
}
|
||||
for _, m := range agentNetworkModules {
|
||||
for _, op := range allOps {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings read rides along because GET /api/accounts (which the
|
||||
// dashboard needs to boot) validates it, like network_admin.
|
||||
for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
|
||||
"agent_network_admin must read %s to build policies and load the dashboard", m)
|
||||
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageViewerRole pins the least-privilege cost role: read on the
|
||||
// aggregated usage overview plus read-only on the resources its filters
|
||||
// and display columns resolve against (users, groups, peers, the provider
|
||||
// list) — no policies, no request-level logs (which can contain captured
|
||||
// prompts), nothing else in the account.
|
||||
func TestUsageViewerRole(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleUsageViewer]
|
||||
require.True(t, ok, "usage_viewer must exist in RolesMap")
|
||||
|
||||
readOnly := []modules.Module{
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkProviders,
|
||||
modules.Users,
|
||||
modules.Groups,
|
||||
modules.Peers,
|
||||
}
|
||||
for _, m := range readOnly {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
|
||||
"usage_viewer must read %s for the usage view and its filters", m)
|
||||
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"usage_viewer must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
denied := []modules.Module{
|
||||
modules.AgentNetwork,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
modules.Networks,
|
||||
modules.SetupKeys,
|
||||
}
|
||||
for _, m := range denied {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"usage_viewer must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingAdminRoleResolves pins that billing_admin has a proper entry
|
||||
// in the permission map. Its plan/seat/invoice permissions are enforced
|
||||
// outside this map; management-side it carries the regular User baseline
|
||||
// instead of failing role resolution.
|
||||
func TestBillingAdminRoleResolves(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleBillingAdmin]
|
||||
require.True(t, ok, "billing_admin must exist in RolesMap")
|
||||
|
||||
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleBillingAdmin)
|
||||
require.NoError(t, err, "billing_admin role must resolve")
|
||||
require.NotEmpty(t, permissions)
|
||||
|
||||
for _, m := range []modules.Module{modules.AgentNetwork, modules.Networks, modules.Users, modules.Peers} {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"billing_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRolesParse pins the API role strings, which are permanent once
|
||||
// released.
|
||||
func TestNewRolesParse(t *testing.T) {
|
||||
assert.Equal(t, types.UserRoleAgentNetworkAdmin, types.StrRoleToUserRole("agent_network_admin"))
|
||||
assert.Equal(t, types.UserRoleUsageViewer, types.StrRoleToUserRole("usage_viewer"))
|
||||
assert.Equal(t, types.UserRoleBillingAdmin, types.StrRoleToUserRole("billing_admin"))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package permissions
|
||||
|
||||
//go:generate go run github.com/golang/mock/mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//go:generate go tool mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -82,6 +82,9 @@ func (m *managerImpl) ValidateUserPermissions(
|
||||
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
|
||||
}
|
||||
|
||||
// ValidateRoleModuleAccess resolves an operation against the role's explicit
|
||||
// grant for the module, then the grant for its parent module when the module
|
||||
// is a dotted submodule, and finally the role's AutoAllowNew default.
|
||||
func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
ctx context.Context,
|
||||
accountID string,
|
||||
@@ -89,7 +92,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
module modules.Module,
|
||||
operation operations.Operation,
|
||||
) bool {
|
||||
if permissions, ok := role.Permissions[module]; ok {
|
||||
if permissions, ok := lookupModulePermissions(role, module); ok {
|
||||
if allowed, exists := permissions[operation]; exists {
|
||||
return allowed
|
||||
}
|
||||
@@ -100,6 +103,21 @@ func (m *managerImpl) ValidateRoleModuleAccess(
|
||||
return role.AutoAllowNew[operation]
|
||||
}
|
||||
|
||||
// lookupModulePermissions returns the role's explicit permission set for the
|
||||
// module, falling back to the parent module's set for dotted submodules. The
|
||||
// second return reports whether any explicit set was found.
|
||||
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
|
||||
if permissions, ok := role.Permissions[module]; ok {
|
||||
return permissions, true
|
||||
}
|
||||
if parent, hasParent := module.Parent(); hasParent {
|
||||
if permissions, ok := role.Permissions[parent]; ok {
|
||||
return permissions, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
|
||||
if user.AccountID != accountID {
|
||||
return ctx, status.NewUserNotPartOfAccountError()
|
||||
@@ -119,7 +137,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
|
||||
permissions := roles.Permissions{}
|
||||
|
||||
for k := range modules.All {
|
||||
if rolePermissions, ok := roleMap.Permissions[k]; ok {
|
||||
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
|
||||
permissions[k] = rolePermissions
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./manager.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package permissions -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod
|
||||
//
|
||||
|
||||
// Package permissions is a generated GoMock package.
|
||||
package permissions
|
||||
@@ -8,18 +13,19 @@ import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
account "github.com/netbirdio/netbird/management/server/account"
|
||||
modules "github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
operations "github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
roles "github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockManager is a mock of Manager interface.
|
||||
type MockManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockManagerMockRecorder is the mock recorder for MockManager.
|
||||
@@ -49,7 +55,7 @@ func (m *MockManager) GetPermissionsByRole(ctx context.Context, role types.UserR
|
||||
}
|
||||
|
||||
// GetPermissionsByRole indicates an expected call of GetPermissionsByRole.
|
||||
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) GetPermissionsByRole(ctx, role any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPermissionsByRole", reflect.TypeOf((*MockManager)(nil).GetPermissionsByRole), ctx, role)
|
||||
}
|
||||
@@ -61,7 +67,7 @@ func (m *MockManager) SetAccountManager(accountManager account.Manager) {
|
||||
}
|
||||
|
||||
// SetAccountManager indicates an expected call of SetAccountManager.
|
||||
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) SetAccountManager(accountManager any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager)
|
||||
}
|
||||
@@ -76,7 +82,7 @@ func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID strin
|
||||
}
|
||||
|
||||
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
|
||||
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateAccountAccess(ctx, accountID, user, allowOwnerAndAdmin any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateAccountAccess", reflect.TypeOf((*MockManager)(nil).ValidateAccountAccess), ctx, accountID, user, allowOwnerAndAdmin)
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func (m *MockManager) ValidateRoleModuleAccess(ctx context.Context, accountID st
|
||||
}
|
||||
|
||||
// ValidateRoleModuleAccess indicates an expected call of ValidateRoleModuleAccess.
|
||||
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role, module, operation any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateRoleModuleAccess", reflect.TypeOf((*MockManager)(nil).ValidateRoleModuleAccess), ctx, accountID, role, module, operation)
|
||||
}
|
||||
@@ -106,7 +112,7 @@ func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, us
|
||||
}
|
||||
|
||||
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
|
||||
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation interface{}) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) ValidateUserPermissions(ctx, accountID, userID, module, operation any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserPermissions", reflect.TypeOf((*MockManager)(nil).ValidateUserPermissions), ctx, accountID, userID, module, operation)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
fullAccess := map[operations.Operation]bool{
|
||||
operations.Read: true,
|
||||
operations.Create: true,
|
||||
operations.Update: true,
|
||||
operations.Delete: true,
|
||||
}
|
||||
readOnly := map[operations.Operation]bool{
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
}
|
||||
denyAll := map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
}
|
||||
|
||||
t.Run("parent grant covers submodules", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
|
||||
"parent full grant should allow create on a submodule")
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
|
||||
"parent full grant should allow read on a submodule")
|
||||
})
|
||||
|
||||
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
|
||||
"explicit submodule read should be allowed")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
|
||||
"read-only submodule grant should not allow create")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
|
||||
"submodule grant should not grant the parent module")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
|
||||
"submodule grant should not grant a sibling submodule")
|
||||
})
|
||||
|
||||
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: denyAll,
|
||||
Permissions: roles.Permissions{
|
||||
modules.AgentNetwork: fullAccess,
|
||||
modules.AgentNetworkLogs: denyAll,
|
||||
},
|
||||
}
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
|
||||
"explicit submodule deny should override the parent grant")
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
|
||||
"sibling submodules should still resolve through the parent grant")
|
||||
})
|
||||
|
||||
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
|
||||
role := roles.RolePermissions{
|
||||
AutoAllowNew: readOnly,
|
||||
}
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
|
||||
"auto-allow read should apply to submodules")
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
|
||||
"auto-allow should not grant unlisted operations")
|
||||
})
|
||||
}
|
||||
|
||||
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
|
||||
// submodule split must not change: every built-in role resolves the new
|
||||
// submodules exactly as it resolved the agent_network module before.
|
||||
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
submodules := []modules.Module{
|
||||
modules.AgentNetworkProviders,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
}
|
||||
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
|
||||
|
||||
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
|
||||
rolePermissions, ok := roles.RolesMap[role]
|
||||
require.True(t, ok, "role %s must exist in RolesMap", role)
|
||||
|
||||
for _, sub := range submodules {
|
||||
for _, op := range allOperations {
|
||||
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
|
||||
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
|
||||
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
|
||||
require.NoError(t, err, "auditor role must resolve")
|
||||
|
||||
usage, ok := permissions[modules.AgentNetworkUsage]
|
||||
require.True(t, ok, "permissions map should contain the usage submodule")
|
||||
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
|
||||
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
|
||||
|
||||
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
|
||||
require.NoError(t, err, "admin role must resolve")
|
||||
providers, ok := adminPermissions[modules.AgentNetworkProviders]
|
||||
require.True(t, ok, "permissions map should contain the providers submodule")
|
||||
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package modules
|
||||
|
||||
import "strings"
|
||||
|
||||
type Module string
|
||||
|
||||
const (
|
||||
@@ -20,6 +22,17 @@ const (
|
||||
IdentityProviders Module = "identity_providers"
|
||||
Services Module = "services"
|
||||
AgentNetwork Module = "agent_network"
|
||||
|
||||
// Agent Network submodules. A role may grant one of these directly
|
||||
// or grant the AgentNetwork parent, which covers all of them (see
|
||||
// permissions.Manager cascade resolution).
|
||||
AgentNetworkProviders Module = "agent_network.providers"
|
||||
AgentNetworkPolicies Module = "agent_network.policies"
|
||||
AgentNetworkGuardrails Module = "agent_network.guardrails"
|
||||
AgentNetworkBudgets Module = "agent_network.budgets"
|
||||
AgentNetworkUsage Module = "agent_network.usage"
|
||||
AgentNetworkLogs Module = "agent_network.logs"
|
||||
AgentNetworkSettings Module = "agent_network.settings"
|
||||
)
|
||||
|
||||
var All = map[Module]struct{}{
|
||||
@@ -40,4 +53,21 @@ var All = map[Module]struct{}{
|
||||
IdentityProviders: {},
|
||||
Services: {},
|
||||
AgentNetwork: {},
|
||||
|
||||
AgentNetworkProviders: {},
|
||||
AgentNetworkPolicies: {},
|
||||
AgentNetworkGuardrails: {},
|
||||
AgentNetworkBudgets: {},
|
||||
AgentNetworkUsage: {},
|
||||
AgentNetworkLogs: {},
|
||||
AgentNetworkSettings: {},
|
||||
}
|
||||
|
||||
// Parent returns the module owning a dotted submodule name and true, or the
|
||||
// module itself and false when it has no parent.
|
||||
func (m Module) Parent() (Module, bool) {
|
||||
if i := strings.IndexByte(string(m), '.'); i > 0 {
|
||||
return Module(string(m)[:i]), true
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// AgentNetworkAdmin is the delegated administrator for the Agent Network
|
||||
// area: full control over providers, policies, guardrails, budgets, usage,
|
||||
// logs, and its settings, plus read-only visibility into the account
|
||||
// objects needed to build policies (users, groups, peers) and the account
|
||||
// settings/meta read the dashboard needs to boot (GET /api/accounts
|
||||
// validates Settings read, same as network_admin). Nothing else in the
|
||||
// account is visible.
|
||||
var AgentNetworkAdmin = RolePermissions{
|
||||
Role: types.UserRoleAgentNetworkAdmin,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
Permissions: Permissions{
|
||||
modules.AgentNetwork: {
|
||||
operations.Read: true,
|
||||
operations.Create: true,
|
||||
operations.Update: true,
|
||||
operations.Delete: true,
|
||||
},
|
||||
modules.Users: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Groups: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Peers: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Accounts: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Settings: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// BillingAdmin manages plans, seats, and invoices, which are enforced
|
||||
// outside this permission map (NetBird Cloud). Management-side it carries
|
||||
// the regular User baseline; the explicit entry keeps role resolution from
|
||||
// failing with a role-not-found error.
|
||||
var BillingAdmin = RolePermissions{
|
||||
Role: types.UserRoleBillingAdmin,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
}
|
||||
@@ -15,9 +15,12 @@ type RolePermissions struct {
|
||||
type Permissions map[modules.Module]map[operations.Operation]bool
|
||||
|
||||
var RolesMap = map[types.UserRole]RolePermissions{
|
||||
types.UserRoleOwner: Owner,
|
||||
types.UserRoleAdmin: Admin,
|
||||
types.UserRoleUser: User,
|
||||
types.UserRoleAuditor: Auditor,
|
||||
types.UserRoleNetworkAdmin: NetworkAdmin,
|
||||
types.UserRoleOwner: Owner,
|
||||
types.UserRoleAdmin: Admin,
|
||||
types.UserRoleUser: User,
|
||||
types.UserRoleAuditor: Auditor,
|
||||
types.UserRoleNetworkAdmin: NetworkAdmin,
|
||||
types.UserRoleAgentNetworkAdmin: AgentNetworkAdmin,
|
||||
types.UserRoleUsageViewer: UsageViewer,
|
||||
types.UserRoleBillingAdmin: BillingAdmin,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// UsageViewer is the regular User baseline plus read access to the
|
||||
// aggregated Agent Network usage and cost overview, and read-only access
|
||||
// to the resources the usage filters and display columns resolve against:
|
||||
// users and groups (identity filters and name resolution), peers (agent
|
||||
// principals in the caller column), and the provider list (provider and
|
||||
// model filter options — the manager redacts connection config such as
|
||||
// upstream URLs and operator-supplied header values for callers holding
|
||||
// read without update). It sees no policies and no account-wide
|
||||
// request-level access logs (which can contain captured prompts); its own
|
||||
// requests remain readable through the self-scoped endpoints, like any
|
||||
// caller's.
|
||||
var UsageViewer = RolePermissions{
|
||||
Role: types.UserRoleUsageViewer,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
Permissions: Permissions{
|
||||
modules.AgentNetworkUsage: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.AgentNetworkProviders: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Users: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Groups: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Peers: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -67,10 +67,13 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
|
||||
|
||||
action = activity.PolicyUpdated
|
||||
|
||||
policy.PublicID = existingPolicy.PublicID
|
||||
|
||||
if err = transaction.SavePolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
policy.PublicID = xid.New().String()
|
||||
if err = transaction.CreatePolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package posture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
)
|
||||
|
||||
// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against.
|
||||
func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
|
||||
return &nbpeer.MetaDiff{
|
||||
OldMeta: oldMeta,
|
||||
NewMeta: newMeta,
|
||||
OldLocation: oldLoc,
|
||||
NewLocation: newLoc,
|
||||
}
|
||||
}
|
||||
|
||||
func checks(def ChecksDefinition) []*Checks {
|
||||
return []*Checks{{Checks: def}}
|
||||
}
|
||||
|
||||
func TestAffectsPosture_NilDiff(t *testing.T) {
|
||||
assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{
|
||||
NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
|
||||
})))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_NBVersion(t *testing.T) {
|
||||
c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oldVer, newVer string
|
||||
want bool
|
||||
}{
|
||||
{"both above min, no flip", "1.3.0", "1.4.0", false},
|
||||
{"both below min, no flip", "1.0.0", "1.1.0", false},
|
||||
{"crosses up below->above", "1.1.0", "1.3.0", true},
|
||||
{"crosses down above->below", "1.3.0", "1.1.0", true},
|
||||
{"unparsable old only -> flip", "garbage", "1.3.0", true},
|
||||
{"unparsable both -> no flip", "garbage", "junk", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
|
||||
nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
|
||||
c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
|
||||
Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
|
||||
}})
|
||||
|
||||
// Kernel moves but stays above the minimum: verdict stays pass -> not affected.
|
||||
withinMin := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), withinMin, c))
|
||||
|
||||
// Kernel drops below the minimum: verdict flips pass -> fail -> affected.
|
||||
crossesDown := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, AffectsPosture(context.Background(), crossesDown, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
|
||||
// Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a
|
||||
// failing linux kernel flips the verdict pass -> fail.
|
||||
c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
|
||||
Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
|
||||
}})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "freebsd"},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, AffectsPosture(context.Background(), diff, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
|
||||
// Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured)
|
||||
// flips the verdict.
|
||||
c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
|
||||
Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
|
||||
}})
|
||||
|
||||
files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
|
||||
nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.True(t, AffectsPosture(context.Background(), diff, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
|
||||
// A tracked process stays running while an unrelated file is added: the verdict does
|
||||
// not move, so posture is not affected.
|
||||
c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
|
||||
Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
|
||||
}})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
|
||||
{Path: "/usr/bin/foo", ProcessIsRunning: true},
|
||||
}},
|
||||
nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
|
||||
{Path: "/usr/bin/foo", ProcessIsRunning: true},
|
||||
{Path: "/usr/bin/bar", ProcessIsRunning: true},
|
||||
}},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), diff, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_GeoLocation(t *testing.T) {
|
||||
c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{
|
||||
Action: CheckActionAllow,
|
||||
Locations: []Location{{CountryCode: "DE"}},
|
||||
}})
|
||||
|
||||
// Moving within allowed countries keeps the verdict; moving out flips it.
|
||||
stayAllowed := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
|
||||
nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), stayAllowed, c))
|
||||
|
||||
moveOut := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{CountryCode: "DE"},
|
||||
nbpeer.Location{CountryCode: "FR"},
|
||||
)
|
||||
assert.True(t, AffectsPosture(context.Background(), moveOut, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
|
||||
// The check reads the connection IP. Moving out of the allowed range flips the verdict;
|
||||
// moving within it does not.
|
||||
_, allowed, _ := net.ParseCIDR("10.0.0.0/8")
|
||||
c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{
|
||||
Action: CheckActionAllow,
|
||||
Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())},
|
||||
}})
|
||||
|
||||
movesOutOfRange := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
|
||||
)
|
||||
assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c))
|
||||
|
||||
staysInRange := diffFrom(
|
||||
nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
|
||||
nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), staysInRange, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) {
|
||||
// Hostname changes but no check reads it: not affected even with checks present.
|
||||
c := checks(ChecksDefinition{
|
||||
NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
|
||||
GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}},
|
||||
})
|
||||
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
|
||||
nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
|
||||
nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), diff, c))
|
||||
}
|
||||
|
||||
func TestAffectsPosture_NoChecks(t *testing.T) {
|
||||
diff := diffFrom(
|
||||
nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
|
||||
nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
|
||||
nbpeer.Location{}, nbpeer.Location{},
|
||||
)
|
||||
assert.False(t, AffectsPosture(context.Background(), diff, nil))
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
@@ -49,50 +48,12 @@ type Checks struct {
|
||||
// AccountID is a reference to the Account that this object belongs
|
||||
AccountID string `json:"-" gorm:"index"`
|
||||
|
||||
PublicID string `json:"-"`
|
||||
|
||||
// Checks is a set of objects that perform the actual checks
|
||||
Checks ChecksDefinition `gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
// AffectsPosture reports whether the change in diff flips the verdict of any check. It
|
||||
// replays each check against the peer's old and new state and compares verdicts, so a
|
||||
// change that moves a field but stays the right side of a threshold (e.g. a kernel bump
|
||||
// still above the minimum) does not force a re-evaluation. See verdictChanged for how an
|
||||
// evaluation error counts.
|
||||
func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool {
|
||||
if diff == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation}
|
||||
newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation}
|
||||
|
||||
for _, c := range checks {
|
||||
for _, check := range c.GetChecks() {
|
||||
if verdictChanged(ctx, check, oldPeer, newPeer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// verdictChanged replays check against old and new state and reports whether the verdict
|
||||
// differs. Like callers, it treats an evaluation error as deny: two errors are the same
|
||||
// verdict (no change), an error on one side only is a flip.
|
||||
func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool {
|
||||
oldPass, oldErr := check.Check(ctx, oldPeer)
|
||||
newPass, newErr := check.Check(ctx, newPeer)
|
||||
|
||||
oldVerdict := oldPass && (oldErr == nil)
|
||||
newVerdict := newPass && (newErr == nil)
|
||||
changed := oldVerdict != newVerdict
|
||||
|
||||
log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v",
|
||||
check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr)
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// ChecksDefinition contains definition of actual check
|
||||
type ChecksDefinition struct {
|
||||
NBVersionCheck *NBVersionCheck `json:",omitempty"`
|
||||
@@ -167,6 +128,7 @@ func (pc *Checks) Copy() *Checks {
|
||||
Name: pc.Name,
|
||||
Description: pc.Description,
|
||||
AccountID: pc.AccountID,
|
||||
PublicID: pc.PublicID,
|
||||
Checks: pc.Checks.Copy(),
|
||||
}
|
||||
return checks
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user