blob: 4ad76c6193d49e4f24c83f99d78553ef8d72f7bb [file] [log] [blame]
// 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
}