Commit 3ef06a0c by Dhananjay Committed by GitHub

Cloudwatch: Add Support for external ID in assume role (#23685)

Co-authored by: Arve Knudsen
parent 2d4bcbef
......@@ -34,6 +34,7 @@ build dashboards or use Explore with CloudWatch metrics and CloudWatch Logs.
| _Auth Provider_ | Specify the provider to get credentials. |
| _Credentials_ profile name | Specify the name of the profile to use (if you use `~/.aws/credentials` file), leave blank for default. |
| _Assume Role Arn_ | Specify the ARN of the role to assume |
| _External ID_ | If you are assuming a role in another account, that has been created with an external ID, specify the exterrnal ID here. |
## Authentication
......
......@@ -25,6 +25,7 @@ require (
github.com/go-sql-driver/mysql v1.5.0
github.com/go-stack/stack v1.8.0
github.com/gobwas/glob v0.2.3
github.com/golang/mock v1.4.3
github.com/golang/protobuf v1.4.0
github.com/google/go-cmp v0.4.0
github.com/gorilla/websocket v1.4.1
......
......@@ -108,7 +108,10 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
......@@ -437,6 +440,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
......@@ -450,6 +454,7 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
......@@ -526,6 +531,8 @@ honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
xorm.io/builder v0.3.6 h1:ha28mQ2M+TFx96Hxo+iq6tQgnkC9IZkM6D8w9sKHHF8=
xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU=
xorm.io/core v0.7.2/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM=
......
......@@ -32,6 +32,7 @@ type DatasourceInfo struct {
Region string
AuthType string
AssumeRoleArn string
ExternalID string
Namespace string
AccessKey string
......
......@@ -7,6 +7,7 @@ import (
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/client"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
......@@ -18,6 +19,7 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/aws/aws-sdk-go/service/sts/stsiface"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
......@@ -30,7 +32,25 @@ type cache struct {
var awsCredentialCache = make(map[string]cache)
var credentialCacheLock sync.RWMutex
func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
// Session factory.
// Stubbable by tests.
var newSession = func(cfgs ...*aws.Config) (*session.Session, error) {
return session.NewSession(cfgs...)
}
// STS service factory.
// Stubbable by tests.
var newSTSService = func(p client.ConfigProvider, cfgs ...*aws.Config) stsiface.STSAPI {
return sts.New(p, cfgs...)
}
// EC2Metadata service factory.
// Stubbable by tests.
var newEC2Metadata = func(p client.ConfigProvider, cfgs ...*aws.Config) *ec2metadata.EC2Metadata {
return ec2metadata.New(p, cfgs...)
}
func getCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("%s:%s:%s:%s", dsInfo.AuthType, dsInfo.AccessKey, dsInfo.Profile, dsInfo.AssumeRoleArn)
credentialCacheLock.RLock()
if _, ok := awsCredentialCache[cacheKey]; ok {
......@@ -53,8 +73,11 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
RoleSessionName: aws.String("GrafanaSession"),
DurationSeconds: aws.Int64(900),
}
if dsInfo.ExternalID != "" {
params.ExternalId = aws.String(dsInfo.ExternalID)
}
stsSess, err := session.NewSession()
stsSess, err := newSession()
if err != nil {
return nil, err
}
......@@ -70,11 +93,11 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
Credentials: stsCreds,
}
sess, err := session.NewSession(stsConfig)
sess, err := newSession(stsConfig)
if err != nil {
return nil, err
}
svc := sts.New(sess, stsConfig)
svc := newSTSService(sess, stsConfig)
resp, err := svc.AssumeRole(params)
if err != nil {
return nil, err
......@@ -91,7 +114,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
expiration = &e
}
sess, err := session.NewSession()
sess, err := newSession()
if err != nil {
return nil, err
}
......@@ -123,7 +146,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
}
func webIdentityProvider(sess *session.Session) credentials.Provider {
svc := sts.New(sess)
svc := newSTSService(sess)
roleARN := os.Getenv("AWS_ROLE_ARN")
tokenFilepath := os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
......@@ -152,7 +175,7 @@ func ecsCredProvider(sess *session.Session, uri string) credentials.Provider {
}
func ec2RoleProvider(sess *session.Session) credentials.Provider {
return &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute}
return &ec2rolecreds.EC2RoleProvider{Client: newEC2Metadata(sess), ExpiryWindow: 5 * time.Minute}
}
func (e *CloudWatchExecutor) getDsInfo(region string) *DatasourceInfo {
......@@ -167,6 +190,7 @@ func retrieveDsInfo(datasource *models.DataSource, region string) *DatasourceInf
authType := datasource.JsonData.Get("authType").MustString()
assumeRoleArn := datasource.JsonData.Get("assumeRoleArn").MustString()
externalID := datasource.JsonData.Get("externalId").MustString()
decrypted := datasource.DecryptedValues()
accessKey := decrypted["accessKey"]
secretKey := decrypted["secretKey"]
......@@ -176,6 +200,7 @@ func retrieveDsInfo(datasource *models.DataSource, region string) *DatasourceInf
Profile: datasource.Database,
AuthType: authType,
AssumeRoleArn: assumeRoleArn,
ExternalID: externalID,
AccessKey: accessKey,
SecretKey: secretKey,
}
......@@ -184,7 +209,7 @@ func retrieveDsInfo(datasource *models.DataSource, region string) *DatasourceInf
}
func getAwsConfig(dsInfo *DatasourceInfo) (*aws.Config, error) {
creds, err := GetCredentials(dsInfo)
creds, err := getCredentials(dsInfo)
if err != nil {
return nil, err
}
......@@ -204,7 +229,7 @@ func (e *CloudWatchExecutor) getClient(region string) (*cloudwatch.CloudWatch, e
return nil, err
}
sess, err := session.NewSession(cfg)
sess, err := newSession(cfg)
if err != nil {
return nil, err
}
......@@ -224,7 +249,7 @@ func retrieveLogsClient(datasourceInfo *DatasourceInfo) (*cloudwatchlogs.CloudWa
return nil, err
}
sess, err := session.NewSession(cfg)
sess, err := newSession(cfg)
if err != nil {
return nil, err
}
......
......@@ -4,39 +4,120 @@ import (
"os"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/client"
"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
"github.com/aws/aws-sdk-go/aws/ec2metadata"
"github.com/aws/aws-sdk-go/aws/session"
. "github.com/smartystreets/goconvey/convey"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/aws/aws-sdk-go/service/sts/stsiface"
"github.com/golang/mock/gomock"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mock_stsiface"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestECSCredProvider(t *testing.T) {
Convey("Running in an ECS container task", t, func() {
defer os.Clearenv()
os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/abc/123")
t.Cleanup(func() {
os.Unsetenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
})
sess, _ := session.NewSession()
sess, err := session.NewSession()
require.NoError(t, err)
provider := remoteCredProvider(sess)
So(provider, ShouldNotBeNil)
require.NotNil(t, provider)
ecsProvider, ok := provider.(*endpointcreds.Provider)
So(ecsProvider, ShouldNotBeNil)
So(ok, ShouldBeTrue)
require.NotNil(t, ecsProvider)
require.True(t, ok)
So(ecsProvider.Client.Endpoint, ShouldEqual, "http://169.254.170.2/abc/123")
})
assert.Equal(t, "http://169.254.170.2/abc/123", ecsProvider.Client.Endpoint)
}
func TestDefaultEC2RoleProvider(t *testing.T) {
Convey("Running outside an ECS container task", t, func() {
sess, _ := session.NewSession()
sess, err := session.NewSession()
require.NoError(t, err)
provider := remoteCredProvider(sess)
So(provider, ShouldNotBeNil)
require.NotNil(t, provider)
ec2Provider, ok := provider.(*ec2rolecreds.EC2RoleProvider)
So(ec2Provider, ShouldNotBeNil)
So(ok, ShouldBeTrue)
require.NotNil(t, ec2Provider)
require.True(t, ok)
}
func TestGetCredentials_ARNAuthType(t *testing.T) {
ctrl := gomock.NewController(t)
var stsMock *mock_stsiface.MockSTSAPI
origNewSession := newSession
origNewSTSService := newSTSService
origNewEC2Metadata := newEC2Metadata
t.Cleanup(func() {
newSession = origNewSession
newSTSService = origNewSTSService
newEC2Metadata = origNewEC2Metadata
})
newSession = func(cfgs ...*aws.Config) (*session.Session, error) {
return &session.Session{}, nil
}
newSTSService = func(p client.ConfigProvider, cfgs ...*aws.Config) stsiface.STSAPI {
return stsMock
}
newEC2Metadata = func(p client.ConfigProvider, cfgs ...*aws.Config) *ec2metadata.EC2Metadata {
return nil
}
t.Run("Without external ID", func(t *testing.T) {
stsMock = mock_stsiface.NewMockSTSAPI(ctrl)
stsMock.
EXPECT().
AssumeRole(gomock.Eq(&sts.AssumeRoleInput{
RoleArn: aws.String(""),
DurationSeconds: aws.Int64(900),
RoleSessionName: aws.String("GrafanaSession"),
})).
Return(&sts.AssumeRoleOutput{
Credentials: &sts.Credentials{
AccessKeyId: aws.String("id"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("token"),
},
}, nil).
Times(1)
creds, err := getCredentials(&DatasourceInfo{
AuthType: "arn",
})
require.NoError(t, err)
require.NotNil(t, creds)
})
t.Run("With external ID", func(t *testing.T) {
stsMock = mock_stsiface.NewMockSTSAPI(ctrl)
stsMock.
EXPECT().
AssumeRole(gomock.Eq(&sts.AssumeRoleInput{
RoleArn: aws.String(""),
DurationSeconds: aws.Int64(900),
RoleSessionName: aws.String("GrafanaSession"),
ExternalId: aws.String("external-id"),
})).
Return(&sts.AssumeRoleOutput{
Credentials: &sts.Credentials{
AccessKeyId: aws.String("id"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("token"),
},
}, nil).
Times(1)
creds, err := getCredentials(&DatasourceInfo{
AuthType: "arn",
ExternalID: "external-id",
})
require.NoError(t, err)
require.NotNil(t, creds)
})
}
......@@ -740,7 +740,7 @@ func (e *CloudWatchExecutor) resourceGroupsGetResources(region string, filters [
}
func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) {
creds, err := GetCredentials(cwData)
creds, err := getCredentials(cwData)
if err != nil {
return cloudwatch.ListMetricsOutput{}, err
}
......
......@@ -42,6 +42,7 @@ const setup = (propOverrides?: object) => {
},
jsonData: {
assumeRoleArn: '',
externalId: '',
database: '',
customMetricsNamespaces: '',
authType: 'keys',
......
......@@ -130,6 +130,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
onChange={option => {
if (options.jsonData.authType === 'arn' && option.value !== 'arn') {
delete this.props.options.jsonData.assumeRoleArn;
delete this.props.options.jsonData.externalId;
}
onUpdateDatasourceJsonDataOptionSelect(this.props, 'authType')(option);
}}
......@@ -239,6 +240,22 @@ export class ConfigEditor extends PureComponent<Props, State> {
/>
</div>
</div>
<div className="gf-form">
<InlineFormLabel
className="width-14"
tooltip="If you are assuming a role in another account, that has been created with an external ID, specify the external ID here."
>
External ID
</InlineFormLabel>
<div className="width-30">
<Input
className="width-30"
placeholder="External ID"
value={options.jsonData.externalId || ''}
onChange={onUpdateDatasourceJsonDataOption(this.props, 'externalId')}
/>
</div>
</div>
</div>
)}
<div className="gf-form-inline">
......
......@@ -59,6 +59,7 @@ export type SelectableStrings = Array<SelectableValue<string>>;
export interface CloudWatchJsonData extends DataSourceJsonData {
timeField?: string;
assumeRoleArn?: string;
externalId?: string;
database?: string;
customMetricsNamespaces?: string;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment