mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 20:19:07 +02:00
add ssh affected user resolve
This commit is contained in:
@@ -1629,6 +1629,7 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
var user *types.User
|
var user *types.User
|
||||||
var change affectedpeers.Change
|
var change affectedpeers.Change
|
||||||
var snap *affectedpeers.Snapshot
|
var snap *affectedpeers.Snapshot
|
||||||
|
var requiresAccountUpdate bool
|
||||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
|
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1668,6 +1669,9 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
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
|
||||||
|
|
||||||
// Propagate changes to peers if group propagation is enabled
|
// Propagate changes to peers if group propagation is enabled
|
||||||
if settings.GroupsPropagationEnabled {
|
if settings.GroupsPropagationEnabled {
|
||||||
@@ -1692,16 +1696,9 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
|
|
||||||
change.LinkGroups = allGroupChanges
|
change.LinkGroups = allGroupChanges
|
||||||
|
|
||||||
// An IPv6 reconcile can change the peers' addresses, which are visible
|
// The reconciliation reassigns IPv6 addresses across the account, which
|
||||||
// through ALL their group memberships, so seed the full walk instead of
|
// every peer that can reach the reassigned ones observes.
|
||||||
// folding the peers alone.
|
requiresAccountUpdate = ipv6ReconcileNeeded(settings, allGroupChanges)
|
||||||
for _, g := range allGroupChanges {
|
|
||||||
if slices.Contains(settings.IPv6EnabledGroups, g) {
|
|
||||||
change.ChangedPeerIDs = change.OutputPeerIDs
|
|
||||||
change.OutputPeerIDs = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
||||||
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||||
@@ -1712,16 +1709,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user->group mapping shipped to SSH destination peers (GroupIDToUserIDs)
|
|
||||||
// changed for these groups even without peer propagation, so the destinations
|
|
||||||
// of SSH rules authorizing them must refresh.
|
|
||||||
sshDistributionGroups, sshPeerIDs, err := sshAuthorizedGroupConsumers(ctx, transaction, userAuth.AccountId, allGroupChanges)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
change.DistributionGroupIDs = sshDistributionGroups
|
|
||||||
change.OutputPeerIDs = append(change.OutputPeerIDs, sshPeerIDs...)
|
|
||||||
|
|
||||||
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
|
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1749,12 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if requiresAccountUpdate {
|
||||||
|
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})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
|
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
|
||||||
bgCtx := context.WithoutCancel(ctx)
|
bgCtx := context.WithoutCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1777,53 +1770,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sshAuthorizedGroupConsumers returns the destination groups and destination peers of
|
|
||||||
// enabled SSH rules whose AuthorizedGroups reference any of the changed groups. Their
|
|
||||||
// network maps carry the user->group mapping for those groups.
|
|
||||||
func sshAuthorizedGroupConsumers(ctx context.Context, transaction store.Store, accountID string, changedGroupIDs []string) ([]string, []string, error) {
|
|
||||||
if len(changedGroupIDs) == 0 {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("error getting account policies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
changed := make(map[string]struct{}, len(changedGroupIDs))
|
|
||||||
for _, id := range changedGroupIDs {
|
|
||||||
changed[id] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupIDs []string
|
|
||||||
var peerIDs []string
|
|
||||||
for _, policy := range policies {
|
|
||||||
if policy == nil || !policy.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, rule := range policy.Rules {
|
|
||||||
if !rule.Enabled || rule.Protocol != types.PolicyRuleProtocolNetbirdSSH {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
authorized := false
|
|
||||||
for groupID := range rule.AuthorizedGroups {
|
|
||||||
if _, ok := changed[groupID]; ok {
|
|
||||||
authorized = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !authorized {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
groupIDs = append(groupIDs, rule.Destinations...)
|
|
||||||
if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
|
|
||||||
peerIDs = append(peerIDs, rule.DestinationResource.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groupIDs, peerIDs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAccountIDWithAuthorizationClaims retrieves an account ID using JWT Claims.
|
// getAccountIDWithAuthorizationClaims retrieves an account ID using JWT Claims.
|
||||||
// if domain is not private or domain is invalid, it will return the account ID by user ID.
|
// if domain is not private or domain is invalid, it will return the account ID by user ID.
|
||||||
// if domain is of the PrivateCategory category, it will evaluate
|
// if domain is of the PrivateCategory category, it will evaluate
|
||||||
@@ -2502,30 +2448,27 @@ func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Contex
|
|||||||
return fmt.Errorf("get account settings: %w", err)
|
return fmt.Errorf("get account settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(settings.IPv6EnabledGroups) == 0 {
|
if !ipv6ReconcileNeeded(settings, groupIDs) {
|
||||||
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 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
|
||||||
|
// reconciliation. A reconciliation reassigns addresses across the whole account, and a
|
||||||
|
// peer's address is visible to everyone that reaches it through any of its groups, so
|
||||||
|
// callers that otherwise compute an affected-peers set must fall back to updating the
|
||||||
|
// whole account.
|
||||||
|
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 {
|
func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error {
|
||||||
if settings.NetworkRangeV6.IsValid() {
|
if settings.NetworkRangeV6.IsValid() {
|
||||||
network.NetV6 = net.IPNet{
|
network.NetV6 = net.IPNet{
|
||||||
|
|||||||
@@ -8,12 +8,81 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
"github.com/netbirdio/netbird/management/server/types"
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
"github.com/netbirdio/netbird/shared/auth"
|
"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
|
// TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated verifies that a JWT
|
||||||
// auto-group change updates only the user's peers and the peers linked to the changed
|
// 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.
|
// group through policies, instead of fanning out to the whole account.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
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
|
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
|
hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
|
||||||
// the resource<->router bridge can fire for any of these
|
// 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 needsRoutersResources {
|
||||||
if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
|
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).
|
// (correct when the peer's own attributes changed, e.g. IP/status).
|
||||||
OutputPeerIDs []string
|
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
|
// 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
|
// 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
|
// 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.Resources) == 0 &&
|
||||||
len(c.Networks) == 0 &&
|
len(c.Networks) == 0 &&
|
||||||
len(c.PostureCheckIDs) == 0 &&
|
len(c.PostureCheckIDs) == 0 &&
|
||||||
|
len(c.UserGroupIDs) == 0 &&
|
||||||
|
!c.AllowedUsersChanged &&
|
||||||
len(c.DistributionGroupIDs) == 0 &&
|
len(c.DistributionGroupIDs) == 0 &&
|
||||||
len(c.RemovedPeersByGroup) == 0 &&
|
len(c.RemovedPeersByGroup) == 0 &&
|
||||||
len(c.LinkGroups) == 0 &&
|
len(c.LinkGroups) == 0 &&
|
||||||
@@ -359,6 +374,9 @@ func (r *resolver) walk() {
|
|||||||
r.collectFromProxyServices()
|
r.collectFromProxyServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.collectFromSSHAuthorizedGroups()
|
||||||
|
r.collectFromAllowedUsers()
|
||||||
|
|
||||||
r.collectFromChangedRoutes(r.change.Routes)
|
r.collectFromChangedRoutes(r.change.Routes)
|
||||||
r.collectFromChangedRouters(r.change.Routers)
|
r.collectFromChangedRouters(r.change.Routers)
|
||||||
r.collectFromChangedResources(r.change.Resources)
|
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() {
|
func (r *resolver) collectFromDNSSettings() {
|
||||||
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
|
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ func TestChangeIsEmpty(t *testing.T) {
|
|||||||
assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
|
assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
|
||||||
assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
|
assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
|
||||||
assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.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) {
|
func TestPolicyReferencesPostureChecks(t *testing.T) {
|
||||||
|
|||||||
+76
-17
@@ -593,7 +593,9 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateAccountPeers bool
|
var requiresAccountUpdate bool
|
||||||
|
var snaps []*affectedpeers.Snapshot
|
||||||
|
var changes []affectedpeers.Change
|
||||||
var peersToExpire []*nbpeer.Peer
|
var peersToExpire []*nbpeer.Peer
|
||||||
var addUserEvents []func()
|
var addUserEvents []func()
|
||||||
var usersToSave = make([]*types.User, 0, len(updates))
|
var usersToSave = make([]*types.User, 0, len(updates))
|
||||||
@@ -629,20 +631,26 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
_, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
|
effect, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
|
||||||
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
|
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
|
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAccountPeers = true
|
|
||||||
|
|
||||||
err = transaction.SaveUser(ctx, updatedUser)
|
err = transaction.SaveUser(ctx, updatedUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save updated user %s: %w", update.Id, err)
|
return fmt.Errorf("failed to save updated user %s: %w", update.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snap, err := affectedpeers.Load(ctx, transaction, accountID, effect.change)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
requiresAccountUpdate = requiresAccountUpdate || effect.requiresAccountUpdate
|
||||||
|
snaps = append(snaps, snap)
|
||||||
|
changes = append(changes, effect.change)
|
||||||
usersToSave = append(usersToSave, updatedUser)
|
usersToSave = append(usersToSave, updatedUser)
|
||||||
addUserEvents = append(addUserEvents, userEvents...)
|
addUserEvents = append(addUserEvents, userEvents...)
|
||||||
peersToExpire = append(peersToExpire, userPeersToExpire...)
|
peersToExpire = append(peersToExpire, userPeersToExpire...)
|
||||||
@@ -683,11 +691,15 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
|
|||||||
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
|
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else if updateAccountPeers {
|
} else if len(usersToSave) > 0 {
|
||||||
if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
|
if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||||
return nil, fmt.Errorf("failed to increment network serial: %w", err)
|
return nil, fmt.Errorf("failed to increment network serial: %w", err)
|
||||||
}
|
}
|
||||||
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
if requiresAccountUpdate {
|
||||||
|
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
||||||
|
} else {
|
||||||
|
go am.dispatchAffected(ctx, accountID, snaps, changes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedUsersInfo, globalErr
|
return updatedUsersInfo, globalErr
|
||||||
@@ -758,20 +770,31 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac
|
|||||||
return eventsToStore
|
return eventsToStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userUpdateEffect describes how a user update has to reach peers. Users only enter a
|
||||||
|
// network map through the SSH rules, so the change resolves to those rules' peers —
|
||||||
|
// except when the update triggers an IPv6 reconciliation, which reassigns addresses
|
||||||
|
// across the account and so has to reach everyone.
|
||||||
|
type userUpdateEffect struct {
|
||||||
|
change affectedpeers.Change
|
||||||
|
requiresAccountUpdate bool
|
||||||
|
}
|
||||||
|
|
||||||
func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group,
|
func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group,
|
||||||
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (bool, *types.User, []*nbpeer.Peer, []func(), error) {
|
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (userUpdateEffect, *types.User, []*nbpeer.Peer, []func(), error) {
|
||||||
|
|
||||||
|
var effect userUpdateEffect
|
||||||
|
|
||||||
if update == nil {
|
if update == nil {
|
||||||
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
|
return effect, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
|
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, nil, err
|
return effect, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil {
|
if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil {
|
||||||
return false, nil, nil, nil, err
|
return effect, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// only auto groups, revoked status, and integration reference can be updated for now
|
// only auto groups, revoked status, and integration reference can be updated for now
|
||||||
@@ -792,13 +815,13 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
|||||||
var transferredOwnerRole bool
|
var transferredOwnerRole bool
|
||||||
result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update)
|
result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, nil, err
|
return effect, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
transferredOwnerRole = result
|
transferredOwnerRole = result
|
||||||
|
|
||||||
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, updatedUser.AccountID, update.Id)
|
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, updatedUser.AccountID, update.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, nil, err
|
return effect, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var peersToExpire []*nbpeer.Peer
|
var peersToExpire []*nbpeer.Peer
|
||||||
@@ -807,6 +830,21 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
|||||||
peersToExpire = userPeers
|
peersToExpire = userPeers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A user reaches a peer's network map only through the SSH rules: as part of a
|
||||||
|
// group -> user mapping, and as part of the account's allowed-user set. Creating,
|
||||||
|
// blocking or unblocking a user adds it to or removes it from both, so every group
|
||||||
|
// it maps into changes — including the All group that holds every active user.
|
||||||
|
// Otherwise only the auto-groups it joined or left do.
|
||||||
|
if isNewUser || oldUser.IsBlocked() != updatedUser.IsBlocked() {
|
||||||
|
effect.change.AllowedUsersChanged = true
|
||||||
|
effect.change.UserGroupIDs = slices.Concat(oldUser.AutoGroups, updatedUser.AutoGroups, allGroupIDs(groupsMap))
|
||||||
|
} else {
|
||||||
|
effect.change.UserGroupIDs = slices.Concat(
|
||||||
|
util.Difference(oldUser.AutoGroups, updatedUser.AutoGroups),
|
||||||
|
util.Difference(updatedUser.AutoGroups, oldUser.AutoGroups),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var removedGroups, addedGroups []string
|
var removedGroups, addedGroups []string
|
||||||
if update.AutoGroups != nil && settings.GroupsPropagationEnabled {
|
if update.AutoGroups != nil && settings.GroupsPropagationEnabled {
|
||||||
removedGroups = util.Difference(oldUser.AutoGroups, update.AutoGroups)
|
removedGroups = util.Difference(oldUser.AutoGroups, update.AutoGroups)
|
||||||
@@ -814,26 +852,47 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
|
|||||||
for _, peer := range userPeers {
|
for _, peer := range userPeers {
|
||||||
for _, groupID := range removedGroups {
|
for _, groupID := range removedGroups {
|
||||||
if err := transaction.RemovePeerFromGroup(ctx, peer.ID, groupID); err != nil {
|
if err := transaction.RemovePeerFromGroup(ctx, peer.ID, groupID); err != nil {
|
||||||
return false, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
|
return effect, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, groupID := range addedGroups {
|
for _, groupID := range addedGroups {
|
||||||
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
|
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
|
||||||
return false, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
|
return effect, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allGroupChanges := slices.Concat(removedGroups, addedGroups)
|
allGroupChanges := slices.Concat(removedGroups, addedGroups)
|
||||||
|
if len(allGroupChanges) > 0 {
|
||||||
|
effect.change.LinkGroups = allGroupChanges
|
||||||
|
for _, peer := range userPeers {
|
||||||
|
effect.change.OutputPeerIDs = append(effect.change.OutputPeerIDs, peer.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconciliation reassigns IPv6 addresses across the account, which every
|
||||||
|
// peer that can reach the reassigned ones observes.
|
||||||
|
effect.requiresAccountUpdate = ipv6ReconcileNeeded(settings, allGroupChanges)
|
||||||
|
|
||||||
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
|
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
|
||||||
return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
return effect, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAccountPeers := len(userPeers) > 0
|
|
||||||
userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction)
|
userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction)
|
||||||
|
|
||||||
return updateAccountPeers, updatedUser, peersToExpire, userEventsToAdd, nil
|
return effect, updatedUser, peersToExpire, userEventsToAdd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allGroupIDs returns the ID of the account's All group, which every active user maps
|
||||||
|
// into, as a slice so callers can concatenate it.
|
||||||
|
func allGroupIDs(groupsMap map[string]*types.Group) []string {
|
||||||
|
for _, group := range groupsMap {
|
||||||
|
if group.IsGroupAll() {
|
||||||
|
return []string{group.ID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist.
|
// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist.
|
||||||
|
|||||||
Reference in New Issue
Block a user