diff --git a/.changelog/41284.txt b/.changelog/41284.txt new file mode 100644 index 000000000000..c53cf0bb465c --- /dev/null +++ b/.changelog/41284.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/aws_quicksight_data_source: Add `parameters.s3.role_arn` argument to allow override an account-wide role for a specific S3 data source +``` diff --git a/internal/service/quicksight/data_source.go b/internal/service/quicksight/data_source.go index 86d63b426a6f..5532eb4b5111 100644 --- a/internal/service/quicksight/data_source.go +++ b/internal/service/quicksight/data_source.go @@ -5,6 +5,7 @@ package quicksight import ( "context" + "errors" "fmt" "log" "strings" @@ -28,6 +29,15 @@ import ( "github.com/hashicorp/terraform-provider-aws/names" ) +const ( + // Allow IAM role to become visible to the index + propagationTimeout = 2 * time.Minute + + // accessDeniedExceptionMessage describes the error returned when the IAM role has not yet propagated + accessDeniedExceptionAssumeRoleMessage = "Failed to assume your role. Verify the trust relationships of the role in the IAM console" + accessDeniedExceptionInsufficientPermissionsMessage = "Insufficient permission to access the manifest file" +) + // @SDKResource("aws_quicksight_data_source", name="Data Source") // @Tags(identifierAttribute="arn") // @Testing(existsType="github.com/aws/aws-sdk-go-v2/service/quicksight/types;awstypes;awstypes.DataSource") @@ -124,12 +134,29 @@ func resourceDataSourceCreate(ctx context.Context, d *schema.ResourceData, meta input.VpcConnectionProperties = quicksightschema.ExpandVPCConnectionProperties(v.([]interface{})) } - _, err := conn.CreateDataSource(ctx, input) + outputRaw, err := tfresource.RetryWhen(ctx, propagationTimeout, + func() (interface{}, error) { + return conn.CreateDataSource(ctx, input) + }, + func(err error) (bool, error) { + var accessDeniedException *awstypes.AccessDeniedException + + if errors.As(err, &accessDeniedException) && (strings.Contains(accessDeniedException.ErrorMessage(), accessDeniedExceptionAssumeRoleMessage) || strings.Contains(accessDeniedException.ErrorMessage(), accessDeniedExceptionInsufficientPermissionsMessage)) { + return true, err + } + + return false, err + }, + ) if err != nil { return sdkdiag.AppendErrorf(diags, "creating QuickSight Data Source (%s): %s", id, err) } + if outputRaw == nil { + return sdkdiag.AppendErrorf(diags, "creating QuickSight Data Source (%s): empty output", id) + } + d.SetId(id) if _, err := waitDataSourceCreated(ctx, conn, awsAccountID, dataSourceID); err != nil { @@ -220,12 +247,29 @@ func resourceDataSourceUpdate(ctx context.Context, d *schema.ResourceData, meta input.VpcConnectionProperties = quicksightschema.ExpandVPCConnectionProperties(v.([]interface{})) } - _, err = conn.UpdateDataSource(ctx, input) + outputRaw, err := tfresource.RetryWhen(ctx, propagationTimeout, + func() (interface{}, error) { + return conn.UpdateDataSource(ctx, input) + }, + func(err error) (bool, error) { + var accessDeniedException *awstypes.AccessDeniedException + + if errors.As(err, &accessDeniedException) && (strings.Contains(accessDeniedException.ErrorMessage(), accessDeniedExceptionAssumeRoleMessage) || strings.Contains(accessDeniedException.ErrorMessage(), accessDeniedExceptionInsufficientPermissionsMessage)) { + return true, err + } + + return false, err + }, + ) if err != nil { return sdkdiag.AppendErrorf(diags, "updating QuickSight Data Source (%s): %s", d.Id(), err) } + if outputRaw == nil { + return sdkdiag.AppendErrorf(diags, "updating QuickSight Data Source (%s): empty output", d.Id()) + } + if _, err := waitDataSourceUpdated(ctx, conn, awsAccountID, dataSourceID); err != nil { return sdkdiag.AppendErrorf(diags, "waiting for QuickSight Data Source (%s) update: %s", d.Id(), err) } diff --git a/internal/service/quicksight/data_source_test.go b/internal/service/quicksight/data_source_test.go index ee7062b3bc7e..de57c0a3814c 100644 --- a/internal/service/quicksight/data_source_test.go +++ b/internal/service/quicksight/data_source_test.go @@ -237,6 +237,58 @@ func TestAccQuickSightDataSource_secretARN(t *testing.T) { }) } +func TestAccQuickSightDataSource_s3RoleARN(t *testing.T) { + ctx := acctest.Context(t) + var dataSource awstypes.DataSource + resourceName := "aws_quicksight_data_source.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + rName2 := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + rId := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + iamRoleResourceName := "aws_iam_role.test" + iamRoleResourceNameUpdated := "aws_iam_role.test2" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + ErrorCheck: acctest.ErrorCheck(t, names.QuickSightServiceID), + CheckDestroy: testAccCheckDataSourceDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccDataSourceConfig_s3RoleARN(rId, rName, rName2, iamRoleResourceName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDataSourceExists(ctx, resourceName, &dataSource), + resource.TestCheckResourceAttr(resourceName, "data_source_id", rId), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.#", "1"), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.#", "1"), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.0.bucket", rName), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.0.key", rName), + resource.TestCheckResourceAttrPair(resourceName, "parameters.0.s3.0.role_arn", iamRoleResourceName, names.AttrARN), + resource.TestCheckResourceAttr(resourceName, names.AttrType, string(awstypes.DataSourceTypeS3)), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + // change the selector update the data source with the new Role + { + Config: testAccDataSourceConfig_s3RoleARN(rId, rName, rName2, iamRoleResourceNameUpdated), + Check: resource.ComposeTestCheckFunc( + testAccCheckDataSourceExists(ctx, resourceName, &dataSource), + resource.TestCheckResourceAttr(resourceName, "data_source_id", rId), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.#", "1"), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.#", "1"), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.0.bucket", rName), + resource.TestCheckResourceAttr(resourceName, "parameters.0.s3.0.manifest_file_location.0.key", rName), + resource.TestCheckResourceAttrPair(resourceName, "parameters.0.s3.0.role_arn", iamRoleResourceNameUpdated, names.AttrARN), + resource.TestCheckResourceAttr(resourceName, names.AttrType, string(awstypes.DataSourceTypeS3)), + ), + }, + }, + }) +} + func testAccCheckDataSourceExists(ctx context.Context, n string, v *awstypes.DataSource) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -364,6 +416,57 @@ EOF `, rName) } +func testAccDataSourceConfig_baseNoACL(rName string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} +data "aws_region" "current" {} + +resource "aws_s3_bucket" "test" { + bucket = %[1]q + force_destroy = true +} + +resource "aws_s3_bucket_public_access_block" "test" { + bucket = aws_s3_bucket.test.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_object" "test_data" { + bucket = aws_s3_bucket.test.bucket + key = "%[1]s-test-data.csv" + content = <<-EOT +name,sentiment +a,happy +b,happy +EOT +} + +resource "aws_s3_object" "test" { + bucket = aws_s3_bucket.test.bucket + key = %[1]q + content = jsonencode({ + fileLocations = [ + { + URIs = [ + "https://${aws_s3_bucket.test.id}.s3.${data.aws_region.current.name}.${data.aws_partition.current.dns_suffix}/%[1]s-test-data.csv" + ] + } + ] + globalUploadSettings = { + format = "CSV" + delimiter = "," + textqualifier = "\"" + containsHeader = true + } + }) +} +`, rName) +} + func testAccDataSourceConfig_basic(rId, rName string) string { return acctest.ConfigCompose( testAccDataSourceConfig_base(rName), @@ -757,3 +860,102 @@ resource "aws_quicksight_data_source" "test" { } `, rId, rName) } + +func testAccDataSourceConfig_s3RoleARN(rId, rName, rName2, iamRoleResourceName string) string { + return acctest.ConfigCompose( + testAccDataSourceConfig_baseNoACL(rName), + fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_iam_role" "test" { + name = %[2]q + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "quicksight.amazonaws.com" + } + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + } + ] + }) +} + +resource "aws_iam_role" "test2" { + name = %[3]q + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "quicksight.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_policy" "test" { + name = %[2]q + description = "Policy to allow QuickSight access to S3 bucket" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = ["s3:GetObject"], + Effect = "Allow", + Resource = "${aws_s3_bucket.test.arn}/${aws_s3_object.test.key}" + }, + { + Action = ["s3:ListBucket"], + Effect = "Allow", + Resource = aws_s3_bucket.test.arn + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "test" { + policy_arn = aws_iam_policy.test.arn + role = aws_iam_role.test.name +} + +resource "aws_iam_role_policy_attachment" "test2" { + policy_arn = aws_iam_policy.test.arn + role = aws_iam_role.test2.name +} + +resource "aws_quicksight_data_source" "test" { + data_source_id = %[1]q + name = %[2]q + + parameters { + s3 { + manifest_file_location { + bucket = aws_s3_bucket.test.bucket + key = aws_s3_object.test.key + } + role_arn = %[4]s.arn + } + } + + type = "S3" + + depends_on = [ + aws_iam_role_policy_attachment.test + ] +} +`, rId, rName, rName2, iamRoleResourceName)) +} diff --git a/internal/service/quicksight/schema/data_source.go b/internal/service/quicksight/schema/data_source.go index e49bdf398495..1d205197109c 100644 --- a/internal/service/quicksight/schema/data_source.go +++ b/internal/service/quicksight/schema/data_source.go @@ -428,6 +428,11 @@ func DataSourceParametersSchema() *schema.Schema { }, }, }, + names.AttrRoleARN: { + Type: schema.TypeString, + Optional: true, + ValidateFunc: verify.ValidARN, + }, }, }, ExactlyOneOf: exactlyOneOf, @@ -906,6 +911,10 @@ func ExpandDataSourceParameters(tfList []interface{}) awstypes.DataSourceParamet } } + if v, ok := tfMap[names.AttrRoleARN].(string); ok && v != "" { + ps.Value.RoleArn = aws.String(v) + } + apiObject = ps } } @@ -1130,6 +1139,7 @@ func FlattenDataSourceParameters(apiObject awstypes.DataSourceParameters) []inte names.AttrKey: aws.ToString(v.Value.ManifestFileLocation.Key), }, }, + names.AttrRoleARN: aws.ToString(v.Value.RoleArn), }, } case *awstypes.DataSourceParametersMemberServiceNowParameters: diff --git a/website/docs/r/quicksight_data_source.html.markdown b/website/docs/r/quicksight_data_source.html.markdown index fece17577afc..cd03a35a06ec 100644 --- a/website/docs/r/quicksight_data_source.html.markdown +++ b/website/docs/r/quicksight_data_source.html.markdown @@ -12,6 +12,8 @@ Resource for managing QuickSight Data Source ## Example Usage +### S3 Data Source + ```terraform resource "aws_quicksight_data_source" "default" { data_source_id = "example-id" @@ -30,6 +32,102 @@ resource "aws_quicksight_data_source" "default" { } ``` +### S3 Data Source with IAM Role ARN + +```terraform +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} +data "aws_region" "current" {} + +resource "aws_s3_bucket" "example" { +} + +resource "aws_s3_object" "example" { + bucket = aws_s3_bucket.example.bucket + key = "manifest.json" + content = jsonencode({ + fileLocations = [ + { + URIPrefixes = [ + "https://${aws_s3_bucket.example.id}.s3-${data.aws_region.current.name}.${data.aws_partition.current.dns_suffix}" + ] + } + ] + globalUploadSettings = { + format = "CSV" + delimiter = "," + textqualifier = "\"" + containsHeader = true + } + }) +} + +resource "aws_iam_role" "example" { + name = "example" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "quicksight.amazonaws.com" + } + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + } + ] + }) +} + +resource "aws_iam_policy" "example" { + name = "example" + description = "Policy to allow QuickSight access to S3 bucket" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = ["s3:GetObject"], + Effect = "Allow", + Resource = "${aws_s3_bucket.example.arn}/${aws_s3_object.example.key}" + }, + { + Action = ["s3:ListBucket"], + Effect = "Allow", + Resource = aws_s3_bucket.example.arn + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "example" { + policy_arn = aws_iam_policy.example.arn + role = aws_iam_role.example.name +} + +resource "aws_quicksight_data_source" "example" { + data_source_id = "example-id" + name = "manifest in S3" + + parameters { + s3 { + manifest_file_location { + bucket = aws_s3_bucket.example.arn + key = aws_s3_object.example.key + } + role_arn = aws_iam_role.example.arn + } + } + + type = "S3" +} +``` + ## Argument Reference The following arguments are required: @@ -178,6 +276,7 @@ To specify data source connection parameters, exactly one of the following sub-o ### s3 Argument Reference * `manifest_file_location` - (Required) An [object containing the S3 location](#manifest_file_location-argument-reference) of the S3 manifest file. +* `role_arn` - (Optional) Use the `role_arn` to override an account-wide role for a specific S3 data source. For example, say an account administrator has turned off all S3 access with an account-wide role. The administrator can then use `role_arn` to bypass the account-wide role and allow S3 access for the single S3 data source that is specified in the structure, even if the account-wide role forbidding S3 access is still active. ### manifest_file_location Argument Reference