From 2fd2e1e626f23d7b4305f5767a6d21682b71e3e9 Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Thu, 26 Oct 2023 10:45:58 -0400 Subject: [PATCH 1/4] Added IAM Authentication support for Amazon MemoryDB Access --- lib/srv/db/access_test.go | 44 ++++++++++++++++++++- lib/srv/db/auth_test.go | 28 +++++++++++++ lib/srv/db/cloud/iam.go | 8 ++++ lib/srv/db/cloud/iam_test.go | 23 +++++++++++ lib/srv/db/common/auth.go | 66 ++++++++++++++++++++++--------- lib/srv/db/common/errors.go | 29 ++++++++++++++ lib/srv/db/common/iam/aws.go | 53 +++++++++++++++++++++++++ lib/srv/db/common/iam/aws_test.go | 32 +++++++++++++++ lib/srv/db/redis/client.go | 25 ++++++++++++ lib/srv/db/redis/engine.go | 57 +++++++++++++++++++++----- 10 files changed, 336 insertions(+), 29 deletions(-) diff --git a/lib/srv/db/access_test.go b/lib/srv/db/access_test.go index a8b03b1c9f06d..16718abb8424e 100644 --- a/lib/srv/db/access_test.go +++ b/lib/srv/db/access_test.go @@ -2242,6 +2242,8 @@ type agentParams struct { GCPSQL *mocks.GCPSQLAdminClientMock // ElastiCache defines the AWS ElastiCache mock to use for ElastiCache API calls. ElastiCache *mocks.ElastiCacheMock + // MemoryDB defines the AWS MemoryDB mock to use for MemoryDB API calls. + MemoryDB *mocks.MemoryDBMock // OnHeartbeat defines a heartbeat function that generates heartbeat events. OnHeartbeat func(error) // CADownloader defines the CA downloader. @@ -2275,6 +2277,9 @@ func (p *agentParams) setDefaults(c *testContext) { if p.ElastiCache == nil { p.ElastiCache = &mocks.ElastiCacheMock{} } + if p.MemoryDB == nil { + p.MemoryDB = &mocks.MemoryDBMock{} + } if p.CADownloader == nil { p.CADownloader = &fakeDownloader{ cert: []byte(fixtures.TLSCACertPEM), @@ -2288,7 +2293,7 @@ func (p *agentParams) setDefaults(c *testContext) { Redshift: &mocks.RedshiftMock{}, RedshiftServerless: &mocks.RedshiftServerlessMock{}, ElastiCache: p.ElastiCache, - MemoryDB: &mocks.MemoryDBMock{}, + MemoryDB: p.MemoryDB, SecretsManager: secrets.NewMockSecretsManagerClient(secrets.MockSecretsManagerClientConfig{}), IAM: &mocks.IAMMock{}, GCPSQL: p.GCPSQL, @@ -3078,6 +3083,43 @@ func withElastiCacheRedis(name string, token, engineVersion string) withDatabase } } +func withMemoryDBRedis(name string, token, engineVersion string) withDatabaseOption { + return func(t testing.TB, ctx context.Context, testCtx *testContext) types.Database { + redisServer, err := redis.NewTestServer(t, common.TestServerConfig{ + Name: name, + AuthClient: testCtx.authClient, + }, redis.TestServerPassword(token)) + require.NoError(t, err) + + database, err := types.NewDatabaseV3(types.Metadata{ + Name: name, + Labels: map[string]string{ + "engine-version": engineVersion, + }, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolRedis, + URI: fmt.Sprintf("rediss://%s", net.JoinHostPort("localhost", redisServer.Port())), + DynamicLabels: dynamicLabels, + AWS: types.AWS{ + Region: "us-west-1", + MemoryDB: types.MemoryDB{ + ClusterName: "example-cluster", + }, + }, + // Set CA cert to pass cert validation. + TLS: types.DatabaseTLS{ + CACert: string(testCtx.databaseCA.GetActiveKeys().TLS[0].Cert), + }, + }) + require.NoError(t, err) + testCtx.redis[name] = testRedis{ + db: redisServer, + resource: database, + } + return database + } +} + func withAzureRedis(name string, token string) withDatabaseOption { return func(t testing.TB, ctx context.Context, testCtx *testContext) types.Database { redisServer, err := redis.NewTestServer(t, common.TestServerConfig{ diff --git a/lib/srv/db/auth_test.go b/lib/srv/db/auth_test.go index c15ffb50c232c..04a0d47c4266c 100644 --- a/lib/srv/db/auth_test.go +++ b/lib/srv/db/auth_test.go @@ -25,6 +25,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/aws/aws-sdk-go/service/memorydb" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" "github.com/sirupsen/logrus" @@ -64,6 +65,8 @@ func TestAuthTokens(t *testing.T) { withAzureRedis("redis-azure-incorrect-token", "qwe123"), withElastiCacheRedis("redis-elasticache-correct-token", elastiCacheRedisToken, "7.0.0"), withElastiCacheRedis("redis-elasticache-incorrect-token", "qwe123", "7.0.0"), + withMemoryDBRedis("redis-memorydb-correct-token", memorydbToken, "7.0"), + withMemoryDBRedis("redis-memorydb-incorrect-token", "qwe123", "7.0"), } databases := make([]types.Database, 0, len(withDBs)) for _, withDB := range withDBs { @@ -75,9 +78,16 @@ func TestAuthTokens(t *testing.T) { Authentication: &elasticache.Authentication{Type: aws.String("iam")}, } ecMock.AddMockUser(elastiCacheIAMUser, nil) + memorydbMock := &mocks.MemoryDBMock{} + memorydbIAMUser := &memorydb.User{ + Name: aws.String("default"), + Authentication: &memorydb.Authentication{Type: aws.String("iam")}, + } + memorydbMock.AddMockUser(memorydbIAMUser, nil) testCtx.server = testCtx.setupDatabaseServer(ctx, t, agentParams{ Databases: databases, ElastiCache: ecMock, + MemoryDB: memorydbMock, }) go testCtx.startHandlingConnections() @@ -169,6 +179,18 @@ func TestAuthTokens(t *testing.T) { // Make sure we print a user-friendly IAM auth error. err: "Make sure that IAM auth is enabled", }, + { + desc: "correct MemoryDB auth token", + service: "redis-memorydb-correct-token", + protocol: defaults.ProtocolRedis, + }, + { + desc: "incorrect MemoryDB auth token", + service: "redis-memorydb-incorrect-token", + protocol: defaults.ProtocolRedis, + // Make sure we print a user-friendly IAM auth error. + err: "Make sure that IAM auth is enabled", + }, } for _, test := range tests { @@ -245,6 +267,8 @@ const ( azureRedisToken = "azure-redis-token" // elastiCacheRedisToken is a mock ElastiCache Redis token. elastiCacheRedisToken = "elasticache-redis-token" + // memorydbToken is a mock MemoryDB auth token. + memorydbToken = "memorydb-token" // atlasAuthUser is a mock Mongo Atlas IAM auth user. atlasAuthUser = "arn:aws:iam::111111111111:role/alice" // atlasAuthToken is a mock Mongo Atlas IAM auth token. @@ -273,6 +297,10 @@ func (a *testAuth) GetElastiCacheRedisToken(ctx context.Context, sessionCtx *com return elastiCacheRedisToken, nil } +func (a *testAuth) GetMemoryDBToken(ctx context.Context, sessionCtx *common.Session) (string, error) { + return memorydbToken, nil +} + // GetCloudSQLAuthToken generates Cloud SQL auth token. func (a *testAuth) GetCloudSQLAuthToken(ctx context.Context, sessionCtx *common.Session) (string, error) { a.Infof("Generating Cloud SQL auth token for %v.", sessionCtx) diff --git a/lib/srv/db/cloud/iam.go b/lib/srv/db/cloud/iam.go index f01d589b49ff3..ffebea867d44d 100644 --- a/lib/srv/db/cloud/iam.go +++ b/lib/srv/db/cloud/iam.go @@ -198,6 +198,14 @@ func (c *IAM) isSetupRequiredForDatabase(database types.Database) bool { return true } return ok + case types.DatabaseTypeMemoryDB: + ok, err := iam.CheckMemoryDBSupportsIAMAuth(database) + if err != nil { + c.log.WithError(err).Debugf("Assuming database %s supports IAM auth.", + database.GetName()) + return true + } + return ok default: return false } diff --git a/lib/srv/db/cloud/iam_test.go b/lib/srv/db/cloud/iam_test.go index 669fa50b51c4d..6236d6a66b852 100644 --- a/lib/srv/db/cloud/iam_test.go +++ b/lib/srv/db/cloud/iam_test.go @@ -129,6 +129,22 @@ func TestAWSIAM(t *testing.T) { }) require.NoError(t, err) + memorydb, err := types.NewDatabaseV3(types.Metadata{ + Name: "aws-memorydb", + }, types.DatabaseSpecV3{ + Protocol: "redis", + URI: "clustercfg.my-memorydb.xxxxxx.memorydb.us-east-1.amazonaws.com:6379", + AWS: types.AWS{ + AccountID: "123456789012", + MemoryDB: types.MemoryDB{ + ClusterName: "my-memorydb", + TLSEnabled: true, + EndpointType: "cluster", + }, + }, + }) + require.NoError(t, err) + // Make configurator. taskChan := make(chan struct{}) waitForTaskProcessed := func(t *testing.T) { @@ -207,6 +223,13 @@ func TestAWSIAM(t *testing.T) { return true // it always is for ElastiCache. }, }, + "MemoryDB": { + database: memorydb, + wantPolicyContains: memorydb.GetAWS().MemoryDB.ClusterName, + getIAMAuthEnabled: func() bool { + return true // it always is for ElastiCache. + }, + }, } for testName, tt := range tests { diff --git a/lib/srv/db/common/auth.go b/lib/srv/db/common/auth.go index a01ad267169f4..d7ddabdb9da21 100644 --- a/lib/srv/db/common/auth.go +++ b/lib/srv/db/common/auth.go @@ -35,6 +35,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" v4 "github.com/aws/aws-sdk-go/aws/signer/v4" "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/aws/aws-sdk-go/service/memorydb" "github.com/aws/aws-sdk-go/service/rds/rdsutils" "github.com/aws/aws-sdk-go/service/redshift" "github.com/aws/aws-sdk-go/service/redshiftserverless" @@ -74,6 +75,8 @@ type Auth interface { GetRedshiftServerlessAuthToken(ctx context.Context, sessionCtx *Session) (string, string, error) // GetElastiCacheRedisToken generates an ElastiCache Redis auth token. GetElastiCacheRedisToken(ctx context.Context, sessionCtx *Session) (string, error) + // GetMemoryDBToken generates a MemoryDB auth token. + GetMemoryDBToken(ctx context.Context, sessionCtx *Session) (string, error) // GetCloudSQLAuthToken generates Cloud SQL auth token. GetCloudSQLAuthToken(ctx context.Context, sessionCtx *Session) (string, error) // GetCloudSQLPassword generates password for a Cloud SQL database user. @@ -415,14 +418,35 @@ func (a *dbAuth) GetElastiCacheRedisToken(ctx context.Context, sessionCtx *Sessi return "", trace.Wrap(err) } a.cfg.Log.Debugf("Generating ElastiCache Redis auth token for %s.", sessionCtx) - tokenReq := &elastiCacheRedisIAMTokenRequest{ + tokenReq := &awsRedisIAMTokenRequest{ // For IAM-enabled ElastiCache users, the username and user id properties must be identical. // https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-limits - userID: sessionCtx.DatabaseUser, - replicationGroupId: meta.ElastiCache.ReplicationGroupID, - region: meta.Region, - credentials: awsSession.Config.Credentials, - clock: a.cfg.Clock, + userID: sessionCtx.DatabaseUser, + targetID: meta.ElastiCache.ReplicationGroupID, + region: meta.Region, + credentials: awsSession.Config.Credentials, + clock: a.cfg.Clock, + serviceName: elasticache.ServiceName, + } + token, err := tokenReq.toSignedRequestURI() + return token, trace.Wrap(err) +} + +// GetMemoryDBToken generates a MemoryDB auth token. +func (a *dbAuth) GetMemoryDBToken(ctx context.Context, sessionCtx *Session) (string, error) { + meta := sessionCtx.Database.GetAWS() + awsSession, err := a.cfg.Clients.GetAWSSession(ctx, meta.Region, cloud.WithAssumeRoleFromAWSMeta(meta)) + if err != nil { + return "", trace.Wrap(err) + } + a.cfg.Log.Debugf("Generating MemoryDB auth token for %s.", sessionCtx) + tokenReq := &awsRedisIAMTokenRequest{ + userID: sessionCtx.DatabaseUser, + targetID: meta.MemoryDB.ClusterName, + region: meta.Region, + credentials: awsSession.Config.Credentials, + clock: a.cfg.Clock, + serviceName: strings.ToLower(memorydb.ServiceName), } token, err := tokenReq.toSignedRequestURI() return token, trace.Wrap(err) @@ -954,30 +978,33 @@ func redshiftServerlessUsernameToRoleARN(aws types.AWS, username string) (string return awsutils.BuildRoleARN(username, aws.Region, aws.AccountID) } -// elastiCacheRedisIAMTokenRequest builds an AWS IAM auth token for ElastiCache +// awsRedisIAMTokenRequest builds an AWS IAM auth token for ElastiCache // Redis. -// Implemented following the AWS example: +// Implemented following the AWS examples: // https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-Connecting -type elastiCacheRedisIAMTokenRequest struct { +// https://docs.aws.amazon.com/memorydb/latest/devguide/auth-iam.html#auth-iam-Connecting +type awsRedisIAMTokenRequest struct { // userID is the ElastiCache user ID. userID string - // replicationGroupId is the ElastiCache replication group ID. - replicationGroupId string + // targetID is the ElastiCache replication group ID or the MemoryDB cluster name. + targetID string // region is the AWS region. region string // credentials are used to presign with AWS SigV4. credentials *credentials.Credentials // clock is the clock implementation. clock clockwork.Clock + // serviceName is the AWS service name used for signinig. + serviceName string } // checkAndSetDefaults validates config and sets defaults. -func (r *elastiCacheRedisIAMTokenRequest) checkAndSetDefaults() error { +func (r *awsRedisIAMTokenRequest) checkAndSetDefaults() error { if r.userID == "" { return trace.BadParameter("missing user ID") } - if r.replicationGroupId == "" { - return trace.BadParameter("missing replication group ID") + if r.targetID == "" { + return trace.BadParameter("missing host name for signing") } if r.region == "" { return trace.BadParameter("missing region") @@ -985,6 +1012,9 @@ func (r *elastiCacheRedisIAMTokenRequest) checkAndSetDefaults() error { if r.credentials == nil { return trace.BadParameter("missing credentials") } + if r.serviceName == "" { + return trace.BadParameter("missing service name") + } if r.clock == nil { r.clock = clockwork.NewRealClock() } @@ -994,7 +1024,7 @@ func (r *elastiCacheRedisIAMTokenRequest) checkAndSetDefaults() error { // toSignedRequestURI creates a new AWS SigV4 pre-signed request URI. // This pre-signed request URI can then be used to authenticate as an // ElastiCache Redis user. -func (r *elastiCacheRedisIAMTokenRequest) toSignedRequestURI() (string, error) { +func (r *awsRedisIAMTokenRequest) toSignedRequestURI() (string, error) { if err := r.checkAndSetDefaults(); err != nil { return "", trace.Wrap(err) } @@ -1003,7 +1033,7 @@ func (r *elastiCacheRedisIAMTokenRequest) toSignedRequestURI() (string, error) { return "", trace.Wrap(err) } s := v4.NewSigner(r.credentials) - _, err = s.Presign(req, nil, elasticache.ServiceName, r.region, time.Minute*15, r.clock.Now()) + _, err = s.Presign(req, nil, r.serviceName, r.region, time.Minute*15, r.clock.Now()) if err != nil { return "", trace.Wrap(err) } @@ -1016,14 +1046,14 @@ func (r *elastiCacheRedisIAMTokenRequest) toSignedRequestURI() (string, error) { } // getSignableRequest creates a new request suitable for pre-signing with SigV4. -func (r *elastiCacheRedisIAMTokenRequest) getSignableRequest() (*http.Request, error) { +func (r *awsRedisIAMTokenRequest) getSignableRequest() (*http.Request, error) { query := url.Values{ "Action": {"connect"}, "User": {r.userID}, } reqURI := url.URL{ Scheme: "http", - Host: r.replicationGroupId, + Host: r.targetID, Path: "/", RawQuery: query.Encode(), } diff --git a/lib/srv/db/common/errors.go b/lib/srv/db/common/errors.go index 94c657d6a2897..4476b8ed19cb1 100644 --- a/lib/srv/db/common/errors.go +++ b/lib/srv/db/common/errors.go @@ -145,6 +145,8 @@ func ConvertConnectError(err error, sessionCtx *Session) error { switch sessionCtx.Database.GetType() { case types.DatabaseTypeElastiCache: return createElastiCacheRedisAccessDeniedError(err, sessionCtx) + case types.DatabaseTypeMemoryDB: + return createMemoryDBAccessDeniedError(err, sessionCtx) case types.DatabaseTypeRDS: return createRDSAccessDeniedError(err, orgErr, sessionCtx) case types.DatabaseTypeRDSProxy: @@ -183,6 +185,33 @@ take a few minutes to propagate): } } +// createMemoryDBAccessDeniedError creates an error with help message +// to setup IAM auth for MemoryDB Redis. +func createMemoryDBAccessDeniedError(err error, sessionCtx *Session) error { + policy, getPolicyErr := dbiam.GetReadableAWSPolicyDocument(sessionCtx.Database) + if getPolicyErr != nil { + policy = fmt.Sprintf("failed to generate IAM policy: %v", getPolicyErr) + } + + switch sessionCtx.Database.GetProtocol() { + case defaults.ProtocolRedis: + return trace.AccessDenied(`Could not connect to database: + + %v + +Make sure that IAM auth is enabled for MemoryDB user %q and the user is in the +ACL associated with the MemoryDB cluster. Also Teleport database agent's IAM +policy must have "memorydb:Connect" permissions (note that IAM changes may take +a few minutes to propagate): + +%v +`, err, sessionCtx.DatabaseUser, policy) + + default: + return trace.Wrap(err) + } +} + func isRDSMySQLIAMAuthError(err error) bool { if causer, ok := err.(causer); ok { return isRDSMySQLIAMAuthError(causer.Cause()) diff --git a/lib/srv/db/common/iam/aws.go b/lib/srv/db/common/iam/aws.go index 18fcbe4dc96d4..45e82e3d8f0ca 100644 --- a/lib/srv/db/common/iam/aws.go +++ b/lib/srv/db/common/iam/aws.go @@ -38,6 +38,8 @@ func GetAWSPolicyDocument(db types.Database) (*awslib.PolicyDocument, Placeholde return getRedshiftPolicyDocument(db) case types.DatabaseTypeElastiCache: return getElastiCachePolicyDocument(db) + case types.DatabaseTypeMemoryDB: + return getMemoryDBPolicyDocument(db) default: return nil, nil, trace.BadParameter("GetAWSPolicyDocument is not supported for database type %s", db.GetType()) } @@ -89,6 +91,10 @@ func CheckElastiCacheSupportsIAMAuth(database types.Database) (bool, error) { if !ok { return false, trace.NotFound("database missing engine-version label") } + return checkRedisEngineVersionSupportsIAMAuth(version) +} + +func checkRedisEngineVersionSupportsIAMAuth(version string) (bool, error) { v, err := semver.NewVersion(strings.TrimPrefix(version, "v")) if err != nil { return false, trace.Wrap(err, "failed to parse engine-version") @@ -96,6 +102,22 @@ func CheckElastiCacheSupportsIAMAuth(database types.Database) (bool, error) { return v.Major >= 7, nil } +// CheckMemoryDBSupportsIAMAuth returns whether the given MemoryDB database +// supports IAM auth. +// AWS MemoryDB Redis supports IAM auth for redis version 7+. +func CheckMemoryDBSupportsIAMAuth(database types.Database) (bool, error) { + version, ok := database.GetLabel("engine-version") + if !ok { + return false, trace.NotFound("database missing engine-version label") + } + + // MemoryDB version may not have patch version (e.g. "7.0") + if strings.Count(version, ".") == 1 { + version = version + ".0" + } + return checkRedisEngineVersionSupportsIAMAuth(version) +} + func getRDSPolicyDocument(db types.Database) (*awslib.PolicyDocument, Placeholders, error) { aws := db.GetAWS() partition := awsutils.GetPartitionFromRegion(aws.Region) @@ -211,6 +233,37 @@ func getElastiCachePolicyDocument(db types.Database) (*awslib.PolicyDocument, Pl return policyDoc, placeholders, nil } +// getMemoryDBPolicyDocument returns the policy document used for MemoryDB +// databases. +// +// https://docs.aws.amazon.com/memorydb/latest/devguide/auth-iam.html +func getMemoryDBPolicyDocument(db types.Database) (*awslib.PolicyDocument, Placeholders, error) { + meta := db.GetAWS() + partition := awsutils.GetPartitionFromRegion(meta.Region) + region := meta.Region + accountID := meta.AccountID + clusterName := meta.MemoryDB.ClusterName + + placeholders := Placeholders(nil). + setPlaceholderIfEmpty(®ion, "{region}"). + setPlaceholderIfEmpty(&partition, "{partition}"). + setPlaceholderIfEmpty(&accountID, "{account_id}"). + setPlaceholderIfEmpty(&clusterName, "{cluster_name}") + + policyDoc := awslib.NewPolicyDocument(&awslib.Statement{ + Effect: awslib.EffectAllow, + Actions: awslib.SliceOrString{"memorydb:Connect"}, + Resources: awslib.SliceOrString{ + // Note that MemoryDB requires `/` to divide resources like + // `cluster/`, whereas ElastiCache requires `:` like + // `replicationgroup:`. + fmt.Sprintf("arn:%v:memorydb:%v:%v:cluster/%v", partition, region, accountID, clusterName), + fmt.Sprintf("arn:%v:memorydb:%v:%v:user/*", partition, region, accountID), + }, + }) + return policyDoc, placeholders, nil +} + // Placeholders defines a slice of strings used as placeholders. type Placeholders []string diff --git a/lib/srv/db/common/iam/aws_test.go b/lib/srv/db/common/iam/aws_test.go index e2a144f47b7be..0e1bee504896f 100644 --- a/lib/srv/db/common/iam/aws_test.go +++ b/lib/srv/db/common/iam/aws_test.go @@ -76,6 +76,22 @@ func TestGetAWSPolicyDocument(t *testing.T) { }) require.NoError(t, err) + memorydb, err := types.NewDatabaseV3(types.Metadata{ + Name: "aws-memorydb", + }, types.DatabaseSpecV3{ + Protocol: "redis", + URI: "clustercfg.my-memorydb.xxxxxx.memorydb.us-east-1.amazonaws.com:6379", + AWS: types.AWS{ + AccountID: "123456789012", + MemoryDB: types.MemoryDB{ + ClusterName: "my-memorydb", + TLSEnabled: true, + EndpointType: "cluster", + }, + }, + }) + require.NoError(t, err) + tests := []struct { inputDatabase types.Database expectPolicyDocument string @@ -140,6 +156,22 @@ func TestGetAWSPolicyDocument(t *testing.T) { ] } ] +}`, + }, + { + inputDatabase: memorydb, + expectPolicyDocument: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "memorydb:Connect", + "Resource": [ + "arn:aws:memorydb:us-east-1:123456789012:cluster/my-memorydb", + "arn:aws:memorydb:us-east-1:123456789012:user/*" + ] + } + ] }`, }, } diff --git a/lib/srv/db/redis/client.go b/lib/srv/db/redis/client.go index 91460a197d935..6b05cc0695a03 100644 --- a/lib/srv/db/redis/client.go +++ b/lib/srv/db/redis/client.go @@ -30,6 +30,7 @@ import ( "github.com/gravitational/trace" "github.com/redis/go-redis/v9" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/srv/db/common" "github.com/gravitational/teleport/lib/srv/db/common/role" @@ -226,6 +227,30 @@ func elasticacheIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) } } +// memorydbIAMTokenFetchFunc fetches an AWS MemoryDB IAM auth token. +func memorydbIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) fetchCredentialsFunc { + return func(ctx context.Context) (string, string, error) { + password, err := auth.GetMemoryDBToken(ctx, sessionCtx) + if err != nil { + return "", "", trace.AccessDenied( + "failed to get AWS MemoryDB IAM auth token for %v: %v", + sessionCtx.DatabaseUser, err) + } + return sessionCtx.DatabaseUser, password, nil + } +} + +func awsIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) fetchCredentialsFunc { + switch sessionCtx.Database.GetType() { + case types.DatabaseTypeElastiCache: + return elasticacheIAMTokenFetchFunc(sessionCtx, auth) + case types.DatabaseTypeMemoryDB: + return memorydbIAMTokenFetchFunc(sessionCtx, auth) + default: + return nil + } +} + // authConnection is a helper function that sends "auth" command to provided // Redis connection with provided username and password. func authConnection(ctx context.Context, conn *redis.Conn, username, password string) error { diff --git a/lib/srv/db/redis/engine.go b/lib/srv/db/redis/engine.go index c40ee08cccf1d..3101d8a4fa7c5 100644 --- a/lib/srv/db/redis/engine.go +++ b/lib/srv/db/redis/engine.go @@ -25,6 +25,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/aws/aws-sdk-go/service/memorydb" "github.com/gravitational/trace" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" @@ -282,8 +283,9 @@ func (e *Engine) createOnClientConnectFunc(ctx context.Context, sessionCtx *comm // See: https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-limits // So we must check that the database supports IAM auth and that the // ElastiCache user has IAM auth enabled. + // Same applies for AWS MemoryDB. case e.isAWSIAMAuthSupported(ctx, sessionCtx): - credFetchFn := elasticacheIAMTokenFetchFunc(sessionCtx, e.Auth) + credFetchFn := awsIAMTokenFetchFunc(sessionCtx, e.Auth) return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn) default: @@ -300,6 +302,9 @@ func (e *Engine) isAWSIAMAuthSupported(ctx context.Context, sessionCtx *common.S defer func() { // cache result to avoid API calls on each new instance connection. e.awsIAMAuthSupported = &res + if res { + logrus.Debugf("IAM Auth is enabled for user %q in database %q.", sessionCtx.DatabaseUser, sessionCtx.Database.GetName()) + } }() // check if the db supports IAM auth. If we get an error, assume the db does // support IAM auth. False positives just incur an extra API call. @@ -309,9 +314,8 @@ func (e *Engine) isAWSIAMAuthSupported(ctx context.Context, sessionCtx *common.S } else if !ok { return false } - awsMeta := sessionCtx.Database.GetAWS() dbUser := sessionCtx.DatabaseUser - ok, err := checkUserIAMAuthIsEnabled(ctx, e.CloudClients, awsMeta, dbUser) + ok, err := checkUserIAMAuthIsEnabled(ctx, sessionCtx, e.CloudClients, dbUser) if err != nil { e.Log.WithError(err).Debugf("Assuming IAM auth is not enabled for user %s.", dbUser) @@ -321,18 +325,33 @@ func (e *Engine) isAWSIAMAuthSupported(ctx context.Context, sessionCtx *common.S } // checkDBSupportsIAMAuth returns whether the given database is an ElastiCache -// database that supports IAM auth. -// AWS ElastiCache Redis supports IAM auth for redis version 7+. +// or MemoryDB database that supports IAM auth. +// AWS ElastiCache Redis/MemoryDB supports IAM auth for redis version 7+. func checkDBSupportsIAMAuth(database types.Database) (bool, error) { - if !database.IsElastiCache() { + switch database.GetType() { + case types.DatabaseTypeElastiCache: + return iam.CheckElastiCacheSupportsIAMAuth(database) + case types.DatabaseTypeMemoryDB: + return iam.CheckMemoryDBSupportsIAMAuth(database) + default: return false, nil } - return iam.CheckElastiCacheSupportsIAMAuth(database) } -// checkUserIAMAuthIsEnabled returns whether a given ElastiCache user has IAM auth -// enabled. -func checkUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Clients, awsMeta types.AWS, username string) (bool, error) { +// checkUserIAMAuthIsEnabled returns whether a given ElastiCache or MemoryDB +// user has IAM auth enabled. +func checkUserIAMAuthIsEnabled(ctx context.Context, sessionCtx *common.Session, clients cloud.Clients, username string) (bool, error) { + switch sessionCtx.Database.GetType() { + case types.DatabaseTypeElastiCache: + return checkElastiCacheUserIAMAuthIsEnabled(ctx, clients, sessionCtx.Database.GetAWS(), username) + case types.DatabaseTypeMemoryDB: + return checkMemoryDBUserIAMAuthIsEnabled(ctx, clients, sessionCtx.Database.GetAWS(), username) + default: + return false, nil + } +} + +func checkElastiCacheUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Clients, awsMeta types.AWS, username string) (bool, error) { client, err := clients.GetAWSElastiCacheClient(ctx, awsMeta.Region, cloud.WithAssumeRoleFromAWSMeta(awsMeta)) if err != nil { @@ -353,6 +372,24 @@ func checkUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Clients, awsMe return elasticache.AuthenticationTypeIam == authType, nil } +func checkMemoryDBUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Clients, awsMeta types.AWS, username string) (bool, error) { + client, err := clients.GetAWSMemoryDBClient(ctx, awsMeta.Region, + cloud.WithAssumeRoleFromAWSMeta(awsMeta)) + if err != nil { + return false, trace.Wrap(err) + } + input := memorydb.DescribeUsersInput{UserName: aws.String(username)} + out, err := client.DescribeUsersWithContext(ctx, &input) + if err != nil { + return false, trace.Wrap(err) + } + if len(out.Users) < 1 || out.Users[0].Authentication == nil { + return false, nil + } + authType := aws.StringValue(out.Users[0].Authentication.Type) + return memorydb.AuthenticationTypeIam == authType, nil +} + // reconnect closes the current Redis server connection and creates a new one pre-authenticated // with provided username and password. func (e *Engine) reconnect(username, password string) (redis.UniversalClient, error) { From d456ed237f796638bb5a84f7137061322c0ef8bf Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Thu, 26 Oct 2023 11:32:28 -0400 Subject: [PATCH 2/4] update configurator --- lib/configurators/aws/aws.go | 2 ++ lib/configurators/aws/aws_test.go | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/configurators/aws/aws.go b/lib/configurators/aws/aws.go index eaec6c0244206..c8c862605c026 100644 --- a/lib/configurators/aws/aws.go +++ b/lib/configurators/aws/aws.go @@ -252,6 +252,8 @@ var ( "memorydb:UpdateUser", }, requireSecretsManager: true, + authBoundary: []string{"memorydb:Connect"}, + requireIAMEdit: true, } // awsKeyspacesActions contains IAM actions for static AWS Keyspaces databases. awsKeyspacesActions = databaseActions{ diff --git a/lib/configurators/aws/aws_test.go b/lib/configurators/aws/aws_test.go index 8b2790ab45868..3f749c8c83bc7 100644 --- a/lib/configurators/aws/aws_test.go +++ b/lib/configurators/aws/aws_test.go @@ -432,6 +432,9 @@ func TestAWSIAMDocuments(t *testing.T) { }, Resources: []string{"arn:aws:secretsmanager:*:123456789012:secret:teleport/*"}, }, + {Effect: awslib.EffectAllow, Resources: []string{roleTarget.String()}, Actions: []string{ + "iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", + }}, }, boundaryStatements: []*awslib.Statement{ {Effect: awslib.EffectAllow, Resources: []string{"*"}, Actions: []string{ @@ -440,6 +443,7 @@ func TestAWSIAMDocuments(t *testing.T) { "memorydb:DescribeSubnetGroups", "memorydb:DescribeUsers", "memorydb:UpdateUser", + "memorydb:Connect", }}, { Effect: awslib.EffectAllow, @@ -451,6 +455,9 @@ func TestAWSIAMDocuments(t *testing.T) { }, Resources: []string{"arn:aws:secretsmanager:*:123456789012:secret:teleport/*"}, }, + {Effect: awslib.EffectAllow, Resources: []string{roleTarget.String()}, Actions: []string{ + "iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", + }}, }, }, "MemoryDB static database": { @@ -506,6 +513,9 @@ func TestAWSIAMDocuments(t *testing.T) { "arn:aws:kms:*:123456789012:key/my-kms-id", }, }, + {Effect: awslib.EffectAllow, Resources: []string{roleTarget.String()}, Actions: []string{ + "iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", + }}, }, boundaryStatements: []*awslib.Statement{ {Effect: awslib.EffectAllow, Resources: []string{"*"}, Actions: []string{ @@ -514,6 +524,7 @@ func TestAWSIAMDocuments(t *testing.T) { "memorydb:DescribeSubnetGroups", "memorydb:DescribeUsers", "memorydb:UpdateUser", + "memorydb:Connect", }}, { Effect: "Allow", @@ -535,6 +546,9 @@ func TestAWSIAMDocuments(t *testing.T) { "arn:aws:kms:*:123456789012:key/my-kms-id", }, }, + {Effect: awslib.EffectAllow, Resources: []string{roleTarget.String()}, Actions: []string{ + "iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy", + }}, }, }, "AutoDiscovery EC2": { @@ -1339,12 +1353,17 @@ func TestAWSIAMDocuments(t *testing.T) { }, Resources: []string{"arn:aws:secretsmanager:*:123456789012:secret:teleport/*"}, }, + { + Effect: awslib.EffectAllow, + Resources: []string{roleTarget.String()}, + Actions: []string{"iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy"}, + }, }, boundaryStatements: []*awslib.Statement{ { Effect: awslib.EffectAllow, Resources: []string{"*"}, - Actions: []string{"memorydb:DescribeClusters", "memorydb:DescribeUsers", "memorydb:UpdateUser"}, + Actions: []string{"memorydb:DescribeClusters", "memorydb:DescribeUsers", "memorydb:UpdateUser", "memorydb:Connect"}, }, { Effect: awslib.EffectAllow, @@ -1356,6 +1375,11 @@ func TestAWSIAMDocuments(t *testing.T) { }, Resources: []string{"arn:aws:secretsmanager:*:123456789012:secret:teleport/*"}, }, + { + Effect: awslib.EffectAllow, + Resources: []string{roleTarget.String()}, + Actions: []string{"iam:GetRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy"}, + }, }, }, "OpenSearch": { From d666734c70d1d9cc4a9074dbfa157570a8e8d29e Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Thu, 26 Oct 2023 12:02:55 -0400 Subject: [PATCH 3/4] minor typos --- lib/srv/db/cloud/iam_test.go | 2 +- lib/srv/db/common/auth.go | 10 +++++----- lib/srv/db/common/iam/aws.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/srv/db/cloud/iam_test.go b/lib/srv/db/cloud/iam_test.go index 6236d6a66b852..4fcaca02692c7 100644 --- a/lib/srv/db/cloud/iam_test.go +++ b/lib/srv/db/cloud/iam_test.go @@ -227,7 +227,7 @@ func TestAWSIAM(t *testing.T) { database: memorydb, wantPolicyContains: memorydb.GetAWS().MemoryDB.ClusterName, getIAMAuthEnabled: func() bool { - return true // it always is for ElastiCache. + return true // it always is for MemoryDB. }, }, } diff --git a/lib/srv/db/common/auth.go b/lib/srv/db/common/auth.go index d7ddabdb9da21..60ac555ca0ed1 100644 --- a/lib/srv/db/common/auth.go +++ b/lib/srv/db/common/auth.go @@ -423,10 +423,10 @@ func (a *dbAuth) GetElastiCacheRedisToken(ctx context.Context, sessionCtx *Sessi // https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-limits userID: sessionCtx.DatabaseUser, targetID: meta.ElastiCache.ReplicationGroupID, + serviceName: elasticache.ServiceName, region: meta.Region, credentials: awsSession.Config.Credentials, clock: a.cfg.Clock, - serviceName: elasticache.ServiceName, } token, err := tokenReq.toSignedRequestURI() return token, trace.Wrap(err) @@ -443,10 +443,10 @@ func (a *dbAuth) GetMemoryDBToken(ctx context.Context, sessionCtx *Session) (str tokenReq := &awsRedisIAMTokenRequest{ userID: sessionCtx.DatabaseUser, targetID: meta.MemoryDB.ClusterName, + serviceName: strings.ToLower(memorydb.ServiceName), region: meta.Region, credentials: awsSession.Config.Credentials, clock: a.cfg.Clock, - serviceName: strings.ToLower(memorydb.ServiceName), } token, err := tokenReq.toSignedRequestURI() return token, trace.Wrap(err) @@ -979,7 +979,7 @@ func redshiftServerlessUsernameToRoleARN(aws types.AWS, username string) (string } // awsRedisIAMTokenRequest builds an AWS IAM auth token for ElastiCache -// Redis. +// Redis and MemoryDB. // Implemented following the AWS examples: // https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-Connecting // https://docs.aws.amazon.com/memorydb/latest/devguide/auth-iam.html#auth-iam-Connecting @@ -1004,7 +1004,7 @@ func (r *awsRedisIAMTokenRequest) checkAndSetDefaults() error { return trace.BadParameter("missing user ID") } if r.targetID == "" { - return trace.BadParameter("missing host name for signing") + return trace.BadParameter("missing target ID for signing") } if r.region == "" { return trace.BadParameter("missing region") @@ -1023,7 +1023,7 @@ func (r *awsRedisIAMTokenRequest) checkAndSetDefaults() error { // toSignedRequestURI creates a new AWS SigV4 pre-signed request URI. // This pre-signed request URI can then be used to authenticate as an -// ElastiCache Redis user. +// ElastiCache Redis or MemoryDB user. func (r *awsRedisIAMTokenRequest) toSignedRequestURI() (string, error) { if err := r.checkAndSetDefaults(); err != nil { return "", trace.Wrap(err) diff --git a/lib/srv/db/common/iam/aws.go b/lib/srv/db/common/iam/aws.go index 45e82e3d8f0ca..c2e859ed16761 100644 --- a/lib/srv/db/common/iam/aws.go +++ b/lib/srv/db/common/iam/aws.go @@ -104,7 +104,7 @@ func checkRedisEngineVersionSupportsIAMAuth(version string) (bool, error) { // CheckMemoryDBSupportsIAMAuth returns whether the given MemoryDB database // supports IAM auth. -// AWS MemoryDB Redis supports IAM auth for redis version 7+. +// AWS MemoryDB supports IAM auth for redis version 7+. func CheckMemoryDBSupportsIAMAuth(database types.Database) (bool, error) { version, ok := database.GetLabel("engine-version") if !ok { From 510a659250adb8ab190e2d773c6d20261e1b3792 Mon Sep 17 00:00:00 2001 From: STeve Huang Date: Wed, 8 Nov 2023 10:02:57 -0500 Subject: [PATCH 4/4] address review comments --- lib/srv/db/common/auth.go | 2 +- lib/srv/db/redis/client.go | 13 +++++++++---- lib/srv/db/redis/engine.go | 29 ++++++++++++++++++----------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/srv/db/common/auth.go b/lib/srv/db/common/auth.go index 60ac555ca0ed1..011eb4a422dd6 100644 --- a/lib/srv/db/common/auth.go +++ b/lib/srv/db/common/auth.go @@ -994,7 +994,7 @@ type awsRedisIAMTokenRequest struct { credentials *credentials.Credentials // clock is the clock implementation. clock clockwork.Clock - // serviceName is the AWS service name used for signinig. + // serviceName is the AWS service name used for signing. serviceName string } diff --git a/lib/srv/db/redis/client.go b/lib/srv/db/redis/client.go index 6b05cc0695a03..4dbabf4adcf2e 100644 --- a/lib/srv/db/redis/client.go +++ b/lib/srv/db/redis/client.go @@ -144,6 +144,10 @@ type onClientConnectFunc func(context.Context, *redis.Conn) error // fetchCredentialsFunc fetches credentials for a new connection. type fetchCredentialsFunc func(ctx context.Context) (username, password string, err error) +func noopOnConnect(context.Context, *redis.Conn) error { + return nil +} + // authWithPasswordOnConnect returns an onClientConnectFunc that sends "auth" // with provided username and password. func authWithPasswordOnConnect(username, password string) onClientConnectFunc { @@ -240,14 +244,15 @@ func memorydbIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) fet } } -func awsIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) fetchCredentialsFunc { +func awsIAMTokenFetchFunc(sessionCtx *common.Session, auth common.Auth) (fetchCredentialsFunc, error) { switch sessionCtx.Database.GetType() { case types.DatabaseTypeElastiCache: - return elasticacheIAMTokenFetchFunc(sessionCtx, auth) + return elasticacheIAMTokenFetchFunc(sessionCtx, auth), nil case types.DatabaseTypeMemoryDB: - return memorydbIAMTokenFetchFunc(sessionCtx, auth) + return memorydbIAMTokenFetchFunc(sessionCtx, auth), nil default: - return nil + // If this happens it means something wrong with our implementation. + return nil, trace.BadParameter("database type %q not supported for AWS IAM Auth", sessionCtx.Database.GetType()) } } diff --git a/lib/srv/db/redis/engine.go b/lib/srv/db/redis/engine.go index 3101d8a4fa7c5..cbb8e08d3cd1b 100644 --- a/lib/srv/db/redis/engine.go +++ b/lib/srv/db/redis/engine.go @@ -34,6 +34,7 @@ import ( "github.com/gravitational/teleport/api/types" apiawsutils "github.com/gravitational/teleport/api/utils/aws" "github.com/gravitational/teleport/lib/cloud" + libaws "github.com/gravitational/teleport/lib/cloud/aws" "github.com/gravitational/teleport/lib/cloud/azure" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/srv/db/common" @@ -245,7 +246,10 @@ func (e *Engine) getNewClientFn(ctx context.Context, sessionCtx *common.Session) } return func(username, password string) (redis.UniversalClient, error) { - onConnect := e.createOnClientConnectFunc(ctx, sessionCtx, username, password) + onConnect, err := e.createOnClientConnectFunc(ctx, sessionCtx, username, password) + if err != nil { + return nil, trace.Wrap(err) + } redisClient, err := newClient(ctx, connectionOptions, tlsConfig, onConnect) if err != nil { @@ -258,16 +262,16 @@ func (e *Engine) getNewClientFn(ctx context.Context, sessionCtx *common.Session) // createOnClientConnectFunc creates a callback function that is called after a // successful client connection with the Redis server. -func (e *Engine) createOnClientConnectFunc(ctx context.Context, sessionCtx *common.Session, username, password string) onClientConnectFunc { +func (e *Engine) createOnClientConnectFunc(ctx context.Context, sessionCtx *common.Session, username, password string) (onClientConnectFunc, error) { switch { // If password is provided by client. case password != "": - return authWithPasswordOnConnect(username, password) + return authWithPasswordOnConnect(username, password), nil // Azure databases authenticate via access keys. case sessionCtx.Database.IsAzure(): credFetchFn := azureAccessKeyFetchFunc(sessionCtx, e.Auth) - return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn) + return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn), nil // If database user is one of managed users (AWS only). // @@ -277,7 +281,7 @@ func (e *Engine) createOnClientConnectFunc(ctx context.Context, sessionCtx *comm // Redis is in cluster mode. case slices.Contains(sessionCtx.Database.GetManagedUsers(), sessionCtx.DatabaseUser): credFetchFn := managedUserCredFetchFunc(sessionCtx, e.Auth, e.Users) - return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn) + return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn), nil // AWS ElastiCache has limited support for IAM authentication. // See: https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/auth-iam.html#auth-iam-limits @@ -285,11 +289,14 @@ func (e *Engine) createOnClientConnectFunc(ctx context.Context, sessionCtx *comm // ElastiCache user has IAM auth enabled. // Same applies for AWS MemoryDB. case e.isAWSIAMAuthSupported(ctx, sessionCtx): - credFetchFn := awsIAMTokenFetchFunc(sessionCtx, e.Auth) - return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn) + credFetchFn, err := awsIAMTokenFetchFunc(sessionCtx, e.Auth) + if err != nil { + return nil, trace.Wrap(err) + } + return fetchCredentialsOnConnect(e.Context, sessionCtx, e.Audit, credFetchFn), nil default: - return nil + return noopOnConnect, nil } } @@ -303,7 +310,7 @@ func (e *Engine) isAWSIAMAuthSupported(ctx context.Context, sessionCtx *common.S // cache result to avoid API calls on each new instance connection. e.awsIAMAuthSupported = &res if res { - logrus.Debugf("IAM Auth is enabled for user %q in database %q.", sessionCtx.DatabaseUser, sessionCtx.Database.GetName()) + e.Log.Debugf("IAM Auth is enabled for user %q in database %q.", sessionCtx.DatabaseUser, sessionCtx.Database.GetName()) } }() // check if the db supports IAM auth. If we get an error, assume the db does @@ -363,7 +370,7 @@ func checkElastiCacheUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Cli input := elasticache.DescribeUsersInput{UserId: aws.String(username)} out, err := client.DescribeUsersWithContext(ctx, &input) if err != nil { - return false, trace.Wrap(err) + return false, trace.Wrap(libaws.ConvertRequestFailureError(err)) } if len(out.Users) < 1 || out.Users[0].Authentication == nil { return false, nil @@ -381,7 +388,7 @@ func checkMemoryDBUserIAMAuthIsEnabled(ctx context.Context, clients cloud.Client input := memorydb.DescribeUsersInput{UserName: aws.String(username)} out, err := client.DescribeUsersWithContext(ctx, &input) if err != nil { - return false, trace.Wrap(err) + return false, trace.Wrap(libaws.ConvertRequestFailureError(err)) } if len(out.Users) < 1 || out.Users[0].Authentication == nil { return false, nil