terraform-provider-stackitp.../stackit/internal/services/authorization/roleassignments/resource.go
Marcel Jacek 24b7387db9
feat: add logging for trace id (#1061)
relates to STACKITTPR-290
2025-11-27 10:06:18 +00:00

370 lines
13 KiB
Go

package roleassignments
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
authorizationUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/utils"
"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-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// List of permission assignments targets in form [TF resource name]:[api name]
var roleTargets = []string{
"project",
"organization",
}
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &roleAssignmentResource{}
_ resource.ResourceWithConfigure = &roleAssignmentResource{}
_ resource.ResourceWithImportState = &roleAssignmentResource{}
errRoleAssignmentNotFound = errors.New("response members did not contain expected role assignment")
errRoleAssignmentDuplicateFound = errors.New("found a duplicate role assignment.")
)
// Provider's internal model
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ResourceId types.String `tfsdk:"resource_id"`
Role types.String `tfsdk:"role"`
Subject types.String `tfsdk:"subject"`
}
// NewProjectRoleAssignmentResource is a helper function to simplify the provider implementation.
func NewRoleAssignmentResources() []func() resource.Resource {
resources := make([]func() resource.Resource, 0)
for _, v := range roleTargets {
resources = append(resources, func() resource.Resource {
return &roleAssignmentResource{
apiName: v,
}
})
}
return resources
}
// roleAssignmentResource is the resource implementation.
type roleAssignmentResource struct {
authorizationClient *authorization.APIClient
apiName string
}
// Metadata returns the resource type name.
func (r *roleAssignmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = fmt.Sprintf("%s_authorization_%s_role_assignment", req.ProviderTypeName, r.apiName)
}
// Configure adds the provider configured client to the resource.
func (r *roleAssignmentResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
features.CheckExperimentEnabled(ctx, &providerData, features.IamExperiment, fmt.Sprintf("stackit_authorization_%s_role_assignment", r.apiName), core.Resource, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
apiClient := authorizationUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.authorizationClient = apiClient
tflog.Info(ctx, fmt.Sprintf("Resource Manager %s Role Assignment client configured", r.apiName))
}
// Schema defines the schema for the resource.
func (r *roleAssignmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": features.AddExperimentDescription(fmt.Sprintf("%s Role Assignment resource schema.", r.apiName), features.IamExperiment, core.Resource),
"id": "Terraform's internal resource identifier. It is structured as \"[resource_id],[role],[subject]\".",
"resource_id": fmt.Sprintf("%s Resource to assign the role to.", r.apiName),
"role": "Role to be assigned",
"subject": "Identifier of user, service account or client. Usually email address or name in case of clients",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"resource_id": schema.StringAttribute{
Description: descriptions["resource_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"role": schema.StringAttribute{
Description: descriptions["role"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"subject": schema.StringAttribute{
Description: descriptions["subject"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *roleAssignmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
ctx = r.annotateLogger(ctx, &model)
if err := r.checkDuplicate(ctx, model); err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error while checking for duplicate role assignments", err.Error())
return
}
// Create new project role assignment
payload, err := r.toCreatePayload(&model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Creating API payload: %v", err))
return
}
createResp, err := r.authorizationClient.AddMembers(ctx, model.ResourceId.ValueString()).AddMembersPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error creating %s role assignment", r.apiName), fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapMembersResponse(createResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error creating %s role assignment", r.apiName), fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, fmt.Sprintf("%s role assignment created", r.apiName))
}
// Read refreshes the Terraform state with the latest data.
func (r *roleAssignmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
ctx = r.annotateLogger(ctx, &model)
listResp, err := r.authorizationClient.ListMembers(ctx, r.apiName, model.ResourceId.ValueString()).Subject(model.Subject.ValueString()).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading authorizations", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapListMembersResponse(listResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading authorization", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, fmt.Sprintf("%s role assignment read successful", r.apiName))
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *roleAssignmentResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// does nothing since resource updates should always trigger resource replacement
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *roleAssignmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
ctx = r.annotateLogger(ctx, &model)
payload := authorization.RemoveMembersPayload{
ResourceType: &r.apiName,
Members: &[]authorization.Member{
*authorization.NewMember(model.Role.ValueString(), model.Subject.ValueString()),
},
}
// Delete existing project role assignment
_, err := r.authorizationClient.RemoveMembers(ctx, model.ResourceId.ValueString()).RemoveMembersPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error deleting %s role assignment", r.apiName), fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, fmt.Sprintf("%s role assignment deleted", r.apiName))
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the project role assignment resource import identifier is: resource_id,role,subject
func (r *roleAssignmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
fmt.Sprintf("Error importing %s role assignment", r.apiName),
fmt.Sprintf("Expected import identifier with format [resource_id],[role],[subject], got %q", req.ID),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("resource_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("role"), idParts[1])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("subject"), idParts[2])...)
tflog.Info(ctx, fmt.Sprintf("%s role assignment state imported", r.apiName))
}
// Maps project role assignment fields to the provider's internal model.
func mapListMembersResponse(resp *authorization.ListMembersResponse, model *Model) error {
if resp == nil {
return fmt.Errorf("response input is nil")
}
if resp.Members == nil {
return fmt.Errorf("response members are nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
model.Id = utils.BuildInternalTerraformId(model.ResourceId.ValueString(), model.Role.ValueString(), model.Subject.ValueString())
model.ResourceId = types.StringPointerValue(resp.ResourceId)
for _, m := range *resp.Members {
if *m.Role == model.Role.ValueString() && *m.Subject == model.Subject.ValueString() {
model.Role = types.StringPointerValue(m.Role)
model.Subject = types.StringPointerValue(m.Subject)
return nil
}
}
return errRoleAssignmentNotFound
}
func mapMembersResponse(resp *authorization.MembersResponse, model *Model) error {
listMembersResponse, err := typeConverter[authorization.ListMembersResponse](resp)
if err != nil {
return err
}
return mapListMembersResponse(listMembersResponse, model)
}
// Helper to convert objects with equal JSON tags
func typeConverter[R any](data any) (*R, error) {
var result R
b, err := json.Marshal(&data)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &result)
if err != nil {
return nil, err
}
return &result, err
}
// Build Createproject role assignmentPayload from provider's model
func (r *roleAssignmentResource) toCreatePayload(model *Model) (*authorization.AddMembersPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &authorization.AddMembersPayload{
ResourceType: &r.apiName,
Members: &[]authorization.Member{
*authorization.NewMember(model.Role.ValueString(), model.Subject.ValueString()),
},
}, nil
}
func (r *roleAssignmentResource) annotateLogger(ctx context.Context, model *Model) context.Context {
resourceId := model.ResourceId.ValueString()
ctx = tflog.SetField(ctx, "resource_id", resourceId)
ctx = tflog.SetField(ctx, "subject", model.Subject.ValueString())
ctx = tflog.SetField(ctx, "role", model.Role.ValueString())
ctx = tflog.SetField(ctx, "resource_type", r.apiName)
return ctx
}
// returns an error if duplicate role assignment exists
func (r *roleAssignmentResource) checkDuplicate(ctx context.Context, model Model) error { //nolint:gocritic // A read only copy is required since an api response is parsed into the model and this check should not affect the model parameter
listResp, err := r.authorizationClient.ListMembers(ctx, r.apiName, model.ResourceId.ValueString()).Subject(model.Subject.ValueString()).Execute()
if err != nil {
return err
}
// Map response body to schema
err = mapListMembersResponse(listResp, &model)
if err != nil {
if errors.Is(err, errRoleAssignmentNotFound) {
return nil
}
return err
}
return errRoleAssignmentDuplicateFound
}