Skip to content

Commit

Permalink
Merge pull request #29099 from hashicorp/f-aws_auditmanager_assessmen…
Browse files Browse the repository at this point in the history
…t_delegate

New Resource: `aws_auditmanager_assessment_delegation`
  • Loading branch information
jar-b authored Jan 25, 2023
2 parents 2778d4c + 2210053 commit fa51917
Show file tree
Hide file tree
Showing 5 changed files with 780 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .changelog/29099.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-resource
aws_auditmanager_assessment_delegation
```
346 changes: 346 additions & 0 deletions internal/service/auditmanager/assessment_delegation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
package auditmanager

import (
"context"
"errors"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/auditmanager"
awstypes "github.com/aws/aws-sdk-go-v2/service/auditmanager/types"
"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"
sdkv2resource "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/hashicorp/terraform-provider-aws/internal/create"
"github.com/hashicorp/terraform-provider-aws/internal/enum"
"github.com/hashicorp/terraform-provider-aws/internal/flex"
"github.com/hashicorp/terraform-provider-aws/internal/framework"
"github.com/hashicorp/terraform-provider-aws/internal/tfresource"
"github.com/hashicorp/terraform-provider-aws/names"
)

func init() {
_sp.registerFrameworkResourceFactory(newResourceAssessmentDelegation)
}

func newResourceAssessmentDelegation(_ context.Context) (resource.ResourceWithConfigure, error) {
return &resourceAssessmentDelegation{}, nil
}

const (
ResNameAssessmentDelegation = "AssessmentDelegation"
)

type resourceAssessmentDelegation struct {
framework.ResourceWithConfigure
}

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

func (r *resourceAssessmentDelegation) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"assessment_id": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"comment": schema.StringAttribute{
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"control_set_id": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
// The AWS-generated ID for delegations has been observed to change between creation
// and subseqeunt read operations. As such, this value cannot be used as the resource ID
// or the input to finder functions. However, it is still required as part of the delete
// request input, so will be stored as a separate computed attribute.
"delegation_id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"id": framework.IDAttribute(),
"role_arn": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"role_type": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
enum.FrameworkValidate[awstypes.RoleType](),
},
},
"status": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}

func (r *resourceAssessmentDelegation) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
conn := r.Meta().AuditManagerClient()

var plan resourceAssessmentDelegationData
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}

delegationIn := awstypes.CreateDelegationRequest{
RoleArn: aws.String(plan.RoleARN.ValueString()),
RoleType: awstypes.RoleType(plan.RoleType.ValueString()),
ControlSetId: aws.String(plan.ControlSetID.ValueString()),
}
if !plan.Comment.IsNull() {
delegationIn.Comment = aws.String(plan.Comment.ValueString())
}
in := auditmanager.BatchCreateDelegationByAssessmentInput{
AssessmentId: aws.String(plan.AssessmentID.ValueString()),
CreateDelegationRequests: []awstypes.CreateDelegationRequest{delegationIn},
}

// Include retry handling to allow for IAM propagation
//
// Example:
// ResourceNotFoundException: The operation tried to access a nonexistent resource. The resource
// might not be specified correctly, or its status might not be active. Check and try again.
var out *auditmanager.BatchCreateDelegationByAssessmentOutput
err := tfresource.Retry(ctx, iamPropagationTimeout, func() *sdkv2resource.RetryError {
var err error
out, err = conn.BatchCreateDelegationByAssessment(ctx, &in)
if err != nil {
var nfe *awstypes.ResourceNotFoundException
if errors.As(err, &nfe) {
return sdkv2resource.RetryableError(err)
}
return sdkv2resource.NonRetryableError(err)
}

return nil
})
if err != nil {
resp.Diagnostics.AddError(
create.ProblemStandardMessage(names.AuditManager, create.ErrActionCreating, ResNameAssessmentDelegation, plan.RoleARN.String(), nil),
err.Error(),
)
return
}
if out == nil || len(out.Delegations) == 0 {
resp.Diagnostics.AddError(
create.ProblemStandardMessage(names.AuditManager, create.ErrActionCreating, ResNameAssessmentDelegation, plan.RoleARN.String(), nil),
errors.New("empty output").Error(),
)
return
}

// This response object will return ALL delegations assigned to the assessment, not just those
// added in this batch request. In order to write to state, the response should be filtered to
// the item with a matching role_arn and control_set_id.
//
// Also, assessment_id is returned as null in the BatchCreateDelegationByAssessment response
// object, and therefore is not included as one of the matching parameters.
delegation, err := getMatchingDelegation(out.Delegations, plan.RoleARN.ValueString(), plan.ControlSetID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
create.ProblemStandardMessage(names.AuditManager, create.ErrActionCreating, ResNameAssessmentDelegation, plan.RoleARN.String(), nil),
err.Error(),
)
return
}

state := plan

// The AWS-generated ID for delegations has been observed to change between creation
// and subseqeunt read operations. As such, the ID attribute will use a combination of
// attributes that are unique to a single delegation instead.
id := toID(plan.AssessmentID.ValueString(), plan.RoleARN.ValueString(), plan.ControlSetID.ValueString())
state.ID = types.StringValue(id)

state.refreshFromOutput(ctx, delegation)
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
}

func (r *resourceAssessmentDelegation) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
conn := r.Meta().AuditManagerClient()

var state resourceAssessmentDelegationData
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

out, err := FindAssessmentDelegationByID(ctx, conn, state.ID.ValueString())
if tfresource.NotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.AddError(
create.ProblemStandardMessage(names.AuditManager, create.ErrActionReading, ResNameAssessmentDelegation, state.ID.String(), nil),
err.Error(),
)
return
}

state.refreshFromOutputMetadata(ctx, out)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

// There is no update API, so this method is a no-op
func (r *resourceAssessmentDelegation) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
}

func (r *resourceAssessmentDelegation) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
conn := r.Meta().AuditManagerClient()

var state resourceAssessmentDelegationData
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

_, err := conn.BatchDeleteDelegationByAssessment(ctx, &auditmanager.BatchDeleteDelegationByAssessmentInput{
AssessmentId: aws.String(state.AssessmentID.ValueString()),
DelegationIds: []string{state.DelegationID.ValueString()},
})
if err != nil {
var nfe *awstypes.ResourceNotFoundException
if errors.As(err, &nfe) {
return
}
resp.Diagnostics.AddError(
create.ProblemStandardMessage(names.AuditManager, create.ErrActionDeleting, ResNameAssessmentDelegation, state.ID.String(), nil),
err.Error(),
)
}
}

func (r *resourceAssessmentDelegation) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

func FindAssessmentDelegationByID(ctx context.Context, conn *auditmanager.Client, id string) (*awstypes.DelegationMetadata, error) {
assessmentID, roleARN, controlSetID := fromID(id)

// The GetDelegations API behaves like a List* API, so the results are paged
// through until an entry with a matching ID is found
in := &auditmanager.GetDelegationsInput{}
pages := auditmanager.NewGetDelegationsPaginator(conn, in)

for pages.HasMorePages() {
page, err := pages.NextPage(ctx)
if err != nil {
return nil, err
}

for _, d := range page.Delegations {
if aws.ToString(d.AssessmentId) == assessmentID &&
strings.EqualFold(aws.ToString(d.RoleArn), roleARN) && // IAM role names are case-insensitive
aws.ToString(d.ControlSetName) == controlSetID {
return &d, nil
}
}
}

return nil, &sdkv2resource.NotFoundError{
LastRequest: in,
}
}

// getMatchingDelegation will return the delegation matching the provided role ARN and
// control set ID. If no match is found, an error is returned.
func getMatchingDelegation(out []awstypes.Delegation, roleARN, controlSetID string) (*awstypes.Delegation, error) {
for _, d := range out {
if strings.EqualFold(aws.ToString(d.RoleArn), roleARN) && // IAM role names are case-insensitive
aws.ToString(d.ControlSetId) == controlSetID {
return &d, nil
}
}
return nil, errors.New("no matching delegations in response")
}

func fromID(id string) (string, string, string) {
parts := strings.Split(id, ",")
if len(parts) != 3 {
return "", "", ""
}
return parts[0], parts[1], parts[2]
}

func toID(assessmentID, roleARN, controlSetID string) string {
return strings.Join([]string{assessmentID, roleARN, controlSetID}, ",")
}

type resourceAssessmentDelegationData struct {
AssessmentID types.String `tfsdk:"assessment_id"`
Comment types.String `tfsdk:"comment"`
ControlSetID types.String `tfsdk:"control_set_id"`
DelegationID types.String `tfsdk:"delegation_id"`
ID types.String `tfsdk:"id"`
RoleARN types.String `tfsdk:"role_arn"`
RoleType types.String `tfsdk:"role_type"`
Status types.String `tfsdk:"status"`
}

// refreshFromOutput writes state data from an AWS response object
//
// This variant of the refresh method is for use with the create operation
// response type (Delegation).
func (rd *resourceAssessmentDelegationData) refreshFromOutput(ctx context.Context, out *awstypes.Delegation) {
if out == nil {
return
}

// The response from create operations always includes a nil AssessmentId. This is likely
// a bug in the AWS API, so for now skip using the response output and copy the state
// value directly from plan.
// rd.AssessmentID = flex.StringToFramework(ctx, out.AssessmentId)

rd.Comment = flex.StringToFramework(ctx, out.Comment)
rd.ControlSetID = flex.StringToFramework(ctx, out.ControlSetId)
rd.DelegationID = flex.StringToFramework(ctx, out.Id)
rd.RoleARN = flex.StringToFramework(ctx, out.RoleArn)
rd.RoleType = flex.StringValueToFramework(ctx, out.RoleType)
rd.Status = flex.StringValueToFramework(ctx, out.Status)
}

// refreshFromOutputMetadata writes state data from an AWS response object
//
// This variant of the refresh method is for use with the get operation
// response type (DelegationMetadata). Notably, this response omits certain
// attributes such as comment, control_set_id, and role_type which means
// drift cannot be detected after the initial create action.
func (rd *resourceAssessmentDelegationData) refreshFromOutputMetadata(ctx context.Context, out *awstypes.DelegationMetadata) {
if out == nil {
return
}

rd.AssessmentID = flex.StringToFramework(ctx, out.AssessmentId)
rd.DelegationID = flex.StringToFramework(ctx, out.Id)
rd.RoleARN = flex.StringToFramework(ctx, out.RoleArn)
rd.Status = flex.StringValueToFramework(ctx, out.Status)
}
Loading

0 comments on commit fa51917

Please sign in to comment.