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 }