|
| 1 | +// Copyright 2021 The Go Authors. All rights reserved. |
| 2 | +// Use of this source code is governed by a BSD-style |
| 3 | +// license that can be found in the LICENSE file. |
| 4 | + |
| 5 | +/* |
| 6 | +Package downscope implements the ability to downscope, or restrict, the |
| 7 | +Identity and AccessManagement permissions that a short-lived Token |
| 8 | +can use. Please note that only Google Cloud Storage supports this feature. |
| 9 | +For complete documentation, see https://cloud.google.com/iam/docs/downscoping-short-lived-credentials |
| 10 | +*/ |
| 11 | +package downscope |
| 12 | + |
| 13 | +import ( |
| 14 | + "context" |
| 15 | + "encoding/json" |
| 16 | + "fmt" |
| 17 | + "io/ioutil" |
| 18 | + "net/http" |
| 19 | + "net/url" |
| 20 | + "time" |
| 21 | + |
| 22 | + "golang.org/x/oauth2" |
| 23 | +) |
| 24 | + |
| 25 | +var ( |
| 26 | + identityBindingEndpoint = "https://sts.googleapis.com/v1/token" |
| 27 | +) |
| 28 | + |
| 29 | +type accessBoundary struct { |
| 30 | + AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` |
| 31 | +} |
| 32 | + |
| 33 | +// An AvailabilityCondition restricts access to a given Resource. |
| 34 | +type AvailabilityCondition struct { |
| 35 | + // An Expression specifies the Cloud Storage objects where |
| 36 | + // permissions are available. For further documentation, see |
| 37 | + // https://cloud.google.com/iam/docs/conditions-overview |
| 38 | + Expression string `json:"expression"` |
| 39 | + // Title is short string that identifies the purpose of the condition. Optional. |
| 40 | + Title string `json:"title,omitempty"` |
| 41 | + // Description details about the purpose of the condition. Optional. |
| 42 | + Description string `json:"description,omitempty"` |
| 43 | +} |
| 44 | + |
| 45 | +// An AccessBoundaryRule Sets the permissions (and optionally conditions) |
| 46 | +// that the new token has on given resource. |
| 47 | +type AccessBoundaryRule struct { |
| 48 | + // AvailableResource is the full resource name of the Cloud Storage bucket that the rule applies to. |
| 49 | + // Use the format //storage.googleapis.com/projects/_/buckets/bucket-name. |
| 50 | + AvailableResource string `json:"availableResource"` |
| 51 | + // AvailablePermissions is a list that defines the upper bound on the available permissions |
| 52 | + // for the resource. Each value is the identifier for an IAM predefined role or custom role, |
| 53 | + // with the prefix inRole:. For example: inRole:roles/storage.objectViewer. |
| 54 | + // Only the permissions in these roles will be available. |
| 55 | + AvailablePermissions []string `json:"availablePermissions"` |
| 56 | + // An Condition restricts the availability of permissions |
| 57 | + // to specific Cloud Storage objects. Optional. |
| 58 | + // |
| 59 | + // A Condition can be used to make permissions available for specific objects, |
| 60 | + // rather than all objects in a Cloud Storage bucket. |
| 61 | + Condition *AvailabilityCondition `json:"availabilityCondition,omitempty"` |
| 62 | +} |
| 63 | + |
| 64 | +type downscopedTokenResponse struct { |
| 65 | + AccessToken string `json:"access_token"` |
| 66 | + IssuedTokenType string `json:"issued_token_type"` |
| 67 | + TokenType string `json:"token_type"` |
| 68 | + ExpiresIn int `json:"expires_in"` |
| 69 | +} |
| 70 | + |
| 71 | +// DownscopingConfig specifies the information necessary to request a downscoped token. |
| 72 | +type DownscopingConfig struct { |
| 73 | + // RootSource is the TokenSource used to create the downscoped token. |
| 74 | + // The downscoped token therefore has some subset of the accesses of |
| 75 | + // the original RootSource. |
| 76 | + RootSource oauth2.TokenSource |
| 77 | + // Rules defines the accesses held by the new |
| 78 | + // downscoped Token. One or more AccessBoundaryRules are required to |
| 79 | + // define permissions for the new downscoped token. Each one defines an |
| 80 | + // access (or set of accesses) that the new token has to a given resource. |
| 81 | + // There can be a maximum of 10 AccessBoundaryRules. |
| 82 | + Rules []AccessBoundaryRule |
| 83 | +} |
| 84 | + |
| 85 | +// A downscopingTokenSource is used to retrieve a downscoped token with restricted |
| 86 | +// permissions compared to the root Token that is used to generate it. |
| 87 | +type downscopingTokenSource struct { |
| 88 | + // ctx is the context used to query the API to retrieve a downscoped Token. |
| 89 | + ctx context.Context |
| 90 | + // config holds the information necessary to generate a downscoped Token. |
| 91 | + config DownscopingConfig |
| 92 | +} |
| 93 | + |
| 94 | +// NewTokenSource returns an empty downscopingTokenSource. |
| 95 | +func NewTokenSource(ctx context.Context, conf DownscopingConfig) (oauth2.TokenSource, error) { |
| 96 | + if conf.RootSource == nil { |
| 97 | + return nil, fmt.Errorf("downscope: rootSource cannot be nil") |
| 98 | + } |
| 99 | + if len(conf.Rules) == 0 { |
| 100 | + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") |
| 101 | + } |
| 102 | + if len(conf.Rules) > 10 { |
| 103 | + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") |
| 104 | + } |
| 105 | + for _, val := range conf.Rules { |
| 106 | + if val.AvailableResource == "" { |
| 107 | + return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource: %+v", val) |
| 108 | + } |
| 109 | + if len(val.AvailablePermissions) == 0 { |
| 110 | + return nil, fmt.Errorf("downscope: all rules must provide at least one permission: %+v", val) |
| 111 | + } |
| 112 | + } |
| 113 | + return downscopingTokenSource{ctx: ctx, config: conf}, nil |
| 114 | +} |
| 115 | + |
| 116 | +// Token() uses a downscopingTokenSource to generate an oauth2 Token. |
| 117 | +// Do note that the returned TokenSource is an oauth2.StaticTokenSource. If you wish |
| 118 | +// to refresh this token automatically, then initialize a locally defined |
| 119 | +// TokenSource struct with the Token held by the StaticTokenSource and wrap |
| 120 | +// that TokenSource in an oauth2.ReuseTokenSource. |
| 121 | +func (dts downscopingTokenSource) Token() (*oauth2.Token, error) { |
| 122 | + |
| 123 | + downscopedOptions := struct { |
| 124 | + Boundary accessBoundary `json:"accessBoundary"` |
| 125 | + }{ |
| 126 | + Boundary: accessBoundary{ |
| 127 | + AccessBoundaryRules: dts.config.Rules, |
| 128 | + }, |
| 129 | + } |
| 130 | + |
| 131 | + tok, err := dts.config.RootSource.Token() |
| 132 | + if err != nil { |
| 133 | + return nil, fmt.Errorf("downscope: unable to obtain root token: %v", err) |
| 134 | + } |
| 135 | + |
| 136 | + b, err := json.Marshal(downscopedOptions) |
| 137 | + if err != nil { |
| 138 | + return nil, fmt.Errorf("downscope: unable to marshal AccessBoundary payload %v", err) |
| 139 | + } |
| 140 | + |
| 141 | + form := url.Values{} |
| 142 | + form.Add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") |
| 143 | + form.Add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") |
| 144 | + form.Add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") |
| 145 | + form.Add("subject_token", tok.AccessToken) |
| 146 | + form.Add("options", string(b)) |
| 147 | + |
| 148 | + myClient := oauth2.NewClient(dts.ctx, nil) |
| 149 | + resp, err := myClient.PostForm(identityBindingEndpoint, form) |
| 150 | + if err != nil { |
| 151 | + return nil, fmt.Errorf("unable to generate POST Request %v", err) |
| 152 | + } |
| 153 | + defer resp.Body.Close() |
| 154 | + respBody, err := ioutil.ReadAll(resp.Body) |
| 155 | + if err != nil { |
| 156 | + return nil, fmt.Errorf("downscope: unable to read reaponse body: %v", err) |
| 157 | + } |
| 158 | + if resp.StatusCode != http.StatusOK { |
| 159 | + b, err := ioutil.ReadAll(resp.Body) |
| 160 | + if err != nil { |
| 161 | + return nil, fmt.Errorf("downscope: unable to exchange token; %v. Failed to read response body: %v", resp.StatusCode, err) |
| 162 | + } |
| 163 | + return nil, fmt.Errorf("downscope: unable to exchange token; %v. Server responsed: %v", resp.StatusCode, string(b)) |
| 164 | + } |
| 165 | + |
| 166 | + var tresp downscopedTokenResponse |
| 167 | + |
| 168 | + err = json.Unmarshal(respBody, &tresp) |
| 169 | + if err != nil { |
| 170 | + return nil, fmt.Errorf("downscope: unable to unmarshal response body: %v", err) |
| 171 | + } |
| 172 | + |
| 173 | + // an exchanged token that is derived from a service account (2LO) has an expired_in value |
| 174 | + // a token derived from a users token (3LO) does not. |
| 175 | + // The following code uses the time remaining on rootToken for a user as the value for the |
| 176 | + // derived token's lifetime |
| 177 | + var expiryTime time.Time |
| 178 | + if tresp.ExpiresIn > 0 { |
| 179 | + expiryTime = time.Now().Add(time.Duration(tresp.ExpiresIn) * time.Second) |
| 180 | + } else { |
| 181 | + expiryTime = tok.Expiry |
| 182 | + } |
| 183 | + |
| 184 | + newToken := &oauth2.Token{ |
| 185 | + AccessToken: tresp.AccessToken, |
| 186 | + TokenType: tresp.TokenType, |
| 187 | + Expiry: expiryTime, |
| 188 | + } |
| 189 | + return newToken, nil |
| 190 | +} |
0 commit comments