diff --git a/.changelog/19278.txt b/.changelog/19278.txt new file mode 100644 index 00000000000..2d83b0f17b7 --- /dev/null +++ b/.changelog/19278.txt @@ -0,0 +1,7 @@ +```release-note:new-resource +aws_servicecatalog_organizations_access +``` + +```release-note:new-resource +aws_servicecatalog_portfolio_share +``` \ No newline at end of file diff --git a/aws/internal/envvar/testing_funcs.go b/aws/internal/envvar/testing_funcs.go index 841ec3d0115..ce81e250a60 100644 --- a/aws/internal/envvar/testing_funcs.go +++ b/aws/internal/envvar/testing_funcs.go @@ -50,3 +50,18 @@ func TestSkipIfEmpty(t testing.T, name string, usageMessage string) string { return value } + +// TestSkipIfAllEmpty verifies that at least one environment variable is non-empty or skips the test. +// +// If at lease one environment variable is non-empty, returns the first name and value. +func TestSkipIfAllEmpty(t testing.T, names []string, usageMessage string) (string, string) { + t.Helper() + + name, value, err := RequireOneOf(names, usageMessage) + if err != nil { + t.Skipf("skipping test because %s.", err) + return "", "" + } + + return name, value +} diff --git a/aws/internal/service/organizations/finder/finder.go b/aws/internal/service/organizations/finder/finder.go new file mode 100644 index 00000000000..e9ee347b189 --- /dev/null +++ b/aws/internal/service/organizations/finder/finder.go @@ -0,0 +1,25 @@ +package finder + +import ( + "github.com/aws/aws-sdk-go/service/organizations" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func Organization(conn *organizations.Organizations) (*organizations.Organization, error) { + input := &organizations.DescribeOrganizationInput{} + + output, err := conn.DescribeOrganization(input) + + if err != nil { + return nil, err + } + + if output == nil || output.Organization == nil { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output.Organization, nil +} diff --git a/aws/internal/service/servicecatalog/finder/finder.go b/aws/internal/service/servicecatalog/finder/finder.go new file mode 100644 index 00000000000..c7347d6e558 --- /dev/null +++ b/aws/internal/service/servicecatalog/finder/finder.go @@ -0,0 +1,37 @@ +package finder + +import ( + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/servicecatalog" +) + +func PortfolioShare(conn *servicecatalog.ServiceCatalog, portfolioID, shareType, principalID string) (*servicecatalog.PortfolioShareDetail, error) { + input := &servicecatalog.DescribePortfolioSharesInput{ + PortfolioId: aws.String(portfolioID), + Type: aws.String(shareType), + } + var result *servicecatalog.PortfolioShareDetail + + err := conn.DescribePortfolioSharesPages(input, func(page *servicecatalog.DescribePortfolioSharesOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, deet := range page.PortfolioShareDetails { + if deet == nil { + continue + } + + if strings.Contains(principalID, aws.StringValue(deet.PrincipalId)) { + result = deet + return false + } + } + + return !lastPage + }) + + return result, err +} diff --git a/aws/internal/service/servicecatalog/id.go b/aws/internal/service/servicecatalog/id.go new file mode 100644 index 00000000000..43cfe94a16f --- /dev/null +++ b/aws/internal/service/servicecatalog/id.go @@ -0,0 +1,20 @@ +package servicecatalog + +import ( + "fmt" + "strings" +) + +func PortfolioShareParseResourceID(id string) (string, string, string, error) { + parts := strings.SplitN(id, ":", 3) + + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return "", "", "", fmt.Errorf("unexpected format of ID (%s), expected portfolioID:type:principalID", id) + } + + return parts[0], parts[1], parts[2], nil +} + +func PortfolioShareCreateResourceID(portfolioID, shareType, principalID string) string { + return strings.Join([]string{portfolioID, shareType, principalID}, ":") +} diff --git a/aws/internal/service/servicecatalog/waiter/status.go b/aws/internal/service/servicecatalog/waiter/status.go index 25ebfd22468..6807e057f57 100644 --- a/aws/internal/service/servicecatalog/waiter/status.go +++ b/aws/internal/service/servicecatalog/waiter/status.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/service/servicecatalog" "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog/finder" ) func ProductStatus(conn *servicecatalog.ServiceCatalog, acceptLanguage, productID string) resource.StateRefreshFunc { @@ -68,3 +69,74 @@ func TagOptionStatus(conn *servicecatalog.ServiceCatalog, id string) resource.St return output.TagOptionDetail, servicecatalog.StatusAvailable, err } } + +func PortfolioShareStatusWithToken(conn *servicecatalog.ServiceCatalog, token string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + input := &servicecatalog.DescribePortfolioShareStatusInput{ + PortfolioShareToken: aws.String(token), + } + output, err := conn.DescribePortfolioShareStatus(input) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return nil, StatusNotFound, err + } + + if err != nil { + return nil, servicecatalog.ShareStatusError, fmt.Errorf("error describing portfolio share status: %w", err) + } + + if output == nil { + return nil, StatusUnavailable, fmt.Errorf("error describing portfolio share status: empty response") + } + + return output, aws.StringValue(output.Status), err + } +} + +func PortfolioShareStatus(conn *servicecatalog.ServiceCatalog, portfolioID, shareType, principalID string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := finder.PortfolioShare(conn, portfolioID, shareType, principalID) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return nil, StatusNotFound, err + } + + if err != nil { + return nil, servicecatalog.ShareStatusError, fmt.Errorf("error finding portfolio share: %w", err) + } + + if output == nil { + return nil, StatusNotFound, &resource.NotFoundError{ + Message: fmt.Sprintf("error finding portfolio share (%s:%s:%s): empty response", portfolioID, shareType, principalID), + } + } + + if !aws.BoolValue(output.Accepted) { + return output, servicecatalog.ShareStatusInProgress, err + } + + return output, servicecatalog.ShareStatusCompleted, err + } +} + +func OrganizationsAccessStatus(conn *servicecatalog.ServiceCatalog) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + input := &servicecatalog.GetAWSOrganizationsAccessStatusInput{} + + output, err := conn.GetAWSOrganizationsAccessStatus(input) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return nil, StatusNotFound, err + } + + if err != nil { + return nil, OrganizationAccessStatusError, fmt.Errorf("error getting Organizations Access: %w", err) + } + + if output == nil { + return nil, StatusUnavailable, fmt.Errorf("error getting Organizations Access: empty response") + } + + return output, aws.StringValue(output.AccessStatus), err + } +} diff --git a/aws/internal/service/servicecatalog/waiter/waiter.go b/aws/internal/service/servicecatalog/waiter/waiter.go index 01b3f8a7341..590d3478140 100644 --- a/aws/internal/service/servicecatalog/waiter/waiter.go +++ b/aws/internal/service/servicecatalog/waiter/waiter.go @@ -3,9 +3,11 @@ package waiter import ( "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/servicecatalog" "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) const ( @@ -15,11 +17,17 @@ const ( TagOptionReadyTimeout = 3 * time.Minute TagOptionDeleteTimeout = 3 * time.Minute + PortfolioShareCreateTimeout = 3 * time.Minute + + OrganizationsAccessStableTimeout = 3 * time.Minute + StatusNotFound = "NOT_FOUND" StatusUnavailable = "UNAVAILABLE" // AWS documentation is wrong, says that status will be "AVAILABLE" but it is actually "CREATED" ProductStatusCreated = "CREATED" + + OrganizationAccessStatusError = "ERROR" ) func ProductReady(conn *servicecatalog.ServiceCatalog, acceptLanguage, productID string) (*servicecatalog.DescribeProductAsAdminOutput, error) { @@ -44,7 +52,7 @@ func ProductDeleted(conn *servicecatalog.ServiceCatalog, acceptLanguage, product Pending: []string{servicecatalog.StatusCreating, servicecatalog.StatusAvailable, ProductStatusCreated, StatusUnavailable}, Target: []string{StatusNotFound}, Refresh: ProductStatus(conn, acceptLanguage, productID), - Timeout: ProductReadyTimeout, + Timeout: ProductDeleteTimeout, } _, err := stateConf.WaitForState() @@ -89,3 +97,104 @@ func TagOptionDeleted(conn *servicecatalog.ServiceCatalog, id string) error { return err } + +func PortfolioShareReady(conn *servicecatalog.ServiceCatalog, portfolioID, shareType, principalID string, acceptRequired bool) (*servicecatalog.PortfolioShareDetail, error) { + targets := []string{servicecatalog.ShareStatusCompleted} + + if !acceptRequired { + targets = append(targets, servicecatalog.ShareStatusInProgress) + } + + stateConf := &resource.StateChangeConf{ + Pending: []string{servicecatalog.ShareStatusNotStarted, servicecatalog.ShareStatusInProgress, StatusNotFound, StatusUnavailable}, + Target: targets, + Refresh: PortfolioShareStatus(conn, portfolioID, shareType, principalID), + Timeout: PortfolioShareCreateTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if output, ok := outputRaw.(*servicecatalog.PortfolioShareDetail); ok { + return output, err + } + + return nil, err +} + +func PortfolioShareCreatedWithToken(conn *servicecatalog.ServiceCatalog, token string, acceptRequired bool) (*servicecatalog.DescribePortfolioShareStatusOutput, error) { + targets := []string{servicecatalog.ShareStatusCompleted} + + if !acceptRequired { + targets = append(targets, servicecatalog.ShareStatusInProgress) + } + + stateConf := &resource.StateChangeConf{ + Pending: []string{servicecatalog.ShareStatusNotStarted, servicecatalog.ShareStatusInProgress, StatusNotFound, StatusUnavailable}, + Target: targets, + Refresh: PortfolioShareStatusWithToken(conn, token), + Timeout: PortfolioShareCreateTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if output, ok := outputRaw.(*servicecatalog.DescribePortfolioShareStatusOutput); ok { + return output, err + } + + return nil, err +} + +func PortfolioShareDeleted(conn *servicecatalog.ServiceCatalog, portfolioID, shareType, principalID string) (*servicecatalog.PortfolioShareDetail, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{servicecatalog.ShareStatusNotStarted, servicecatalog.ShareStatusInProgress, servicecatalog.ShareStatusCompleted, StatusUnavailable}, + Target: []string{StatusNotFound}, + Refresh: PortfolioShareStatus(conn, portfolioID, shareType, principalID), + Timeout: PortfolioShareCreateTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if tfresource.NotFound(err) { + return nil, nil + } + + if output, ok := outputRaw.(*servicecatalog.PortfolioShareDetail); ok { + return output, err + } + + return nil, err +} + +func PortfolioShareDeletedWithToken(conn *servicecatalog.ServiceCatalog, token string) (*servicecatalog.DescribePortfolioShareStatusOutput, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{servicecatalog.ShareStatusNotStarted, servicecatalog.ShareStatusInProgress, StatusNotFound, StatusUnavailable}, + Target: []string{servicecatalog.ShareStatusCompleted}, + Refresh: PortfolioShareStatusWithToken(conn, token), + Timeout: PortfolioShareCreateTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if output, ok := outputRaw.(*servicecatalog.DescribePortfolioShareStatusOutput); ok { + return output, err + } + + return nil, err +} + +func OrganizationsAccessStable(conn *servicecatalog.ServiceCatalog) (string, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{servicecatalog.AccessStatusUnderChange, StatusNotFound, StatusUnavailable}, + Target: []string{servicecatalog.AccessStatusEnabled, servicecatalog.AccessStatusDisabled}, + Refresh: OrganizationsAccessStatus(conn), + Timeout: OrganizationsAccessStableTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if output, ok := outputRaw.(*servicecatalog.GetAWSOrganizationsAccessStatusOutput); ok { + return aws.StringValue(output.AccessStatus), err + } + + return "", err +} diff --git a/aws/internal/service/sts/finder/finder.go b/aws/internal/service/sts/finder/finder.go new file mode 100644 index 00000000000..3ec4da3ef74 --- /dev/null +++ b/aws/internal/service/sts/finder/finder.go @@ -0,0 +1,25 @@ +package finder + +import ( + "github.com/aws/aws-sdk-go/service/sts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func CallerIdentity(conn *sts.STS) (*sts.GetCallerIdentityOutput, error) { + input := &sts.GetCallerIdentityInput{} + + output, err := conn.GetCallerIdentity(input) + + if err != nil { + return nil, err + } + + if output == nil { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output, nil +} diff --git a/aws/provider.go b/aws/provider.go index 9cb1937d824..b3aa0cd1736 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -1005,7 +1005,9 @@ func Provider() *schema.Provider { "aws_securityhub_organization_admin_account": resourceAwsSecurityHubOrganizationAdminAccount(), "aws_securityhub_product_subscription": resourceAwsSecurityHubProductSubscription(), "aws_securityhub_standards_subscription": resourceAwsSecurityHubStandardsSubscription(), + "aws_servicecatalog_organizations_access": resourceAwsServiceCatalogOrganizationsAccess(), "aws_servicecatalog_portfolio": resourceAwsServiceCatalogPortfolio(), + "aws_servicecatalog_portfolio_share": resourceAwsServiceCatalogPortfolioShare(), "aws_servicecatalog_product": resourceAwsServiceCatalogProduct(), "aws_servicecatalog_tag_option": resourceAwsServiceCatalogTagOption(), "aws_service_discovery_http_namespace": resourceAwsServiceDiscoveryHttpNamespace(), diff --git a/aws/provider_test.go b/aws/provider_test.go index 3acef649007..7f9495aa6f6 100644 --- a/aws/provider_test.go +++ b/aws/provider_test.go @@ -24,6 +24,8 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/terraform-providers/terraform-provider-aws/aws/internal/envvar" + organizationsfinder "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/organizations/finder" + stsfinder "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/sts/finder" ) const ( @@ -698,10 +700,10 @@ func testAccGetThirdRegionPartition() string { } func testAccAlternateAccountPreCheck(t *testing.T) { - envvar.TestFailIfAllEmpty(t, []string{envvar.AwsAlternateProfile, envvar.AwsAlternateAccessKeyId}, "credentials for running acceptance testing in alternate AWS account") + envvar.TestSkipIfAllEmpty(t, []string{envvar.AwsAlternateProfile, envvar.AwsAlternateAccessKeyId}, "credentials for running acceptance testing in alternate AWS account") if os.Getenv(envvar.AwsAlternateAccessKeyId) != "" { - envvar.TestFailIfEmpty(t, envvar.AwsAlternateSecretAccessKey, "static credentials value when using "+envvar.AwsAlternateAccessKeyId) + envvar.TestSkipIfEmpty(t, envvar.AwsAlternateSecretAccessKey, "static credentials value when using "+envvar.AwsAlternateAccessKeyId) } } @@ -792,6 +794,24 @@ func testAccOrganizationsEnabledPreCheck(t *testing.T) { } } +func testAccOrganizationManagementAccountPreCheck(t *testing.T) { + organization, err := organizationsfinder.Organization(testAccProvider.Meta().(*AWSClient).organizationsconn) + + if err != nil { + t.Fatalf("error describing AWS Organization: %s", err) + } + + callerIdentity, err := stsfinder.CallerIdentity(testAccProvider.Meta().(*AWSClient).stsconn) + + if err != nil { + t.Fatalf("error getting current identity: %s", err) + } + + if aws.StringValue(organization.MasterAccountId) != aws.StringValue(callerIdentity.Account) { + t.Skip("this AWS account must be the management account of an AWS Organization") + } +} + func testAccPreCheckIamServiceLinkedRole(t *testing.T, pathPrefix string) { conn := testAccProvider.Meta().(*AWSClient).iamconn diff --git a/aws/resource_aws_servicecatalog_organizations_access.go b/aws/resource_aws_servicecatalog_organizations_access.go new file mode 100644 index 00000000000..a8faf81c1f9 --- /dev/null +++ b/aws/resource_aws_servicecatalog_organizations_access.go @@ -0,0 +1,108 @@ +package aws + +import ( + "fmt" + "log" + + "github.com/aws/aws-sdk-go/service/servicecatalog" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog/waiter" +) + +func resourceAwsServiceCatalogOrganizationsAccess() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsServiceCatalogOrganizationsAccessCreate, + Read: resourceAwsServiceCatalogOrganizationsAccessRead, + Delete: resourceAwsServiceCatalogOrganizationsAccessDelete, + + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Required: true, + ForceNew: true, + }, + }, + } +} + +func resourceAwsServiceCatalogOrganizationsAccessCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + d.SetId(meta.(*AWSClient).accountid) + + // During create, if enabled = "true", then Enable Access and vice versa + // During delete, the opposite + + if _, ok := d.GetOk("enabled"); ok { + _, err := conn.EnableAWSOrganizationsAccess(&servicecatalog.EnableAWSOrganizationsAccessInput{}) + + if err != nil { + return fmt.Errorf("error enabling Service Catalog AWS Organizations Access: %w", err) + } + + return resourceAwsServiceCatalogOrganizationsAccessRead(d, meta) + } + + _, err := conn.DisableAWSOrganizationsAccess(&servicecatalog.DisableAWSOrganizationsAccessInput{}) + + if err != nil { + return fmt.Errorf("error disabling Service Catalog AWS Organizations Access: %w", err) + } + + return resourceAwsServiceCatalogOrganizationsAccessRead(d, meta) +} + +func resourceAwsServiceCatalogOrganizationsAccessRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + output, err := waiter.OrganizationsAccessStable(conn) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + // theoretically this should not be possible + log.Printf("[WARN] Service Catalog Organizations Access (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error describing Service Catalog AWS Organizations Access (%s): %w", d.Id(), err) + } + + if output == "" { + return fmt.Errorf("error getting Service Catalog AWS Organizations Access (%s): empty response", d.Id()) + } + + if output == servicecatalog.AccessStatusEnabled { + d.Set("enabled", true) + return nil + } + + d.Set("enabled", false) + return nil +} + +func resourceAwsServiceCatalogOrganizationsAccessDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + // During create, if enabled = "true", then Enable Access and vice versa + // During delete, the opposite + + if _, ok := d.GetOk("enabled"); !ok { + _, err := conn.EnableAWSOrganizationsAccess(&servicecatalog.EnableAWSOrganizationsAccessInput{}) + + if err != nil { + return fmt.Errorf("error enabling Service Catalog AWS Organizations Access: %w", err) + } + + return nil + } + + _, err := conn.DisableAWSOrganizationsAccess(&servicecatalog.DisableAWSOrganizationsAccessInput{}) + + if err != nil { + return fmt.Errorf("error disabling Service Catalog AWS Organizations Access: %w", err) + } + + return nil +} diff --git a/aws/resource_aws_servicecatalog_organizations_access_test.go b/aws/resource_aws_servicecatalog_organizations_access_test.go new file mode 100644 index 00000000000..e71e06b4474 --- /dev/null +++ b/aws/resource_aws_servicecatalog_organizations_access_test.go @@ -0,0 +1,99 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/service/servicecatalog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog/waiter" +) + +func TestAccAWSServiceCatalogOrganizationsAccess_basic(t *testing.T) { + resourceName := "aws_servicecatalog_organizations_access.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccOrganizationsEnabledPreCheck(t) + testAccOrganizationManagementAccountPreCheck(t) + }, + ErrorCheck: testAccErrorCheck(t, servicecatalog.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAwsServiceCatalogOrganizationsAccessDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSServiceCatalogOrganizationsAccessConfig_basic(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsServiceCatalogOrganizationsAccessExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + }, + }) +} + +func testAccCheckAwsServiceCatalogOrganizationsAccessDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).scconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_servicecatalog_organizations_access" { + continue + } + + output, err := waiter.OrganizationsAccessStable(conn) + + if err != nil { + return fmt.Errorf("error describing Service Catalog AWS Organizations Access (%s): %w", rs.Primary.ID, err) + } + + if output == "" { + return fmt.Errorf("error getting Service Catalog AWS Organizations Access (%s): empty response", rs.Primary.ID) + } + + return nil + } + + return nil +} + +func testAccCheckAwsServiceCatalogOrganizationsAccessExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + conn := testAccProvider.Meta().(*AWSClient).scconn + + output, err := waiter.OrganizationsAccessStable(conn) + + if err != nil { + return fmt.Errorf("error describing Service Catalog AWS Organizations Access (%s): %w", rs.Primary.ID, err) + } + + if output == "" { + return fmt.Errorf("error getting Service Catalog AWS Organizations Access (%s): empty response", rs.Primary.ID) + } + + if output != servicecatalog.AccessStatusEnabled && rs.Primary.Attributes["enabled"] == "true" { + return fmt.Errorf("error getting Service Catalog AWS Organizations Access (%s): wrong setting", rs.Primary.ID) + } + + if output == servicecatalog.AccessStatusEnabled && rs.Primary.Attributes["enabled"] == "false" { + return fmt.Errorf("error getting Service Catalog AWS Organizations Access (%s): wrong setting", rs.Primary.ID) + } + + return nil + } +} + +func testAccAWSServiceCatalogOrganizationsAccessConfig_basic() string { + return ` +resource "aws_servicecatalog_organizations_access" "test" { + enabled = "true" +} +` +} diff --git a/aws/resource_aws_servicecatalog_portfolio.go b/aws/resource_aws_servicecatalog_portfolio.go index 43bfbff9ec8..dc0dc20741a 100644 --- a/aws/resource_aws_servicecatalog_portfolio.go +++ b/aws/resource_aws_servicecatalog_portfolio.go @@ -43,7 +43,7 @@ func resourceAwsServiceCatalogPortfolio() *schema.Resource { "name": { Type: schema.TypeString, Required: true, - ValidateFunc: validation.StringLenBetween(1, 20), + ValidateFunc: validation.StringLenBetween(1, 100), }, "description": { Type: schema.TypeString, @@ -54,7 +54,7 @@ func resourceAwsServiceCatalogPortfolio() *schema.Resource { "provider_name": { Type: schema.TypeString, Optional: true, - ValidateFunc: validation.StringLenBetween(1, 20), + ValidateFunc: validation.StringLenBetween(1, 50), }, "tags": tagsSchema(), "tags_all": tagsSchemaComputed(), diff --git a/aws/resource_aws_servicecatalog_portfolio_share.go b/aws/resource_aws_servicecatalog_portfolio_share.go new file mode 100644 index 00000000000..2f12c649afc --- /dev/null +++ b/aws/resource_aws_servicecatalog_portfolio_share.go @@ -0,0 +1,298 @@ +package aws + +import ( + "fmt" + "log" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/service/servicecatalog" + "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/hashicorp/terraform-plugin-sdk/v2/helper/validation" + iamwaiter "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam/waiter" + tfservicecatalog "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog/waiter" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +func resourceAwsServiceCatalogPortfolioShare() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsServiceCatalogPortfolioShareCreate, + Read: resourceAwsServiceCatalogPortfolioShareRead, + Update: resourceAwsServiceCatalogPortfolioShareUpdate, + Delete: resourceAwsServiceCatalogPortfolioShareDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "accept_language": { + Type: schema.TypeString, + Optional: true, + Default: "en", + ValidateFunc: validation.StringInSlice(tfservicecatalog.AcceptLanguage_Values(), false), + }, + "accepted": { + Type: schema.TypeBool, + Computed: true, + }, + "portfolio_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + // maintaining organization_node as a separate config block makes weird configs with duplicate types + // also, principal_id is true to API since describe gives "PrincipalId" + "principal_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateServiceCatalogSharePrincipal, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + newARN, err := arn.Parse(new) + + if err != nil { + return old == new + } + + parts := strings.Split(newARN.Resource, "/") + + return old == parts[len(parts)-1] + }, + }, + "share_tag_options": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(servicecatalog.DescribePortfolioShareType_Values(), false), + }, + "wait_for_acceptance": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + }, + } +} + +func resourceAwsServiceCatalogPortfolioShareCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + input := &servicecatalog.CreatePortfolioShareInput{ + PortfolioId: aws.String(d.Get("portfolio_id").(string)), + } + + if v, ok := d.GetOk("accept_language"); ok { + input.AcceptLanguage = aws.String(v.(string)) + } + + if v, ok := d.GetOk("type"); ok && v.(string) == servicecatalog.DescribePortfolioShareTypeAccount { + input.AccountId = aws.String(d.Get("principal_id").(string)) + } else { + orgNode := &servicecatalog.OrganizationNode{} + orgNode.Value = aws.String(d.Get("principal_id").(string)) + + if v.(string) == servicecatalog.DescribePortfolioShareTypeOrganizationMemberAccount { + // portfolio_share type ORGANIZATION_MEMBER_ACCOUNT = org node type ACCOUNT + orgNode.Type = aws.String(servicecatalog.OrganizationNodeTypeAccount) + } else { + orgNode.Type = aws.String(d.Get("type").(string)) + } + + input.OrganizationNode = orgNode + } + + if v, ok := d.GetOk("share_tag_options"); ok { + input.ShareTagOptions = aws.Bool(v.(bool)) + } + + var output *servicecatalog.CreatePortfolioShareOutput + err := resource.Retry(iamwaiter.PropagationTimeout, func() *resource.RetryError { + var err error + + output, err = conn.CreatePortfolioShare(input) + + if tfawserr.ErrMessageContains(err, servicecatalog.ErrCodeInvalidParametersException, "profile does not exist") { + return resource.RetryableError(err) + } + + if err != nil { + return resource.NonRetryableError(err) + } + + return nil + }) + + if tfresource.TimedOut(err) { + output, err = conn.CreatePortfolioShare(input) + } + + if err != nil { + return fmt.Errorf("error creating Service Catalog Portfolio Share: %w", err) + } + + if output == nil { + return fmt.Errorf("error creating Service Catalog Portfolio Share: empty response") + } + + d.SetId(tfservicecatalog.PortfolioShareCreateResourceID(d.Get("portfolio_id").(string), d.Get("type").(string), d.Get("principal_id").(string))) + + waitForAcceptance := false + if v, ok := d.GetOk("wait_for_acceptance"); ok { + waitForAcceptance = v.(bool) + } + + // only get a token if organization node, otherwise check without token + if output.PortfolioShareToken != nil { + if _, err := waiter.PortfolioShareCreatedWithToken(conn, aws.StringValue(output.PortfolioShareToken), waitForAcceptance); err != nil { + return fmt.Errorf("error waiting for Service Catalog Portfolio Share (%s) to be ready: %w", d.Id(), err) + } + } else { + if _, err := waiter.PortfolioShareReady(conn, d.Get("portfolio_id").(string), d.Get("type").(string), d.Get("principal_id").(string), waitForAcceptance); err != nil { + return fmt.Errorf("error waiting for Service Catalog Portfolio Share (%s) to be ready: %w", d.Id(), err) + } + } + + return resourceAwsServiceCatalogPortfolioShareRead(d, meta) +} + +func resourceAwsServiceCatalogPortfolioShareRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + portfolioID, shareType, principalID, err := tfservicecatalog.PortfolioShareParseResourceID(d.Id()) + + if err != nil { + return fmt.Errorf("could not parse ID (%s): %w", d.Id(), err) + } + + waitForAcceptance := false + if v, ok := d.GetOk("wait_for_acceptance"); ok { + waitForAcceptance = v.(bool) + } + + output, err := waiter.PortfolioShareReady(conn, portfolioID, shareType, principalID, waitForAcceptance) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + log.Printf("[WARN] Service Catalog Portfolio Share (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error describing Service Catalog Portfolio Share (%s): %w", d.Id(), err) + } + + if output == nil { + return fmt.Errorf("error getting Service Catalog Portfolio Share (%s): empty response", d.Id()) + } + + d.Set("accepted", output.Accepted) + d.Set("portfolio_id", portfolioID) + d.Set("principal_id", output.PrincipalId) + d.Set("share_tag_options", output.ShareTagOptions) + d.Set("type", output.Type) + d.Set("wait_for_acceptance", waitForAcceptance) + + return nil +} + +func resourceAwsServiceCatalogPortfolioShareUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + if d.HasChanges("accept_language", "share_tag_options") { + input := &servicecatalog.UpdatePortfolioShareInput{ + PortfolioId: aws.String(d.Get("portfolio_id").(string)), + } + + if v, ok := d.GetOk("accept_language"); ok { + input.AcceptLanguage = aws.String(v.(string)) + } + + if v, ok := d.GetOk("share_tag_options"); ok { + input.ShareTagOptions = aws.Bool(v.(bool)) + } + + err := resource.Retry(iamwaiter.PropagationTimeout, func() *resource.RetryError { + _, err := conn.UpdatePortfolioShare(input) + + if tfawserr.ErrMessageContains(err, servicecatalog.ErrCodeInvalidParametersException, "profile does not exist") { + return resource.RetryableError(err) + } + + if err != nil { + return resource.NonRetryableError(err) + } + + return nil + }) + + if tfresource.TimedOut(err) { + _, err = conn.UpdatePortfolioShare(input) + } + + if err != nil { + return fmt.Errorf("error updating Service Catalog Portfolio Share (%s): %w", d.Id(), err) + } + } + + return resourceAwsServiceCatalogPortfolioShareRead(d, meta) +} + +func resourceAwsServiceCatalogPortfolioShareDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).scconn + + input := &servicecatalog.DeletePortfolioShareInput{ + PortfolioId: aws.String(d.Get("portfolio_id").(string)), + } + + if v, ok := d.GetOk("accept_language"); ok { + input.AcceptLanguage = aws.String(v.(string)) + } + + if v, ok := d.GetOk("type"); ok && v.(string) == servicecatalog.DescribePortfolioShareTypeAccount { + input.AccountId = aws.String(d.Get("principal_id").(string)) + } else { + orgNode := &servicecatalog.OrganizationNode{} + orgNode.Value = aws.String(d.Get("principal_id").(string)) + + if v.(string) == servicecatalog.DescribePortfolioShareTypeOrganizationMemberAccount { + // portfolio_share type ORGANIZATION_MEMBER_ACCOUNT = org node type ACCOUNT + orgNode.Type = aws.String(servicecatalog.OrganizationNodeTypeAccount) + } else { + orgNode.Type = aws.String(d.Get("type").(string)) + } + + input.OrganizationNode = orgNode + } + + output, err := conn.DeletePortfolioShare(input) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting Service Catalog Portfolio Share (%s): %w", d.Id(), err) + } + + // only get a token if organization node, otherwise check without token + if output.PortfolioShareToken != nil { + if _, err := waiter.PortfolioShareDeletedWithToken(conn, aws.StringValue(output.PortfolioShareToken)); err != nil { + return fmt.Errorf("error waiting for Service Catalog Portfolio Share (%s) to be deleted: %w", d.Id(), err) + } + } else { + if _, err := waiter.PortfolioShareDeleted(conn, d.Get("portfolio_id").(string), d.Get("type").(string), d.Get("principal_id").(string)); err != nil { + return fmt.Errorf("error waiting for Service Catalog Portfolio Share (%s) to be deleted: %w", d.Id(), err) + } + } + + return nil +} diff --git a/aws/resource_aws_servicecatalog_portfolio_share_test.go b/aws/resource_aws_servicecatalog_portfolio_share_test.go new file mode 100644 index 00000000000..62afd2a22d8 --- /dev/null +++ b/aws/resource_aws_servicecatalog_portfolio_share_test.go @@ -0,0 +1,209 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/service/servicecatalog" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/servicecatalog/finder" +) + +func TestAccAWSServiceCatalogPortfolioShare_basic(t *testing.T) { + var providers []*schema.Provider + resourceName := "aws_servicecatalog_portfolio_share.test" + compareName := "aws_servicecatalog_portfolio.test" + dataSourceName := "data.aws_caller_identity.alternate" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccAlternateAccountPreCheck(t) + testAccPartitionHasServicePreCheck(servicecatalog.EndpointsID, t) + }, + ErrorCheck: testAccErrorCheck(t, servicecatalog.EndpointsID), + ProviderFactories: testAccProviderFactoriesAlternate(&providers), + CheckDestroy: testAccCheckAwsServiceCatalogPortfolioShareDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSServiceCatalogPortfolioShareConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsServiceCatalogPortfolioShareExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "accept_language", "en"), + resource.TestCheckResourceAttr(resourceName, "accepted", "false"), + resource.TestCheckResourceAttrPair(resourceName, "principal_id", dataSourceName, "account_id"), + resource.TestCheckResourceAttrPair(resourceName, "portfolio_id", compareName, "id"), + resource.TestCheckResourceAttr(resourceName, "share_tag_options", "true"), + resource.TestCheckResourceAttr(resourceName, "type", servicecatalog.DescribePortfolioShareTypeAccount), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "accept_language", + }, + }, + }, + }) +} + +func TestAccAWSServiceCatalogPortfolioShare_organizationalUnit(t *testing.T) { + resourceName := "aws_servicecatalog_portfolio_share.test" + compareName := "aws_servicecatalog_portfolio.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccOrganizationsEnabledPreCheck(t) + testAccOrganizationManagementAccountPreCheck(t) + testAccPartitionHasServicePreCheck(servicecatalog.EndpointsID, t) + }, + ErrorCheck: testAccErrorCheck(t, servicecatalog.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAwsServiceCatalogPortfolioShareDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSServiceCatalogPortfolioShareConfig_organizationalUnit(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsServiceCatalogPortfolioShareExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "accept_language", "en"), + resource.TestCheckResourceAttr(resourceName, "accepted", "true"), + resource.TestCheckResourceAttrPair(resourceName, "principal_id", "aws_organizations_organizational_unit.test", "id"), + resource.TestCheckResourceAttrPair(resourceName, "portfolio_id", compareName, "id"), + resource.TestCheckResourceAttr(resourceName, "share_tag_options", "true"), + resource.TestCheckResourceAttr(resourceName, "type", servicecatalog.DescribePortfolioShareTypeOrganizationalUnit), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "accept_language", + }, + }, + }, + }) +} + +func testAccCheckAwsServiceCatalogPortfolioShareDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).scconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_servicecatalog_portfolio_share" { + continue + } + + output, err := finder.PortfolioShare( + conn, + rs.Primary.Attributes["portfolio_id"], + rs.Primary.Attributes["type"], + rs.Primary.Attributes["principal_id"], + ) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return nil + } + + if err != nil { + return fmt.Errorf("error getting Service Catalog Portfolio Share (%s): %w", rs.Primary.ID, err) + } + + if output != nil { + return fmt.Errorf("Service Catalog Portfolio Share (%s) still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccCheckAwsServiceCatalogPortfolioShareExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + conn := testAccProvider.Meta().(*AWSClient).scconn + + _, err := finder.PortfolioShare( + conn, + rs.Primary.Attributes["portfolio_id"], + rs.Primary.Attributes["type"], + rs.Primary.Attributes["principal_id"], + ) + + if tfawserr.ErrCodeEquals(err, servicecatalog.ErrCodeResourceNotFoundException) { + return fmt.Errorf("Service Catalog Portfolio Share (%s) not found", rs.Primary.ID) + } + + if err != nil { + return fmt.Errorf("error getting Service Catalog Portfolio Share (%s): %w", rs.Primary.ID, err) + } + + return nil + } +} + +func testAccAWSServiceCatalogPortfolioShareConfig_basic(rName string) string { + return composeConfig(testAccAlternateAccountProviderConfig(), fmt.Sprintf(` +data "aws_caller_identity" "alternate" { + provider = "awsalternate" +} + +resource "aws_servicecatalog_portfolio" "test" { + name = %[1]q + description = %[1]q + provider_name = %[1]q +} + +resource "aws_servicecatalog_portfolio_share" "test" { + accept_language = "en" + portfolio_id = aws_servicecatalog_portfolio.test.id + share_tag_options = true + type = "ACCOUNT" + principal_id = data.aws_caller_identity.alternate.account_id + wait_for_acceptance = false +} +`, rName)) +} + +func testAccAWSServiceCatalogPortfolioShareConfig_organizationalUnit(rName string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} + +resource "aws_servicecatalog_organizations_access" "test" { + enabled = "true" +} + +resource "aws_servicecatalog_portfolio" "test" { + name = %[1]q + description = %[1]q + provider_name = %[1]q +} + +data "aws_organizations_organization" "test" {} + +resource "aws_organizations_organizational_unit" "test" { + name = %[1]q + parent_id = data.aws_organizations_organization.test.roots[0].id +} + +resource "aws_servicecatalog_portfolio_share" "test" { + accept_language = "en" + portfolio_id = aws_servicecatalog_portfolio.test.id + share_tag_options = true + type = "ORGANIZATIONAL_UNIT" + principal_id = aws_organizations_organizational_unit.test.arn +} +`, rName) +} diff --git a/aws/validators.go b/aws/validators.go index f997a79dd7f..f2b09daad56 100644 --- a/aws/validators.go +++ b/aws/validators.go @@ -697,6 +697,32 @@ func validatePrincipal(v interface{}, k string) (ws []string, errors []error) { return ws, errors } +func validateServiceCatalogSharePrincipal(v interface{}, k string) (ws []string, errors []error) { + value := v.(string) + // either account ID, or organization or organization unit + + wsAccount, errorsAccount := validateAwsAccountId(v, k) + + if len(errorsAccount) == 0 { + return wsAccount, errorsAccount + } + + wsARN, errorsARN := validateArn(v, k) + ws = append(ws, wsARN...) + errors = append(errors, errorsARN...) + + pattern := `^arn:[\w-]+:organizations:.*:(ou|organization)/` + if !regexp.MustCompile(pattern).MatchString(value) { + errors = append(errors, fmt.Errorf("%q does not look like an OU or organization: %q", k, value)) + } + + if len(errors) > 0 { + errors = append(errors, errorsAccount...) + } + + return ws, errors +} + func validateArn(v interface{}, k string) (ws []string, errors []error) { value := v.(string) diff --git a/aws/validators_test.go b/aws/validators_test.go index 1102b44fdd6..835ad78bc0b 100644 --- a/aws/validators_test.go +++ b/aws/validators_test.go @@ -436,6 +436,58 @@ func TestValidatePrincipal(t *testing.T) { } } +func TestValidateServiceCatalogSharePrincipal(t *testing.T) { + v := "" + _, errors := validateServiceCatalogSharePrincipal(v, "arn") + if len(errors) == 0 { + t.Fatalf("%q should not be validated as a principal %d: %q", v, len(errors), errors) + } + + validNames := []string{ + "123456789012", // lintignore:AWSAT005 // Example Account ID (Valid looking but not real) + "111122223333", // lintignore:AWSAT005 // Example Account ID (Valid looking but not real) + "arn:aws:organizations::111122223333:organization/o-abcdefghijkl", // lintignore:AWSAT005 // organization + "arn:aws:organizations::111122223333:ou/o-abcdefghijkl/ou-ab00-cdefgh", // lintignore:AWSAT005 // ou + "arn:aws-us-gov:organizations::111122223333:ou/o-abcdefghijkl/ou-ab00-cdefgh", // lintignore:AWSAT005 // GovCloud ou + } + for _, v := range validNames { + _, errors := validateServiceCatalogSharePrincipal(v, "arn") + if len(errors) != 0 { + t.Fatalf("%q should be a valid principal: %q", v, errors) + } + } + + invalidNames := []string{ + "IAM_ALLOWED_PRINCIPALS", // Special principal + "IAM_NOT_ALLOWED_PRINCIPALS", // doesn't exist + "arn", + "1234567890125", //not an account id + "arn:aws", + "arn:aws:logs", //lintignore:AWSAT005 + "arn:aws:logs:region:*:*", //lintignore:AWSAT005 + "arn:aws:elasticbeanstalk:us-east-1:123456789012:environment/My App/MyEnvironment", // lintignore:AWSAT003,AWSAT005 // not a user or role + "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess", // lintignore:AWSAT005 // not a user or role + "arn:aws:rds:eu-west-1:123456789012:db:mysql-db", // lintignore:AWSAT003,AWSAT005 // not a user or role + "arn:aws:s3:::my_corporate_bucket/exampleobject.png", // lintignore:AWSAT005 // not a user or role + "arn:aws:events:us-east-1:319201112229:rule/rule_name", // lintignore:AWSAT003,AWSAT005 // not a user or role + "arn:aws-us-gov:ec2:us-gov-west-1:123456789012:instance/i-12345678", // lintignore:AWSAT003,AWSAT005 // not a user or role + "arn:aws-us-gov:s3:::bucket/object", // lintignore:AWSAT005 // not a user or role + "arn:aws-us-gov:iam::357342307427:role/tf-acc-test-3217321001347236965", // lintignore:AWSAT005 // IAM Role + "arn:aws:iam::123456789012:user/David", // lintignore:AWSAT005 // IAM User + "arn:aws-us-gov:iam:us-west-2:357342307427:role/tf-acc-test-3217321001347236965", // lintignore:AWSAT003,AWSAT005 // Non-global IAM Role? + "arn:aws:iam:us-east-1:123456789012:user/David", // lintignore:AWSAT003,AWSAT005 // Non-global IAM User? + "arn:aws:iam::111122223333:saml-provider/idp1:group/data-scientists", // lintignore:AWSAT005 // SAML group + "arn:aws:iam::111122223333:saml-provider/idp1:user/Paul", // lintignore:AWSAT005 // SAML user + "arn:aws:quicksight:us-east-1:111122223333:group/default/data_scientists", // lintignore:AWSAT003,AWSAT005 // quicksight group + } + for _, v := range invalidNames { + _, errors := validateServiceCatalogSharePrincipal(v, "arn") + if len(errors) == 0 { + t.Fatalf("%q should be an invalid principal", v) + } + } +} + func TestValidateEC2AutomateARN(t *testing.T) { validNames := []string{ "arn:aws:automate:us-east-1:ec2:reboot", //lintignore:AWSAT003,AWSAT005 diff --git a/website/docs/r/servicecatalog_organizations_access.html.markdown b/website/docs/r/servicecatalog_organizations_access.html.markdown new file mode 100644 index 00000000000..1347e9faf33 --- /dev/null +++ b/website/docs/r/servicecatalog_organizations_access.html.markdown @@ -0,0 +1,35 @@ +--- +subcategory: "Service Catalog" +layout: "aws" +page_title: "AWS: aws_servicecatalog_organizations_access" +description: |- + Manages Service Catalog Organizations Access +--- + +# Resource: aws_servicecatalog_organizations_access + +Manages Service Catalog AWS Organizations Access, a portfolio sharing feature through AWS Organizations. This allows Service Catalog to receive updates on your organization in order to sync your shares with the current structure. This resource will prompt AWS to set `organizations:EnableAWSServiceAccess` on your behalf so that your shares can be in sync with any changes in your AWS Organizations structure. + +~> **NOTE:** This resource can only be used by the management account in the organization. In other words, a delegated administrator is not authorized to use the resource. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_servicecatalog_organizations_access" "example" { + enabled = "true" +} +``` + +## Argument Reference + +The following arguments are required: + +* `enabled` - (Required) Whether to enable AWS Organizations access. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - Account ID for the account using the resource. diff --git a/website/docs/r/servicecatalog_portfolio_share.html.markdown b/website/docs/r/servicecatalog_portfolio_share.html.markdown new file mode 100644 index 00000000000..fd9547c7aca --- /dev/null +++ b/website/docs/r/servicecatalog_portfolio_share.html.markdown @@ -0,0 +1,59 @@ +--- +subcategory: "Service Catalog" +layout: "aws" +page_title: "AWS: aws_servicecatalog_portfolio_share" +description: |- + Manages a Service Catalog Portfolio Share +--- + +# Resource: aws_servicecatalog_portfolio_share + +Manages a Service Catalog Portfolio Share. Shares the specified portfolio with the specified account or organization node. You can share portfolios to an organization, an organizational unit, or a specific account. + +If the portfolio share with the specified account or organization node already exists, using this resource to re-create the share will have no effect and will not return an error. You can then use this resource to update the share. + +~> **NOTE:** Shares to an organization node can only be created by the management account of an organization or by a delegated administrator. If a delegated admin is de-registered, they can no longer create portfolio shares. + +~> **NOTE:** AWSOrganizationsAccess must be enabled in order to create a portfolio share to an organization node. + +~> **NOTE:** You can't share a shared resource, including portfolios that contain a shared product. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_servicecatalog_portfolio_share" "example" { + principal_id = "012128675309" + portfolio_id = aws_servicecatalog_portfolio.example.id + type = "ACCOUNT" +} +``` + +## Argument Reference + +The following arguments are required: + +* `portfolio_id` - (Required) Portfolio identifier. +* `principal_id` - (Required) Identifier of the principal with whom you will share the portfolio. Valid values AWS account IDs and ARNs of AWS Organizations and organizational units. +* `type` - (Required) Type of portfolio share. Valid values are `ACCOUNT` (an external account), `ORGANIZATION` (a share to every account in an organization), `ORGANIZATIONAL_UNIT`, `ORGANIZATION_MEMBER_ACCOUNT` (a share to an account in an organization). + +The following arguments are optional: + +* `accept_language` - (Optional) Language code. Valid values: `en` (English), `jp` (Japanese), `zh` (Chinese). Default value is `en`. +* `share_tag_options` - (Optional) Whether to enable sharing of `aws_servicecatalog_tag_option` resources when creating the portfolio share. +* `wait_for_acceptance` - (Optional) Whether to wait (up to the timeout) for the share to be accepted. Organizational shares are automatically accepted. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `accepted` - Whether the shared portfolio is imported by the recipient account. If the recipient is organizational, the share is automatically imported, and the field is always set to true. + +## Import + +`aws_servicecatalog_portfolio_share` can be imported using the portfolio share ID, e.g. + +``` +$ terraform import aws_servicecatalog_portfolio_share.example port-12344321:ACCOUNT:123456789012 +```