diff --git a/.changelog/19957.txt b/.changelog/19957.txt new file mode 100644 index 00000000000..e4b1f3b3064 --- /dev/null +++ b/.changelog/19957.txt @@ -0,0 +1,3 @@ +```release-note:new-data-source +aws_iam_session_context +``` \ No newline at end of file diff --git a/aws/data_source_aws_caller_identity.go b/aws/data_source_aws_caller_identity.go index 04b0b8c52d3..366275518eb 100644 --- a/aws/data_source_aws_caller_identity.go +++ b/aws/data_source_aws_caller_identity.go @@ -39,7 +39,7 @@ func dataSourceAwsCallerIdentityRead(d *schema.ResourceData, meta interface{}) e res, err := client.GetCallerIdentity(&sts.GetCallerIdentityInput{}) if err != nil { - return fmt.Errorf("Error getting Caller Identity: %w", err) + return fmt.Errorf("getting Caller Identity: %w", err) } log.Printf("[DEBUG] Received Caller Identity: %s", res) diff --git a/aws/data_source_aws_iam_session_context.go b/aws/data_source_aws_iam_session_context.go new file mode 100644 index 00000000000..9b976e16d1a --- /dev/null +++ b/aws/data_source_aws_iam_session_context.go @@ -0,0 +1,128 @@ +package aws + +import ( + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam/waiter" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +func dataSourceAwsIAMSessionContext() *schema.Resource { + return &schema.Resource{ + Read: dataSourceAwsIAMSessionContextRead, + + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validateArn, + }, + "issuer_arn": { + Type: schema.TypeString, + Computed: true, + }, + "issuer_id": { + Type: schema.TypeString, + Computed: true, + }, + "issuer_name": { + Type: schema.TypeString, + Computed: true, + }, + "session_name": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceAwsIAMSessionContextRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).iamconn + + arn := d.Get("arn").(string) + + d.SetId(arn) + + roleName := "" + sessionName := "" + var err error + + if roleName, sessionName = roleNameSessionFromARN(arn); roleName == "" { + d.Set("issuer_arn", arn) + d.Set("issuer_id", "") + d.Set("issuer_name", "") + d.Set("session_name", "") + + return nil + } + + var role *iam.Role + + err = resource.Retry(waiter.PropagationTimeout, func() *resource.RetryError { + var err error + + role, err = finder.Role(conn, roleName) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, iam.ErrCodeNoSuchEntityException) { + return resource.RetryableError(err) + } + + if err != nil { + return resource.NonRetryableError(err) + } + + return nil + }) + + if tfresource.TimedOut(err) { + role, err = finder.Role(conn, roleName) + } + + if err != nil { + return fmt.Errorf("unable to get role (%s): %w", roleName, err) + } + + if role == nil || role.Arn == nil { + return fmt.Errorf("empty role returned (%s)", roleName) + } + + d.Set("issuer_arn", role.Arn) + d.Set("issuer_id", role.RoleId) + d.Set("issuer_name", roleName) + d.Set("session_name", sessionName) + + return nil +} + +// roleNameSessionFromARN returns the role and session names in an ARN if any. +// Otherwise, it returns empty strings. +func roleNameSessionFromARN(rawARN string) (string, string) { + parsedARN, err := arn.Parse(rawARN) + + if err != nil { + return "", "" + } + + reAssume := regexp.MustCompile(`^assumed-role/.{1,}/.{2,}`) + + if !reAssume.MatchString(parsedARN.Resource) || parsedARN.Service != "sts" { + return "", "" + } + + parts := strings.Split(parsedARN.Resource, "/") + + if len(parts) < 3 { + return "", "" + } + + return parts[len(parts)-2], parts[len(parts)-1] +} diff --git a/aws/data_source_aws_iam_session_context_test.go b/aws/data_source_aws_iam_session_context_test.go new file mode 100644 index 00000000000..ba458d8586a --- /dev/null +++ b/aws/data_source_aws_iam_session_context_test.go @@ -0,0 +1,267 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/service/iam" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAssumedRoleRoleSessionName(t *testing.T) { + testCases := []struct { + Name string + ARN string + ExpectedRoleName string + ExpectedSessionName string + ExpectedError bool + }{ + { + Name: "not an ARN", + ARN: "abcd", + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "regular role ARN", + ARN: "arn:aws:iam::111122223333:role/role_name", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "assumed role ARN", + ARN: "arn:aws:sts::444433332222:assumed-role/something_something-admin/sessionIDNotPartOfRoleARN", //lintignore:AWSAT005 + ExpectedRoleName: "something_something-admin", + ExpectedSessionName: "sessionIDNotPartOfRoleARN", + }, + { + Name: "'assumed-role' part of ARN resource", + ARN: "arn:aws:iam::444433332222:user/assumed-role-but-not-really", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "user ARN", + ARN: "arn:aws:iam::123456789012:user/Bob", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "assumed role from AWS example", + ARN: "arn:aws:sts::123456789012:assumed-role/example-role/AWSCLI-Session", //lintignore:AWSAT005 + ExpectedRoleName: "example-role", + ExpectedSessionName: "AWSCLI-Session", + }, + { + Name: "multiple slashes in resource", // not sure this is even valid + ARN: "arn:aws:sts::123456789012:assumed-role/path/role-name/AWSCLI-Session", //lintignore:AWSAT005 + ExpectedRoleName: "role-name", + ExpectedSessionName: "AWSCLI-Session", + }, + { + Name: "not an sts ARN", + ARN: "arn:aws:iam::123456789012:assumed-role/example-role/AWSCLI-Session", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "role with path", + ARN: "arn:aws:iam::123456789012:role/this/is/the/path/role-name", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + { + Name: "wrong service", + ARN: "arn:aws:ec2::123456789012:role/role-name", //lintignore:AWSAT005 + ExpectedRoleName: "", + ExpectedSessionName: "", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + role, session := roleNameSessionFromARN(testCase.ARN) + + if testCase.ExpectedRoleName != role || testCase.ExpectedSessionName != session { + t.Errorf("for %s: got role %s, session %s; expected role %s, session %s", testCase.ARN, role, session, testCase.ExpectedRoleName, testCase.ExpectedSessionName) + } + }) + } +} + +func TestAccAWSDataSourceIAMSessionContext_basic(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + dataSourceName := "data.aws_iam_session_context.test" + resourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, iam.EndpointsID), + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAwsIAMSessionContextConfig(rName, "/", "session-id"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_arn", resourceName, "arn"), + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_id", resourceName, "unique_id"), + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_name", resourceName, "name"), + resource.TestCheckResourceAttr(dataSourceName, "session_name", "session-id"), + ), + }, + }, + }) +} + +func TestAccAWSDataSourceIAMSessionContext_withPath(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + dataSourceName := "data.aws_iam_session_context.test" + resourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, iam.EndpointsID), + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAwsIAMSessionContextConfig(rName, "/this/is/a/long/path/", "session-id"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_arn", resourceName, "arn"), + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_name", resourceName, "name"), + resource.TestCheckResourceAttr(dataSourceName, "session_name", "session-id"), + ), + }, + }, + }) +} + +func TestAccAWSDataSourceIAMSessionContext_notAssumedRole(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + dataSourceName := "data.aws_iam_session_context.test" + resourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, iam.EndpointsID), + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAwsIAMSessionContextNotAssumedConfig(rName, "/"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_arn", resourceName, "arn"), + resource.TestCheckResourceAttr(dataSourceName, "issuer_name", ""), + resource.TestCheckResourceAttr(dataSourceName, "session_name", ""), + ), + }, + }, + }) +} + +func TestAccAWSDataSourceIAMSessionContext_notAssumedRoleWithPath(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + dataSourceName := "data.aws_iam_session_context.test" + resourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, iam.EndpointsID), + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAwsIAMSessionContextNotAssumedConfig(rName, "/this/is/a/long/path/"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "issuer_arn", resourceName, "arn"), + resource.TestCheckResourceAttr(dataSourceName, "issuer_name", ""), + resource.TestCheckResourceAttr(dataSourceName, "session_name", ""), + ), + }, + }, + }) +} + +func TestAccAWSDataSourceIAMSessionContext_notAssumedRoleUser(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + dataSourceName := "data.aws_iam_session_context.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ErrorCheck: testAccErrorCheck(t, iam.EndpointsID), + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAwsIAMSessionContextUserConfig(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckResourceAttrGlobalARN(dataSourceName, "arn", "iam", fmt.Sprintf("user/division/extra-division/not-assumed-role/%[1]s", rName)), + resource.TestCheckResourceAttr(dataSourceName, "issuer_name", ""), + resource.TestCheckResourceAttr(dataSourceName, "session_name", ""), + ), + }, + }, + }) +} + +func testAccAwsIAMSessionContextConfig(rName, path, sessionID string) string { + return fmt.Sprintf(` +resource "aws_iam_role" "test" { + name = %[1]q + path = %[2]q + + assume_role_policy = jsonencode({ + "Version" = "2012-10-17" + + "Statement" = [{ + "Action" = "sts:AssumeRole" + "Principal" = { + "Service" = "ec2.${data.aws_partition.current.dns_suffix}" + } + "Effect" = "Allow" + }] + }) +} + +data "aws_partition" "current" {} +data "aws_caller_identity" "current" {} + +data "aws_iam_session_context" "test" { + arn = "arn:${data.aws_partition.current.partition}:sts::${data.aws_caller_identity.current.account_id}:assumed-role/${aws_iam_role.test.name}/%[3]s" +} +`, rName, path, sessionID) +} + +func testAccAwsIAMSessionContextNotAssumedConfig(rName, path string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} + +resource "aws_iam_role" "test" { + name = %[1]q + path = %[2]q + + assume_role_policy = jsonencode({ + "Version" = "2012-10-17" + + "Statement" = [{ + "Action" = "sts:AssumeRole" + "Principal" = { + "Service" = "ec2.${data.aws_partition.current.dns_suffix}" + } + "Effect" = "Allow" + }] + }) +} + +data "aws_iam_session_context" "test" { + arn = aws_iam_role.test.arn +} +`, rName, path) +} + +func testAccAwsIAMSessionContextUserConfig(rName string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} +data "aws_caller_identity" "current" {} + +data "aws_iam_session_context" "test" { + arn = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:user/division/extra-division/not-assumed-role/%[1]s" +} +`, rName) +} diff --git a/aws/internal/service/iam/finder/finder.go b/aws/internal/service/iam/finder/finder.go index e9bad76a9fe..8b664f9a130 100644 --- a/aws/internal/service/iam/finder/finder.go +++ b/aws/internal/service/iam/finder/finder.go @@ -1,6 +1,8 @@ package finder import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" ) @@ -109,3 +111,22 @@ func Policies(conn *iam.IAM, arn, name, pathPrefix string) ([]*iam.Policy, error return results, err } + +// Role returns a role's ARN given the role name +func Role(conn *iam.IAM, name string) (*iam.Role, error) { + input := &iam.GetRoleInput{ + RoleName: aws.String(name), + } + + output, err := conn.GetRole(input) + + if err != nil { + return nil, fmt.Errorf("getting IAM Role (%s): %w", name, err) + } + + if output == nil || output.Role == nil { + return nil, fmt.Errorf("getting IAM Role (%s): empty response", name) + } + + return output.Role, nil +} diff --git a/aws/provider.go b/aws/provider.go index bb29044942d..15a0d8f4f02 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -304,6 +304,7 @@ func Provider() *schema.Provider { "aws_iam_policy_document": dataSourceAwsIamPolicyDocument(), "aws_iam_role": dataSourceAwsIAMRole(), "aws_iam_server_certificate": dataSourceAwsIAMServerCertificate(), + "aws_iam_session_context": dataSourceAwsIAMSessionContext(), "aws_iam_user": dataSourceAwsIAMUser(), "aws_identitystore_group": dataSourceAwsIdentityStoreGroup(), "aws_identitystore_user": dataSourceAwsIdentityStoreUser(), diff --git a/website/docs/d/caller_identity.html.markdown b/website/docs/d/caller_identity.html.markdown index d349ef137ef..bc5a8e19c32 100644 --- a/website/docs/d/caller_identity.html.markdown +++ b/website/docs/d/caller_identity.html.markdown @@ -36,7 +36,7 @@ There are no arguments available for this data source. ## Attributes Reference -* `account_id` - The AWS Account ID number of the account that owns or contains the calling entity. -* `arn` - The AWS ARN associated with the calling entity. -* `id` - The AWS Account ID number of the account that owns or contains the calling entity. -* `user_id` - The unique identifier of the calling entity. +* `account_id` - AWS Account ID number of the account that owns or contains the calling entity. +* `arn` - ARN associated with the calling entity. +* `id` - Account ID number of the account that owns or contains the calling entity. +* `user_id` - Unique identifier of the calling entity. diff --git a/website/docs/d/iam_session_context.markdown b/website/docs/d/iam_session_context.markdown new file mode 100644 index 00000000000..4cea6665624 --- /dev/null +++ b/website/docs/d/iam_session_context.markdown @@ -0,0 +1,50 @@ +--- +subcategory: "IAM" +layout: "aws" +page_title: "AWS: aws_iam_session_context" +description: |- + Get information on the IAM source role of an STS assumed role +--- + +# Data Source: aws_iam_session_context + +This data source provides information on the IAM source role of an STS assumed role. For non-role ARNs, this data source simply passes the ARN through in `issuer_arn`. + +For some AWS resources, multiple types of principals are allowed in the same argument (e.g., IAM users and IAM roles). However, these arguments often do not allow assumed-role (i.e., STS, temporary credential) principals. Given an STS ARN, this data source provides the ARN for the source IAM role. + +## Example Usage + +### Basic Example + +```terraform +data "aws_iam_session_context" "example" { + arn = "arn:aws:sts::123456789012:assumed-role/Audien-Heaven/MatyNoyes" +} +``` + +### Find the Terraform Runner's Source Role + +Combined with `aws_caller_identity`, you can get the current user's source IAM role ARN (`issuer_arn`) if you're using an assumed role. If you're not using an assumed role, the caller's (e.g., an IAM user's) ARN will simply be passed through. In environments where both IAM users and individuals using assumed roles need to apply the same configurations, this data source enables seamless use. + +```terraform +data "aws_caller_identity" "current" {} + +data "aws_iam_session_context" "example" { + arn = data.aws_caller_identity.current.arn +} +``` + +## Argument Reference + +* `arn` - (Required) ARN for an assumed role. + +~> If `arn` is a non-role ARN, Terraform gives no error and `issuer_arn` will be equal to the `arn` value. For STS assumed-role ARNs, Terraform gives an error if the identified IAM role does not exist. + +## Attributes Reference + +~> With the exception of `issuer_arn`, the attributes will not be populated unless the `arn` corresponds to an STS assumed role. + +* `issuer_arn` - IAM source role ARN if `arn` corresponds to an STS assumed role. Otherwise, `issuer_arn` is equal to `arn`. +* `issuer_id` - Unique identifier of the IAM role that issues the STS assumed role. +* `issuer_name` - Name of the source role. Only available if `arn` corresponds to an STS assumed role. +* `session_name` - Name of the STS session. Only available if `arn` corresponds to an STS assumed role.