Create downscoped tokens with restricted IAM permissions for Google Cloud Storage, enabling principle of least privilege and secure third-party access.
import "golang.org/x/oauth2/google/downscope"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:
Documentation: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
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.
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
}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"`
}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
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 ...
}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)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",
},
},
},
}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))
}// 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"Expression: `resource.name.startsWith("projects/_/buckets/my-bucket/objects/folder/")`Expression: `resource.name.endsWith(".pdf") || resource.name.endsWith(".txt")`Expression: `request.time < timestamp("2024-12-31T23:59:59Z")`Expression: `resource.name.startsWith("projects/_/buckets/my-bucket/objects/public/") &&
request.time < timestamp("2024-12-31T23:59:59Z")`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)
}
}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
}