Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

r/aws_securityhub_standards_control_association: new resource #39511

Merged
3 changes: 3 additions & 0 deletions .changelog/39511.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-resource
aws_securityhub_standards_control_association
```
2 changes: 2 additions & 0 deletions internal/service/securityhub/exports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
ResourceOrganizationConfiguration = resourceOrganizationConfiguration
ResourceProductSubscription = resourceProductSubscription
ResourceStandardsControl = resourceStandardsControl
ResourceStandardsControlAssociation = newStandardsControlAssociationResource
ResourceStandardsSubscription = resourceStandardsSubscription

AccountHubARN = accountHubARN
Expand All @@ -33,6 +34,7 @@ var (
FindMemberByAccountID = findMemberByAccountID
FindOrganizationConfiguration = findOrganizationConfiguration
FindProductSubscriptionByARN = findProductSubscriptionByARN
FindStandardsControlAssociationByTwoPartKey = findStandardsControlAssociationByTwoPartKey
FindStandardsControlByTwoPartKey = findStandardsControlByTwoPartKey
FindStandardsSubscriptionByARN = findStandardsSubscriptionByARN
StandardsControlARNToStandardsSubscriptionARN = standardsControlARNToStandardsSubscriptionARN
Expand Down
3 changes: 3 additions & 0 deletions internal/service/securityhub/securityhub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ func TestAccSecurityHub_serial(t *testing.T) {
"DisabledControlStatus": testAccStandardsControl_disabledControlStatus,
"EnabledControlStatusAndDisabledReason": testAccStandardsControl_enabledControlStatusAndDisabledReason,
},
"StandardsControlAssociation": {
acctest.CtBasic: testAccStandardsControlAssociation_basic,
},
"StandardsControlAssociationsDataSource": {
acctest.CtBasic: testAccStandardsControlAssociationsDataSource_basic,
},
Expand Down
4 changes: 4 additions & 0 deletions internal/service/securityhub/service_package_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

318 changes: 318 additions & 0 deletions internal/service/securityhub/standards_control_association.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package securityhub

import (
"context"
"errors"
"fmt"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/securityhub"
awstypes "github.com/aws/aws-sdk-go-v2/service/securityhub/types"
"github.com/hashicorp/aws-sdk-go-base/v2/tfawserr"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/hashicorp/terraform-provider-aws/internal/errs/fwdiag"
autoflex "github.com/hashicorp/terraform-provider-aws/internal/flex"
"github.com/hashicorp/terraform-provider-aws/internal/framework"
"github.com/hashicorp/terraform-provider-aws/internal/framework/flex"
fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types"
tfslices "github.com/hashicorp/terraform-provider-aws/internal/slices"
"github.com/hashicorp/terraform-provider-aws/internal/tfresource"
"github.com/hashicorp/terraform-provider-aws/names"
)

// @FrameworkResource("aws_securityhub_standards_control_association", name="Standards Control Association")
func newStandardsControlAssociationResource(_ context.Context) (resource.ResourceWithConfigure, error) {
r := &standardsControlAssociationResource{}

return r, nil
}

type standardsControlAssociationResource struct {
framework.ResourceWithConfigure
framework.WithNoOpDelete
}

func (*standardsControlAssociationResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) {
response.TypeName = "aws_securityhub_standards_control_association"
}

func (r *standardsControlAssociationResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) {
response.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"association_status": schema.StringAttribute{
CustomType: fwtypes.StringEnumType[awstypes.AssociationStatus](),
Required: true,
},
names.AttrID: framework.IDAttribute(),
"security_control_id": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"standards_arn": schema.StringAttribute{
CustomType: fwtypes.ARNType,
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"updated_reason": schema.StringAttribute{
Optional: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
},
}
}

func (r *standardsControlAssociationResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) {
var data standardsControlAssociationResourceModel
response.Diagnostics.Append(request.Plan.Get(ctx, &data)...)
if response.Diagnostics.HasError() {
return
}

conn := r.Meta().SecurityHubClient(ctx)

input := &securityhub.BatchUpdateStandardsControlAssociationsInput{
StandardsControlAssociationUpdates: []awstypes.StandardsControlAssociationUpdate{
{
AssociationStatus: awstypes.AssociationStatus(data.AssociationStatus.ValueString()),
SecurityControlId: data.SecurityControlID.ValueStringPointer(),
StandardsArn: data.StandardsARN.ValueStringPointer(),
UpdatedReason: data.UpdatedReason.ValueStringPointer(),
},
},
}

output, err := conn.BatchUpdateStandardsControlAssociations(ctx, input)

if err == nil {
err = unprocessedAssociationUpdatesError(output.UnprocessedAssociationUpdates)
}

if err != nil {
response.Diagnostics.AddError("creating Standards Control Association", err.Error())

return
}

// Set values for unknowns.
data.setID()

response.Diagnostics.Append(response.State.Set(ctx, data)...)
}

func (r *standardsControlAssociationResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) {
var data standardsControlAssociationResourceModel
response.Diagnostics.Append(request.State.Get(ctx, &data)...)
if response.Diagnostics.HasError() {
return
}

if err := data.InitFromID(ctx); err != nil {
response.Diagnostics.AddError("parsing resource ID", err.Error())

return
}

conn := r.Meta().SecurityHubClient(ctx)

securityControlID, standardsARN := data.SecurityControlID.ValueString(), data.StandardsARN.ValueString()
output, err := findStandardsControlAssociationByTwoPartKey(ctx, conn, securityControlID, standardsARN)

if tfresource.NotFound(err) {
response.Diagnostics.Append(fwdiag.NewResourceNotFoundWarningDiagnostic(err))
response.State.RemoveResource(ctx)
return
}

if err != nil {
response.Diagnostics.AddError(fmt.Sprintf("reading SecurityHub Standards Control Association (%s/%s)", securityControlID, standardsARN), err.Error())

return
}

response.Diagnostics.Append(flex.Flatten(ctx, output, &data)...)
if response.Diagnostics.HasError() {
return
}

response.Diagnostics.Append(response.State.Set(ctx, &data)...)
}

func (r *standardsControlAssociationResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) {
var data standardsControlAssociationResourceModel
response.Diagnostics.Append(request.Plan.Get(ctx, &data)...)
if response.Diagnostics.HasError() {
return
}

conn := r.Meta().SecurityHubClient(ctx)

input := &securityhub.BatchUpdateStandardsControlAssociationsInput{
StandardsControlAssociationUpdates: []awstypes.StandardsControlAssociationUpdate{
{
AssociationStatus: awstypes.AssociationStatus(data.AssociationStatus.ValueString()),
SecurityControlId: data.SecurityControlID.ValueStringPointer(),
StandardsArn: data.StandardsARN.ValueStringPointer(),
UpdatedReason: data.UpdatedReason.ValueStringPointer(),
},
},
}

output, err := conn.BatchUpdateStandardsControlAssociations(ctx, input)

if err == nil {
err = unprocessedAssociationUpdatesError(output.UnprocessedAssociationUpdates)
}

if err != nil {
response.Diagnostics.AddError("updating Standards Control Association", err.Error())

return
}

response.Diagnostics.Append(response.State.Set(ctx, &data)...)
}

func (r *standardsControlAssociationResource) ValidateConfig(ctx context.Context, request resource.ValidateConfigRequest, response *resource.ValidateConfigResponse) {
var data standardsControlAssociationResourceModel
response.Diagnostics.Append(request.Config.Get(ctx, &data)...)
if response.Diagnostics.HasError() {
return
}

if data.AssociationStatus == fwtypes.StringEnumValue(awstypes.AssociationStatusEnabled) {
return
}

if !data.UpdatedReason.IsNull() {
return
}

response.Diagnostics.Append(
fwdiag.NewAttributeRequiredWhenError(
path.Root("updated_reason"),
path.Root("association_status"),
data.AssociationStatus.ValueString(),
),
)
}

type standardsControlAssociationResourceModel struct {
AssociationStatus fwtypes.StringEnum[awstypes.AssociationStatus] `tfsdk:"association_status"`
ID types.String `tfsdk:"id"`
SecurityControlID types.String `tfsdk:"security_control_id"`
StandardsARN fwtypes.ARN `tfsdk:"standards_arn"`
UpdatedReason types.String `tfsdk:"updated_reason"`
}

const (
standardsControlAssociationResourceIDPartCount = 2
)

func (m *standardsControlAssociationResourceModel) InitFromID(ctx context.Context) error {
parts, err := autoflex.ExpandResourceId(m.ID.ValueString(), standardsControlAssociationResourceIDPartCount, false)
if err != nil {
return err
}

m.SecurityControlID = types.StringValue(parts[0])
m.StandardsARN = fwtypes.ARNValue(parts[1])

return nil
}

func (m *standardsControlAssociationResourceModel) setID() {
id, _ := standardsControlAssociationCreateResourceID(m.SecurityControlID.ValueString(), m.StandardsARN.ValueString())
m.ID = types.StringValue(id)
}

func standardsControlAssociationCreateResourceID(securityControlID, standardsARN string) (string, error) {
return autoflex.FlattenResourceId([]string{securityControlID, standardsARN}, standardsControlAssociationResourceIDPartCount, false)
}

func findStandardsControlAssociationByTwoPartKey(ctx context.Context, conn *securityhub.Client, securityControlID string, standardsARN string) (*awstypes.StandardsControlAssociationSummary, error) {
input := &securityhub.ListStandardsControlAssociationsInput{
SecurityControlId: aws.String(securityControlID),
}

return findStandardsControlAssociation(ctx, conn, input, func(v *awstypes.StandardsControlAssociationSummary) bool {
return aws.ToString(v.StandardsArn) == standardsARN
})
}

func findStandardsControlAssociation(ctx context.Context, conn *securityhub.Client, input *securityhub.ListStandardsControlAssociationsInput, filter tfslices.Predicate[*awstypes.StandardsControlAssociationSummary]) (*awstypes.StandardsControlAssociationSummary, error) {
output, err := findStandardsControlAssociations(ctx, conn, input, filter)

if err != nil {
return nil, err
}

return tfresource.AssertSingleValueResult(output)
}

func findStandardsControlAssociations(ctx context.Context, conn *securityhub.Client, input *securityhub.ListStandardsControlAssociationsInput, filter tfslices.Predicate[*awstypes.StandardsControlAssociationSummary]) ([]awstypes.StandardsControlAssociationSummary, error) {
var output []awstypes.StandardsControlAssociationSummary

pages := securityhub.NewListStandardsControlAssociationsPaginator(conn, input)
for pages.HasMorePages() {
page, err := pages.NextPage(ctx)

if tfawserr.ErrCodeEquals(err, errCodeResourceNotFoundException) || tfawserr.ErrMessageContains(err, errCodeInvalidAccessException, "not subscribed to AWS Security Hub") {
return nil, &retry.NotFoundError{
LastError: err,
LastRequest: input,
}
}

if err != nil {
return nil, err
}

for _, v := range page.StandardsControlAssociationSummaries {
if filter(&v) {
output = append(output, v)
}
}
}

return output, nil
}

func unprocessedAssociationUpdatesError(apiObjects []awstypes.UnprocessedStandardsControlAssociationUpdate) error {
var errs []error

for _, apiObject := range apiObjects {
err := unprocessedAssociationUpdateError(&apiObject)
if v := apiObject.StandardsControlAssociationUpdate; v != nil {
id, _ := standardsControlAssociationCreateResourceID(aws.ToString(v.SecurityControlId), aws.ToString(v.StandardsArn))
err = fmt.Errorf("%s: %w", id, err)
}
errs = append(errs, err)
}

return errors.Join(errs...)
}

func unprocessedAssociationUpdateError(apiObject *awstypes.UnprocessedStandardsControlAssociationUpdate) error {
if apiObject == nil {
return nil
}

return fmt.Errorf("%s: %s", apiObject.ErrorCode, aws.ToString(apiObject.ErrorReason))
}
Loading
Loading