sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/config/gangway.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package config knows how to read and parse config.yaml.
    18  package config
    19  
    20  import (
    21  	"errors"
    22  	"fmt"
    23  	"regexp"
    24  
    25  	"google.golang.org/grpc/metadata"
    26  )
    27  
    28  type Gangway struct {
    29  	// AllowedApiClients encodes identifying information about API clients
    30  	// (AllowedApiClient). An AllowedApiClient has authority to trigger a subset
    31  	// of Prow Jobs.
    32  	AllowedApiClients []AllowedApiClient `json:"allowed_api_clients,omitempty"`
    33  }
    34  
    35  type AllowedApiClient struct {
    36  	// ApiClientGcp contains GoogleCloudPlatform details about a web API client.
    37  	// We currently only support GoogleCloudPlatform but other cloud vendors are
    38  	// possible as additional fields in this struct.
    39  	GCP *ApiClientGcp `json:"gcp,omitempty"`
    40  
    41  	// AllowedJobsFilters contains information about what kinds of Prow jobs this
    42  	// API client is authorized to trigger.
    43  	AllowedJobsFilters []AllowedJobsFilter `json:"allowed_jobs_filters,omitempty"`
    44  }
    45  
    46  // ApiClientGcp encodes GCP Cloud Endpoints-specific HTTP metadata header
    47  // information, which are expected to be populated by the ESPv2 sidecar
    48  // container for GKE applications (in our case, the gangway pod).
    49  type ApiClientGcp struct {
    50  	// EndpointApiConsumerType is the expected value of the
    51  	// x-endpoint-api-consumer-type HTTP metadata header. Typically this will be
    52  	// "PROJECT".
    53  	EndpointApiConsumerType string `json:"endpoint_api_consumer_type,omitempty"`
    54  	// EndpointApiConsumerNumber is the expected value of the
    55  	// x-endpoint-api-consumer-number HTTP metadata header. Typically this
    56  	// encodes the GCP Project number value, which uniquely identifies a GCP
    57  	// Project.
    58  	EndpointApiConsumerNumber string `json:"endpoint_api_consumer_number,omitempty"`
    59  }
    60  
    61  type ApiClientCloudVendor interface {
    62  	GetVendorName() string
    63  	GetRequiredMdHeaders() []string
    64  	GetUUID() string
    65  	Validate() error
    66  }
    67  
    68  func (gcp *ApiClientGcp) GetVendorName() string {
    69  	return "gcp"
    70  }
    71  
    72  func (gcp *ApiClientGcp) GetRequiredMdHeaders() []string {
    73  	// These headers were drawn from this example:
    74  	// https://github.com/envoyproxy/envoy/issues/13207 (source code appears
    75  	// to be
    76  	// https://github.com/GoogleCloudPlatform/esp-v2/blob/3828042e5b3f840e17837c1a019f4014276014d8/tests/endpoints/bookstore_grpc/server/server.go).
    77  	// Here's an example of what these headers can look like in practice
    78  	// (whitespace edited for readability):
    79  	//
    80  	//     map[
    81  	//       :authority:[localhost:20785]
    82  	//       accept-encoding:[gzip]
    83  	//       content-type:[application/grpc]
    84  	//       user-agent:[Go-http-client/1.1]
    85  	//       x-endpoint-api-consumer-number:[123456]
    86  	//       x-endpoint-api-consumer-type:[PROJECT]
    87  	//       x-envoy-original-method:[GET]
    88  	//       x-envoy-original-path:[/v1/shelves/200?key=api-key]
    89  	//       x-forwarded-proto:[http]
    90  	//       x-request-id:[44770c9a-ee5f-4e36-944e-198b8d9c5196]
    91  	//       ]
    92  	//
    93  	//  We only use 2 of the above because the others are not that useful at this level.
    94  	return []string{"x-endpoint-api-consumer-type", "x-endpoint-api-consumer-number"}
    95  }
    96  
    97  func (gcp *ApiClientGcp) Validate() error {
    98  	if gcp == nil {
    99  		return nil
   100  	}
   101  
   102  	if gcp.EndpointApiConsumerType != "PROJECT" {
   103  		return fmt.Errorf("unsupported GCP API consumer type: %q", gcp.EndpointApiConsumerType)
   104  	}
   105  
   106  	var validProjectNumber = regexp.MustCompile(`^[0-9]+$`)
   107  	if !validProjectNumber.MatchString(gcp.EndpointApiConsumerNumber) {
   108  		return fmt.Errorf("invalid EndpointApiConsumerNumber: %q", gcp.EndpointApiConsumerNumber)
   109  	}
   110  
   111  	return nil
   112  }
   113  
   114  func (gcp *ApiClientGcp) GetUUID() string {
   115  	return fmt.Sprintf("gcp-%s-%s", gcp.EndpointApiConsumerType, gcp.EndpointApiConsumerNumber)
   116  }
   117  
   118  func (allowedApiClient *AllowedApiClient) GetApiClientCloudVendor() (ApiClientCloudVendor, error) {
   119  	if allowedApiClient.GCP != nil {
   120  		return allowedApiClient.GCP, nil
   121  	}
   122  
   123  	return nil, errors.New("allowedApiClient did not have a cloud vendor set")
   124  }
   125  
   126  // IdentifyAllowedClient looks at the HTTP request headers (metadata) and tries
   127  // to match it up with an allowlisted Client already defined in the main Config.
   128  //
   129  // Each supported client type (e.g., GCP) has custom logic around the HTTP
   130  // metadata headers to know what kind of headers to look for. Different cloud
   131  // vendors will have different HTTP metdata headers, although technically
   132  // nothing stops users from injecting these headers manually on their own.
   133  func (c *Config) IdentifyAllowedClient(md *metadata.MD) (*AllowedApiClient, error) {
   134  	if md == nil {
   135  		return nil, errors.New("metadata cannot be nil")
   136  	}
   137  
   138  	if c == nil {
   139  		return nil, errors.New("config cannot be nil")
   140  	}
   141  
   142  	for _, client := range c.Gangway.AllowedApiClients {
   143  		cv, err := client.GetApiClientCloudVendor()
   144  		if err != nil {
   145  			return nil, err
   146  		}
   147  
   148  		switch cv.GetVendorName() {
   149  		// For GCP (GKE) Prow installations Gangway must receive the special headers
   150  		// "x-endpoint-api-consumer-type" and "x-endpoint-api-consumer-number". This is
   151  		// because in GKE, Gangway must run behind a Cloud Endpoints sidecar container
   152  		// (which acts as a proxy and injects these special headers). These headers
   153  		// allow us to identify the caller's associated GCP Project, which we need in
   154  		// order to filter out only those Prow Jobs that this project is allowed to
   155  		// create. Otherwise, any caller could trigger any Prow Job, which is far from
   156  		// ideal from a security standpoint.
   157  		case "gcp":
   158  			v := md.Get("x-endpoint-api-consumer-type")
   159  			if len(v) == 0 {
   160  				return nil, errors.New("missing x-endpoint-api-consumer-type header")
   161  			}
   162  			if client.GCP.EndpointApiConsumerType != "PROJECT" {
   163  				return nil, fmt.Errorf("unsupported GCP API consumer type: %q", v[0])
   164  			}
   165  			v = md.Get("x-endpoint-api-consumer-number")
   166  			if len(v) == 0 {
   167  				return nil, errors.New("missing x-endpoint-api-consumer-number header")
   168  			}
   169  
   170  			// Now check whether we can find the same information in the Config's allowlist.
   171  			//
   172  			// Note that we do not check whether multiple AllowedApiClient
   173  			// elements match here. That case (where there are duplicate clients
   174  			// with the same EndpointApiConsumerNumber) is taken care of during
   175  			// validation.
   176  			if client.GCP.EndpointApiConsumerNumber == v[0] {
   177  				return &client, nil
   178  			}
   179  		}
   180  	}
   181  
   182  	return nil, fmt.Errorf("could not find allowed client from %v", md)
   183  }
   184  
   185  // AllowedJobsFilter defines filters for jobs that are allowed by an
   186  // authenticated API client.
   187  type AllowedJobsFilter struct {
   188  	TenantID string `json:"tenant_id,omitempty"`
   189  }
   190  
   191  func (ajf AllowedJobsFilter) Validate() error {
   192  	// TODO (listx): If there are other filter fields, we have to make sure that
   193  	// all filters with a non-empty value are valid. Currently we only have a
   194  	// TenantID filter so this one must be set and not empty.
   195  	if len(ajf.TenantID) == 0 {
   196  		return errors.New("AllowedJobsFilters entry has an empty tenant_id")
   197  	}
   198  	return nil
   199  }
   200  
   201  func (g *Gangway) Validate() error {
   202  	declaredClients := make(map[string]bool)
   203  	for _, allowedApiClient := range g.AllowedApiClients {
   204  		cv, err := allowedApiClient.GetApiClientCloudVendor()
   205  		if err != nil {
   206  			return err
   207  		}
   208  		if err := cv.Validate(); err != nil {
   209  			return err
   210  		}
   211  
   212  		switch cv.GetVendorName() {
   213  		case "gcp":
   214  			if _, declaredAlready := declaredClients[cv.GetUUID()]; declaredAlready {
   215  				return fmt.Errorf("AllowedApiClient %q declared multiple times", cv.GetUUID())
   216  			}
   217  			declaredClients[cv.GetUUID()] = true
   218  		}
   219  
   220  		if len(allowedApiClient.AllowedJobsFilters) == 0 {
   221  			return errors.New("allowed_jobs_filters field cannot be empty")
   222  		}
   223  
   224  		for _, allowedJobsFilter := range allowedApiClient.AllowedJobsFilters {
   225  			if err := allowedJobsFilter.Validate(); err != nil {
   226  				return err
   227  			}
   228  		}
   229  	}
   230  
   231  	return nil
   232  }