Merge remote-tracking branch 'origin/main' into feature/native-grpc

This commit is contained in:
pascal
2026-09-23 11:31:53 +02:00
1475 changed files with 138989 additions and 25007 deletions
+74 -51
View File
@@ -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()
+7 -6
View File
@@ -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)
}
+16 -95
View File
@@ -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
}
+245 -13
View File
@@ -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,
+11 -3
View File
@@ -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)
})
}
+72 -1
View File
@@ -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")
}
+14 -18
View File
@@ -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, &notFound) {
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
}
+51
View File
@@ -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")
+57
View File
@@ -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
View File
@@ -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)
})
}
+63
View File
@@ -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
View File
@@ -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)
})
}
+11 -36
View File
@@ -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
View File
@@ -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")
}
+2 -2
View File
@@ -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
View File
@@ -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 {
+42
View File
@@ -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
+5 -1
View File
@@ -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))
+6 -1
View File
@@ -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
View File
@@ -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) {
+1 -2
View File
@@ -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
+295 -1
View File
@@ -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")
})
}
+2 -2
View File
@@ -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"
+23 -4
View File
@@ -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)
}
+22 -15
View File
@@ -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
+84 -5
View File
@@ -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")
}
+18 -11
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+166 -2
View File
@@ -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")
})
}
}
+14
View File
@@ -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
+9 -7
View File
@@ -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"
+3 -2
View File
@@ -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{}{}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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(),
+126
View File
@@ -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")
}
+4
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+23 -5
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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)
+18 -2
View File
@@ -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 &&
+203
View File
@@ -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")
}
+15 -14
View File
@@ -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"))
}
+21 -3
View File
@@ -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
}
+12 -6
View File
@@ -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,
},
},
}
+3
View File
@@ -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))
}
+3 -41
View File
@@ -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