diff --git a/api/types/database.go b/api/types/database.go index 05dcaa97e6a09..0155b200608a3 100644 --- a/api/types/database.go +++ b/api/types/database.go @@ -508,16 +508,21 @@ func (d *DatabaseV3) CheckAndSetDefaults() error { return trace.BadParameter("database %q protocol is empty", d.GetName()) } if d.Spec.URI == "" { - switch { - case d.IsAWSKeyspaces() && d.Spec.AWS.Region != "": - // In case of AWS Hosted Cassandra allow to omit URI. - // The URL will be constructed from the database resource based on the region and account ID. - d.Spec.URI = awsutils.CassandraEndpointURLForRegion(d.Spec.AWS.Region) - case d.IsDynamoDB(): + switch d.GetType() { + case DatabaseTypeAWSKeyspaces: + if d.Spec.AWS.Region != "" { + // In case of AWS Hosted Cassandra allow to omit URI. + // The URL will be constructed from the database resource based on the region and account ID. + d.Spec.URI = awsutils.CassandraEndpointURLForRegion(d.Spec.AWS.Region) + } else { + return trace.BadParameter("AWS Keyspaces database %q URI is empty and cannot be derived without a configured AWS region", + d.GetName()) + } + case DatabaseTypeDynamoDB: if d.Spec.AWS.Region != "" { d.Spec.URI = awsutils.DynamoDBURIForRegion(d.Spec.AWS.Region) } else { - return trace.BadParameter("DynamoDB database %q URI is missing and cannot be derived from an empty configured AWS region", + return trace.BadParameter("DynamoDB database %q URI is empty and cannot be derived without a configured AWS region", d.GetName()) } default: @@ -679,6 +684,13 @@ func (d *DatabaseV3) CheckAndSetDefaults() error { } } + if d.Spec.AWS.ExternalID != "" && d.Spec.AWS.AssumeRoleARN == "" && !d.RequireAWSIAMRolesAsUsers() { + // Databases that use database username to assume an IAM role do not + // need assume_role_arn in configuration when external_id is set. + return trace.BadParameter("AWS database %q has external_id %q, but assume_role_arn is empty", + d.GetName(), d.Spec.AWS.ExternalID) + } + // Validate Cloud SQL specific configuration. switch { case d.Spec.GCP.ProjectID != "" && d.Spec.GCP.InstanceID == "": @@ -704,7 +716,7 @@ func (d *DatabaseV3) handleDynamoDBConfig() error { // so we check if the region is configured to see if this is really a configuration error. if d.Spec.AWS.Region == "" { // the AWS region is empty and we can't derive it from the URI, so this is a config error. - return trace.BadParameter("database %q AWS region is missing and cannot be derived from the URI %q", + return trace.BadParameter("database %q AWS region is empty and cannot be derived from the URI %q", d.GetName(), d.Spec.URI) } if awsutils.IsAWSEndpoint(d.Spec.URI) { diff --git a/api/types/database_test.go b/api/types/database_test.go index 11f6ee4c84ef9..c5febcfe9de84 100644 --- a/api/types/database_test.go +++ b/api/types/database_test.go @@ -549,6 +549,8 @@ func TestDynamoDBConfig(t *testing.T) { uri string region string account string + roleARN string + externalID string wantSpec DatabaseSpecV3 wantErrMsg string }{ @@ -564,6 +566,22 @@ func TestDynamoDBConfig(t *testing.T) { }, }, }, + { + desc: "account and region and assume role is correct", + region: "us-west-1", + account: "123456789012", + roleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + externalID: "externalid123", + wantSpec: DatabaseSpecV3{ + URI: "aws://dynamodb.us-west-1.amazonaws.com", + AWS: AWS{ + Region: "us-west-1", + AccountID: "123456789012", + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalid123", + }, + }, + }, { desc: "account and AWS URI and empty region is correct", uri: "dynamodb.us-west-1.amazonaws.com", @@ -626,6 +644,21 @@ func TestDynamoDBConfig(t *testing.T) { }, }, }, + { + desc: "configured external ID but not assume role is ok", + uri: "localhost:8080", + region: "us-west-1", + account: "123456789012", + externalID: "externalid123", + wantSpec: DatabaseSpecV3{ + URI: "localhost:8080", + AWS: AWS{ + Region: "us-west-1", + AccountID: "123456789012", + ExternalID: "externalid123", + }, + }, + }, { desc: "region and different AWS URI region is an error", uri: "dynamodb.us-west-2.amazonaws.com", @@ -644,12 +677,12 @@ func TestDynamoDBConfig(t *testing.T) { desc: "custom URI and missing region is an error", uri: "localhost:8080", account: "123456789012", - wantErrMsg: "region is missing", + wantErrMsg: "region is empty", }, { desc: "missing URI and missing region is an error", account: "123456789012", - wantErrMsg: "URI is missing", + wantErrMsg: "URI is empty", }, { desc: "invalid AWS account ID is an error", @@ -658,6 +691,11 @@ func TestDynamoDBConfig(t *testing.T) { account: "12345", wantErrMsg: "must be 12-digit", }, + { + region: "us-west-1", + desc: "missing account id", + wantErrMsg: "account ID is empty", + }, } for _, tt := range tests { @@ -670,8 +708,10 @@ func TestDynamoDBConfig(t *testing.T) { Protocol: "dynamodb", URI: tt.uri, AWS: AWS{ - Region: tt.region, - AccountID: tt.account, + Region: tt.region, + AccountID: tt.account, + AssumeRoleARN: tt.roleARN, + ExternalID: tt.externalID, }, }) if tt.wantErrMsg != "" { diff --git a/lib/config/configuration.go b/lib/config/configuration.go index 3b8f79f84754c..762e75aae81d3 100644 --- a/lib/config/configuration.go +++ b/lib/config/configuration.go @@ -145,6 +145,8 @@ type CommandLineFlags struct { DatabaseAWSRegion string // DatabaseAWSAccountID is an optional AWS account ID e.g. when using Keyspaces. DatabaseAWSAccountID string + // DatabaseAWSAssumeRoleARN is an optional AWS IAM role ARN to assume when accessing the database. + DatabaseAWSAssumeRoleARN string // DatabaseAWSExternalID is an optional AWS external ID used to enable assuming an AWS role across accounts. DatabaseAWSExternalID string // DatabaseAWSRedshiftClusterID is Redshift cluster identifier. @@ -1215,9 +1217,13 @@ func applyDiscoveryConfig(fc *FileConfig, cfg *servicecfg.Config) error { services.AWSMatcher{ Types: matcher.Types, Regions: matcher.Regions, - Tags: matcher.Tags, - Params: installParams, - SSM: &services.AWSSSM{DocumentName: matcher.SSM.DocumentName}, + AssumeRole: services.AssumeRole{ + RoleARN: matcher.AssumeRoleARN, + ExternalID: matcher.ExternalID, + }, + Tags: matcher.Tags, + Params: installParams, + SSM: &services.AWSSSM{DocumentName: matcher.SSM.DocumentName}, }) } @@ -1321,6 +1327,10 @@ func applyDatabasesConfig(fc *FileConfig, cfg *servicecfg.Config) error { Types: matcher.Types, Regions: matcher.Regions, Tags: matcher.Tags, + AssumeRole: services.AssumeRole{ + RoleARN: matcher.AssumeRoleARN, + ExternalID: matcher.ExternalID, + }, }) } for _, matcher := range fc.Databases.AzureMatchers { @@ -1370,9 +1380,10 @@ func applyDatabasesConfig(fc *FileConfig, cfg *servicecfg.Config) error { Mode: servicecfg.TLSMode(database.TLS.Mode), }, AWS: servicecfg.DatabaseAWS{ - AccountID: database.AWS.AccountID, - ExternalID: database.AWS.ExternalID, - Region: database.AWS.Region, + AccountID: database.AWS.AccountID, + AssumeRoleARN: database.AWS.AssumeRoleARN, + ExternalID: database.AWS.ExternalID, + Region: database.AWS.Region, Redshift: servicecfg.DatabaseAWSRedshift{ ClusterID: database.AWS.Redshift.ClusterID, }, @@ -1905,9 +1916,10 @@ func Configure(clf *CommandLineFlags, cfg *servicecfg.Config, legacyAppFlags boo CACert: caBytes, }, AWS: servicecfg.DatabaseAWS{ - Region: clf.DatabaseAWSRegion, - AccountID: clf.DatabaseAWSAccountID, - ExternalID: clf.DatabaseAWSExternalID, + Region: clf.DatabaseAWSRegion, + AccountID: clf.DatabaseAWSAccountID, + AssumeRoleARN: clf.DatabaseAWSAssumeRoleARN, + ExternalID: clf.DatabaseAWSExternalID, Redshift: servicecfg.DatabaseAWSRedshift{ ClusterID: clf.DatabaseAWSRedshiftClusterID, }, diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go index 46294ef08cfd3..117baccc475da 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -354,6 +354,8 @@ func TestConfigReading(t *testing.T) { Tags: map[string]apiutils.Strings{ "a": {"b"}, }, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", InstallParams: &InstallParams{ JoinParams: JoinParams{ TokenName: "aws-discovery-iam-token", @@ -471,6 +473,8 @@ func TestConfigReading(t *testing.T) { Tags: map[string]apiutils.Strings{ "a": {"b"}, }, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, { Types: []string{"rds"}, @@ -478,6 +482,7 @@ func TestConfigReading(t *testing.T) { Tags: map[string]apiutils.Strings{ "c": {"d"}, }, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", }, }, AzureMatchers: []AzureMatcher{ @@ -833,6 +838,17 @@ SREzU8onbBsjMg9QDiSf5oJLKvd/Ren+zGY7 }, }, })) + require.Empty(t, cmp.Diff(cfg.Databases.AWSMatchers, + []services.AWSMatcher{ + { + Types: []string{"rds"}, + Regions: []string{"us-west-1"}, + AssumeRole: services.AssumeRole{ + RoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", + }, + }, + })) require.True(t, cfg.Kube.Enabled) require.Empty(t, cmp.Diff(cfg.Kube.ResourceMatchers, @@ -854,6 +870,8 @@ SREzU8onbBsjMg9QDiSf5oJLKvd/Ren+zGY7 require.True(t, cfg.Discovery.Enabled) require.Equal(t, cfg.Discovery.AWSMatchers[0].Regions, []string{"eu-central-1"}) require.Equal(t, cfg.Discovery.AWSMatchers[0].Types, []string{"ec2"}) + require.Equal(t, cfg.Discovery.AWSMatchers[0].AssumeRole.RoleARN, "arn:aws:iam::123456789012:role/DBDiscoverer") + require.Equal(t, cfg.Discovery.AWSMatchers[0].AssumeRole.ExternalID, "externalID123") require.Equal(t, cfg.Discovery.AWSMatchers[0].Params, services.InstallerParams{ InstallTeleport: true, JoinMethod: "iam", @@ -1431,9 +1449,11 @@ func makeConfigFixture() string { conf.Discovery.EnabledFlag = "true" conf.Discovery.AWSMatchers = []AWSMatcher{ { - Types: []string{"ec2"}, - Regions: []string{"us-west-1", "us-east-1"}, - Tags: map[string]apiutils.Strings{"a": {"b"}}, + Types: []string{"ec2"}, + Regions: []string{"us-west-1", "us-east-1"}, + Tags: map[string]apiutils.Strings{"a": {"b"}}, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, } @@ -1527,14 +1547,17 @@ func makeConfigFixture() string { } conf.Databases.AWSMatchers = []AWSMatcher{ { - Types: []string{"rds"}, - Regions: []string{"us-west-1", "us-east-1"}, - Tags: map[string]apiutils.Strings{"a": {"b"}}, + Types: []string{"rds"}, + Regions: []string{"us-west-1", "us-east-1"}, + Tags: map[string]apiutils.Strings{"a": {"b"}}, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, { - Types: []string{"rds"}, - Regions: []string{"us-central-1"}, - Tags: map[string]apiutils.Strings{"c": {"d"}}, + Types: []string{"rds"}, + Regions: []string{"us-central-1"}, + Tags: map[string]apiutils.Strings{"c": {"d"}}, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", }, } conf.Databases.AzureMatchers = []AzureMatcher{ @@ -2615,17 +2638,23 @@ func TestDatabaseCLIFlags(t *testing.T) { { desc: "RDS database", inFlags: CommandLineFlags{ - DatabaseName: "rds", - DatabaseProtocol: defaults.ProtocolMySQL, - DatabaseURI: "localhost:3306", - DatabaseAWSRegion: "us-east-1", + DatabaseName: "rds", + DatabaseProtocol: defaults.ProtocolMySQL, + DatabaseURI: "localhost:3306", + DatabaseAWSRegion: "us-east-1", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, outDatabase: servicecfg.Database{ Name: "rds", Protocol: defaults.ProtocolMySQL, URI: "localhost:3306", AWS: servicecfg.DatabaseAWS{ - Region: "us-east-1", + Region: "us-east-1", + AccountID: "123456789012", // this gets derived from the assumed role. + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, StaticLabels: map[string]string{ types.OriginLabel: types.OriginConfigFile, @@ -2644,6 +2673,8 @@ func TestDatabaseCLIFlags(t *testing.T) { DatabaseURI: "localhost:5432", DatabaseAWSRegion: "us-east-1", DatabaseAWSRedshiftClusterID: "redshift-cluster-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, outDatabase: servicecfg.Database{ Name: "redshift", @@ -2654,6 +2685,9 @@ func TestDatabaseCLIFlags(t *testing.T) { Redshift: servicecfg.DatabaseAWSRedshift{ ClusterID: "redshift-cluster-1", }, + AccountID: "123456789012", // this gets derived from the assumed role. + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, StaticLabels: map[string]string{ types.OriginLabel: types.OriginConfigFile, @@ -2748,19 +2782,23 @@ func TestDatabaseCLIFlags(t *testing.T) { { desc: "AWS Keyspaces", inFlags: CommandLineFlags{ - DatabaseName: "keyspace", - DatabaseProtocol: defaults.ProtocolCassandra, - DatabaseURI: "cassandra.us-east-1.amazonaws.com:9142", - DatabaseAWSAccountID: "123456789012", - DatabaseAWSRegion: "us-east-1", + DatabaseName: "keyspace", + DatabaseProtocol: defaults.ProtocolCassandra, + DatabaseURI: "cassandra.us-east-1.amazonaws.com:9142", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSRegion: "us-east-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, outDatabase: servicecfg.Database{ Name: "keyspace", Protocol: defaults.ProtocolCassandra, URI: "cassandra.us-east-1.amazonaws.com:9142", AWS: servicecfg.DatabaseAWS{ - Region: "us-east-1", - AccountID: "123456789012", + Region: "us-east-1", + AccountID: "123456789012", + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, StaticLabels: map[string]string{ types.OriginLabel: types.OriginConfigFile, @@ -2774,21 +2812,23 @@ func TestDatabaseCLIFlags(t *testing.T) { { desc: "AWS DynamoDB", inFlags: CommandLineFlags{ - DatabaseName: "ddb", - DatabaseProtocol: defaults.ProtocolDynamoDB, - DatabaseURI: "dynamodb.us-east-1.amazonaws.com", - DatabaseAWSAccountID: "123456789012", - DatabaseAWSExternalID: "12345678901234", - DatabaseAWSRegion: "us-east-1", + DatabaseName: "ddb", + DatabaseProtocol: defaults.ProtocolDynamoDB, + DatabaseURI: "dynamodb.us-east-1.amazonaws.com", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSRegion: "us-east-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, outDatabase: servicecfg.Database{ Name: "ddb", Protocol: defaults.ProtocolDynamoDB, URI: "dynamodb.us-east-1.amazonaws.com", AWS: servicecfg.DatabaseAWS{ - Region: "us-east-1", - AccountID: "123456789012", - ExternalID: "12345678901234", + Region: "us-east-1", + AccountID: "123456789012", + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, StaticLabels: map[string]string{ types.OriginLabel: types.OriginConfigFile, diff --git a/lib/config/database.go b/lib/config/database.go index a9904244cd2a0..ff82d0b7e87a0 100644 --- a/lib/config/database.go +++ b/lib/config/database.go @@ -333,7 +333,7 @@ db_service: tls: ca_cert_file: "{{ .DatabaseCACertFile }}" {{- end }} - {{- if or .DatabaseAWSRegion .DatabaseAWSAccountID .DatabaseAWSExternalID .DatabaseAWSRedshiftClusterID .DatabaseAWSRDSInstanceID .DatabaseAWSRDSClusterID .DatabaseAWSElastiCacheGroupID .DatabaseAWSMemoryDBClusterName }} + {{- if or .DatabaseAWSRegion .DatabaseAWSAccountID .DatabaseAWSAssumeRoleARN .DatabaseAWSExternalID .DatabaseAWSRedshiftClusterID .DatabaseAWSRDSInstanceID .DatabaseAWSRDSClusterID .DatabaseAWSElastiCacheGroupID .DatabaseAWSMemoryDBClusterName }} aws: {{- if .DatabaseAWSRegion }} region: "{{ .DatabaseAWSRegion }}" @@ -341,6 +341,9 @@ db_service: {{- if .DatabaseAWSAccountID }} account_id: "{{ .DatabaseAWSAccountID }}" {{- end }} + {{- if .DatabaseAWSAssumeRoleARN }} + assume_role_arn: "{{ .DatabaseAWSAssumeRoleARN }}" + {{- end }} {{- if .DatabaseAWSExternalID }} external_id: "{{ .DatabaseAWSExternalID }}" {{- end }} @@ -580,6 +583,8 @@ type DatabaseSampleFlags struct { DatabaseAWSRegion string // DatabaseAWSAccountID is an optional AWS account ID e.g. when using Keyspaces or DynamoDB. DatabaseAWSAccountID string + // DatabaseAWSAssumeRoleARN is an optional AWS IAM role ARN to assume when accessing the database. + DatabaseAWSAssumeRoleARN string // DatabaseAWSExternalID is an optional AWS database external ID, used when assuming roles. DatabaseAWSExternalID string // DatabaseAWSRedshiftClusterID is Redshift cluster identifier. diff --git a/lib/config/database_test.go b/lib/config/database_test.go index 2b0f333a84638..68c6ba015cd3c 100644 --- a/lib/config/database_test.go +++ b/lib/config/database_test.go @@ -232,25 +232,27 @@ func TestMakeDatabaseConfig(t *testing.T) { }, "AWSKeyspaces": { flags: DatabaseSampleFlags{ - StaticDatabaseName: "sample", - StaticDatabaseProtocol: "cassandra", - StaticDatabaseURI: "cassandra.us-west-1.amazonaws.com", - DatabaseCACertFile: pemfile, - DatabaseAWSRegion: "us-west-1", - DatabaseAWSAccountID: "123456789012", - DatabaseAWSExternalID: "1234567890", + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "cassandra", + StaticDatabaseURI: "cassandra.us-west-1.amazonaws.com", + DatabaseCACertFile: pemfile, + DatabaseAWSRegion: "us-west-1", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, "AWSKeyspacesDeriveURIFromAWSRegion": { flags: DatabaseSampleFlags{ - StaticDatabaseName: "sample", - StaticDatabaseProtocol: "cassandra", - StaticDatabaseURI: "", - DatabaseCACertFile: pemfile, - DatabaseAWSRegion: "us-west-1", - DatabaseAWSAccountID: "123456789012", - DatabaseAWSExternalID: "1234567890", + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "cassandra", + StaticDatabaseURI: "", + DatabaseCACertFile: pemfile, + DatabaseAWSRegion: "us-west-1", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -261,6 +263,8 @@ func TestMakeDatabaseConfig(t *testing.T) { StaticDatabaseURI: "redshift-cluster-1.abcdefghijklmnop.us-west-1.redshift.amazonaws.com:5439", DatabaseAWSRegion: "us-west-1", DatabaseAWSRedshiftClusterID: "redshift-cluster-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -271,16 +275,20 @@ func TestMakeDatabaseConfig(t *testing.T) { StaticDatabaseURI: "rds-instance-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", DatabaseAWSRegion: "us-west-1", DatabaseAWSRDSInstanceID: "rsd-instance-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, "AWSRDSCluster": { flags: DatabaseSampleFlags{ - StaticDatabaseName: "sample", - StaticDatabaseProtocol: "postgres", - StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", - DatabaseAWSRegion: "us-west-1", - DatabaseAWSRDSClusterID: "aurora-cluster-1", + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "postgres", + StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSRDSClusterID: "aurora-cluster-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -291,6 +299,8 @@ func TestMakeDatabaseConfig(t *testing.T) { StaticDatabaseURI: "clustercfg.my-memorydb.xxxxxx.memorydb.us-east-1.amazonaws.com:6379", DatabaseAWSRegion: "us-west-1", DatabaseAWSMemoryDBClusterName: "my-memorydb", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -301,6 +311,8 @@ func TestMakeDatabaseConfig(t *testing.T) { StaticDatabaseURI: "master.redis-cluster-example.abcdef.usw1.cache.amazonaws.com:6379", DatabaseAWSRegion: "us-west-1", DatabaseAWSElastiCacheGroupID: "redis-cluster-example", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -327,10 +339,12 @@ func TestMakeDatabaseConfig(t *testing.T) { }, "DynamoDBDeriveURIFromAWSRegion": { flags: DatabaseSampleFlags{ - StaticDatabaseName: "sample", - StaticDatabaseProtocol: "dynamodb", - DatabaseAWSAccountID: "123456789012", - DatabaseAWSRegion: "us-west-1", + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "dynamodb", + DatabaseAWSAccountID: "123456789012", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + DatabaseAWSExternalID: "externalID123", }, requireFn: require.NoError, }, @@ -385,6 +399,54 @@ func TestMakeDatabaseConfig(t *testing.T) { }, requireFn: require.Error, }, + "AWSExternalIDMissingAWSRoleARN": { + flags: DatabaseSampleFlags{ + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "postgres", + StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSRDSClusterID: "aurora-cluster-1", + DatabaseAWSAssumeRoleARN: "", // missing role arn raises error because external id is set. + DatabaseAWSExternalID: "externalID123", + }, + requireFn: require.Error, + }, + "MissingAWSRoleARNName": { + flags: DatabaseSampleFlags{ + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "postgres", + StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSRDSClusterID: "aurora-cluster-1", + DatabaseAWSAssumeRoleARN: "arn:aws:iam::123456789012:role", // missing role name + DatabaseAWSExternalID: "externalID123", + }, + requireFn: require.Error, + }, + "InvalidAWSRoleARNFormat": { + flags: DatabaseSampleFlags{ + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "postgres", + StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSRDSClusterID: "aurora-cluster-1", + DatabaseAWSAssumeRoleARN: "foobar", + DatabaseAWSExternalID: "externalID123", + }, + requireFn: require.Error, + }, + "InvalidAWSRoleARNResourceService": { + flags: DatabaseSampleFlags{ + StaticDatabaseName: "sample", + StaticDatabaseProtocol: "postgres", + StaticDatabaseURI: "aurora-cluster-1.abcdefghijklmnop.us-west-1.rds.amazonaws.com:5432", + DatabaseAWSRegion: "us-west-1", + DatabaseAWSRDSClusterID: "aurora-cluster-1", + DatabaseAWSAssumeRoleARN: "arn:aws:sts::123456789012:federated-user/Alice", // sts != iam + DatabaseAWSExternalID: "externalID123", + }, + requireFn: require.Error, + }, } for name, tt := range tests { diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go index fbc7b0b9620a6..93f5cda59dd9d 100644 --- a/lib/config/fileconf.go +++ b/lib/config/fileconf.go @@ -53,6 +53,7 @@ import ( "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/sshutils/x11" "github.com/gravitational/teleport/lib/utils" + awsutils "github.com/gravitational/teleport/lib/utils/aws" ) // FileConfig structure represents the teleport configuration stored in a config file @@ -511,6 +512,16 @@ func checkAndSetDefaultsForAWSMatchers(matcherInput []AWSMatcher) error { } } + if matcher.AssumeRoleARN != "" { + _, err := awsutils.ParseRoleARN(matcher.AssumeRoleARN) + if err != nil { + return trace.Wrap(err, "discovery service AWS matcher assume_role_arn is invalid") + } + } else if matcher.ExternalID != "" { + return trace.BadParameter("discovery service AWS matcher assume_role_arn is empty, but has external_id %q", + matcher.ExternalID) + } + if matcher.Tags == nil || len(matcher.Tags) == 0 { matcher.Tags = map[string]apiutils.Strings{types.Wildcard: {types.Wildcard}} } @@ -1632,6 +1643,11 @@ type AWSMatcher struct { Types []string `yaml:"types,omitempty"` // Regions are AWS regions to query for databases. Regions []string `yaml:"regions,omitempty"` + // AssumeRoleARN is the AWS role to assume for database discovery. + AssumeRoleARN string `yaml:"assume_role_arn,omitempty"` + // ExternalID is the AWS external ID to use when assuming a role for + // database discovery in an external AWS account. + ExternalID string `yaml:"external_id,omitempty"` // Tags are AWS tags to match. Tags map[string]apiutils.Strings `yaml:"tags,omitempty"` // InstallParams sets the join method when installing on @@ -1790,6 +1806,8 @@ type DatabaseAWS struct { MemoryDB DatabaseAWSMemoryDB `yaml:"memorydb"` // AccountID is the AWS account ID. AccountID string `yaml:"account_id,omitempty"` + // AssumeRoleARN is the AWS role to assume to before accessing the database. + AssumeRoleARN string `yaml:"assume_role_arn,omitempty"` // ExternalID is an optional AWS external ID used to enable assuming an AWS role across accounts. ExternalID string `yaml:"external_id,omitempty"` // RedshiftServerless contains RedshiftServerless specific settings. diff --git a/lib/config/fileconf_test.go b/lib/config/fileconf_test.go index 1b5d77bbae43b..96778adcf3ad6 100644 --- a/lib/config/fileconf_test.go +++ b/lib/config/fileconf_test.go @@ -922,6 +922,8 @@ func TestDiscoveryConfig(t *testing.T) { "ssm": cfgMap{ "document_name": "hello_document", }, + "assume_role_arn": "arn:aws:iam::123456789012:role/DBDiscoverer", + "external_id": "externalID123", }, } }, @@ -941,7 +943,9 @@ func TestDiscoveryConfig(t *testing.T) { SSHDConfig: "/etc/ssh/sshd_config", ScriptName: "installer-custom", }, - SSM: AWSSSM{DocumentName: "hello_document"}, + SSM: AWSSSM{DocumentName: "hello_document"}, + AssumeRoleARN: "arn:aws:iam::123456789012:role/DBDiscoverer", + ExternalID: "externalID123", }, }, }, @@ -983,6 +987,64 @@ func TestDiscoveryConfig(t *testing.T) { }, expectedDiscoverySection: Discovery{}, }, + { + desc: "AWS section is filled with external_id but empty assume_role_arn", + expectError: require.Error, + expectEnabled: require.True, + mutate: func(cfg cfgMap) { + cfg["discovery_service"].(cfgMap)["enabled"] = "yes" + cfg["discovery_service"].(cfgMap)["aws"] = []cfgMap{ + { + "types": []string{"rds"}, + "regions": []string{"us-west-1"}, + "assume_role_arn": "", + "external_id": "externalid123", + "tags": cfgMap{ + "discover_teleport": "yes", + }, + }, + } + }, + expectedDiscoverySection: Discovery{}, + }, + { + desc: "AWS section is filled with invalid assume_role_arn", + expectError: require.Error, + expectEnabled: require.True, + mutate: func(cfg cfgMap) { + cfg["discovery_service"].(cfgMap)["enabled"] = "yes" + cfg["discovery_service"].(cfgMap)["aws"] = []cfgMap{ + { + "types": []string{"rds"}, + "regions": []string{"us-west-1"}, + "assume_role_arn": "foobar", + "tags": cfgMap{ + "discover_teleport": "yes", + }, + }, + } + }, + expectedDiscoverySection: Discovery{}, + }, + { + desc: "AWS section is filled with assume_role_arn that is not an iam ARN", + expectError: require.Error, + expectEnabled: require.True, + mutate: func(cfg cfgMap) { + cfg["discovery_service"].(cfgMap)["enabled"] = "yes" + cfg["discovery_service"].(cfgMap)["aws"] = []cfgMap{ + { + "types": []string{"rds"}, + "regions": []string{"us-west-1"}, + "assume_role_arn": "arn:aws:sts::123456789012:federated-user/Alice", + "tags": cfgMap{ + "discover_teleport": "yes", + }, + }, + } + }, + expectedDiscoverySection: Discovery{}, + }, { desc: "AWS section is filled with no token", expectError: require.NoError, diff --git a/lib/config/testdata_test.go b/lib/config/testdata_test.go index 84704aeb77216..604711a7c29bb 100644 --- a/lib/config/testdata_test.go +++ b/lib/config/testdata_test.go @@ -181,6 +181,11 @@ db_service: regions: ["westus"] tags: "c": "d" + aws: + - types: ["rds"] + regions: ["us-west-1"] + assume_role_arn: "arn:aws:iam::123456789012:role/DBDiscoverer" + external_id: "externalID123" kubernetes_service: enabled: yes @@ -196,6 +201,8 @@ discovery_service: aws: - types: ["ec2"] regions: ["eu-central-1"] + assume_role_arn: "arn:aws:iam::123456789012:role/DBDiscoverer" + external_id: "externalID123" okta_service: enabled: yes diff --git a/lib/service/servicecfg/database.go b/lib/service/servicecfg/database.go index ef5471aec6d6b..91cc52e1292e3 100644 --- a/lib/service/servicecfg/database.go +++ b/lib/service/servicecfg/database.go @@ -24,6 +24,7 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/limiter" "github.com/gravitational/teleport/lib/services" + awsutils "github.com/gravitational/teleport/lib/utils/aws" ) // DatabasesConfig configures the database proxy service. @@ -99,6 +100,17 @@ func (d *Database) CheckAndSetDefaults() error { } } + // If AWS account ID is missing, but assume role ARN is given, + // try to parse the role arn and set the account id to match. + if d.AWS.AccountID == "" && d.AWS.AssumeRoleARN != "" { + parsed, err := awsutils.ParseRoleARN(d.AWS.AssumeRoleARN) + if err != nil { + return trace.BadParameter("database %q invalid AWS assume_role_arn: %v", + d.Name, err) + } + d.AWS.AccountID = parsed.AccountID + } + // Do a test run with extra validations. db, err := d.ToDatabase() if err != nil { @@ -126,9 +138,10 @@ func (d *Database) ToDatabase() (types.Database, error) { ServerVersion: d.MySQL.ServerVersion, }, AWS: types.AWS{ - AccountID: d.AWS.AccountID, - ExternalID: d.AWS.ExternalID, - Region: d.AWS.Region, + AccountID: d.AWS.AccountID, + AssumeRoleARN: d.AWS.AssumeRoleARN, + ExternalID: d.AWS.ExternalID, + Region: d.AWS.Region, Redshift: types.Redshift{ ClusterID: d.AWS.Redshift.ClusterID, }, @@ -204,6 +217,8 @@ type DatabaseAWS struct { SecretStore DatabaseAWSSecretStore // AccountID is the AWS account ID. AccountID string + // AssumeRoleARN is the AWS role to assume to before accessing the database. + AssumeRoleARN string // ExternalID is an optional AWS external ID used to enable assuming an AWS role across accounts. ExternalID string // RedshiftServerless contains AWS Redshift Serverless specific settings. diff --git a/lib/services/database.go b/lib/services/database.go index 2f9e595efbaba..b9908caf7488a 100644 --- a/lib/services/database.go +++ b/lib/services/database.go @@ -45,7 +45,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation" "github.com/gravitational/teleport/api/types" - awsutils "github.com/gravitational/teleport/api/utils/aws" + apiawsutils "github.com/gravitational/teleport/api/utils/aws" azureutils "github.com/gravitational/teleport/api/utils/azure" libcloudaws "github.com/gravitational/teleport/lib/cloud/aws" "github.com/gravitational/teleport/lib/cloud/azure" @@ -53,6 +53,7 @@ import ( "github.com/gravitational/teleport/lib/srv/db/redis/connection" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" + awsutils "github.com/gravitational/teleport/lib/utils/aws" ) // DatabaseGetter defines interface for fetching database resources. @@ -195,6 +196,23 @@ func ValidateDatabase(db types.Database) error { return trace.BadParameter("missing service principal name for database %q", db.GetName()) } } + + awsMeta := db.GetAWS() + if awsMeta.AssumeRoleARN != "" { + if awsMeta.AccountID == "" { + return trace.BadParameter("database %q missing AWS account ID", db.GetName()) + } + parsed, err := awsutils.ParseRoleARN(awsMeta.AssumeRoleARN) + if err != nil { + return trace.BadParameter("database %q assume_role_arn %q is invalid: %v", + db.GetName(), awsMeta.AssumeRoleARN, err) + } + err = awsutils.CheckARNPartitionAndAccount(parsed, awsMeta.Partition(), awsMeta.AccountID) + if err != nil { + return trace.BadParameter("database %q is incompatible with AWS assume_role_arn %q: %v", + db.GetName(), awsMeta.AssumeRoleARN, err) + } + } return nil } @@ -586,7 +604,7 @@ func NewDatabasesFromRDSClusterCustomEndpoints(cluster *rds.DBCluster) (types.Da for _, endpoint := range cluster.CustomEndpoints { // RDS custom endpoint format: // .cluster-custom-. - endpointDetails, err := awsutils.ParseRDSEndpoint(aws.StringValue(endpoint)) + endpointDetails, err := apiawsutils.ParseRDSEndpoint(aws.StringValue(endpoint)) if err != nil { errors = append(errors, trace.Wrap(err)) continue @@ -713,7 +731,7 @@ func NewDatabaseFromElastiCacheConfigurationEndpoint(cluster *elasticache.Replic return nil, trace.BadParameter("missing configuration endpoint") } - return newElastiCacheDatabase(cluster, cluster.ConfigurationEndpoint, awsutils.ElastiCacheConfigurationEndpoint, extraLabels) + return newElastiCacheDatabase(cluster, cluster.ConfigurationEndpoint, apiawsutils.ElastiCacheConfigurationEndpoint, extraLabels) } // NewDatabasesFromElastiCacheNodeGroups creates database resources from @@ -722,7 +740,7 @@ func NewDatabasesFromElastiCacheNodeGroups(cluster *elasticache.ReplicationGroup var databases types.Databases for _, nodeGroup := range cluster.NodeGroups { if nodeGroup.PrimaryEndpoint != nil { - database, err := newElastiCacheDatabase(cluster, nodeGroup.PrimaryEndpoint, awsutils.ElastiCachePrimaryEndpoint, extraLabels) + database, err := newElastiCacheDatabase(cluster, nodeGroup.PrimaryEndpoint, apiawsutils.ElastiCachePrimaryEndpoint, extraLabels) if err != nil { return nil, trace.Wrap(err) } @@ -730,7 +748,7 @@ func NewDatabasesFromElastiCacheNodeGroups(cluster *elasticache.ReplicationGroup } if nodeGroup.ReaderEndpoint != nil { - database, err := newElastiCacheDatabase(cluster, nodeGroup.ReaderEndpoint, awsutils.ElastiCacheReaderEndpoint, extraLabels) + database, err := newElastiCacheDatabase(cluster, nodeGroup.ReaderEndpoint, apiawsutils.ElastiCacheReaderEndpoint, extraLabels) if err != nil { return nil, trace.Wrap(err) } @@ -748,7 +766,7 @@ func newElastiCacheDatabase(cluster *elasticache.ReplicationGroup, endpoint *ela } suffix := make([]string, 0) - if endpointType == awsutils.ElastiCacheReaderEndpoint { + if endpointType == apiawsutils.ElastiCacheReaderEndpoint { suffix = []string{endpointType} } @@ -765,7 +783,7 @@ func newElastiCacheDatabase(cluster *elasticache.ReplicationGroup, endpoint *ela // NewDatabaseFromMemoryDBCluster creates a database resource from a MemoryDB // cluster. func NewDatabaseFromMemoryDBCluster(cluster *memorydb.Cluster, extraLabels map[string]string) (types.Database, error) { - endpointType := awsutils.MemoryDBClusterEndpoint + endpointType := apiawsutils.MemoryDBClusterEndpoint metadata, err := MetadataFromMemoryDBCluster(cluster, endpointType) if err != nil { diff --git a/lib/services/database_test.go b/lib/services/database_test.go index eaa48fbbdcf80..57cd93f838521 100644 --- a/lib/services/database_test.go +++ b/lib/services/database_test.go @@ -126,6 +126,42 @@ func TestValidateDatabase(t *testing.T) { }, expectError: true, }, + { + inputName: "invalid-database-assume-role-arn", + inputSpec: types.DatabaseSpecV3{ + Protocol: defaults.ProtocolDynamoDB, + AWS: types.AWS{ + Region: "us-east-1", + AccountID: "123456789012", + AssumeRoleARN: "foobar", + }, + }, + expectError: true, + }, + { + inputName: "invalid-database-assume-role-arn-resource-type", + inputSpec: types.DatabaseSpecV3{ + Protocol: defaults.ProtocolDynamoDB, + AWS: types.AWS{ + Region: "us-east-1", + AccountID: "123456789012", + AssumeRoleARN: "arn:aws:sts::123456789012:federated-user/Alice", + }, + }, + expectError: true, + }, + { + inputName: "invalid-database-assume-role-arn-account-id-mismatch", + inputSpec: types.DatabaseSpecV3{ + Protocol: defaults.ProtocolDynamoDB, + AWS: types.AWS{ + Region: "us-east-1", + AccountID: "123456789012", + AssumeRoleARN: "arn:aws:iam::111222333444:federated-user/Alice", + }, + }, + expectError: true, + }, { inputName: "invalid-database-CA-cert", inputSpec: types.DatabaseSpecV3{ diff --git a/lib/services/matchers.go b/lib/services/matchers.go index 21e1f56b43cd7..fd33b626f1b8b 100644 --- a/lib/services/matchers.go +++ b/lib/services/matchers.go @@ -69,12 +69,38 @@ type InstallerParams struct { PublicProxyAddr string } +// AssumeRole provides a role ARN and ExternalID to assume an AWS role +// when interacting with AWS resources. +type AssumeRole struct { + // RoleARN is the fully specified AWS IAM role ARN. + RoleARN string + // ExternalID is the external ID used to assume a role in another account. + ExternalID string +} + +// IsEmpty is a helper function that returns whether the assume role info +// is empty. +func (a *AssumeRole) IsEmpty() bool { + return a.RoleARN == "" && a.ExternalID == "" +} + +// AssumeRoleFromAWSMetadata is a conversion helper function that extracts +// AWS IAM role ARN and external ID from AWS metadata. +func AssumeRoleFromAWSMetadata(meta *types.AWS) AssumeRole { + return AssumeRole{ + RoleARN: meta.AssumeRoleARN, + ExternalID: meta.ExternalID, + } +} + // AWSMatcher matches AWS databases. type AWSMatcher struct { // Types are AWS database types to match, "rds" or "redshift". Types []string // Regions are AWS regions to query for databases. Regions []string + // AssumeRole is the AWS role to assume when discovering AWS databases. + AssumeRole AssumeRole // Tags are AWS tags to match. Tags types.Labels // Params are passed to AWS when executing the SSM document diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go index 20540f436a07b..1f0c39e13db84 100644 --- a/tool/teleport/common/teleport.go +++ b/tool/teleport/common/teleport.go @@ -238,6 +238,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con dbStartCmd.Flag("ca-cert", "Database CA certificate path.").StringVar(&ccf.DatabaseCACertFile) dbStartCmd.Flag("aws-region", "(Only for RDS, Aurora, Redshift, ElastiCache or MemoryDB) AWS region AWS hosted database instance is running in.").StringVar(&ccf.DatabaseAWSRegion) dbStartCmd.Flag("aws-account-id", "(Only for Keyspaces or DynamoDB) AWS Account ID.").StringVar(&ccf.DatabaseAWSAccountID) + dbStartCmd.Flag("aws-assume-role-arn", "Optional AWS IAM role to assume.").StringVar(&ccf.DatabaseAWSAssumeRoleARN) dbStartCmd.Flag("aws-external-id", "Optional AWS external ID used when assuming an AWS role.").StringVar(&ccf.DatabaseAWSExternalID) dbStartCmd.Flag("aws-redshift-cluster-id", "(Only for Redshift) Redshift database cluster identifier.").StringVar(&ccf.DatabaseAWSRedshiftClusterID) dbStartCmd.Flag("aws-rds-instance-id", "(Only for RDS) RDS instance identifier.").StringVar(&ccf.DatabaseAWSRDSInstanceID) @@ -280,6 +281,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con dbConfigureCreate.Flag("labels", "Comma-separated list of labels for the database, for example env=dev,dept=it").StringVar(&dbConfigCreateFlags.StaticDatabaseRawLabels) dbConfigureCreate.Flag("aws-region", "(Only for AWS-hosted databases) AWS region RDS, Aurora, Redshift, Redshift Serverless, ElastiCache, or MemoryDB database instance is running in.").StringVar(&dbConfigCreateFlags.DatabaseAWSRegion) dbConfigureCreate.Flag("aws-account-id", "(Only for Keyspaces or DynamoDB) AWS Account ID.").StringVar(&dbConfigCreateFlags.DatabaseAWSAccountID) + dbConfigureCreate.Flag("aws-assume-role-arn", "Optional AWS IAM role to assume.").StringVar(&dbConfigCreateFlags.DatabaseAWSAssumeRoleARN) dbConfigureCreate.Flag("aws-external-id", "(Only for AWS-hosted databases) Optional AWS external ID to use when assuming AWS roles.").StringVar(&dbConfigCreateFlags.DatabaseAWSExternalID) dbConfigureCreate.Flag("aws-redshift-cluster-id", "(Only for Redshift) Redshift database cluster identifier.").StringVar(&dbConfigCreateFlags.DatabaseAWSRedshiftClusterID) dbConfigureCreate.Flag("aws-rds-cluster-id", "(Only for RDS Aurora) RDS Aurora database cluster identifier.").StringVar(&dbConfigCreateFlags.DatabaseAWSRDSClusterID)