or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

auth-handlers.mdclient-credentials.mdcore-oauth2.mdendpoints.mdgoogle-auth.mdgoogle-downscope.mdgoogle-external-account.mdindex.mdjira-oauth.mdjwt-jws.md
tile.json

google-downscope.mddocs/

Google Downscope

Create downscoped tokens with restricted IAM permissions for Google Cloud Storage, enabling principle of least privilege and secure third-party access.

Package

import "golang.org/x/oauth2/google/downscope"

Overview

The downscope package implements the ability to downscope (restrict) the Identity and Access Management permissions that a short-lived token can use. This feature is currently only supported by Google Cloud Storage.

Use cases:

  • Provide third parties with limited access to specific GCS resources
  • Implement least privilege principle for internal services
  • Create token brokers that issue restricted tokens to workloads
  • Temporary access grants with fine-grained permissions

Documentation: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials

Creating Downscoped Tokens

func NewTokenSource(ctx context.Context, conf DownscopingConfig) (oauth2.TokenSource, error)

Returns a configured downscopingTokenSource that creates tokens with restricted permissions based on the provided configuration.

Configuration

type DownscopingConfig struct {
	// RootSource is the TokenSource used to create the downscoped token
	// The downscoped token has a subset of the RootSource's access
	RootSource oauth2.TokenSource

	// Rules defines the access held by the new downscoped token
	// One or more AccessBoundaryRules required (maximum 10)
	Rules []AccessBoundaryRule

	// UniverseDomain is the default service domain for a given Cloud universe
	// Default: "googleapis.com" (optional)
	UniverseDomain string
}

Access Boundary Rules

type AccessBoundaryRule struct {
	// AvailableResource is the full resource name of the GCS bucket
	// Format: //storage.googleapis.com/projects/_/buckets/bucket-name
	AvailableResource string `json:"availableResource"`

	// AvailablePermissions defines upper bound on available permissions
	// Each value is an IAM role identifier with the prefix "inRole:"
	// Example: "inRole:roles/storage.objectViewer"
	AvailablePermissions []string `json:"availablePermissions"`

	// Condition restricts availability of permissions to specific objects (optional)
	Condition *AvailabilityCondition `json:"availabilityCondition,omitempty"`
}

Availability Conditions

type AvailabilityCondition struct {
	// Expression specifies the Cloud Storage objects where permissions are available
	// Uses IAM Conditions syntax
	Expression string `json:"expression"`

	// Title is a short identifier for the condition (optional)
	Title string `json:"title,omitempty"`

	// Description details the purpose of the condition (optional)
	Description string `json:"description,omitempty"`
}

IAM Conditions documentation: https://cloud.google.com/iam/docs/conditions-overview

Example: Basic Downscoping

package main

import (
	"context"
	"log"

	"cloud.google.com/go/storage"
	"golang.org/x/oauth2/google"
	"golang.org/x/oauth2/google/downscope"
	"google.golang.org/api/option"
)

func main() {
	ctx := context.Background()

	// Get root credentials with full access
	rootSource, err := google.DefaultTokenSource(ctx,
		"https://www.googleapis.com/auth/cloud-platform")
	if err != nil {
		log.Fatal(err)
	}

	// Define access boundary - read-only access to specific bucket
	config := downscope.DownscopingConfig{
		RootSource: rootSource,
		Rules: []downscope.AccessBoundaryRule{
			{
				AvailableResource: "//storage.googleapis.com/projects/_/buckets/my-bucket",
				AvailablePermissions: []string{
					"inRole:roles/storage.objectViewer",
				},
			},
		},
	}

	// Create downscoped token source
	downscopedTS, err := downscope.NewTokenSource(ctx, config)
	if err != nil {
		log.Fatal(err)
	}

	// Use downscoped token with GCS client
	client, err := storage.NewClient(ctx, option.WithTokenSource(downscopedTS))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// Client now has only read access to my-bucket
	bucket := client.Bucket("my-bucket")
	// ... use bucket with restricted permissions ...
}

