mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +02:00
[management] Configure per-target HTTP access actions (#8116)
Let HTTP targets inherit service authentication, bypass it, or block access. Validate and persist the action, expose it through the API, and deliver it to the proxy. Private services reject bypass. Validation: service/manager, store, and management-to-proxy integration race tests passed; scoped lint passed.
This commit is contained in:
@@ -339,6 +339,9 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *
|
||||
}
|
||||
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := validateTargetAccessControl(svc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -708,6 +711,12 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
|
||||
}
|
||||
|
||||
m.preserveExistingAuthSecrets(service, existingService)
|
||||
if err := preserveTargetAccessActions(service, existingService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTargetAccessControl(service); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -745,6 +754,68 @@ func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, acc
|
||||
return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster)
|
||||
}
|
||||
|
||||
func validateTargetAccessControl(svc *service.Service) error {
|
||||
if !svc.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
// Legacy requests may acquire an existing action during the update merge.
|
||||
if err := svc.Validate(); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "%s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveTargetAccessActions(updated, existing *service.Service) error {
|
||||
if !existing.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
if slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return target == nil
|
||||
}) {
|
||||
return status.Errorf(status.InvalidArgument, "target must not be nil")
|
||||
}
|
||||
if len(updated.Targets) > 0 && !slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return !target.AccessActionProvided
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
byPath := make(map[string]*service.Target, len(existing.Targets))
|
||||
for _, target := range existing.Targets {
|
||||
location := effectiveTargetPath(target)
|
||||
if _, duplicate := byPath[location]; duplicate {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"cannot preserve target access control for duplicate location %q", location)
|
||||
}
|
||||
byPath[location] = target
|
||||
}
|
||||
actionsProvided := false
|
||||
for _, target := range updated.Targets {
|
||||
actionsProvided = actionsProvided || target.AccessActionProvided
|
||||
location := effectiveTargetPath(target)
|
||||
if previous := byPath[location]; previous != nil && !target.AccessActionProvided {
|
||||
target.AccessAction = previous.AccessAction
|
||||
}
|
||||
delete(byPath, location)
|
||||
}
|
||||
if actionsProvided {
|
||||
return nil
|
||||
}
|
||||
for location, target := range byPath {
|
||||
if target.AccessAction != "" && target.AccessAction != service.TargetAccessActionInherit {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"include access_action when removing or changing controlled target location %q", location)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveTargetPath(target *service.Target) string {
|
||||
if target.Path == nil || *target.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return *target.Path
|
||||
}
|
||||
|
||||
// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters.
|
||||
// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode.
|
||||
// Returns an error if validation fails, otherwise returns nil.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
func TestCreateServiceTargetAccessWithoutActiveProxies(t *testing.T) {
|
||||
for _, action := range []rpservice.TargetAccessAction{
|
||||
rpservice.TargetAccessActionBlock,
|
||||
rpservice.TargetAccessActionBypass,
|
||||
} {
|
||||
t.Run(string(action), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
svc := &rpservice.Service{
|
||||
Name: "target access", Domain: "target.test.netbird.io", Mode: rpservice.ModeHTTP,
|
||||
Enabled: true,
|
||||
Targets: []*rpservice.Target{{
|
||||
AccountID: testAccountID, TargetId: testPeerID,
|
||||
TargetType: rpservice.TargetTypePeer, Protocol: "http", Port: 8080,
|
||||
Enabled: true, AccessAction: action,
|
||||
}},
|
||||
}
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, svc)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 1)
|
||||
assert.Equal(t, action, stored.Targets[0].AccessAction,
|
||||
"accepted target actions must survive persistence")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateServicePreservesOmittedTargetAccessAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Name = "renamed by an older client"
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
rootPath := "/"
|
||||
updated.Targets[0].Path = &rootPath
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, updated.Name, stored.Name, "legacy updates must still apply unrelated edits")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an omitted action must preserve the stored block across equivalent root locations")
|
||||
|
||||
emptyPath := ""
|
||||
updated.Targets[0].Path = &emptyPath
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an older client's empty root location must preserve the stored block")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionInherit, stored.Targets[0].AccessAction,
|
||||
"explicit inherit must clear the stored target block")
|
||||
}
|
||||
|
||||
func TestUpdateServiceActivatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Enabled = false
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Enabled = true
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Enabled, "activating target access must not require an active proxy")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"activation must retain an omitted stored access action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceValidatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "public", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBypass
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Private = true
|
||||
updated.AccessGroups = []string{testGroupID}
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.ErrorContains(t, err, "bypass access_action is not supported for private services")
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.Private, "an invalid preserved bypass must prevent the private-service update")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, stored.Targets[0].AccessAction,
|
||||
"a rejected update must retain the existing target action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceLegacyClientCannotRemoveControlledPath(t *testing.T) {
|
||||
for _, operation := range []string{"rename", "delete"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
blockedPath := "/blocked"
|
||||
existing.Targets = append(existing.Targets, &rpservice.Target{
|
||||
AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer,
|
||||
Protocol: "http", Port: 8080, Enabled: true, Path: &blockedPath,
|
||||
AccessAction: rpservice.TargetAccessActionBlock,
|
||||
})
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Targets[1].AccessAction = rpservice.TargetAccessActionInherit
|
||||
if operation == "rename" {
|
||||
renamedPath := "/renamed"
|
||||
updated.Targets[1].Path = &renamedPath
|
||||
} else {
|
||||
updated.Targets = updated.Targets[:1]
|
||||
}
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.Error(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 2, "a legacy structural edit must retain the controlled target")
|
||||
var blocked *rpservice.Target
|
||||
for _, target := range stored.Targets {
|
||||
if effectiveTargetPath(target) == blockedPath {
|
||||
blocked = target
|
||||
}
|
||||
}
|
||||
require.NotNil(t, blocked, "the original controlled location must still exist")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, blocked.AccessAction,
|
||||
"a rejected legacy edit must not erase the block action")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.HasTargetAccessControl(),
|
||||
"a feature-aware client must be able to intentionally remove or rename the controlled path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveTargetAccessActionsExplicitUpdate(t *testing.T) {
|
||||
existing := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBlock},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit},
|
||||
}}
|
||||
updated := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBypass, AccessActionProvided: true},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit, AccessActionProvided: true},
|
||||
}}
|
||||
require.NoError(t, preserveTargetAccessActions(updated, existing))
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, updated.Targets[0].AccessAction,
|
||||
"explicit actions must not need an ambiguous location-based legacy merge")
|
||||
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an omitted action must be rejected when its stored location is ambiguous")
|
||||
|
||||
existing.Targets = existing.Targets[:1]
|
||||
updated.Targets = nil
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an empty target list must not count as an explicit removal of the stored block")
|
||||
}
|
||||
Reference in New Issue
Block a user