consul/agent/envoyextensions/builtin/ext-authz/structs.go

675 lines
20 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package extauthz
import (
"fmt"
"strconv"
"strings"
"time"
envoy_cluster_v3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
envoy_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
envoy_endpoint_v3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
envoy_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
envoy_http_ext_authz_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3"
envoy_ext_authz_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/ext_authz/v3"
envoy_http_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
envoy_upstreams_http_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/upstreams/http/v3"
envoy_type_matcher_v3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3"
envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
"github.com/hashicorp/go-multierror"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/api"
cmn "github.com/hashicorp/consul/envoyextensions/extensioncommon"
)
const (
LocalExtAuthzClusterName = "local_ext_authz"
defaultMetadataNS = "consul"
defaultStatPrefix = "response"
defaultStatusOnError = 403
)
type extAuthzConfig struct {
BootstrapMetadataLabelsKey string
ClearRouteCache *bool
GrpcService *GrpcService
HttpService *HttpService
IncludePeerCertificate *bool
MetadataContextNamespaces []string
StatusOnError *int
StatPrefix string
WithRequestBody *BufferSettings
failureModeAllow bool
}
func (c *extAuthzConfig) normalize() {
if c.StatPrefix == "" {
c.StatPrefix = defaultStatPrefix
}
if c.isGRPC() {
c.GrpcService.normalize()
}
if c.isHTTP() {
c.HttpService.normalize()
}
}
func (c *extAuthzConfig) validate() error {
c.normalize()
var resultErr error
if c.isGRPC() == c.isHTTP() {
resultErr = multierror.Append(resultErr, fmt.Errorf("exactly one of GrpcService or HttpService must be set"))
}
var field string
var validate func() error
if c.isHTTP() {
field = "HttpService"
validate = c.HttpService.validate
} else {
field = "GrpcService"
validate = c.GrpcService.validate
}
if err := validate(); err != nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("failed to validate Config.%s: %w", field, err))
}
if c.StatusOnError != nil {
if _, ok := envoy_type_v3.StatusCode_name[int32(*c.StatusOnError)]; !ok {
resultErr = multierror.Append(resultErr, fmt.Errorf("failed to validate Config.StatusOnError:"+
"status code %d is not supported by Envoy, please refer to the Envoy documentation for supported status codes",
*c.StatusOnError))
}
}
return resultErr
}
func (c extAuthzConfig) envoyGrpcService(cfg *cmn.RuntimeConfig) (*envoy_core_v3.GrpcService, error) {
target := c.GrpcService.Target
clusterName, err := c.getClusterName(cfg, target)
if err != nil {
return nil, err
}
var initialMetadata []*envoy_core_v3.HeaderValue
for _, meta := range c.GrpcService.InitialMetadata {
initialMetadata = append(initialMetadata, meta.toEnvoy())
}
return &envoy_core_v3.GrpcService{
TargetSpecifier: &envoy_core_v3.GrpcService_EnvoyGrpc_{
EnvoyGrpc: &envoy_core_v3.GrpcService_EnvoyGrpc{
ClusterName: clusterName,
Authority: c.GrpcService.Authority,
},
},
Timeout: target.timeoutDurationPB(),
InitialMetadata: initialMetadata,
}, nil
}
func (c extAuthzConfig) envoyHttpService(cfg *cmn.RuntimeConfig) (*envoy_http_ext_authz_v3.HttpService, error) {
clusterName, err := c.getClusterName(cfg, c.HttpService.Target)
if err != nil {
return nil, err
}
return &envoy_http_ext_authz_v3.HttpService{
ServerUri: &envoy_core_v3.HttpUri{
Uri: clusterName, // not used by Envoy, set to cluster
HttpUpstreamType: &envoy_core_v3.HttpUri_Cluster{Cluster: clusterName},
Timeout: c.HttpService.Target.timeoutDurationPB(),
},
PathPrefix: c.HttpService.PathPrefix,
AuthorizationRequest: c.HttpService.AuthorizationRequest.toEnvoy(),
AuthorizationResponse: c.HttpService.AuthorizationResponse.toEnvoy(),
}, nil
}
// getClusterName returns the name of the cluster for the external authorization service.
// If the extension is configured with an upstream ext-authz service then the name of the cluster for
// that upstream is returned. If the extension is configured with a URI, the only allowed host is `localhost`
// and the extension will insert a new cluster with the name "local_ext_authz", so we use that name.
func (c extAuthzConfig) getClusterName(cfg *cmn.RuntimeConfig, target *Target) (string, error) {
var err error
clusterName := LocalExtAuthzClusterName
if target.isService() {
if clusterName, err = target.clusterName(cfg); err != nil {
return "", err
}
}
return clusterName, nil
}
func (c extAuthzConfig) isGRPC() bool {
return c.GrpcService != nil
}
func (c extAuthzConfig) isHTTP() bool {
return c.HttpService != nil
}
// toEnvoyCluster returns an Envoy cluster for connecting to the ext_authz service.
// If the extension is configured with the ext_authz service locally via the URI set to localhost,
// this func will return a new cluster definition that will allow the proxy to connect to the ext_authz
// service running on localhost on the configured port.
//
// If the extension is configured with the ext_authz service as an upstream there is no need to insert
// a new cluster so this method returns nil.
func (c *extAuthzConfig) toEnvoyCluster(_ *cmn.RuntimeConfig) (*envoy_cluster_v3.Cluster, error) {
var target *Target
if c.isHTTP() {
target = c.HttpService.Target
} else {
target = c.GrpcService.Target
}
// If the target is an upstream we do not need to create a cluster. We will use the cluster of the upstream.
if target.isService() {
return nil, nil
}
host, port, err := target.addr()
if err != nil {
return nil, err
}
var typedExtProtoOpts map[string]*anypb.Any
if c.isGRPC() {
// By default HTTP/1.1 is used for the transport protocol. gRPC requires that we explicitly configure HTTP/2
httpProtoOpts := &envoy_upstreams_http_v3.HttpProtocolOptions{
UpstreamProtocolOptions: &envoy_upstreams_http_v3.HttpProtocolOptions_ExplicitHttpConfig_{
ExplicitHttpConfig: &envoy_upstreams_http_v3.HttpProtocolOptions_ExplicitHttpConfig{
ProtocolConfig: &envoy_upstreams_http_v3.HttpProtocolOptions_ExplicitHttpConfig_Http2ProtocolOptions{},
},
},
}
httpProtoOptsAny, err := anypb.New(httpProtoOpts)
if err != nil {
return nil, err
}
typedExtProtoOpts = make(map[string]*anypb.Any)
typedExtProtoOpts["envoy.extensions.upstreams.http.v3.HttpProtocolOptions"] = httpProtoOptsAny
}
return &envoy_cluster_v3.Cluster{
Name: LocalExtAuthzClusterName,
ClusterDiscoveryType: &envoy_cluster_v3.Cluster_Type{Type: envoy_cluster_v3.Cluster_STATIC},
ConnectTimeout: target.timeoutDurationPB(),
LoadAssignment: &envoy_endpoint_v3.ClusterLoadAssignment{
ClusterName: LocalExtAuthzClusterName,
Endpoints: []*envoy_endpoint_v3.LocalityLbEndpoints{
{
LbEndpoints: []*envoy_endpoint_v3.LbEndpoint{{
HostIdentifier: &envoy_endpoint_v3.LbEndpoint_Endpoint{
Endpoint: &envoy_endpoint_v3.Endpoint{
Address: &envoy_core_v3.Address{
Address: &envoy_core_v3.Address_SocketAddress{
SocketAddress: &envoy_core_v3.SocketAddress{
Address: host,
PortSpecifier: &envoy_core_v3.SocketAddress_PortValue{
PortValue: uint32(port),
},
},
},
},
},
},
}},
},
},
},
TypedExtensionProtocolOptions: typedExtProtoOpts,
}, nil
}
func (c extAuthzConfig) toEnvoyHttpFilter(cfg *cmn.RuntimeConfig) (*envoy_http_v3.HttpFilter, error) {
extAuthzFilter := &envoy_http_ext_authz_v3.ExtAuthz{
StatPrefix: c.StatPrefix,
WithRequestBody: c.WithRequestBody.toEnvoy(),
TransportApiVersion: envoy_core_v3.ApiVersion_V3,
MetadataContextNamespaces: append(c.MetadataContextNamespaces, defaultMetadataNS),
FailureModeAllow: c.failureModeAllow,
BootstrapMetadataLabelsKey: c.BootstrapMetadataLabelsKey,
}
if c.isHTTP() {
httpSvc, err := c.envoyHttpService(cfg)
if err != nil {
return nil, err
}
extAuthzFilter.Services = &envoy_http_ext_authz_v3.ExtAuthz_HttpService{HttpService: httpSvc}
} else {
grpcSvc, err := c.envoyGrpcService(cfg)
if err != nil {
return nil, err
}
extAuthzFilter.Services = &envoy_http_ext_authz_v3.ExtAuthz_GrpcService{GrpcService: grpcSvc}
}
if c.ClearRouteCache != nil {
extAuthzFilter.ClearRouteCache = *c.ClearRouteCache
}
if c.IncludePeerCertificate != nil {
extAuthzFilter.IncludePeerCertificate = *c.IncludePeerCertificate
}
if c.StatusOnError != nil {
extAuthzFilter.StatusOnError = &envoy_type_v3.HttpStatus{
Code: envoy_type_v3.StatusCode(*c.StatusOnError),
}
}
return cmn.MakeEnvoyHTTPFilter("envoy.filters.http.ext_authz", extAuthzFilter)
}
func (c extAuthzConfig) toEnvoyNetworkFilter(cfg *cmn.RuntimeConfig) (*envoy_listener_v3.Filter, error) {
grpcSvc, err := c.envoyGrpcService(cfg)
if err != nil {
return nil, err
}
extAuthzFilter := &envoy_ext_authz_v3.ExtAuthz{
GrpcService: grpcSvc,
StatPrefix: c.StatPrefix,
TransportApiVersion: envoy_core_v3.ApiVersion_V3,
FailureModeAllow: c.failureModeAllow,
}
if c.IncludePeerCertificate != nil {
extAuthzFilter.IncludePeerCertificate = *c.IncludePeerCertificate
}
return cmn.MakeFilter("envoy.filters.network.ext_authz", extAuthzFilter)
}
type validator interface {
validate() error
}
type AuthorizationRequest struct {
AllowedHeaders ListStringMatcher
HeadersToAdd []*HeaderValue
}
func (r *AuthorizationRequest) toEnvoy() *envoy_http_ext_authz_v3.AuthorizationRequest {
if r == nil {
return nil
}
if len(r.AllowedHeaders) == 0 && len(r.HeadersToAdd) == 0 {
return nil
}
req := &envoy_http_ext_authz_v3.AuthorizationRequest{
AllowedHeaders: r.AllowedHeaders.toEnvoy(),
}
for _, header := range r.HeadersToAdd {
req.HeadersToAdd = append(req.HeadersToAdd, header.toEnvoy())
}
return req
}
func (r *AuthorizationRequest) validate() error {
var resultErr error
if r == nil {
return resultErr
}
if err := r.AllowedHeaders.validate(); err != nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("validation failed for AuthorizationRequest.AllowedHeaders: %w", err))
}
return resultErr
}
type AuthorizationResponse struct {
AllowedUpstreamHeaders ListStringMatcher
AllowedUpstreamHeadersToAppend ListStringMatcher
AllowedClientHeaders ListStringMatcher
AllowedClientHeadersOnSuccess ListStringMatcher
DynamicMetadataFromHeaders ListStringMatcher
}
func (r *AuthorizationResponse) toEnvoy() *envoy_http_ext_authz_v3.AuthorizationResponse {
if r == nil {
return nil
}
return &envoy_http_ext_authz_v3.AuthorizationResponse{
AllowedUpstreamHeaders: r.AllowedUpstreamHeaders.toEnvoy(),
AllowedUpstreamHeadersToAppend: r.AllowedUpstreamHeadersToAppend.toEnvoy(),
AllowedClientHeaders: r.AllowedClientHeaders.toEnvoy(),
AllowedClientHeadersOnSuccess: r.AllowedClientHeadersOnSuccess.toEnvoy(),
DynamicMetadataFromHeaders: r.DynamicMetadataFromHeaders.toEnvoy(),
}
}
func (r *AuthorizationResponse) validate() error {
var resultErr error
if r == nil {
return resultErr
}
for field, matchers := range r.fieldMap() {
if err := matchers.validate(); err != nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("validation failed for AuthorizationResponse.%s: %w", field, err))
}
}
return resultErr
}
func (r *AuthorizationResponse) fieldMap() map[string]ListStringMatcher {
if r == nil {
return nil
}
return map[string]ListStringMatcher{
"AllowedUpstreamHeaders": r.AllowedUpstreamHeaders,
"AllowedUpstreamHeadersToAppend": r.AllowedUpstreamHeadersToAppend,
"AllowedClientHeaders": r.AllowedClientHeaders,
"AllowedClientHeadersOnSuccess": r.AllowedClientHeadersOnSuccess,
"DynamicMetadataFromHeaders": r.DynamicMetadataFromHeaders,
}
}
type BufferSettings struct {
MaxRequestBytes *int64
AllowPartialMessage *bool
PackAsBytes *bool
}
func (b *BufferSettings) toEnvoy() *envoy_http_ext_authz_v3.BufferSettings {
if b == nil {
return nil
}
if b.AllowPartialMessage == nil &&
b.MaxRequestBytes == nil &&
b.PackAsBytes == nil {
return nil
}
bufSet := &envoy_http_ext_authz_v3.BufferSettings{}
if b.AllowPartialMessage != nil {
bufSet.AllowPartialMessage = *b.AllowPartialMessage
}
if b.MaxRequestBytes != nil {
bufSet.MaxRequestBytes = uint32(*b.MaxRequestBytes)
}
if b.PackAsBytes != nil {
bufSet.PackAsBytes = *b.PackAsBytes
}
return bufSet
}
type GrpcService struct {
Target *Target
Authority string
InitialMetadata []*HeaderValue
}
func (v *GrpcService) normalize() {
if v == nil {
return
}
v.Target.normalize()
}
func (v *GrpcService) validate() error {
var resultErr error
if v == nil {
return resultErr
}
if v.Target == nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("GrpcService.Target must be set"))
}
if err := v.Target.validate(); err != nil {
resultErr = multierror.Append(resultErr, err)
}
return resultErr
}
type HeaderValue struct {
Key string
Value string
}
func (h *HeaderValue) toEnvoy() *envoy_core_v3.HeaderValue {
if h == nil {
return nil
}
return &envoy_core_v3.HeaderValue{Key: h.Key, Value: h.Value}
}
type HttpService struct {
Target *Target
PathPrefix string
AuthorizationRequest *AuthorizationRequest
AuthorizationResponse *AuthorizationResponse
}
func (v *HttpService) normalize() {
if v == nil {
return
}
v.Target.normalize()
}
func (v *HttpService) validate() error {
var resultErr error
if v == nil {
return resultErr
}
if v.Target == nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("HttpService.Target must be set"))
}
for _, val := range []validator{v.Target, v.AuthorizationRequest, v.AuthorizationResponse} {
if err := val.validate(); err != nil {
resultErr = multierror.Append(resultErr, err)
}
}
return resultErr
}
type ListStringMatcher []*StringMatcher
func (l ListStringMatcher) toEnvoy() *envoy_type_matcher_v3.ListStringMatcher {
if len(l) < 1 {
return nil
}
matchers := &envoy_type_matcher_v3.ListStringMatcher{}
for _, matcher := range l {
matchers.Patterns = append(matchers.Patterns, matcher.toEnvoy())
}
return matchers
}
func (l ListStringMatcher) validate() error {
var resultErr error
if len(l) < 1 {
return nil
}
for idx, matcher := range l {
if err := matcher.validate(); err != nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("validation failed for matcher at index %d: %w", idx, err))
}
}
return resultErr
}
type StringMatcher struct {
Contains string
Exact string
IgnoreCase bool
Prefix string
SafeRegex string
Suffix string
}
func (s *StringMatcher) toEnvoy() *envoy_type_matcher_v3.StringMatcher {
if s == nil {
return nil
}
switch {
case s.Contains != "":
return &envoy_type_matcher_v3.StringMatcher{
MatchPattern: &envoy_type_matcher_v3.StringMatcher_Contains{Contains: s.Contains},
IgnoreCase: s.IgnoreCase,
}
case s.Exact != "":
return &envoy_type_matcher_v3.StringMatcher{
MatchPattern: &envoy_type_matcher_v3.StringMatcher_Exact{Exact: s.Exact},
IgnoreCase: s.IgnoreCase,
}
case s.Prefix != "":
return &envoy_type_matcher_v3.StringMatcher{
MatchPattern: &envoy_type_matcher_v3.StringMatcher_Prefix{Prefix: s.Prefix},
IgnoreCase: s.IgnoreCase,
}
case s.SafeRegex != "":
return &envoy_type_matcher_v3.StringMatcher{
MatchPattern: &envoy_type_matcher_v3.StringMatcher_SafeRegex{
SafeRegex: &envoy_type_matcher_v3.RegexMatcher{
EngineType: &envoy_type_matcher_v3.RegexMatcher_GoogleRe2{},
Regex: s.SafeRegex,
},
},
}
case s.Suffix != "":
return &envoy_type_matcher_v3.StringMatcher{
MatchPattern: &envoy_type_matcher_v3.StringMatcher_Suffix{Suffix: s.Suffix},
IgnoreCase: s.IgnoreCase,
}
default:
return nil
}
}
func (s *StringMatcher) validate() error {
if s == nil {
return nil
}
set := 0
for _, s := range []string{s.Contains, s.Exact, s.Prefix, s.SafeRegex, s.Suffix} {
if s != "" {
set++
}
}
if set != 1 {
return fmt.Errorf("exactly one of Contains, Exact, Prefix, SafeRegex or Suffix must be set")
}
return nil
}
type Target struct {
Service api.CompoundServiceName
URI string
Timeout string
timeout *time.Duration
host string
port int
}
// addr returns the host and port for the target when the target is a URI.
// It returns a non-nil error if the target is not a URI.
func (t Target) addr() (string, int, error) {
if !t.isURI() {
return "", 0, fmt.Errorf("target is not configured with a URI, set Target.URI")
}
return t.host, t.port, nil
}
// clusterName returns the cluster name for the target when the target is an upstream service.
// It searches through the upstreams in the provided runtime configuration and returns the name
// of the cluster for the first upstream service that matches the target service.
// It returns a non-nil error if a matching cluster is not found or if the target is not an
// upstream service.
func (t Target) clusterName(cfg *cmn.RuntimeConfig) (string, error) {
if !t.isService() {
return "", fmt.Errorf("target is not configured with an upstream service, set Target.Service")
}
for service, upstream := range cfg.Upstreams {
if service == t.Service {
for sni := range upstream.SNIs {
return sni, nil
}
}
}
return "", fmt.Errorf("no upstream definition found for service %q", t.Service.Name)
}
func (t Target) isService() bool {
return t.Service.Name != ""
}
func (t Target) isURI() bool {
return t.URI != ""
}
func (t *Target) normalize() {
if t == nil {
return
}
t.Service.Namespace = acl.NamespaceOrDefault(t.Service.Namespace)
t.Service.Partition = acl.PartitionOrDefault(t.Service.Partition)
}
// timeoutDurationPB returns the target's timeout as a *durationpb.Duration.
// It returns nil if the timeout has not been explicitly set.
func (t *Target) timeoutDurationPB() *durationpb.Duration {
if t == nil || t.timeout == nil {
return nil
}
return durationpb.New(*t.timeout)
}
func (t *Target) validate() error {
var err, resultErr error
if t == nil {
return resultErr
}
if t.isURI() == t.isService() {
resultErr = multierror.Append(resultErr, fmt.Errorf("exactly one of Target.Service or Target.URI must be set"))
}
if t.isURI() {
// Strip the protocol if one was provided
if _, addr, hasProto := strings.Cut(t.URI, "://"); hasProto {
t.URI = addr
}
addr := strings.Split(t.URI, ":")
if len(addr) == 2 {
t.host = addr[0]
if t.host != "localhost" && t.host != "127.0.0.1" {
resultErr = multierror.Append(resultErr, fmt.Errorf("invalid host for Target.URI %q: expected 'localhost' or '127.0.0.1'", t.URI))
}
if t.port, err = strconv.Atoi(addr[1]); err != nil {
resultErr = multierror.Append(resultErr, fmt.Errorf("invalid port for Target.URI %q", addr[1]))
}
} else {
resultErr = multierror.Append(resultErr, fmt.Errorf("invalid format for Target.URI %q: expected host:port", t.URI))
}
}
if t.Timeout != "" {
if d, err := time.ParseDuration(t.Timeout); err == nil {
t.timeout = &d
} else {
resultErr = multierror.Append(resultErr, fmt.Errorf("failed to parse Target.Timeout %q as a duration: %w", t.Timeout, err))
}
}
return resultErr
}