trust_boundary.go raw
1 // Copyright 2025 Google LLC
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package trustboundary
16
17 import (
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "log/slog"
24 "net/http"
25 "os"
26 "strings"
27 "sync"
28
29 "cloud.google.com/go/auth"
30 "cloud.google.com/go/auth/internal"
31 "cloud.google.com/go/auth/internal/retry"
32 "cloud.google.com/go/auth/internal/transport/headers"
33 "github.com/googleapis/gax-go/v2/internallog"
34 )
35
36 const (
37 // serviceAccountAllowedLocationsEndpoint is the URL for fetching allowed locations for a given service account email.
38 serviceAccountAllowedLocationsEndpoint = "https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s/allowedLocations"
39 )
40
41 // isEnabled wraps isTrustBoundaryEnabled with sync.OnceValues to ensure it's
42 // called only once.
43 var isEnabled = sync.OnceValues(isTrustBoundaryEnabled)
44
45 // IsEnabled returns if the trust boundary feature is enabled and an error if
46 // the configuration is invalid. The underlying check is performed only once.
47 func IsEnabled() (bool, error) {
48 return isEnabled()
49 }
50
51 // isTrustBoundaryEnabled checks if the trust boundary feature is enabled via
52 // GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED environment variable.
53 //
54 // If the environment variable is not set, it is considered false.
55 //
56 // The environment variable is interpreted as a boolean with the following
57 // (case-insensitive) rules:
58 // - "true", "1" are considered true.
59 // - "false", "0" are considered false.
60 //
61 // Any other values will return an error.
62 func isTrustBoundaryEnabled() (bool, error) {
63 const envVar = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED"
64 val, ok := os.LookupEnv(envVar)
65 if !ok {
66 return false, nil
67 }
68 val = strings.ToLower(val)
69 switch val {
70 case "true", "1":
71 return true, nil
72 case "false", "0":
73 return false, nil
74 default:
75 return false, fmt.Errorf(`invalid value for %s: %q. Must be one of "true", "false", "1", or "0"`, envVar, val)
76 }
77 }
78
79 // ConfigProvider provides specific configuration for trust boundary lookups.
80 type ConfigProvider interface {
81 // GetTrustBoundaryEndpoint returns the endpoint URL for the trust boundary lookup.
82 GetTrustBoundaryEndpoint(ctx context.Context) (url string, err error)
83 // GetUniverseDomain returns the universe domain associated with the credential.
84 // It may return an error if the universe domain cannot be determined.
85 GetUniverseDomain(ctx context.Context) (string, error)
86 }
87
88 // AllowedLocationsResponse is the structure of the response from the Trust Boundary API.
89 type AllowedLocationsResponse struct {
90 // Locations is the list of allowed locations.
91 Locations []string `json:"locations"`
92 // EncodedLocations is the encoded representation of the allowed locations.
93 EncodedLocations string `json:"encodedLocations"`
94 }
95
96 // fetchTrustBoundaryData fetches the trust boundary data from the API.
97 func fetchTrustBoundaryData(ctx context.Context, client *http.Client, url string, token *auth.Token, logger *slog.Logger) (*internal.TrustBoundaryData, error) {
98 if logger == nil {
99 logger = slog.New(slog.NewTextHandler(io.Discard, nil))
100 }
101 if client == nil {
102 return nil, errors.New("trustboundary: HTTP client is required")
103 }
104
105 if url == "" {
106 return nil, errors.New("trustboundary: URL cannot be empty")
107 }
108
109 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
110 if err != nil {
111 return nil, fmt.Errorf("trustboundary: failed to create trust boundary request: %w", err)
112 }
113
114 if token == nil || token.Value == "" {
115 return nil, errors.New("trustboundary: access token required for lookup API authentication")
116 }
117 headers.SetAuthHeader(token, req)
118 logger.DebugContext(ctx, "trust boundary request", "request", internallog.HTTPRequest(req, nil))
119
120 retryer := retry.New()
121 var response *http.Response
122 for {
123 response, err = client.Do(req)
124
125 var statusCode int
126 if response != nil {
127 statusCode = response.StatusCode
128 }
129 pause, shouldRetry := retryer.Retry(statusCode, err)
130
131 if !shouldRetry {
132 break
133 }
134
135 if response != nil {
136 // Drain and close the body to reuse the connection
137 io.Copy(io.Discard, response.Body)
138 response.Body.Close()
139 }
140
141 if err := retry.Sleep(ctx, pause); err != nil {
142 return nil, err
143 }
144 }
145
146 if err != nil {
147 return nil, fmt.Errorf("trustboundary: failed to fetch trust boundary: %w", err)
148 }
149 defer response.Body.Close()
150
151 body, err := io.ReadAll(response.Body)
152 if err != nil {
153 return nil, fmt.Errorf("trustboundary: failed to read trust boundary response: %w", err)
154 }
155
156 logger.DebugContext(ctx, "trust boundary response", "response", internallog.HTTPResponse(response, body))
157
158 if response.StatusCode != http.StatusOK {
159 return nil, fmt.Errorf("trustboundary: trust boundary request failed with status: %s, body: %s", response.Status, string(body))
160 }
161
162 apiResponse := AllowedLocationsResponse{}
163 if err := json.Unmarshal(body, &apiResponse); err != nil {
164 return nil, fmt.Errorf("trustboundary: failed to unmarshal trust boundary response: %w", err)
165 }
166
167 if apiResponse.EncodedLocations == "" {
168 return nil, errors.New("trustboundary: invalid API response: encodedLocations is empty")
169 }
170
171 return internal.NewTrustBoundaryData(apiResponse.Locations, apiResponse.EncodedLocations), nil
172 }
173
174 // serviceAccountConfig holds configuration for SA trust boundary lookups.
175 // It implements the ConfigProvider interface.
176 type serviceAccountConfig struct {
177 ServiceAccountEmail string
178 UniverseDomain string
179 }
180
181 // NewServiceAccountConfigProvider creates a new config for service accounts.
182 func NewServiceAccountConfigProvider(saEmail, universeDomain string) ConfigProvider {
183 return &serviceAccountConfig{
184 ServiceAccountEmail: saEmail,
185 UniverseDomain: universeDomain,
186 }
187 }
188
189 // GetTrustBoundaryEndpoint returns the formatted URL for fetching allowed locations
190 // for the configured service account and universe domain.
191 func (sac *serviceAccountConfig) GetTrustBoundaryEndpoint(ctx context.Context) (url string, err error) {
192 if sac.ServiceAccountEmail == "" {
193 return "", errors.New("trustboundary: service account email cannot be empty for config")
194 }
195 ud := sac.UniverseDomain
196 if ud == "" {
197 ud = internal.DefaultUniverseDomain
198 }
199 return fmt.Sprintf(serviceAccountAllowedLocationsEndpoint, ud, sac.ServiceAccountEmail), nil
200 }
201
202 // GetUniverseDomain returns the configured universe domain, defaulting to
203 // [internal.DefaultUniverseDomain] if not explicitly set.
204 func (sac *serviceAccountConfig) GetUniverseDomain(ctx context.Context) (string, error) {
205 if sac.UniverseDomain == "" {
206 return internal.DefaultUniverseDomain, nil
207 }
208 return sac.UniverseDomain, nil
209 }
210
211 // DataProvider fetches and caches trust boundary Data.
212 // It implements the DataProvider interface and uses a ConfigProvider
213 // to get type-specific details for the lookup.
214 type DataProvider struct {
215 client *http.Client
216 configProvider ConfigProvider
217 data *internal.TrustBoundaryData
218 logger *slog.Logger
219 base auth.TokenProvider
220 }
221
222 // NewProvider wraps the provided base [auth.TokenProvider] to create a new
223 // provider that injects tokens with trust boundary data. It uses the provided
224 // HTTP client and configProvider to fetch the data and attach it to the token's
225 // metadata.
226 func NewProvider(client *http.Client, configProvider ConfigProvider, logger *slog.Logger, base auth.TokenProvider) (*DataProvider, error) {
227 if client == nil {
228 return nil, errors.New("trustboundary: HTTP client cannot be nil for DataProvider")
229 }
230 if configProvider == nil {
231 return nil, errors.New("trustboundary: ConfigProvider cannot be nil for DataProvider")
232 }
233 p := &DataProvider{
234 client: client,
235 configProvider: configProvider,
236 logger: internallog.New(logger),
237 base: base,
238 }
239 return p, nil
240 }
241
242 // Token retrieves a token from the base provider and injects it with trust
243 // boundary data.
244 func (p *DataProvider) Token(ctx context.Context) (*auth.Token, error) {
245 // Get the original token.
246 token, err := p.base.Token(ctx)
247 if err != nil {
248 return nil, err
249 }
250
251 tbData, err := p.GetTrustBoundaryData(ctx, token)
252 if err != nil {
253 return nil, fmt.Errorf("trustboundary: error fetching the trust boundary data: %w", err)
254 }
255 if tbData != nil {
256 if token.Metadata == nil {
257 token.Metadata = make(map[string]interface{})
258 }
259 token.Metadata[internal.TrustBoundaryDataKey] = *tbData
260 }
261 return token, nil
262 }
263
264 // GetTrustBoundaryData retrieves the trust boundary data.
265 // It first checks the universe domain: if it's non-default, a NoOp is returned.
266 // Otherwise, it checks a local cache. If the data is not cached as NoOp,
267 // it fetches new data from the endpoint provided by its ConfigProvider,
268 // using the given accessToken for authentication. Results are cached.
269 // If fetching fails, it returns previously cached data if available, otherwise the fetch error.
270 func (p *DataProvider) GetTrustBoundaryData(ctx context.Context, token *auth.Token) (*internal.TrustBoundaryData, error) {
271 // Check the universe domain.
272 uniDomain, err := p.configProvider.GetUniverseDomain(ctx)
273 if err != nil {
274 return nil, fmt.Errorf("trustboundary: error getting universe domain: %w", err)
275 }
276 if uniDomain != "" && uniDomain != internal.DefaultUniverseDomain {
277 if p.data == nil || p.data.EncodedLocations != internal.TrustBoundaryNoOp {
278 p.data = internal.NewNoOpTrustBoundaryData()
279 }
280 return p.data, nil
281 }
282
283 // Check cache for a no-op result from a previous API call.
284 cachedData := p.data
285 if cachedData != nil && cachedData.EncodedLocations == internal.TrustBoundaryNoOp {
286 return cachedData, nil
287 }
288
289 // Get the endpoint
290 url, err := p.configProvider.GetTrustBoundaryEndpoint(ctx)
291 if err != nil {
292 return nil, fmt.Errorf("trustboundary: error getting the lookup endpoint: %w", err)
293 }
294
295 // Proceed to fetch new data.
296 newData, fetchErr := fetchTrustBoundaryData(ctx, p.client, url, token, p.logger)
297
298 if fetchErr != nil {
299 // Fetch failed. Fallback to cachedData if available.
300 if cachedData != nil {
301 return cachedData, nil // Successful fallback
302 }
303 // No cache to fallback to.
304 return nil, fmt.Errorf("trustboundary: failed to fetch trust boundary data for endpoint %s and no cache available: %w", url, fetchErr)
305 }
306
307 // Fetch successful. Update cache.
308 p.data = newData
309 return newData, nil
310 }
311
312 // GCEConfigProvider implements ConfigProvider for GCE environments.
313 // It lazily fetches and caches the necessary metadata (service account email, universe domain)
314 // from the GCE metadata server.
315 type GCEConfigProvider struct {
316 // universeDomainProvider provides the universe domain and underlying metadata client.
317 universeDomainProvider *internal.ComputeUniverseDomainProvider
318
319 // Caching for service account email
320 saOnce sync.Once
321 saEmail string
322 saEmailErr error
323
324 // Caching for universe domain
325 udOnce sync.Once
326 ud string
327 udErr error
328 }
329
330 // NewGCEConfigProvider creates a new GCEConfigProvider
331 // which uses the provided gceUDP to interact with the GCE metadata server.
332 func NewGCEConfigProvider(gceUDP *internal.ComputeUniverseDomainProvider) *GCEConfigProvider {
333 // The validity of gceUDP and its internal MetadataClient will be checked
334 // within the GetTrustBoundaryEndpoint and GetUniverseDomain methods.
335 return &GCEConfigProvider{
336 universeDomainProvider: gceUDP,
337 }
338 }
339
340 func (g *GCEConfigProvider) fetchSA(ctx context.Context) {
341 if g.universeDomainProvider == nil || g.universeDomainProvider.MetadataClient == nil {
342 g.saEmailErr = errors.New("trustboundary: GCEConfigProvider not properly initialized (missing ComputeUniverseDomainProvider or MetadataClient)")
343 return
344 }
345 mdClient := g.universeDomainProvider.MetadataClient
346 saEmail, err := mdClient.EmailWithContext(ctx, "default")
347 if err != nil {
348 g.saEmailErr = fmt.Errorf("trustboundary: GCE config: failed to get service account email: %w", err)
349 return
350 }
351 g.saEmail = saEmail
352 }
353
354 func (g *GCEConfigProvider) fetchUD(ctx context.Context) {
355 if g.universeDomainProvider == nil || g.universeDomainProvider.MetadataClient == nil {
356 g.udErr = errors.New("trustboundary: GCEConfigProvider not properly initialized (missing ComputeUniverseDomainProvider or MetadataClient)")
357 return
358 }
359 ud, err := g.universeDomainProvider.GetProperty(ctx)
360 if err != nil {
361 g.udErr = fmt.Errorf("trustboundary: GCE config: failed to get universe domain: %w", err)
362 return
363 }
364 if ud == "" {
365 ud = internal.DefaultUniverseDomain
366 }
367 g.ud = ud
368 }
369
370 // GetTrustBoundaryEndpoint constructs the trust boundary lookup URL for a GCE environment.
371 // It uses cached metadata (service account email, universe domain) after the first call.
372 func (g *GCEConfigProvider) GetTrustBoundaryEndpoint(ctx context.Context) (string, error) {
373 g.saOnce.Do(func() { g.fetchSA(ctx) })
374 if g.saEmailErr != nil {
375 return "", g.saEmailErr
376 }
377 g.udOnce.Do(func() { g.fetchUD(ctx) })
378 if g.udErr != nil {
379 return "", g.udErr
380 }
381 return fmt.Sprintf(serviceAccountAllowedLocationsEndpoint, g.ud, g.saEmail), nil
382 }
383
384 // GetUniverseDomain retrieves the universe domain from the GCE metadata server.
385 // It uses a cached value after the first call.
386 func (g *GCEConfigProvider) GetUniverseDomain(ctx context.Context) (string, error) {
387 g.udOnce.Do(func() { g.fetchUD(ctx) })
388 if g.udErr != nil {
389 return "", g.udErr
390 }
391 return g.ud, nil
392 }
393