Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add builtin/ext-authz Envoy Extension #17495

Merged
merged 2 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/17495.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
xds: Add a built-in Envoy extension that inserts External Authorization (ext_authz) network and HTTP filters.
```
133 changes: 133 additions & 0 deletions agent/envoyextensions/builtin/ext-authz/ext_authz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package extauthz

import (
"fmt"

envoy_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
"github.com/mitchellh/mapstructure"

"github.com/hashicorp/consul/api"
ext_cmn "github.com/hashicorp/consul/envoyextensions/extensioncommon"
"github.com/hashicorp/go-multierror"
)

type extAuthz struct {
ext_cmn.BasicExtensionAdapter

// ProxyType identifies the type of Envoy proxy that this extension applies to.
// The extension will only be configured for proxies that match this type and
// will be ignored for all other proxy types.
ProxyType api.ServiceKind
// InsertOptions controls how the extension inserts the filter.
InsertOptions ext_cmn.InsertOptions
// Config holds the extension configuration.
Config extAuthzConfig
}

var _ ext_cmn.BasicExtension = (*extAuthz)(nil)

func Constructor(ext api.EnvoyExtension) (ext_cmn.EnvoyExtender, error) {
auth, err := newExtAuthz(ext)
if err != nil {
return nil, err
}
return &ext_cmn.BasicEnvoyExtender{
Extension: auth,
}, nil
}

// CanApply indicates if the ext-authz extension can be applied to the given extension runtime configuration.
func (a *extAuthz) CanApply(config *ext_cmn.RuntimeConfig) bool {
return config.Kind == api.ServiceKindConnectProxy
}

// PatchClusters modifies the cluster resources for the ext-authz extension.
//
// If the extension is configured to target an ext-authz service running on the local host network
// this func will insert a cluster for calling that service. It does nothing if the extension is
// configured to target an upstream service because the existing cluster for the upstream will be
// used directly by the filter.
func (a *extAuthz) PatchClusters(cfg *ext_cmn.RuntimeConfig, c ext_cmn.ClusterMap) (ext_cmn.ClusterMap, error) {
cluster, err := a.Config.toEnvoyCluster(cfg)
if err != nil {
return c, err
}
if cluster != nil {
c[cluster.Name] = cluster
}
return c, nil
}

// PatchFilters inserts an ext-authz filter into the list of network filters or the filter chain of the HTTP connection manager.
func (a *extAuthz) PatchFilters(cfg *ext_cmn.RuntimeConfig, filters []*envoy_listener_v3.Filter, isInboundListener bool) ([]*envoy_listener_v3.Filter, error) {
// The ext_authz extension only patches filters for inbound listeners.
if !isInboundListener {
return filters, nil
}

switch cfg.Protocol {
case "grpc", "http2", "http":
extAuthzFilter, err := a.Config.toEnvoyHttpFilter(cfg)
if err != nil {
return filters, err
}
return ext_cmn.InsertHTTPFilter(filters, extAuthzFilter, a.InsertOptions)
case "tcp":
fallthrough
default:
extAuthzFilter, err := a.Config.toEnvoyNetworkFilter(cfg)
if err != nil {
return filters, err
}
return ext_cmn.InsertNetworkFilter(filters, extAuthzFilter, a.InsertOptions)
}
}

func newExtAuthz(ext api.EnvoyExtension) (*extAuthz, error) {
auth := &extAuthz{}
if ext.Name != api.BuiltinExtAuthzExtension {
return auth, fmt.Errorf("expected extension name %q but got %q", api.BuiltinExtAuthzExtension, ext.Name)
}
if err := auth.fromArguments(ext.Arguments); err != nil {
return auth, err
}
// The filter's failure mode is always configured based on whether or not the extension is required.
auth.Config.failureModeAllow = !ext.Required
return auth, nil
}

func (a *extAuthz) fromArguments(args map[string]any) error {
if err := mapstructure.Decode(args, a); err != nil {
return err
}
a.normalize()
return a.validate()
}

func (a *extAuthz) normalize() {
if a.ProxyType == "" {
a.ProxyType = api.ServiceKindConnectProxy
}
if a.InsertOptions.Location == "" {
a.InsertOptions.Location = ext_cmn.InsertFirst
}
a.Config.normalize()
}

func (a *extAuthz) validate() error {
var resultErr error
if a.ProxyType != api.ServiceKindConnectProxy {
resultErr = multierror.Append(resultErr, fmt.Errorf("unsupported ProxyType %q, only %q is supported",
a.ProxyType,
api.ServiceKindConnectProxy))
}

if err := a.Config.validate(); err != nil {
resultErr = multierror.Append(resultErr, err)
}

return resultErr
}
174 changes: 174 additions & 0 deletions agent/envoyextensions/builtin/ext-authz/ext_authz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package extauthz

import (
"testing"

"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/envoyextensions/extensioncommon"
"github.com/stretchr/testify/require"
)

func TestConstructor(t *testing.T) {
t.Parallel()
cases := map[string]struct {
extName string
args map[string]any
errMsg string
}{
"invalid name": {
extName: "invalid",
errMsg: `expected extension name "builtin/ext-authz"`,
},
"invalid proxy type": {
args: map[string]any{"ProxyType": "invalid"},
errMsg: `unsupported ProxyType`,
},
"no service type": {
args: map[string]any{"ProxyType": "connect-proxy"},
errMsg: `exactly one of GrpcService or HttpService must be set`,
},
"both service types": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "localhost:9191",
},
},
"HttpService": map[string]any{
"Target": map[string]any{
"URI": "localhost:9191",
},
},
},
},
errMsg: `exactly one of GrpcService or HttpService must be set`,
},
"non-loopback address hostname": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "foo.bar.com:9191",
},
},
},
},
errMsg: `invalid host for Target.URI "foo.bar.com:9191": expected 'localhost' or '127.0.0.1'`,
},
"non-loopback address": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "10.0.0.1:9191",
},
},
},
},
errMsg: `invalid host for Target.URI "10.0.0.1:9191": expected 'localhost' or '127.0.0.1'`,
},
"no uri or service target": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"HttpService": map[string]any{
"Target": map[string]any{
"Timeout": "1s",
},
},
},
},
errMsg: `exactly one of Target.Service or Target.URI must be set`,
},
"uri and service target": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "10.0.0.1:9191",
"Service": map[string]any{
"Name": "test-service",
},
},
},
},
},
errMsg: `exactly one of Target.Service or Target.URI must be set`,
},
"invalid status on error": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"StatusOnError": 1,
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "10.0.0.1:9191",
"Service": map[string]any{
"Name": "test-service",
},
},
},
},
},
errMsg: `failed to validate Config.StatusOnError`,
},
"valid grpc service": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"GrpcService": map[string]any{
"Target": map[string]any{
"URI": "localhost:9191",
},
},
},
},
},
"valid http service": {
args: map[string]any{
"ProxyType": "connect-proxy",
"Config": map[string]any{
"HttpService": map[string]any{
"Target": map[string]any{
"URI": "127.0.0.1:9191",
},
},
},
},
},
}
for name, c := range cases {
c := c
t.Run(name, func(t *testing.T) {
extName := api.BuiltinExtAuthzExtension
if c.extName != "" {
extName = c.extName
}
ext, err := newExtAuthz(api.EnvoyExtension{Name: extName, Arguments: c.args})
if c.errMsg == "" {
require.NoError(t, err)

httpFilter, err := ext.Config.toEnvoyHttpFilter(&extensioncommon.RuntimeConfig{})
require.NoError(t, err)
require.NotNil(t, httpFilter)

if ext.Config.isGRPC() {
netFilter, err := ext.Config.toEnvoyNetworkFilter(&extensioncommon.RuntimeConfig{})
require.NoError(t, err)
require.NotNil(t, netFilter)
}
} else {
require.Error(t, err)
require.Contains(t, err.Error(), c.errMsg)
}
})
}
}
Loading