// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package downscope import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "cloud.google.com/go/auth" "cloud.google.com/go/auth/internal" ) const ( universeDomainPlaceholder = "UNIVERSE_DOMAIN" identityBindingEndpointTemplate = "https://sts.UNIVERSE_DOMAIN/v1/token" ) // Options for configuring [NewCredentials]. type Options struct { // Credentials is the [cloud.google.com/go/auth.Credentials] used to // create the downscoped credentials. Required. Credentials *auth.Credentials // Rules defines the accesses held by the new downscoped credentials. One or // more AccessBoundaryRules are required to define permissions for the new // downscoped credentials. Each one defines an access (or set of accesses) //that the new credentials has to a given resource. There can be a maximum // of 10 AccessBoundaryRules. Required. Rules []AccessBoundaryRule // Client configures the underlying client used to make network requests // when fetching tokens. Optional. Client *http.Client // UniverseDomain is the default service domain for a given Cloud universe. // The default value is "googleapis.com". Optional. UniverseDomain string } func (o *Options) client() *http.Client { if o.Client != nil { return o.Client } return internal.CloneDefaultClient() } // identityBindingEndpoint returns the identity binding endpoint with the // configured universe domain. func (o *Options) identityBindingEndpoint() string { if o.UniverseDomain == "" { return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, internal.DefaultUniverseDomain, 1) } return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, o.UniverseDomain, 1) } // An AccessBoundaryRule Sets the permissions (and optionally conditions) that // the new token has on given resource. type AccessBoundaryRule struct { // AvailableResource is the full resource name of the Cloud Storage bucket // that the rule applies to. Use the format // //storage.googleapis.com/projects/_/buckets/bucket-name. AvailableResource string `json:"availableResource"` // AvailablePermissions is a list that defines the upper bound on the available permissions // for the resource. Each value is the identifier for an IAM predefined role or custom role, // with the prefix inRole:. For example: inRole:roles/storage.objectViewer. // Only the permissions in these roles will be available. AvailablePermissions []string `json:"availablePermissions"` // An Condition restricts the availability of permissions // to specific Cloud Storage objects. Optional. // // A Condition can be used to make permissions available for specific objects, // rather than all objects in a Cloud Storage bucket. Condition *AvailabilityCondition `json:"availabilityCondition,omitempty"` } // An AvailabilityCondition restricts access to a given Resource. type AvailabilityCondition struct { // An Expression specifies the Cloud Storage objects where // permissions are available. For further documentation, see // https://cloud.google.com/iam/docs/conditions-overview. Required. Expression string `json:"expression"` // Title is short string that identifies the purpose of the condition. Optional. Title string `json:"title,omitempty"` // Description details about the purpose of the condition. Optional. Description string `json:"description,omitempty"` } // NewCredentials returns a [cloud.google.com/go/auth.Credentials] that is // more restrictive than [Options.Credentials] provided. The new credentials // will delegate to the base credentials for all non-token activity. func NewCredentials(opts *Options) (*auth.Credentials, error) { if opts == nil { return nil, fmt.Errorf("downscope: providing opts is required") } if opts.Credentials == nil { return nil, fmt.Errorf("downscope: Credentials cannot be nil") } if len(opts.Rules) == 0 { return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") } if len(opts.Rules) > 10 { return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") } for _, val := range opts.Rules { if val.AvailableResource == "" { return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource") } if len(val.AvailablePermissions) == 0 { return nil, fmt.Errorf("downscope: all rules must provide at least one permission") } } return auth.NewCredentials(&auth.CredentialsOptions{ TokenProvider: &downscopedTokenProvider{ Options: opts, Client: opts.client(), identityBindingEndpoint: opts.identityBindingEndpoint(), }, ProjectIDProvider: auth.CredentialsPropertyFunc(opts.Credentials.ProjectID), QuotaProjectIDProvider: auth.CredentialsPropertyFunc(opts.Credentials.QuotaProjectID), UniverseDomainProvider: internal.StaticCredentialsProperty(opts.UniverseDomain), }), nil } // downscopedTokenProvider is used to retrieve a downscoped tokens. type downscopedTokenProvider struct { Options *Options Client *http.Client // identityBindingEndpoint is the identity binding endpoint with the // configured universe domain. identityBindingEndpoint string } type downscopedOptions struct { Boundary accessBoundary `json:"accessBoundary"` } type accessBoundary struct { AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` } type downscopedTokenResponse struct { AccessToken string `json:"access_token"` IssuedTokenType string `json:"issued_token_type"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } func (dts *downscopedTokenProvider) Token(ctx context.Context) (*auth.Token, error) { downscopedOptions := downscopedOptions{ Boundary: accessBoundary{ AccessBoundaryRules: dts.Options.Rules, }, } tok, err := dts.Options.Credentials.Token(ctx) if err != nil { return nil, fmt.Errorf("downscope: unable to obtain root token: %w", err) } b, err := json.Marshal(downscopedOptions) if err != nil { return nil, err } form := url.Values{} form.Add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") form.Add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") form.Add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") form.Add("subject_token", tok.Value) form.Add("options", string(b)) resp, err := dts.Client.PostForm(dts.identityBindingEndpoint, form) if err != nil { return nil, err } defer resp.Body.Close() respBody, err := internal.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("downscope: unable to exchange token, %v: %s", resp.StatusCode, respBody) } var tresp downscopedTokenResponse err = json.Unmarshal(respBody, &tresp) if err != nil { return nil, err } // An exchanged token that is derived from a service account (2LO) has an // expired_in value a token derived from a users token (3LO) does not. // The following code uses the time remaining on rootToken for a user as the // value for the derived token's lifetime. var expiryTime time.Time if tresp.ExpiresIn > 0 { expiryTime = time.Now().Add(time.Duration(tresp.ExpiresIn) * time.Second) } else { expiryTime = tok.Expiry } return &auth.Token{ Value: tresp.AccessToken, Type: tresp.TokenType, Expiry: expiryTime, }, nil }