Skip to content

Commit dda9bff

Browse files
authored
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.
1 parent ac10224 commit dda9bff

File tree

8 files changed

+820
-0
lines changed

8 files changed

+820
-0
lines changed

auth/downscope/doc.go

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 implements the ability to downscope, or restrict, the
16+
// Identity and Access Management permissions that a short-lived Token
17+
// can use. Please note that only Google Cloud Storage supports this feature.
18+
// For complete documentation, see https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
19+
//
20+
// To downscope permissions of a source credential, you need to define
21+
// a Credential Access Boundary. Said Boundary specifies which resources
22+
// the newly created credential can access, an upper bound on the permissions
23+
// it has over those resources, and optionally attribute-based conditional
24+
// access to the aforementioned resources. For more information on IAM
25+
// Conditions, see https://cloud.google.com/iam/docs/conditions-overview.
26+
//
27+
// This functionality can be used to provide a third party with
28+
// limited access to and permissions on resources held by the owner of the root
29+
// credential or internally in conjunction with the principle of least privilege
30+
// to ensure that internal services only hold the minimum necessary privileges
31+
// for their function.
32+
//
33+
// For example, a token broker can be set up on a server in a private network.
34+
// Various workloads (token consumers) in the same network will send
35+
// authenticated requests to that broker for downscoped tokens to access or
36+
// modify specific google cloud storage buckets. See the NewTokenProvider example
37+
// for an example of how a token broker would use this package.
38+
//
39+
// The broker will use the functionality in this package to generate a
40+
// downscoped token with the requested configuration, and then pass it back to
41+
// the token consumer. These downscoped access tokens can then be used to access
42+
// Google Cloud resources.
43+
package downscope

auth/downscope/downscope.go

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
}

auth/downscope/downscope_test.go

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
"io"
20+
"net/http"
21+
"net/http/httptest"
22+
"testing"
23+
24+
"cloud.google.com/go/auth"
25+
)
26+
27+
var (
28+
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"
29+
standardRespBody = `{"access_token":"fake_token","expires_in":42,"token_type":"Bearer"}`
30+
)
31+
32+
type staticTokenProvider string
33+
34+
func (s staticTokenProvider) Token(context.Context) (*auth.Token, error) {
35+
return &auth.Token{Value: string(s)}, nil
36+
}
37+
38+
func TestNewTokenProvider(t *testing.T) {
39+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+
if r.Method != "POST" {
41+
t.Errorf("Unexpected request method, %v is found", r.Method)
42+
}
43+
if r.URL.String() != "/" {
44+
t.Errorf("Unexpected request URL, %v is found", r.URL)
45+
}
46+
body, err := io.ReadAll(r.Body)
47+
if err != nil {
48+
t.Fatalf("Failed to read request body: %v", err)
49+
}
50+
if got, want := string(body), standardReqBody; got != want {
51+
t.Errorf("Unexpected exchange payload: got %v but want %v,", got, want)
52+
}
53+
w.Header().Set("Content-Type", "application/json")
54+
w.Write([]byte(standardRespBody))
55+
56+
}))
57+
defer ts.Close()
58+
identityBindingEndpoint = ts.URL
59+
tp, err := NewTokenProvider(&Options{
60+
BaseProvider: staticTokenProvider("token_base"),
61+
Rules: []AccessBoundaryRule{
62+
{
63+
AvailableResource: "test1",
64+
AvailablePermissions: []string{"Perm1", "Perm2"},
65+
},
66+
},
67+
})
68+
if err != nil {
69+
t.Fatalf("NewTokenProvider() = %v", err)
70+
}
71+
tok, err := tp.Token(context.Background())
72+
if err != nil {
73+
t.Fatalf("NewDownscopedTokenSource failed with error: %v", err)
74+
}
75+
if want := "fake_token"; tok.Value != want {
76+
t.Fatalf("got %v, want %v", tok.Value, want)
77+
}
78+
}
79+
80+
func TestTestNewTokenProvider_Validations(t *testing.T) {
81+
tests := []struct {
82+
name string
83+
opts *Options
84+
}{
85+
{
86+
name: "no opts",
87+
opts: nil,
88+
},
89+
{
90+
name: "no provider",
91+
opts: &Options{},
92+
},
93+
{
94+
name: "no rules",
95+
opts: &Options{
96+
BaseProvider: staticTokenProvider("token_base"),
97+
},
98+
},
99+
{
100+
name: "too many rules",
101+
opts: &Options{
102+
BaseProvider: staticTokenProvider("token_base"),
103+
Rules: []AccessBoundaryRule{{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}},
104+
},
105+
},
106+
{
107+
name: "no resource",
108+
opts: &Options{
109+
BaseProvider: staticTokenProvider("token_base"),
110+
Rules: []AccessBoundaryRule{{}},
111+
},
112+
},
113+
{
114+
name: "no perm",
115+
opts: &Options{
116+
BaseProvider: staticTokenProvider("token_base"),
117+
Rules: []AccessBoundaryRule{{
118+
AvailableResource: "resource",
119+
}},
120+
},
121+
},
122+
}
123+
for _, test := range tests {
124+
t.Run(test.name, func(t *testing.T) {
125+
if _, err := NewTokenProvider(test.opts); err == nil {
126+
t.Fatal("want non-nil err")
127+
}
128+
})
129+
}
130+
}

0 commit comments

Comments
 (0)