|
| 1 | +// Copyright 2023 Google LLC |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +package downscope |
| 16 | + |
| 17 | +import ( |
| 18 | + "context" |
| 19 | + "encoding/json" |
| 20 | + "fmt" |
| 21 | + "net/http" |
| 22 | + "net/url" |
| 23 | + "time" |
| 24 | + |
| 25 | + "cloud.google.com/go/auth" |
| 26 | + "cloud.google.com/go/auth/internal" |
| 27 | +) |
| 28 | + |
| 29 | +var identityBindingEndpoint = "https://sts.googleapis.com/v1/token" |
| 30 | + |
| 31 | +// Options for configuring [NewTokenProvider]. |
| 32 | +type Options struct { |
| 33 | + // BaseProvider is the [cloud.google.com/go/auth.TokenProvider] used to |
| 34 | + // create the downscoped provider. The downscoped provider therefore has |
| 35 | + // some subset of the accesses of the original BaseProvider. Required. |
| 36 | + BaseProvider auth.TokenProvider |
| 37 | + // Rules defines the accesses held by the new downscoped provider. One or |
| 38 | + // more AccessBoundaryRules are required to define permissions for the new |
| 39 | + // downscoped provider. Each one defines an access (or set of accesses) that |
| 40 | + // the new provider has to a given resource. There can be a maximum of 10 |
| 41 | + // AccessBoundaryRules. Required. |
| 42 | + Rules []AccessBoundaryRule |
| 43 | + // Client configures the underlying client used to make network requests |
| 44 | + // when fetching tokens. Optional. |
| 45 | + Client *http.Client |
| 46 | +} |
| 47 | + |
| 48 | +func (c Options) client() *http.Client { |
| 49 | + if c.Client != nil { |
| 50 | + return c.Client |
| 51 | + } |
| 52 | + return internal.CloneDefaultClient() |
| 53 | +} |
| 54 | + |
| 55 | +// An AccessBoundaryRule Sets the permissions (and optionally conditions) that |
| 56 | +// the new token has on given resource. |
| 57 | +type AccessBoundaryRule struct { |
| 58 | + // AvailableResource is the full resource name of the Cloud Storage bucket |
| 59 | + // that the rule applies to. Use the format |
| 60 | + // //storage.googleapis.com/projects/_/buckets/bucket-name. |
| 61 | + AvailableResource string `json:"availableResource"` |
| 62 | + // AvailablePermissions is a list that defines the upper bound on the available permissions |
| 63 | + // for the resource. Each value is the identifier for an IAM predefined role or custom role, |
| 64 | + // with the prefix inRole:. For example: inRole:roles/storage.objectViewer. |
| 65 | + // Only the permissions in these roles will be available. |
| 66 | + AvailablePermissions []string `json:"availablePermissions"` |
| 67 | + // An Condition restricts the availability of permissions |
| 68 | + // to specific Cloud Storage objects. Optional. |
| 69 | + // |
| 70 | + // A Condition can be used to make permissions available for specific objects, |
| 71 | + // rather than all objects in a Cloud Storage bucket. |
| 72 | + Condition *AvailabilityCondition `json:"availabilityCondition,omitempty"` |
| 73 | +} |
| 74 | + |
| 75 | +// An AvailabilityCondition restricts access to a given Resource. |
| 76 | +type AvailabilityCondition struct { |
| 77 | + // An Expression specifies the Cloud Storage objects where |
| 78 | + // permissions are available. For further documentation, see |
| 79 | + // https://cloud.google.com/iam/docs/conditions-overview. Required. |
| 80 | + Expression string `json:"expression"` |
| 81 | + // Title is short string that identifies the purpose of the condition. Optional. |
| 82 | + Title string `json:"title,omitempty"` |
| 83 | + // Description details about the purpose of the condition. Optional. |
| 84 | + Description string `json:"description,omitempty"` |
| 85 | +} |
| 86 | + |
| 87 | +// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] that is |
| 88 | +// more restrictive than [Options.BaseProvider] provided. |
| 89 | +func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { |
| 90 | + if opts == nil { |
| 91 | + return nil, fmt.Errorf("downscope: providing opts is required") |
| 92 | + } |
| 93 | + if opts.BaseProvider == nil { |
| 94 | + return nil, fmt.Errorf("downscope: BaseProvider cannot be nil") |
| 95 | + } |
| 96 | + if len(opts.Rules) == 0 { |
| 97 | + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules must be at least 1") |
| 98 | + } |
| 99 | + if len(opts.Rules) > 10 { |
| 100 | + return nil, fmt.Errorf("downscope: length of AccessBoundaryRules may not be greater than 10") |
| 101 | + } |
| 102 | + for _, val := range opts.Rules { |
| 103 | + if val.AvailableResource == "" { |
| 104 | + return nil, fmt.Errorf("downscope: all rules must have a nonempty AvailableResource") |
| 105 | + } |
| 106 | + if len(val.AvailablePermissions) == 0 { |
| 107 | + return nil, fmt.Errorf("downscope: all rules must provide at least one permission") |
| 108 | + } |
| 109 | + } |
| 110 | + return &downscopedTokenProvider{Options: opts, Client: opts.client()}, nil |
| 111 | +} |
| 112 | + |
| 113 | +// downscopedTokenProvider is used to retrieve a downscoped tokens. |
| 114 | +type downscopedTokenProvider struct { |
| 115 | + Options *Options |
| 116 | + Client *http.Client |
| 117 | +} |
| 118 | + |
| 119 | +type downscopedOptions struct { |
| 120 | + Boundary accessBoundary `json:"accessBoundary"` |
| 121 | +} |
| 122 | + |
| 123 | +type accessBoundary struct { |
| 124 | + AccessBoundaryRules []AccessBoundaryRule `json:"accessBoundaryRules"` |
| 125 | +} |
| 126 | + |
| 127 | +type downscopedTokenResponse struct { |
| 128 | + AccessToken string `json:"access_token"` |
| 129 | + IssuedTokenType string `json:"issued_token_type"` |
| 130 | + TokenType string `json:"token_type"` |
| 131 | + ExpiresIn int `json:"expires_in"` |
| 132 | +} |
| 133 | + |
| 134 | +func (dts *downscopedTokenProvider) Token(ctx context.Context) (*auth.Token, error) { |
| 135 | + downscopedOptions := downscopedOptions{ |
| 136 | + Boundary: accessBoundary{ |
| 137 | + AccessBoundaryRules: dts.Options.Rules, |
| 138 | + }, |
| 139 | + } |
| 140 | + |
| 141 | + tok, err := dts.Options.BaseProvider.Token(ctx) |
| 142 | + if err != nil { |
| 143 | + return nil, fmt.Errorf("downscope: unable to obtain root token: %w", err) |
| 144 | + } |
| 145 | + b, err := json.Marshal(downscopedOptions) |
| 146 | + if err != nil { |
| 147 | + return nil, err |
| 148 | + } |
| 149 | + |
| 150 | + form := url.Values{} |
| 151 | + form.Add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") |
| 152 | + form.Add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") |
| 153 | + form.Add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") |
| 154 | + form.Add("subject_token", tok.Value) |
| 155 | + form.Add("options", string(b)) |
| 156 | + |
| 157 | + resp, err := dts.Client.PostForm(identityBindingEndpoint, form) |
| 158 | + if err != nil { |
| 159 | + return nil, err |
| 160 | + } |
| 161 | + defer resp.Body.Close() |
| 162 | + respBody, err := internal.ReadAll(resp.Body) |
| 163 | + if err != nil { |
| 164 | + return nil, err |
| 165 | + } |
| 166 | + if resp.StatusCode != http.StatusOK { |
| 167 | + return nil, fmt.Errorf("downscope: unable to exchange token, %v: %s", resp.StatusCode, respBody) |
| 168 | + } |
| 169 | + |
| 170 | + var tresp downscopedTokenResponse |
| 171 | + err = json.Unmarshal(respBody, &tresp) |
| 172 | + if err != nil { |
| 173 | + return nil, err |
| 174 | + } |
| 175 | + |
| 176 | + // An exchanged token that is derived from a service account (2LO) has an |
| 177 | + // expired_in value a token derived from a users token (3LO) does not. |
| 178 | + // The following code uses the time remaining on rootToken for a user as the |
| 179 | + // value for the derived token's lifetime. |
| 180 | + var expiryTime time.Time |
| 181 | + if tresp.ExpiresIn > 0 { |
| 182 | + expiryTime = time.Now().Add(time.Duration(tresp.ExpiresIn) * time.Second) |
| 183 | + } else { |
| 184 | + expiryTime = tok.Expiry |
| 185 | + } |
| 186 | + return &auth.Token{ |
| 187 | + Value: tresp.AccessToken, |
| 188 | + Type: tresp.TokenType, |
| 189 | + Expiry: expiryTime, |
| 190 | + }, nil |
| 191 | +} |
0 commit comments