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