Example: Multiple Buckets

config := downscope.DownscopingConfig{
	RootSource: rootSource,
	Rules: []downscope.AccessBoundaryRule{
		{
			AvailableResource: "//storage.googleapis.com/projects/_/buckets/bucket-1",
			AvailablePermissions: []string{
				"inRole:roles/storage.objectViewer", // Read-only
			},
		},
		{
			AvailableResource: "//storage.googleapis.com/projects/_/buckets/bucket-2",
			AvailablePermissions: []string{
				"inRole:roles/storage.objectCreator", // Write-only
			},
		},
	},
}

downscopedTS, err := downscope.NewTokenSource(ctx, config)

Example: Conditional Access

Restrict access to objects matching specific conditions:

config := downscope.DownscopingConfig{
	RootSource: rootSource,
	Rules: []downscope.AccessBoundaryRule{
		{
			AvailableResource: "//storage.googleapis.com/projects/_/buckets/my-bucket",
			AvailablePermissions: []string{
				"inRole:roles/storage.objectViewer",
			},
			Condition: &downscope.AvailabilityCondition{
				Title: "Access to specific prefix",
				Expression: `resource.name.startsWith("projects/_/buckets/my-bucket/objects/documents/")`,
				Description: "Only allow access to objects under documents/ prefix",
			},
		},
	},
}

Example: Token Broker Service

A token broker issues downscoped tokens to workloads:

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"golang.org/x/oauth2/google/downscope"
)

// TokenRequest from a workload
type TokenRequest struct {
	Bucket      string   `json:"bucket"`
	Permissions []string `json:"permissions"`
	Prefix      string   `json:"prefix,omitempty"`
}

// TokenResponse to the workload
type TokenResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	TokenType   string `json:"token_type"`
}

func main() {
	ctx := context.Background()

	// Broker's root credentials
	rootSource, err := google.DefaultTokenSource(ctx,
		"https://www.googleapis.com/auth/cloud-platform")
	if err != nil {
		log.Fatal(err)
	}

	http.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
		// Authenticate the workload (implementation specific)
		// ...

		// Parse token request
		var req TokenRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "Invalid request", http.StatusBadRequest)
			return
		}

		// Create downscoped configuration
		rule := downscope.AccessBoundaryRule{
			AvailableResource:    "//storage.googleapis.com/projects/_/buckets/" + req.Bucket,
			AvailablePermissions: req.Permissions,
		}

		// Add condition if prefix specified
		if req.Prefix != "" {
			rule.Condition = &downscope.AvailabilityCondition{
				Title: "Prefix restriction",
				Expression: `resource.name.startsWith("projects/_/buckets/` +
					req.Bucket + `/objects/` + req.Prefix + `")`,
			}
		}

		config := downscope.DownscopingConfig{
			RootSource: rootSource,
			Rules:      []downscope.AccessBoundaryRule{rule},
		}

		// Generate downscoped token
		downscopedTS, err := downscope.NewTokenSource(ctx, config)
		if err != nil {
			http.Error(w, "Token generation failed", http.StatusInternalServerError)
			return
		}

		// Get token
		token, err := downscopedTS.Token()
		if err != nil {
			http.Error(w, "Token retrieval failed", http.StatusInternalServerError)
			return
		}

		// Return token to workload
		resp := TokenResponse{
			AccessToken: token.AccessToken,
			ExpiresIn:   int(token.Expiry.Sub(time.Now()).Seconds()),
			TokenType:   token.TokenType,
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(resp)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}

Common IAM Roles for GCS

// Read-only access
"inRole:roles/storage.objectViewer"

// Write-only access (create objects)
"inRole:roles/storage.objectCreator"

// Read and write access
"inRole:roles/storage.objectUser"

