| // Copyright 2021 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| /* |
| Package downscope implements the ability to downscope, or restrict, the |
| Identity and Access Management permissions that a short-lived Token |
| can use. Please note that only Google Cloud Storage supports this feature. |
| For complete documentation, see https://cloud.google.com/iam/docs/downscoping-short-lived-credentials |
| |
| To downscope permissions of a source credential, you need to define |
| a Credential Access Boundary. Said Boundary specifies which resources |
| the newly created credential can access, an upper bound on the permissions |
| it has over those resources, and optionally attribute-based conditional |
| access to the aforementioned resources. For more information on IAM |
| Conditions, see https://cloud.google.com/iam/docs/conditions-overview. |
| |
| This functionality can be used to provide a third party with |
| limited access to and permissions on resources held by the owner of the root |
| credential or internally in conjunction with the principle of least privilege |
| to ensure that internal services only hold the minimum necessary privileges |
| for their function. |
| |
| For example, a token broker can be set up on a server in a private network. |
| Various workloads (token consumers) in the same network will send authenticated |
| requests to that broker for downscoped tokens to access or modify specific google |
| cloud storage buckets. See the NewTokenSource example for an example of how a |
| token broker would use this package. |
| |
| The broker will use the functionality in this package to generate a downscoped |
| token with the requested configuration, and then pass it back to the token |
| consumer. These downscoped access tokens can then be used to access Google |
| Storage resources. For instance, you can create a NewClient from the |
| "cloud.google.com/go/storage" package and pass in option.WithTokenSource(yourTokenSource)) |
| */ |
| package downscope |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "time" |
| |
| "golang.org/x/oauth2" |
| ) |
| |
| var ( |
| identityBindingEndpoint = "https://sts.googleapis.com/v1/token" |
| ) |
| |
| type accessBoundary struct { |
| AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` |
| } |
| |
| // 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 |
| 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"` |
| } |
| |
| // 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"` |
| } |
| |
| type downscopedTokenResponse struct { |
| AccessToken string `json:"access_token"` |
| IssuedTokenType string `json:"issued_token_type"` |
| TokenType string `json:"token_type"` |
| ExpiresIn int `json:"expires_in"` |
| } |
| |
| // DownscopingConfig specifies the information necessary to request a downscoped token. |
| type DownscopingConfig struct { |
| // RootSource is the TokenSource used to create the downscoped token. |
| // The downscoped token therefore has some subset of the accesses of |
| // the original RootSource. |
| RootSource oauth2.TokenSource |
| // Rules defines the accesses held by the new |
| // downscoped Token. One or more AccessBoundaryRules are required to |
| // define permissions for the new downscoped token. Each one defines an |
| // access (or set of accesses) that the new token has to a given resource. |
| // There can be a maximum of 10 AccessBoundaryRules. |
| Rules []AccessBoundaryRule |
| } |
| |
| // A downscopingTokenSource is used to retrieve a downscoped token with restricted |
| // permissions compared to the root Token that is used to generate it. |
| type downscopingTokenSource struct { |
| // ctx is the context used to query the API to retrieve a downscoped Token. |
| ctx context.Context |
| // config holds the information necessary to generate a downscoped Token. |
| config DownscopingConfig |
| } |
| |
| // NewTokenSource returns a configured downscopingTokenSource. |
| func NewTokenSource(ctx context.Context, conf DownscopingConfig) (oauth2.TokenSource, error) { |
| if conf.RootSource == nil { |
| return nil, fmt.Errorf("downscope: rootSource cannot be nil") |
| } |
| if len(conf.Rules) == 0 { |
| return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") |
| } |
| if len(conf.Rules) > 10 { |
| return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") |
| } |
| for _, val := range conf.Rules { |
| if val.AvailableResource == "" { |
| return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource: %+v", val) |
| } |
| if len(val.AvailablePermissions) == 0 { |
| return nil, fmt.Errorf("downscope: all rules must provide at least one permission: %+v", val) |
| } |
| } |
| return downscopingTokenSource{ctx: ctx, config: conf}, nil |
| } |
| |
| // Token() uses a downscopingTokenSource to generate an oauth2 Token. |
| // Do note that the returned TokenSource is an oauth2.StaticTokenSource. If you wish |
| // to refresh this token automatically, then initialize a locally defined |
| // TokenSource struct with the Token held by the StaticTokenSource and wrap |
| // that TokenSource in an oauth2.ReuseTokenSource. |
| func (dts downscopingTokenSource) Token() (*oauth2.Token, error) { |
| |
| downscopedOptions := struct { |
| Boundary accessBoundary `json:"accessBoundary"` |
| }{ |
| Boundary: accessBoundary{ |
| AccessBoundaryRules: dts.config.Rules, |
| }, |
| } |
| |
| tok, err := dts.config.RootSource.Token() |
| if err != nil { |
| return nil, fmt.Errorf("downscope: unable to obtain root token: %v", err) |
| } |
| |
| b, err := json.Marshal(downscopedOptions) |
| if err != nil { |
| return nil, fmt.Errorf("downscope: unable to marshal AccessBoundary payload %v", 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.AccessToken) |
| form.Add("options", string(b)) |
| |
| myClient := oauth2.NewClient(dts.ctx, nil) |
| resp, err := myClient.PostForm(identityBindingEndpoint, form) |
| if err != nil { |
| return nil, fmt.Errorf("unable to generate POST Request %v", err) |
| } |
| defer resp.Body.Close() |
| respBody, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return nil, fmt.Errorf("downscope: unable to read response body: %v", err) |
| } |
| if resp.StatusCode != http.StatusOK { |
| return nil, fmt.Errorf("downscope: unable to exchange token; %v. Server responded: %s", resp.StatusCode, respBody) |
| } |
| |
| var tresp downscopedTokenResponse |
| |
| err = json.Unmarshal(respBody, &tresp) |
| if err != nil { |
| return nil, fmt.Errorf("downscope: unable to unmarshal response body: %v", 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 |
| } |
| |
| newToken := &oauth2.Token{ |
| AccessToken: tresp.AccessToken, |
| TokenType: tresp.TokenType, |
| Expiry: expiryTime, |
| } |
| return newToken, nil |
| } |