-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(auth): add downscope package (#8532)
This is roughly the equivalent of the old oauth2/google/downscope package. Some integration tests are being added here but disabling them until a future commit where all test infra will be updated to support these tests. Ran them locally for now and all seems well.
- Loading branch information
Showing
8 changed files
with
820 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
// 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 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 NewTokenProvider 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 Cloud resources. | ||
package downscope |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
// 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" | ||
"time" | ||
|
||
"cloud.google.com/go/auth" | ||
"cloud.google.com/go/auth/internal" | ||
) | ||
|
||
var identityBindingEndpoint = "https://sts.googleapis.com/v1/token" | ||
|
||
// Options for configuring [NewTokenProvider]. | ||
type Options struct { | ||
// BaseProvider is the [cloud.google.com/go/auth.TokenProvider] used to | ||
// create the downscoped provider. The downscoped provider therefore has | ||
// some subset of the accesses of the original BaseProvider. Required. | ||
BaseProvider auth.TokenProvider | ||
// Rules defines the accesses held by the new downscoped provider. One or | ||
// more AccessBoundaryRules are required to define permissions for the new | ||
// downscoped provider. Each one defines an access (or set of accesses) that | ||
// the new provider 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 | ||
} | ||
|
||
func (c Options) client() *http.Client { | ||
if c.Client != nil { | ||
return c.Client | ||
} | ||
return internal.CloneDefaultClient() | ||
} | ||
|
||
// 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"` | ||
} | ||
|
||
// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] that is | ||
// more restrictive than [Options.BaseProvider] provided. | ||
func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { | ||
if opts == nil { | ||
return nil, fmt.Errorf("downscope: providing opts is required") | ||
} | ||
if opts.BaseProvider == nil { | ||
return nil, fmt.Errorf("downscope: BaseProvider 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 &downscopedTokenProvider{Options: opts, Client: opts.client()}, nil | ||
} | ||
|
||
// downscopedTokenProvider is used to retrieve a downscoped tokens. | ||
type downscopedTokenProvider struct { | ||
Options *Options | ||
Client *http.Client | ||
} | ||
|
||
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.BaseProvider.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(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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
// 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" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
|
||
"cloud.google.com/go/auth" | ||
) | ||
|
||
var ( | ||
standardReqBody = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22accessBoundary%22%3A%7B%22accessBoundaryRules%22%3A%5B%7B%22availableResource%22%3A%22test1%22%2C%22availablePermissions%22%3A%5B%22Perm1%22%2C%22Perm2%22%5D%7D%5D%7D%7D&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&subject_token=token_base&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token" | ||
standardRespBody = `{"access_token":"fake_token","expires_in":42,"token_type":"Bearer"}` | ||
) | ||
|
||
type staticTokenProvider string | ||
|
||
func (s staticTokenProvider) Token(context.Context) (*auth.Token, error) { | ||
return &auth.Token{Value: string(s)}, nil | ||
} | ||
|
||
func TestNewTokenProvider(t *testing.T) { | ||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
if r.Method != "POST" { | ||
t.Errorf("Unexpected request method, %v is found", r.Method) | ||
} | ||
if r.URL.String() != "/" { | ||
t.Errorf("Unexpected request URL, %v is found", r.URL) | ||
} | ||
body, err := io.ReadAll(r.Body) | ||
if err != nil { | ||
t.Fatalf("Failed to read request body: %v", err) | ||
} | ||
if got, want := string(body), standardReqBody; got != want { | ||
t.Errorf("Unexpected exchange payload: got %v but want %v,", got, want) | ||
} | ||
w.Header().Set("Content-Type", "application/json") | ||
w.Write([]byte(standardRespBody)) | ||
|
||
})) | ||
defer ts.Close() | ||
identityBindingEndpoint = ts.URL | ||
tp, err := NewTokenProvider(&Options{ | ||
BaseProvider: staticTokenProvider("token_base"), | ||
Rules: []AccessBoundaryRule{ | ||
{ | ||
AvailableResource: "test1", | ||
AvailablePermissions: []string{"Perm1", "Perm2"}, | ||
}, | ||
}, | ||
}) | ||
if err != nil { | ||
t.Fatalf("NewTokenProvider() = %v", err) | ||
} | ||
tok, err := tp.Token(context.Background()) | ||
if err != nil { | ||
t.Fatalf("NewDownscopedTokenSource failed with error: %v", err) | ||
} | ||
if want := "fake_token"; tok.Value != want { | ||
t.Fatalf("got %v, want %v", tok.Value, want) | ||
} | ||
} | ||
|
||
func TestTestNewTokenProvider_Validations(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
opts *Options | ||
}{ | ||
{ | ||
name: "no opts", | ||
opts: nil, | ||
}, | ||
{ | ||
name: "no provider", | ||
opts: &Options{}, | ||
}, | ||
{ | ||
name: "no rules", | ||
opts: &Options{ | ||
BaseProvider: staticTokenProvider("token_base"), | ||
}, | ||
}, | ||
{ | ||
name: "too many rules", | ||
opts: &Options{ | ||
BaseProvider: staticTokenProvider("token_base"), | ||
Rules: []AccessBoundaryRule{{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}}, | ||
}, | ||
}, | ||
{ | ||
name: "no resource", | ||
opts: &Options{ | ||
BaseProvider: staticTokenProvider("token_base"), | ||
Rules: []AccessBoundaryRule{{}}, | ||
}, | ||
}, | ||
{ | ||
name: "no perm", | ||
opts: &Options{ | ||
BaseProvider: staticTokenProvider("token_base"), | ||
Rules: []AccessBoundaryRule{{ | ||
AvailableResource: "resource", | ||
}}, | ||
}, | ||
}, | ||
} | ||
for _, test := range tests { | ||
t.Run(test.name, func(t *testing.T) { | ||
if _, err := NewTokenProvider(test.opts); err == nil { | ||
t.Fatal("want non-nil err") | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.