// Full control (including delete)
"inRole:roles/storage.objectAdmin"

Condition Expression Examples

Prefix-based Access

Expression: `resource.name.startsWith("projects/_/buckets/my-bucket/objects/folder/")`

File Type Restrictions

Expression: `resource.name.endsWith(".pdf") || resource.name.endsWith(".txt")`

Time-based Access

Expression: `request.time < timestamp("2024-12-31T23:59:59Z")`

Combined Conditions

Expression: `resource.name.startsWith("projects/_/buckets/my-bucket/objects/public/") &&
             request.time < timestamp("2024-12-31T23:59:59Z")`

Limitations

  • Maximum 10 AccessBoundaryRules per downscoped token
  • Google Cloud Storage only - other Google Cloud services not supported
  • Short-lived tokens - downscoped tokens inherit expiration from root token
  • No cascading - cannot further downscope an already downscoped token

Error Handling

downscopedTS, err := downscope.NewTokenSource(ctx, config)
if err != nil {
	switch {
	case strings.Contains(err.Error(), "rootSource cannot be nil"):
		log.Fatal("Root token source is required")
	case strings.Contains(err.Error(), "length of AccessBoundaryRules"):
		log.Fatal("Must have 1-10 access boundary rules")
	case strings.Contains(err.Error(), "nonempty AvailableResource"):
		log.Fatal("All rules must specify AvailableResource")
	case strings.Contains(err.Error(), "nonempty AvailablePermissions"):
		log.Fatal("All rules must specify AvailablePermissions")
	default:
		log.Fatalf("Downscope error: %v", err)
	}
}

Security Best Practices

  1. Principle of Least Privilege: Grant minimum necessary permissions
  2. Temporal Restrictions: Use time-based conditions when appropriate
  3. Resource Specificity: Be as specific as possible with resource names
  4. Validate Requests: Authenticate and authorize workloads before issuing tokens
  5. Monitor Usage: Log downscoped token generation and usage
  6. Short TTLs: Keep token lifetimes short
  7. Secure Transport: Always use HTTPS for token distribution

Complete Example: Workload Token Consumer

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log"
	"net/http"

	"cloud.google.com/go/storage"
	"golang.org/x/oauth2"
	"google.golang.org/api/option"
)

// TokenRequest sent to broker
type TokenRequest struct {
	Bucket      string   `json:"bucket"`
	Permissions []string `json:"permissions"`
	Prefix      string   `json:"prefix,omitempty"`
}

func main() {
	ctx := context.Background()

	// Request downscoped token from broker
	token, err := requestTokenFromBroker(ctx, TokenRequest{
		Bucket:      "my-data-bucket",
		Permissions: []string{"inRole:roles/storage.objectViewer"},
		Prefix:      "reports/",
	})
	if err != nil {
		log.Fatal(err)
	}

	// Use downscoped token
	client, err := storage.NewClient(ctx,
		option.WithTokenSource(oauth2.StaticTokenSource(token)))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// Access only allowed objects
	bucket := client.Bucket("my-data-bucket")
	it := bucket.Objects(ctx, &storage.Query{Prefix: "reports/"})

	for {
		attrs, err := it.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			log.Fatal(err)
		}
		log.Printf("Object: %s\n", attrs.Name)
	}
}

func requestTokenFromBroker(ctx context.Context, req TokenRequest) (*oauth2.Token, error) {
	body, _ := json.Marshal(req)
	resp, err := http.Post("https://token-broker.example.com/token",
		"application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err)
	}
	defer resp.Body.Close()

	var tokenResp struct {
		AccessToken string `json:"access_token"`
		TokenType   string `json:"token_type"`
		ExpiresIn   int    `json:"expires_in"`
	}

	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		return nil, err
	}

	return &oauth2.Token{
		AccessToken: tokenResp.AccessToken,
		TokenType:   tokenResp.TokenType,
		Expiry:      time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
	}, nil
}