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