[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:
Maycon Santos
2026-10-12 12:56:17 +02:00
committed by GitHub
parent 1161616c02
commit cd22a9d4f1
10 changed files with 1111 additions and 56 deletions
@@ -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")
}
@@ -116,18 +116,20 @@ type MiddlewareConfig struct {
}
type Target struct {
ID uint `gorm:"primaryKey" json:"-"`
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
Path *string `json:"path,omitempty"`
Host string `json:"host"`
Port uint16 `gorm:"index:idx_target_port" json:"port"`
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
Options TargetOptions `gorm:"embedded" json:"options"`
ProxyProtocol bool `json:"proxy_protocol"`
ID uint `gorm:"primaryKey" json:"-"`
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
Path *string `json:"path,omitempty"`
Host string `json:"host"`
Port uint16 `gorm:"index:idx_target_port" json:"port"`
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
AccessAction TargetAccessAction `gorm:"type:varchar(16);not null;default:'inherit'" json:"access_action"`
AccessActionProvided bool `gorm:"-" json:"-"`
Options TargetOptions `gorm:"embedded" json:"options"`
ProxyProtocol bool `json:"proxy_protocol"`
}
type PasswordAuthConfig struct {
@@ -309,14 +311,16 @@ func (s *Service) ToAPIResponse() *api.Service {
// Convert internal targets to API targets
apiTargets := make([]api.ServiceTarget, 0, len(s.Targets))
for _, target := range s.Targets {
accessAction := api.ServiceTargetAccessAction(target.effectiveAccessAction())
st := api.ServiceTarget{
Path: target.Path,
Host: &target.Host,
Port: int(target.Port),
Protocol: api.ServiceTargetProtocol(target.Protocol),
TargetId: target.TargetId,
TargetType: api.ServiceTargetTargetType(target.TargetType),
Enabled: target.Enabled && !s.Terminated,
Path: target.Path,
Host: &target.Host,
Port: int(target.Port),
Protocol: api.ServiceTargetProtocol(target.Protocol),
TargetId: target.TargetId,
TargetType: api.ServiceTargetTargetType(target.TargetType),
Enabled: target.Enabled && !s.Terminated,
AccessAction: &accessAction,
}
opts := targetOptionsToAPI(target.Options)
if opts == nil {
@@ -463,8 +467,9 @@ func (s *Service) buildPathMappings() []*proto.PathMapping {
}
pm := &proto.PathMapping{
Path: path,
Target: targetURL.String(),
Path: path,
Target: targetURL.String(),
AccessAction: targetAccessActionToProto(target.effectiveAccessAction()),
}
pm.Options = targetOptionsToProto(target.Options)
pathMappings = append(pathMappings, pm)
@@ -727,13 +732,18 @@ func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Ta
targets := make([]*Target, 0, len(apiTargets))
for i, apiTarget := range apiTargets {
target := &Target{
AccountID: accountID,
Path: apiTarget.Path,
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
Protocol: string(apiTarget.Protocol),
TargetId: apiTarget.TargetId,
TargetType: TargetType(apiTarget.TargetType),
Enabled: apiTarget.Enabled,
AccountID: accountID,
Path: apiTarget.Path,
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
Protocol: string(apiTarget.Protocol),
TargetId: apiTarget.TargetId,
TargetType: TargetType(apiTarget.TargetType),
Enabled: apiTarget.Enabled,
AccessAction: TargetAccessActionInherit,
}
if apiTarget.AccessAction != nil {
target.AccessAction = TargetAccessAction(*apiTarget.AccessAction)
target.AccessActionProvided = true
}
if apiTarget.Host != nil {
target.Host = *apiTarget.Host
@@ -953,41 +963,70 @@ func (s *Service) validateTLSMode() error {
}
func (s *Service) validateHTTPTargets() error {
strictPaths := false
for i, target := range s.Targets {
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
// Host is normally overwritten by replaceHostByLookup with the
// resolved peer IP / resource address; operator-supplied values
// are honored only when DirectUpstream is set. Validate the
// override here so misconfigured hosts fail fast at API time.
if err := validateDirectUpstreamHost(i, target); err != nil {
return err
}
case TargetTypeSubnet:
if err := validateSubnetTarget(i, target); err != nil {
return err
}
case TargetTypeCluster:
if err := validateClusterTarget(i, target); err != nil {
return err
}
default:
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
}
if target.TargetId == "" {
return fmt.Errorf("target %d has empty target_id", i)
}
if target.ProxyProtocol {
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i)
}
if err := validateTargetOptions(i, &target.Options); err != nil {
if err := s.validateHTTPTarget(i, target); err != nil {
return err
}
if target.Enabled && target.effectiveAccessAction() != TargetAccessActionInherit {
strictPaths = true
}
}
if !strictPaths {
return nil
}
paths := make(map[string]int, len(s.Targets))
for i, target := range s.Targets {
if !target.Enabled {
continue
}
normalizedPath := normalizedTargetPath(target.Path)
if previous, ok := paths[normalizedPath]; ok {
return fmt.Errorf("targets %d and %d have duplicate path %q", previous, i, normalizedPath)
}
paths[normalizedPath] = i
}
return nil
}
func (s *Service) validateHTTPTarget(idx int, target *Target) error {
if target == nil {
return fmt.Errorf("target %d is nil", idx)
}
if err := validateTargetAccessAction(idx, target, s.Private); err != nil {
return err
}
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
// Host is normally overwritten by replaceHostByLookup with the
// resolved peer IP / resource address; operator-supplied values
// are honored only when DirectUpstream is set. Validate the
// override here so misconfigured hosts fail fast at API time.
if err := validateDirectUpstreamHost(idx, target); err != nil {
return err
}
case TargetTypeSubnet:
if err := validateSubnetTarget(idx, target); err != nil {
return err
}
case TargetTypeCluster:
if err := validateClusterTarget(idx, target); err != nil {
return err
}
default:
return fmt.Errorf("target %d has invalid target_type %q", idx, target.TargetType)
}
if target.TargetId == "" {
return fmt.Errorf("target %d has empty target_id", idx)
}
if target.ProxyProtocol {
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", idx)
}
return validateTargetOptions(idx, &target.Options)
}
func validateSubnetTarget(idx int, target *Target) error {
host := strings.TrimSpace(target.Host)
if host == "" {
@@ -1063,10 +1102,20 @@ func validateDirectUpstreamHost(idx int, target *Target) error {
}
func (s *Service) validateL4Target(target *Target) error {
if target == nil {
return errors.New("target 0 is nil")
}
// L4 services have a single target; per-target disable is meaningless
// (use the service-level Enabled flag instead). Force it on so that
// buildPathMappings always includes the target in the proto.
target.Enabled = true
action, err := validatedTargetAccessAction(0, target)
if err != nil {
return err
}
if action != TargetAccessActionInherit {
return errors.New("access_action is only supported for HTTP services")
}
if target.TargetId == "" {
return errors.New("target_id is required for L4 services")
@@ -62,9 +62,11 @@ func TestValidate_InvalidTargetType(t *testing.T) {
func TestValidate_ResourceTarget(t *testing.T) {
rp := validProxy()
resourcePath := "/resource"
rp.Targets = append(rp.Targets, &Target{
TargetId: "resource-1",
TargetType: TargetTypeHost,
Path: &resourcePath,
Host: "example.org",
Port: 443,
Protocol: "https",
@@ -73,6 +75,232 @@ func TestValidate_ResourceTarget(t *testing.T) {
require.NoError(t, rp.Validate())
}
func TestValidateTargetAccessAction(t *testing.T) {
t.Run("valid actions", func(t *testing.T) {
for _, action := range []TargetAccessAction{
TargetAccessActionInherit,
TargetAccessActionBypass,
TargetAccessActionBlock,
} {
rp := validProxy()
rp.Targets[0].AccessAction = action
require.NoError(t, rp.Validate(), "action %q should be valid", action)
}
})
t.Run("unknown action", func(t *testing.T) {
rp := validProxy()
rp.Targets[0].AccessAction = "allow"
assert.ErrorContains(t, rp.Validate(), `unknown access_action "allow"`)
})
t.Run("explicit empty action", func(t *testing.T) {
rp := validProxy()
rp.Targets[0].AccessActionProvided = true
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
})
t.Run("private bypass", func(t *testing.T) {
rp := validProxy()
rp.Private = true
rp.AccessGroups = []string{"group-1"}
rp.Targets[0].AccessAction = TargetAccessActionBypass
assert.ErrorContains(t, rp.Validate(), "not supported for private services")
})
t.Run("Agent Network bypass", func(t *testing.T) {
rp := validProxy()
rp.Targets[0].AccessAction = TargetAccessActionBypass
rp.Targets[0].Options.AgentNetwork = true
assert.ErrorContains(t, rp.Validate(), "not supported for Agent Network targets")
})
t.Run("block remains available to private services", func(t *testing.T) {
rp := validProxy()
rp.Private = true
rp.AccessGroups = []string{"group-1"}
rp.Targets[0].AccessAction = TargetAccessActionBlock
require.NoError(t, rp.Validate())
})
t.Run("non HTTP service", func(t *testing.T) {
rp := validProxy()
rp.Mode = ModeTCP
rp.ListenPort = 8080
rp.Targets[0].Protocol = TargetProtoTCP
rp.Targets[0].AccessAction = TargetAccessActionBlock
assert.ErrorContains(t, rp.Validate(), "only supported for HTTP services")
})
t.Run("nil non HTTP target", func(t *testing.T) {
rp := validProxy()
rp.Mode = ModeTCP
rp.ListenPort = 8080
rp.Targets[0] = nil
assert.ErrorContains(t, rp.Validate(), "target 0 is nil")
})
}
func TestValidateTargetAccessActionPath(t *testing.T) {
for _, value := range []string{"api", "/api/../admin", "/api/./admin", "/api//admin", "/api%2Fadmin", `/api\admin`, "/api?x=1", "/api#fragment", "/api;admin", "/api/\x00"} {
t.Run(fmt.Sprintf("path_%q", value), func(t *testing.T) {
rp := validProxy()
rp.Targets[0].Path = &value
rp.Targets[0].AccessAction = TargetAccessActionBypass
assert.ErrorContains(t, rp.Validate(), "access_action path")
})
}
t.Run("nil path is canonical root", func(t *testing.T) {
rp := validProxy()
rp.Targets[0].AccessAction = TargetAccessActionBlock
require.NoError(t, rp.Validate())
})
t.Run("empty path is legacy root", func(t *testing.T) {
value := ""
rp := validProxy()
rp.Targets[0].Path = &value
rp.Targets[0].AccessAction = TargetAccessActionBlock
require.NoError(t, rp.Validate())
})
t.Run("trailing slash is a distinct valid prefix", func(t *testing.T) {
value := "/api/"
rp := validProxy()
rp.Targets[0].Path = &value
rp.Targets[0].AccessAction = TargetAccessActionBypass
require.NoError(t, rp.Validate())
})
}
func TestValidateDuplicateEnabledTargetPaths(t *testing.T) {
emptyPath := ""
rootPath := "/"
rootAliases := []struct {
name string
path *string
}{
{name: "nil", path: nil},
{name: "empty", path: &emptyPath},
{name: "slash", path: &rootPath},
}
for _, alias := range rootAliases {
t.Run("legacy all-inherit "+alias.name, func(t *testing.T) {
rp := validProxy()
rp.Targets = append(rp.Targets, &Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Path: alias.path,
Host: "10.0.0.2",
Port: 443,
Protocol: TargetProtoHTTPS,
Enabled: true,
})
require.NoError(t, rp.Validate(), "legacy all-inherit path and protocol twins must remain valid")
})
t.Run("access-controlled "+alias.name, func(t *testing.T) {
rp := validProxy()
rp.Targets = append(rp.Targets, &Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Path: alias.path,
Host: "10.0.0.2",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: true,
AccessAction: TargetAccessActionBlock,
})
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
})
}
t.Run("access action elsewhere enables strict paths", func(t *testing.T) {
publicPath := "/public"
rp := validProxy()
rp.Targets = append(rp.Targets,
&Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Host: "10.0.0.2",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: true,
},
&Target{
TargetId: "peer-3",
TargetType: TargetTypePeer,
Path: &publicPath,
Host: "10.0.0.3",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: true,
AccessAction: TargetAccessActionBypass,
},
)
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
})
t.Run("disabled action preserves legacy validation", func(t *testing.T) {
publicPath := "/public"
rp := validProxy()
rp.Targets = append(rp.Targets,
&Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Host: "10.0.0.2",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: true,
},
&Target{
TargetId: "peer-3",
TargetType: TargetTypePeer,
Path: &publicPath,
Host: "10.0.0.3",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: false,
AccessAction: TargetAccessActionBlock,
},
)
require.NoError(t, rp.Validate())
})
t.Run("disabled target does not collide", func(t *testing.T) {
rp := validProxy()
rp.Targets = append(rp.Targets, &Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Host: "10.0.0.2",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: false,
})
require.NoError(t, rp.Validate())
})
t.Run("trailing slash remains distinct", func(t *testing.T) {
apiPath := "/api"
apiSlashPath := "/api/"
rp := validProxy()
rp.Targets[0].Path = &apiPath
rp.Targets = append(rp.Targets, &Target{
TargetId: "peer-2",
TargetType: TargetTypePeer,
Path: &apiSlashPath,
Host: "10.0.0.2",
Port: 80,
Protocol: TargetProtoHTTP,
Enabled: true,
AccessAction: TargetAccessActionBlock,
})
require.NoError(t, rp.Validate())
})
}
func TestValidate_MultipleTargetsOneInvalid(t *testing.T) {
rp := validProxy()
rp.Targets = append(rp.Targets, &Target{
@@ -308,6 +536,90 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
}
func TestTargetAccessActionConversions(t *testing.T) {
t.Run("API presence", func(t *testing.T) {
bypass := api.ServiceTargetAccessActionBypass
empty := api.ServiceTargetAccessAction("")
apiTargets := []api.ServiceTarget{
{
TargetId: "peer-1",
TargetType: api.ServiceTargetTargetTypePeer,
Protocol: api.ServiceTargetProtocolHttp,
Port: 80,
Enabled: true,
},
{
TargetId: "peer-2",
TargetType: api.ServiceTargetTargetTypePeer,
Protocol: api.ServiceTargetProtocolHttp,
Port: 80,
Enabled: true,
AccessAction: &bypass,
},
{
TargetId: "peer-3",
TargetType: api.ServiceTargetTargetTypePeer,
Protocol: api.ServiceTargetProtocolHttp,
Port: 80,
Enabled: true,
AccessAction: &empty,
},
}
targets, err := targetsFromAPI("account-1", &apiTargets)
require.NoError(t, err)
require.Len(t, targets, 3)
assert.Equal(t, TargetAccessActionInherit, targets[0].AccessAction)
assert.False(t, targets[0].AccessActionProvided)
assert.Equal(t, TargetAccessActionBypass, targets[1].AccessAction)
assert.True(t, targets[1].AccessActionProvided)
assert.Empty(t, targets[2].AccessAction)
assert.True(t, targets[2].AccessActionProvided)
rp := &Service{Name: "test", Domain: "example.com", Targets: []*Target{targets[2]}}
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
})
t.Run("API response", func(t *testing.T) {
rp := validProxy()
rp.Targets[0].AccessAction = TargetAccessActionBlock
response := rp.ToAPIResponse()
require.Len(t, response.Targets, 1)
require.NotNil(t, response.Targets[0].AccessAction)
assert.Equal(t, api.ServiceTargetAccessActionBlock, *response.Targets[0].AccessAction)
})
t.Run("proto mapping", func(t *testing.T) {
root := "/"
bypassPath := "/bypass"
blockPath := "/block"
rp := &Service{
ID: "svc-1",
AccountID: "acc-1",
Domain: "example.com",
Targets: []*Target{
{Path: &root, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true},
{Path: &bypassPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBypass},
{Path: &blockPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBlock},
},
}
mapping := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
require.Len(t, mapping.Path, 3)
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, mapping.Path[0].GetAccessAction())
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, mapping.Path[1].GetAccessAction())
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, mapping.Path[2].GetAccessAction())
})
}
func TestHasTargetAccessControl(t *testing.T) {
rp := validProxy()
assert.False(t, rp.HasTargetAccessControl())
rp.Targets[0].AccessAction = TargetAccessActionBypass
assert.True(t, rp.HasTargetAccessControl())
}
// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session
// cookies on: without it the proxy can only check a cookie's signature, which
// makes a token minted for a user outside the groups a bearer credential.
@@ -0,0 +1,115 @@
package service
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/netbirdio/netbird/shared/management/proto"
)
// TargetAccessAction controls authentication for a selected HTTP target.
type TargetAccessAction string
const (
TargetAccessActionInherit TargetAccessAction = "inherit"
TargetAccessActionBypass TargetAccessAction = "bypass"
TargetAccessActionBlock TargetAccessAction = "block"
)
func (t *Target) effectiveAccessAction() TargetAccessAction {
if t.AccessAction == "" {
return TargetAccessActionInherit
}
return t.AccessAction
}
func targetAccessActionToProto(action TargetAccessAction) proto.TargetAccessAction {
switch action {
case TargetAccessActionInherit:
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT
case TargetAccessActionBypass:
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS
case TargetAccessActionBlock:
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
default:
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
}
}
// HasTargetAccessControl reports whether any target overrides service authentication.
func (s *Service) HasTargetAccessControl() bool {
for _, target := range s.Targets {
if target != nil && target.effectiveAccessAction() != TargetAccessActionInherit {
return true
}
}
return false
}
func validateTargetAccessAction(idx int, target *Target, private bool) error {
action, err := validatedTargetAccessAction(idx, target)
if err != nil {
return err
}
if action == TargetAccessActionBypass {
if private {
return fmt.Errorf("target %d: bypass access_action is not supported for private services", idx)
}
if target.Options.AgentNetwork {
return fmt.Errorf("target %d: bypass access_action is not supported for Agent Network targets", idx)
}
}
if action == TargetAccessActionInherit {
return nil
}
return validateAccessActionPath(idx, target.Path)
}
func validatedTargetAccessAction(idx int, target *Target) (TargetAccessAction, error) {
if target.AccessActionProvided && target.AccessAction == "" {
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
}
action := target.effectiveAccessAction()
switch action {
case TargetAccessActionInherit, TargetAccessActionBypass, TargetAccessActionBlock:
return action, nil
default:
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
}
}
func normalizedTargetPath(configured *string) string {
if configured == nil || *configured == "" {
return "/"
}
return *configured
}
func validateAccessActionPath(idx int, configured *string) error {
if configured == nil {
return nil
}
value := *configured
if value == "" {
return nil
}
if !strings.HasPrefix(value, "/") {
return fmt.Errorf("target %d: access_action path %q must start with /", idx, value)
}
if !utf8.ValidString(value) || strings.ContainsAny(value, "%\\?#;") || strings.IndexFunc(value, unicode.IsControl) >= 0 {
return fmt.Errorf("target %d: access_action path %q contains invalid characters", idx, value)
}
segments := strings.Split(strings.TrimPrefix(value, "/"), "/")
for i, segment := range segments {
if segment == "." || segment == ".." {
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
}
if segment == "" && value != "/" && i != len(segments)-1 {
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
}
}
return nil
}
@@ -18,7 +18,7 @@ import (
)
const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol,
target_id, target_type, enabled, proxy_protocol,
target_id, target_type, enabled, access_action, proxy_protocol,
skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers,
direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes,
capture_content_types, agent_network, disable_access_log`
@@ -37,6 +37,7 @@ func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) (
func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) {
var t rpservice.Target
var path sql.NullString
var accessAction sql.NullString
var pathRewrite sql.NullString
var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool
var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64
@@ -52,6 +53,7 @@ func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) {
&t.TargetId,
&t.TargetType,
&t.Enabled,
&accessAction,
&proxyProtocol,
&skipTLSVerify,
&requestTimeout,
@@ -72,6 +74,11 @@ func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) {
if path.Valid {
t.Path = &path.String
}
if accessAction.Valid && accessAction.String != "" {
t.AccessAction = rpservice.TargetAccessAction(accessAction.String)
} else {
t.AccessAction = rpservice.TargetAccessActionInherit
}
t.ProxyProtocol = proxyProtocol.Bool
t.Options.SkipTLSVerify = skipTLSVerify.Bool
@@ -87,6 +87,7 @@ func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) {
Protocol: "http",
TargetId: "tgt-1",
Enabled: true,
AccessAction: rpservice.TargetAccessActionBlock,
ProxyProtocol: true,
Options: rpservice.TargetOptions{
SkipTLSVerify: true,
@@ -119,6 +120,7 @@ func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) {
require.Len(t, got.Targets, 1)
tg := got.Targets[0]
assert.Equal(t, rpservice.TargetAccessActionBlock, tg.AccessAction, "target access action")
assert.True(t, tg.ProxyProtocol, "target proxy protocol")
assert.True(t, tg.Options.SkipTLSVerify, "options skip TLS verify")
assert.Equal(t, 30*time.Second, tg.Options.RequestTimeout, "options request timeout")
@@ -0,0 +1,270 @@
package proxy
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/hash/argon2id"
"github.com/netbirdio/netbird/shared/management/proto"
)
func targetAccessService(t *testing.T, upstreamURL string) *service.Service {
t.Helper()
u, err := url.Parse(upstreamURL)
require.NoError(t, err)
host, portText, err := net.SplitHostPort(u.Host)
require.NoError(t, err)
port, err := strconv.ParseUint(portText, 10, 16)
require.NoError(t, err)
headerHash, err := argon2id.Hash(targetAccessSecret)
require.NoError(t, err)
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
newTarget := func(path string, action service.TargetAccessAction) *service.Target {
return &service.Target{
Path: strPtr(path),
Host: host,
Port: uint16(port),
Protocol: u.Scheme,
TargetId: "target-access-upstream",
TargetType: service.TargetTypeCluster,
Enabled: true,
AccessAction: action,
Options: service.TargetOptions{
DirectUpstream: true,
},
}
}
return &service.Service{
ID: "target-access-service",
AccountID: targetAccessAccountID,
Name: "Target Access Control",
Domain: targetAccessDomain,
ProxyCluster: targetAccessCluster,
Enabled: true,
SessionPrivateKey: base64.StdEncoding.EncodeToString(privateKey),
SessionPublicKey: base64.StdEncoding.EncodeToString(publicKey),
Targets: []*service.Target{
newTarget("/", service.TargetAccessActionInherit),
newTarget("/public", service.TargetAccessActionBypass),
newTarget("/blocked", service.TargetAccessActionBlock),
},
Auth: service.AuthConfig{HeaderAuths: []*service.HeaderAuthConfig{{
Enabled: true,
Header: targetAccessHeader,
Value: headerHash,
}}},
Restrictions: service.AccessRestrictions{
BlockedCIDRs: []string{"203.0.113.0/24"},
},
}
}
func privateServiceCapabilities() *proto.ProxyCapabilities {
supported := true
return &proto.ProxyCapabilities{
SupportsPrivateService: &supported,
}
}
func targetAccessMappingStream(t *testing.T, setup *integrationTestSetup, proxyID string, caps *proto.ProxyCapabilities) proto.ProxyService_GetMappingUpdateClient {
t.Helper()
conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
stream, err := proto.NewProxyServiceClient(conn).GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
ProxyId: proxyID,
Version: "target-access-test",
Address: targetAccessCluster,
Capabilities: caps,
})
require.NoError(t, err)
return stream
}
func receiveTargetAccessSnapshot(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient) map[string]*proto.ProxyMapping {
t.Helper()
mappings := make(map[string]*proto.ProxyMapping)
for {
msg, err := stream.Recv()
require.NoError(t, err)
for _, mapping := range msg.GetMapping() {
mappings[mapping.GetId()] = mapping
}
if msg.GetInitialSyncComplete() {
return mappings
}
}
}
func receiveTargetAccessUpdate(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient, serviceID string) *proto.ProxyMapping {
t.Helper()
for {
msg, err := stream.Recv()
require.NoError(t, err)
for _, mapping := range msg.GetMapping() {
if mapping.GetId() == serviceID {
return mapping
}
}
}
}
func TestIntegration_TargetAccessControl_ManagementToUpstream(t *testing.T) {
setup := setupIntegrationTest(t)
t.Cleanup(setup.cleanup)
upstreamRecorder := &targetAccessUpstream{}
upstream := httptest.NewServer(upstreamRecorder)
t.Cleanup(upstream.Close)
svc := targetAccessService(t, upstream.URL)
require.NoError(t, setup.store.CreateService(t.Context(), svc))
stream := targetAccessMappingStream(t, setup, "target-access-proxy", nil)
snapshot := receiveTargetAccessSnapshot(t, stream)
mapping := snapshot[svc.ID]
require.NotNil(t, mapping, "proxy must receive the target-access service")
actions := make(map[string]proto.TargetAccessAction)
for _, pathMapping := range mapping.GetPath() {
actions[pathMapping.GetPath()] = pathMapping.GetAccessAction()
}
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, actions["/"])
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, actions["/public"])
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, actions["/blocked"])
runtime, handler := newTargetAccessRuntime(t)
applyTargetAccessMapping(t, runtime, mapping)
before := len(upstreamRecorder.snapshot())
res := targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", nil)
assert.Equal(t, http.StatusUnauthorized, res.Code, "inherit must retain service authentication")
assert.Len(t, upstreamRecorder.snapshot(), before, "unauthenticated inherited request must not reach upstream")
validHeader := http.Header{targetAccessHeader: []string{targetAccessSecret}}
res = targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", validHeader)
assert.Equal(t, http.StatusOK, res.Code, "valid service authentication must pass an inherited target")
inheritedRequests := upstreamRecorder.snapshot()
require.Len(t, inheritedRequests, before+1)
assert.Empty(t, inheritedRequests[len(inheritedRequests)-1].headers.Get(targetAccessHeader),
"proxy authentication credential must not be forwarded upstream")
bypassHeaders := http.Header{
targetAccessHeader: []string{"spoofed-auth"},
"X-NetBird-User": []string{"spoofed@example.com"},
"X-NetBird-Groups": []string{"administrators"},
}
res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", bypassHeaders)
assert.Equal(t, http.StatusOK, res.Code, "bypass target must reach upstream without authenticating")
bypassRequests := upstreamRecorder.snapshot()
require.Len(t, bypassRequests, before+2)
bypassed := bypassRequests[len(bypassRequests)-1]
assert.Equal(t, "/ready", bypassed.path, "routing must retain the existing literal-prefix rewrite")
assert.Empty(t, bypassed.headers.Get(targetAccessHeader), "configured auth header must be stripped on bypass")
assert.Empty(t, bypassed.headers.Get("X-NetBird-User"), "bypass must not permit spoofed identity")
assert.Empty(t, bypassed.headers.Get("X-NetBird-Groups"), "bypass must not permit spoofed groups")
before = len(bypassRequests)
res = targetAccessRequestTo(handler, "/blocked/secret", "198.51.100.10:12000", validHeader)
assert.Equal(t, http.StatusForbidden, res.Code, "block must win even when service authentication is valid")
assert.Len(t, upstreamRecorder.snapshot(), before, "blocked request must not reach upstream")
res = targetAccessRequestTo(handler, "/public/ready", "203.0.113.8:12000", nil)
assert.Equal(t, http.StatusForbidden, res.Code, "connection restrictions must run before bypass")
assert.Len(t, upstreamRecorder.snapshot(), before, "restricted bypass request must not reach upstream")
for _, target := range svc.Targets {
switch *target.Path {
case "/public":
target.AccessAction = service.TargetAccessActionBlock
case "/blocked":
target.AccessAction = service.TargetAccessActionBypass
}
}
require.NoError(t, setup.store.UpdateService(t.Context(), svc))
persisted, err := setup.store.GetServiceByID(t.Context(), store.LockingStrengthNone, targetAccessAccountID, svc.ID)
require.NoError(t, err)
update := persisted.ToProtoMapping(service.Update, "", nbproxy.OIDCValidationConfig{})
setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{update}})
receivedUpdate := receiveTargetAccessUpdate(t, stream, svc.ID)
applyTargetAccessMapping(t, runtime, receivedUpdate)
res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", validHeader)
assert.Equal(t, http.StatusForbidden, res.Code, "modified action must replace bypass with block")
assert.Len(t, upstreamRecorder.snapshot(), before, "newly blocked request must not reach upstream")
res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil)
assert.Equal(t, http.StatusOK, res.Code, "modified action must replace block with bypass")
assert.Len(t, upstreamRecorder.snapshot(), before+1, "newly bypassed request must reach upstream")
require.NoError(t, setup.store.DeleteService(t.Context(), targetAccessAccountID, svc.ID))
removed := persisted.ToProtoMapping(service.Delete, "", nbproxy.OIDCValidationConfig{})
setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{removed}})
receivedRemoval := receiveTargetAccessUpdate(t, stream, svc.ID)
runtime.removeMapping(t.Context(), receivedRemoval)
res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil)
assert.Equal(t, http.StatusNotFound, res.Code, "removed mapping must no longer route")
assert.Len(t, upstreamRecorder.snapshot(), before+1, "request after removal must not reach upstream")
}
func TestIntegration_TargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) {
setup := setupIntegrationTest(t)
t.Cleanup(setup.cleanup)
upstreamRecorder := &targetAccessUpstream{}
upstream := httptest.NewServer(upstreamRecorder)
t.Cleanup(upstream.Close)
svc := targetAccessService(t, upstream.URL)
svc.ID = "target-access-private"
svc.Domain = "target-access-private.test.proxy.io"
svc.Private = true
svc.Targets = svc.Targets[1:2]
require.NoError(t, setup.store.CreateService(t.Context(), svc))
stream := targetAccessMappingStream(t, setup, "target-access-private", privateServiceCapabilities())
mapping := receiveTargetAccessSnapshot(t, stream)[svc.ID]
require.NotNil(t, mapping, "proxy supporting private services must receive private target-access mapping")
runtime, handler := newTargetAccessRuntime(t)
err := runtime.updateMapping(t.Context(), mapping)
require.Error(t, err, "private service with a bypass target must be rejected")
req := httptest.NewRequest(http.MethodGet, "http://"+svc.Domain+"/public/ready", nil)
req.Host = svc.Domain
req.RemoteAddr = "100.64.0.20:12000"
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusNotFound, recorder.Code, "rejected private mapping must not be published")
assert.Empty(t, upstreamRecorder.snapshot(), "rejected private mapping must not reach upstream")
}
+6
View File
@@ -3104,6 +3104,12 @@ components:
type: string
description: URL path prefix for this target (HTTP only)
example: "/"
access_action:
type: string
description: Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only.
enum: [inherit, bypass, block]
default: inherit
example: "inherit"
protocol:
type: string
description: Protocol to use when connecting to the backend
+27
View File
@@ -1037,6 +1037,27 @@ func (e ServiceRequestMode) Valid() bool {
}
}
// Defines values for ServiceTargetAccessAction.
const (
ServiceTargetAccessActionBlock ServiceTargetAccessAction = "block"
ServiceTargetAccessActionBypass ServiceTargetAccessAction = "bypass"
ServiceTargetAccessActionInherit ServiceTargetAccessAction = "inherit"
)
// Valid indicates whether the value is a known member of the ServiceTargetAccessAction enum.
func (e ServiceTargetAccessAction) Valid() bool {
switch e {
case ServiceTargetAccessActionBlock:
return true
case ServiceTargetAccessActionBypass:
return true
case ServiceTargetAccessActionInherit:
return true
default:
return false
}
}
// Defines values for ServiceTargetProtocol.
const (
ServiceTargetProtocolHttp ServiceTargetProtocol = "http"
@@ -5113,6 +5134,9 @@ type ServiceRequestMode string
// ServiceTarget defines model for ServiceTarget.
type ServiceTarget struct {
// AccessAction Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only.
AccessAction *ServiceTargetAccessAction `json:"access_action,omitempty"`
// Enabled Whether this target is enabled
Enabled bool `json:"enabled"`
@@ -5136,6 +5160,9 @@ type ServiceTarget struct {
TargetType ServiceTargetTargetType `json:"target_type"`
}
// ServiceTargetAccessAction Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only.
type ServiceTargetAccessAction string
// ServiceTargetProtocol Protocol to use when connecting to the backend
type ServiceTargetProtocol string