diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go index a7e0fe2..b801d2f 100644 --- a/internal/credential/attributes.go +++ b/internal/credential/attributes.go @@ -186,3 +186,63 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) RoleTags: roleTags, }, nil } + +// GetTargetCredentialAttributes parses values out of a protobuf struct input and returns a CredentialAttributes used +// for configuring a second AWS session. target_role_arn is required when parsing target credentials. +// A status error is returned with an InvalidArgument code when target account attributes are missing +// required values or have invalid value types. +func GetTargetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) { + badFields := make(map[string]string) + + // Parse the role ARN of the target account to AssumeRole into. + targetRoleArn, err := values.GetStringValue(in, ConstTargetRoleArn, true) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleArn)] = err.Error() + } + + // try parsing target_region, fallback to region, adds target_region to bad fields only when malformed since not required + targetRegion, err := values.GetStringValue(in, ConstTargetRegion, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstTargetRegion)] = err.Error() + } + if targetRegion == "" { + targetRegion, err = values.GetStringValue(in, ConstRegion, true) // region is required for processing + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error() + } + } + + // the remaining fields are optional and only raise errors when malformed + disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error() + } + + targetRoleExternalId, err := values.GetStringValue(in, ConstTargetRoleExternalId, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleExternalId)] = err.Error() + } + + targetRoleSessionName, err := values.GetStringValue(in, ConstTargetRoleSessionName, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleSessionName)] = err.Error() + } + + targetRoleTags, err := values.GetMapStringString(in, ConstTargetRoleTags, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleTags)] = err.Error() + } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields) + } + + return &CredentialAttributes{ + Region: targetRegion, + DisableCredentialRotation: disableCredentialRotation, + RoleArn: targetRoleArn, + RoleExternalId: targetRoleExternalId, + RoleSessionName: targetRoleSessionName, + RoleTags: targetRoleTags, + }, nil +} diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go index 780ec13..e205977 100644 --- a/internal/credential/attributes_test.go +++ b/internal/credential/attributes_test.go @@ -101,6 +101,232 @@ func TestGetCredentialAttributes(t *testing.T) { } } +func TestGetTargetCredentialAttributes(t *testing.T) { + const targetRoleArn = "arn:aws:iam::222222222222:role/Target" + + cases := []struct { + name string + in map[string]any + expected *CredentialAttributes + expectedErrContains string + }{ + { + name: "missing target_role_arn", + in: map[string]any{ + ConstRegion: "us-west-2", + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "missing target_role_arn and region", + in: map[string]any{}, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "empty target_role_arn", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: "", + }, + expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty", + }, + { + name: "wrong type target_role_arn", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: true, + }, + expectedErrContains: "attributes.target_role_arn: unexpected type for value \"target_role_arn\": want string, got bool", + }, + { + name: "target_region without target_role_arn", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRegion: "eu-west-1", + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "other target fields without target_role_arn", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleExternalId: "ext", + ConstTargetRoleSessionName: "sess", + ConstTargetRoleTags: map[string]any{"k": "v"}, + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "target_role_arn uses region when target_region omitted", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + RoleArn: targetRoleArn, + }, + }, + { + name: "target_role_arn without target_region or region", + in: map[string]any{ + ConstTargetRoleArn: targetRoleArn, + }, + expectedErrContains: "missing required value \"region\"", + }, + { + name: "target_region overrides region", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRegion: "eu-west-1", + ConstTargetRoleArn: targetRoleArn, + }, + expected: &CredentialAttributes{ + Region: "eu-west-1", + RoleArn: targetRoleArn, + }, + }, + { + name: "empty target_region falls back to region", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRegion: "", + ConstTargetRoleArn: targetRoleArn, + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + RoleArn: targetRoleArn, + }, + }, + { + name: "all optional target fields", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstDisableCredentialRotation: true, + ConstTargetRoleArn: targetRoleArn, + ConstTargetRegion: "eu-west-1", + ConstTargetRoleExternalId: "target-ext", + ConstTargetRoleSessionName: "target-sess", + ConstTargetRoleTags: map[string]any{"env": "target"}, + }, + expected: &CredentialAttributes{ + Region: "eu-west-1", + DisableCredentialRotation: true, + RoleArn: targetRoleArn, + RoleExternalId: "target-ext", + RoleSessionName: "target-sess", + RoleTags: map[string]string{"env": "target"}, + }, + }, + { + name: "optional target fields omitted do not copy principal role fields", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstRoleArn: "arn:aws:iam::111111111111:role/Principal", + ConstRoleExternalId: "principal-ext", + ConstRoleSessionName: "principal-sess", + ConstRoleTags: map[string]any{"env": "principal"}, + ConstTargetRoleArn: targetRoleArn, + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + RoleArn: targetRoleArn, + }, + }, + { + name: "disable_credential_rotation false is copied", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstDisableCredentialRotation: false, + ConstTargetRoleArn: targetRoleArn, + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + RoleArn: targetRoleArn, + }, + }, + { + name: "wrong type target_region", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstTargetRegion: true, + }, + expectedErrContains: "attributes.target_region: unexpected type for value \"target_region\": want string, got bool", + }, + { + name: "wrong type target_role_external_id", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstTargetRoleExternalId: true, + }, + expectedErrContains: "attributes.target_role_external_id: unexpected type for value \"target_role_external_id\": want string, got bool", + }, + { + name: "wrong type target_role_session_name", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstTargetRoleSessionName: true, + }, + expectedErrContains: "attributes.target_role_session_name: unexpected type for value \"target_role_session_name\": want string, got bool", + }, + { + name: "wrong type target_role_tags", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstTargetRoleTags: true, + }, + expectedErrContains: "attributes.target_role_tags: unexpected type for value \"target_role_tags\": want map[string]string, got bool", + }, + { + name: "wrong type target_role_tag value", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstTargetRoleTags: map[string]any{ + "env": true, + }, + }, + expectedErrContains: `unexpected type for value in map["env"]: want string, got bool`, + }, + { + name: "wrong type disable_credential_rotation", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstTargetRoleArn: targetRoleArn, + ConstDisableCredentialRotation: "sure", + }, + expectedErrContains: "attributes.disable_credential_rotation: unexpected type for value \"disable_credential_rotation\": want bool, got string", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + + input, err := structpb.NewStruct(tc.in) + require.NoError(err) + + actual, err := GetTargetCredentialAttributes(input) + if tc.expectedErrContains != "" { + require.Error(err) + require.Nil(actual) + require.Contains(err.Error(), tc.expectedErrContains) + require.Equal(codes.InvalidArgument, status.Code(err)) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} + func TestGetCredentialsConfig(t *testing.T) { cases := []struct { name string diff --git a/internal/credential/const.go b/internal/credential/const.go index 7a42632..7a56d8a 100644 --- a/internal/credential/const.go +++ b/internal/credential/const.go @@ -4,10 +4,10 @@ package credential const ( - // ConstAccessKey is the key for the region in the aws credentials. + // ConstRegion is the key for the region in the aws credentials. ConstRegion = "region" - // ConstAccessKey is the key for the access key id in the aws credentials. + // ConstAccessKeyId is the key for the access key id in the aws credentials. ConstAccessKeyId = "access_key_id" // ConstSecretAccessKey is the key for the secret access key in the aws credentials. @@ -30,4 +30,19 @@ const ( // ConstRoleTags is the key for the tags used for assuming a IAM role. ConstRoleTags = "role_tags" + + // ConstTargetRegion is the key for the region of the target account in the aws credentials. + ConstTargetRegion = "target_region" + + // ConstTargetRoleArn is the key for assuming a IAM role for the target account. + ConstTargetRoleArn = "target_role_arn" + + // ConstTargetRoleExternalId is the key for the external id used for assuming a IAM role for the target account. + ConstTargetRoleExternalId = "target_role_external_id" + + // ConstTargetRoleSessionName is the key for the session name used for assuming a IAM role of the target account. + ConstTargetRoleSessionName = "target_role_session_name" + + // ConstTargetRoleTags is the key for the tags used for assuming a IAM role of the target account. + ConstTargetRoleTags = "target_role_tags" ) diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 8b1aa0c..3aa221e 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -20,6 +20,9 @@ import ( type CatalogAttributes struct { *cred.CredentialAttributes + // TargetAccount optionally holds the credentials used to AssumeRole into a second, affiliated account when provided. + TargetAccount *cred.CredentialAttributes + // DualStack is used for configuring how the aws client will resolve requests. DualStack bool @@ -39,6 +42,14 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { return nil, err } + var targetCredAttributes *cred.CredentialAttributes + if hasTargetAccountAttributes(in) { + targetCredAttributes, err = cred.GetTargetCredentialAttributes(in) + if err != nil { + return nil, err + } + } + dualStack, err := values.GetBoolValue(in, ConstAwsDualStack, false) if err != nil { badFields[fmt.Sprintf("attributes.%s", ConstAwsDualStack)] = err.Error() @@ -66,6 +77,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { continue case cred.ConstRoleTags: continue + case cred.ConstTargetRegion: + continue + case cred.ConstTargetRoleArn: + continue + case cred.ConstTargetRoleExternalId: + continue + case cred.ConstTargetRoleSessionName: + continue + case cred.ConstTargetRoleTags: + continue default: badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field" } @@ -77,6 +98,7 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { return &CatalogAttributes{ CredentialAttributes: credAttributes, + TargetAccount: targetCredAttributes, DualStack: dualStack, InstanceAddressesOnly: instanceAddressesOnly, }, nil @@ -121,3 +143,19 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { return &setAttrs, nil } + +func hasTargetAccountAttributes(in *structpb.Struct) bool { + fields := in.GetFields() + + _, hasTargetRegion := fields[cred.ConstTargetRegion] + _, hasTargetRoleArn := fields[cred.ConstTargetRoleArn] + _, hasTargetRoleExternalID := fields[cred.ConstTargetRoleExternalId] + _, hasTargetRoleSessionName := fields[cred.ConstTargetRoleSessionName] + _, hasTargetRoleTags := fields[cred.ConstTargetRoleTags] + + return hasTargetRegion || + hasTargetRoleArn || + hasTargetRoleExternalID || + hasTargetRoleSessionName || + hasTargetRoleTags +} diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index ad91a77..627dc58 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -118,6 +118,170 @@ func TestGetCatalogAttributes(t *testing.T) { }, }, }, + { + name: "target_region without target_role_arn", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "target_region": structpb.NewStringValue("eu-west-1"), + }, + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "target role field without target_role_arn", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "target_role_external_id": structpb.NewStringValue("target-ext"), + }, + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "other target role fields without target_role_arn", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "target_role_session_name": structpb.NewStringValue("target-session"), + "target_role_tags": structpb.NewStructValue(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "env": structpb.NewStringValue("target"), + }, + }), + }, + }, + expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"", + }, + { + name: "empty target_role_arn with target_region", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "target_role_arn": structpb.NewStringValue(""), + "target_region": structpb.NewStringValue("eu-west-1"), + }, + }, + expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty", + }, + { + name: "target_role_arn without target_region uses region; principal sts fields are not copied", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "disable_credential_rotation": structpb.NewBoolValue(true), + "role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"), + "role_external_id": structpb.NewStringValue("principal-ext"), + "role_session_name": structpb.NewStringValue("principal-sess"), + "role_tags": structpb.NewStructValue(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "env": structpb.NewStringValue("principal"), + }, + }), + "target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &cred.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: true, + RoleArn: "arn:aws:iam::111111111111:role/Principal", + RoleExternalId: "principal-ext", + RoleSessionName: "principal-sess", + RoleTags: map[string]string{ + "env": "principal", + }, + }, + TargetAccount: &cred.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: true, + RoleArn: "arn:aws:iam::222222222222:role/Target", + }, + }, + }, + { + name: "target attributes are independent of principal", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "disable_credential_rotation": structpb.NewBoolValue(true), + "role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"), + "role_external_id": structpb.NewStringValue("principal-ext"), + "role_session_name": structpb.NewStringValue("principal-sess"), + "role_tags": structpb.NewStructValue(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "env": structpb.NewStringValue("principal"), + }, + }), + "target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + "target_region": structpb.NewStringValue("eu-west-1"), + "target_role_external_id": structpb.NewStringValue("target-ext"), + "target_role_session_name": structpb.NewStringValue("target-sess"), + "target_role_tags": structpb.NewStructValue(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "env": structpb.NewStringValue("target"), + }, + }), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &cred.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: true, + RoleArn: "arn:aws:iam::111111111111:role/Principal", + RoleExternalId: "principal-ext", + RoleSessionName: "principal-sess", + RoleTags: map[string]string{ + "env": "principal", + }, + }, + TargetAccount: &cred.CredentialAttributes{ + Region: "eu-west-1", + DisableCredentialRotation: true, + RoleArn: "arn:aws:iam::222222222222:role/Target", + RoleExternalId: "target-ext", + RoleSessionName: "target-sess", + RoleTags: map[string]string{ + "env": "target", + }, + }, + }, + }, + { + name: "catalog dual_stack and instance_addresses_only with target", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "dual_stack": structpb.NewBoolValue(true), + "instance_addresses_only": structpb.NewBoolValue(true), + "target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + TargetAccount: &cred.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + RoleArn: "arn:aws:iam::222222222222:role/Target", + }, + DualStack: true, + InstanceAddressesOnly: true, + }, + }, + { + name: "unknown field with target_role_arn", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"), + "foo": structpb.NewBoolValue(true), + }, + }, + expectedErrContains: "attributes.foo: unrecognized field", + }, } for _, tc := range cases {