package provider import ( "context" "fmt" "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/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/quorate/terraform-provider-versitygw/internal/client" ) var ( _ resource.Resource = (*UserResource)(nil) _ resource.ResourceWithConfigure = (*UserResource)(nil) ) func NewUserResource() resource.Resource { return &UserResource{} } type UserResource struct { client *client.Client } type userModel struct { ID types.String `tfsdk:"id"` AccessKeyID types.String `tfsdk:"access_key_id"` SecretAccessKey types.String `tfsdk:"secret_access_key"` Role types.String `tfsdk:"role"` UserID types.Int64 `tfsdk:"user_id"` GroupID types.Int64 `tfsdk:"group_id"` ProjectID types.Int64 `tfsdk:"project_id"` } func (r *UserResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_user" } func (r *UserResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "VersityGW IAM user managed through the VersityGW admin API.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, Description: "User access key ID.", }, "access_key_id": schema.StringAttribute{ Required: true, Description: "VersityGW username/access key ID.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "secret_access_key": schema.StringAttribute{ Required: true, Sensitive: true, WriteOnly: true, Description: "VersityGW user secret access key.", }, "role": schema.StringAttribute{ Optional: true, Computed: true, Description: "VersityGW user role: user, userplus, or admin. Defaults to user.", }, "user_id": schema.Int64Attribute{ Optional: true, Computed: true, Description: "Optional POSIX user ID stored on the VersityGW user.", PlanModifiers: []planmodifier.Int64{ int64planmodifier.UseStateForUnknown(), }, }, "group_id": schema.Int64Attribute{ Optional: true, Computed: true, Description: "Optional POSIX group ID stored on the VersityGW user.", PlanModifiers: []planmodifier.Int64{ int64planmodifier.UseStateForUnknown(), }, }, "project_id": schema.Int64Attribute{ Optional: true, Computed: true, Description: "Optional project ID stored on the VersityGW user.", PlanModifiers: []planmodifier.Int64{ int64planmodifier.UseStateForUnknown(), }, }, }, } } func (r *UserResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } api, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("Expected *client.Client, got %T", req.ProviderData)) return } r.client = api } func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan userModel var config userModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } if !validateUser(plan, config.SecretAccessKey, &resp.Diagnostics) { return } role := valueOrDefault(plan.Role, "user") account := client.Account{ Access: plan.AccessKeyID.ValueString(), Secret: config.SecretAccessKey.ValueString(), Role: role, UserID: int64OrZero(plan.UserID), GroupID: int64OrZero(plan.GroupID), ProjectID: int64OrZero(plan.ProjectID), } if err := r.client.CreateUser(ctx, account); err != nil { if !client.IsErrorCode(err, "XAdminUserExists") { resp.Diagnostics.AddError("Create VersityGW user failed", err.Error()) return } secret := account.Secret props := client.MutableProps{ Secret: &secret, Role: account.Role, UserID: &account.UserID, GroupID: &account.GroupID, ProjectID: &account.ProjectID, } if err := r.client.UpdateUser(ctx, account.Access, props); err != nil { resp.Diagnostics.AddError("Adopt existing VersityGW user failed", err.Error()) return } } plan.ID = plan.AccessKeyID plan.Role = types.StringValue(role) plan.UserID = types.Int64Value(account.UserID) plan.GroupID = types.Int64Value(account.GroupID) plan.ProjectID = types.Int64Value(account.ProjectID) plan.SecretAccessKey = types.StringNull() resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state userModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } account, found, err := r.client.GetUser(ctx, state.AccessKeyID.ValueString()) if err != nil { resp.Diagnostics.AddError("Read VersityGW user failed", err.Error()) return } if !found { resp.State.RemoveResource(ctx) return } state.ID = types.StringValue(account.Access) state.AccessKeyID = types.StringValue(account.Access) state.Role = types.StringValue(account.Role) state.UserID = types.Int64Value(account.UserID) state.GroupID = types.Int64Value(account.GroupID) state.ProjectID = types.Int64Value(account.ProjectID) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan userModel var state userModel var config userModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) resp.Diagnostics.Append(req.State.Get(ctx, &state)...) resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } if !validateUser(plan, config.SecretAccessKey, &resp.Diagnostics) { return } role := valueOrDefault(plan.Role, "user") secret := config.SecretAccessKey.ValueString() userID := int64OrZero(plan.UserID) groupID := int64OrZero(plan.GroupID) projectID := int64OrZero(plan.ProjectID) props := client.MutableProps{ Secret: &secret, Role: role, UserID: &userID, GroupID: &groupID, ProjectID: &projectID, } if err := r.client.UpdateUser(ctx, state.AccessKeyID.ValueString(), props); err != nil { resp.Diagnostics.AddError("Update VersityGW user failed", err.Error()) return } plan.ID = plan.AccessKeyID plan.Role = types.StringValue(role) plan.UserID = types.Int64Value(userID) plan.GroupID = types.Int64Value(groupID) plan.ProjectID = types.Int64Value(projectID) plan.SecretAccessKey = types.StringNull() resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state userModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteUser(ctx, state.AccessKeyID.ValueString()); err != nil { resp.Diagnostics.AddError("Delete VersityGW user failed", err.Error()) return } } func validateUser(model userModel, secretAccessKey types.String, diags interface { AddAttributeError(path.Path, string, string) }) bool { ok := true if model.AccessKeyID.IsNull() || model.AccessKeyID.IsUnknown() || model.AccessKeyID.ValueString() == "" { diags.AddAttributeError(path.Root("access_key_id"), "Invalid access key ID", "access_key_id must be non-empty.") ok = false } if secretAccessKey.IsNull() || secretAccessKey.IsUnknown() || secretAccessKey.ValueString() == "" { diags.AddAttributeError(path.Root("secret_access_key"), "Invalid secret access key", "secret_access_key must be non-empty.") ok = false } role := valueOrDefault(model.Role, "user") if role != "user" && role != "userplus" && role != "admin" { diags.AddAttributeError(path.Root("role"), "Invalid role", "role must be one of: user, userplus, admin.") ok = false } if isNegative(model.UserID) { diags.AddAttributeError(path.Root("user_id"), "Invalid user ID", "user_id must be non-negative.") ok = false } if isNegative(model.GroupID) { diags.AddAttributeError(path.Root("group_id"), "Invalid group ID", "group_id must be non-negative.") ok = false } if isNegative(model.ProjectID) { diags.AddAttributeError(path.Root("project_id"), "Invalid project ID", "project_id must be non-negative.") ok = false } return ok } func valueOrDefault(value types.String, fallback string) string { if value.IsNull() || value.IsUnknown() || value.ValueString() == "" { return fallback } return value.ValueString() } func int64OrZero(value types.Int64) int64 { if value.IsNull() || value.IsUnknown() { return 0 } return value.ValueInt64() } func isNegative(value types.Int64) bool { return !value.IsNull() && !value.IsUnknown() && value.ValueInt64() < 0 }