internal/provider/resource_gitlab_member_role.go (472 lines of code) (raw):
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
"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/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/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
gitlab "gitlab.com/gitlab-org/api/client-go"
"gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/api"
"gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/utils"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &gitlabMemberRoleResource{}
_ resource.ResourceWithConfigure = &gitlabMemberRoleResource{}
_ resource.ResourceWithImportState = &gitlabMemberRoleResource{}
_ resource.ResourceWithModifyPlan = &gitlabMemberRoleResource{}
)
func init() {
registerResource(NewGitLabMemberRoleResource)
}
func NewGitLabMemberRoleResource() resource.Resource {
return &gitlabMemberRoleResource{}
}
type gitlabMemberRoleResource struct {
client *gitlab.Client
}
type gitlabMemberRoleResourceModel struct {
Id types.String `tfsdk:"id"`
Iid types.Int64 `tfsdk:"iid"`
GroupPath types.String `tfsdk:"group_path"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
EditPath types.String `tfsdk:"edit_path"`
CreatedAt types.String `tfsdk:"created_at"`
BaseAccessLevel types.String `tfsdk:"base_access_level"`
EnabledPermissions []types.String `tfsdk:"enabled_permissions"`
}
// Metadata returns the resource name
func (d *gitlabMemberRoleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_member_role"
}
func (r *gitlabMemberRoleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
// The API requires these to be in all uppercase, which is why they're done this way even though it's inconsistent
// with other similar access levels in the provider.
allowedBaseAccessLevels := []string{"DEVELOPER", "GUEST", "MAINTAINER", "MINIMAL_ACCESS", "OWNER", "REPORTER"}
// similarly, these are also required to all be in uppercase.
allowedEnabledPermissions := []string{
"ADMIN_CICD_VARIABLES", "ADMIN_COMPLIANCE_FRAMEWORK", "ADMIN_GROUP_MEMBER",
"ADMIN_INTEGRATIONS", "ADMIN_MERGE_REQUEST", "ADMIN_PROTECTED_BRANCH", "ADMIN_PUSH_RULES", "ADMIN_RUNNERS", "ADMIN_TERRAFORM_STATE",
"ADMIN_VULNERABILITY", "ADMIN_WEB_HOOK", "ARCHIVE_PROJECT", "MANAGE_DEPLOY_TOKENS", "MANAGE_GROUP_ACCESS_TOKENS",
"MANAGE_MERGE_REQUEST_SETTINGS", "MANAGE_PROJECT_ACCESS_TOKENS", "MANAGE_SECURITY_POLICY_LINK", "READ_ADMIN_CICD", "READ_ADMIN_DASHBOARD",
"READ_CODE", "READ_COMPLIANCE_DASHBOARD", "READ_CRM_CONTACT", "READ_DEPENDENCY", "READ_RUNNERS", "READ_VULNERABILITY", "REMOVE_GROUP",
"REMOVE_PROJECT",
}
resp.Schema = schema.Schema{
MarkdownDescription: `The ` + "`gitlab_member_role`" + ` resource allows to manage the lifecycle of a custom member role.
Custom roles allow an organization to create user roles with the precise privileges and permissions required for that organization’s needs.
-> This resource requires an Ultimate license.
-> Most custom roles are considered billable users that use a seat. [Custom roles billing and seat usage](https://docs.gitlab.com/user/custom_roles/#billing-and-seat-usage)
-> There can be only 10 custom roles on your instance or namespace. See [issue 450929](https://gitlab.com/gitlab-org/gitlab/-/issues/450929) for more details.
**Upstream API**: [GitLab GraphQL API docs](https://docs.gitlab.com/api/graphql/reference/#mutationmemberrolecreate)`,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "Globally unique ID of the member role. In the format of `gid://gitlab/MemberRole/1`",
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"iid": schema.Int64Attribute{
MarkdownDescription: "The id integer value extracted from the `id` attribute",
Computed: true,
PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()},
},
"group_path": schema.StringAttribute{
MarkdownDescription: "Full path of the namespace to create the member role in. **Required for SAAS** **Not allowed for self-managed**",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"name": schema.StringAttribute{
MarkdownDescription: "Name for the member role.",
Required: true,
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"description": schema.StringAttribute{
MarkdownDescription: "Description for the member role.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"edit_path": schema.StringAttribute{
MarkdownDescription: "The Web UI path to edit the member role",
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"created_at": schema.StringAttribute{
MarkdownDescription: "Timestamp of when the member role was created.",
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"base_access_level": schema.StringAttribute{
MarkdownDescription: fmt.Sprintf("The base access level for the custom role. Valid values are: %s", utils.RenderValueListForDocs(allowedBaseAccessLevels)),
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Validators: []validator.String{stringvalidator.OneOf(allowedBaseAccessLevels...)},
},
"enabled_permissions": schema.SetAttribute{
MarkdownDescription: fmt.Sprintf("All permissions enabled for the custom role. Valid values are: %s", utils.RenderValueListForDocs(allowedEnabledPermissions)),
Required: true,
ElementType: types.StringType,
Validators: []validator.Set{setvalidator.ValueStringsAre(stringvalidator.OneOf(allowedEnabledPermissions...))},
},
},
}
}
// Configure adds the provider configured client to the resource.
func (r *gitlabMemberRoleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
resourceData := req.ProviderData.(*GitLabResourceData)
r.client = resourceData.Client
}
// Use the `ModifyPlan` to determine if Gitlab instance is self-hosted vs SaaS.
// If instance is SaaS, group_path is required. If instance is self-hosted, group_path is not permitted.
func (r *gitlabMemberRoleResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
// Retrieve the plan data to start with
var planData *gitlabMemberRoleResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...)
if planData == nil {
// Log a note that there is no plan data, usually because we're importing.
tflog.Debug(ctx, "Plan data is nil, no check for instance type needed")
return
}
if len(r.client.BaseURL().Host) == 0 || r.client.BaseURL().Host == "gitlab.com" {
if planData.GroupPath.IsNull() || planData.GroupPath.ValueString() == "" {
resp.Diagnostics.AddAttributeError(path.Root("group_path"), "Missing Attribute", "`group_path` is required when using GitLab SaaS")
}
} else {
if !planData.GroupPath.IsNull() && planData.GroupPath.ValueString() != "" {
resp.Diagnostics.AddAttributeError(path.Root("group_path"), "Attribute Not Permitted", "`group_path` is not allowed when using GitLab self-managed")
}
}
}
func (r *gitlabMemberRoleResource) memberRoleToStateModel(response *MemberRole, groupPath string, data *gitlabMemberRoleResourceModel) {
data.Id = types.StringValue(response.ID)
data.Name = types.StringValue(response.Name)
data.Description = types.StringValue(response.Description)
data.EditPath = types.StringValue(response.EditPath)
data.CreatedAt = types.StringValue(response.CreatedAt)
data.BaseAccessLevel = types.StringValue(response.BaseAccessLevel.StringValue)
data.GroupPath = types.StringValue(groupPath)
enabledPermissions := []basetypes.StringValue{}
for _, v := range response.EnabledPermissions.Nodes {
enabledPermissions = append(enabledPermissions, types.StringValue(v.Value))
}
data.EnabledPermissions = enabledPermissions
iid, _ := api.ExtractIIDFromGlobalID(response.ID)
data.Iid = types.Int64Value(int64(iid))
}
// Read refreshes the Terraform state with the latest data.
func (r *gitlabMemberRoleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data *gitlabMemberRoleResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// read all information for refresh from resource id
id := data.Id.ValueString()
groupPath := data.GroupPath.ValueString()
query := gitlab.GraphQLQuery{
Query: fmt.Sprintf(`
query {
memberRole(id: "%s") {
baseAccessLevel {
stringValue
},
createdAt,
description,
editPath,
enabledPermissions {
nodes {
value
}
},
id,
name,
}
}`, id),
}
tflog.Debug(ctx, "executing GraphQL Query to retrieve current custom member role", map[string]any{
"query": query.Query,
})
var response MemberRoleResponse
if _, err := r.client.GraphQL.Do(ctx, query, &response); err != nil {
if response.Data.MemberRole.ID == "" {
tflog.Debug(ctx, "member role does not exist, removing from state", map[string]any{
"id": id,
})
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to read member role details: %s", err.Error()))
return
}
// persist API response in state model
r.memberRoleToStateModel(&response.Data.MemberRole, groupPath, data)
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
// Create creates a new upstream resource and adds it into the Terraform state.
func (r *gitlabMemberRoleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data *gitlabMemberRoleResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
name := data.Name.ValueString()
description := data.Description.ValueString()
baseAccessLevel := data.BaseAccessLevel.ValueString()
groupPath := data.GroupPath.ValueString()
var permissions []string
for _, v := range data.EnabledPermissions {
permissions = append(permissions, v.ValueString())
}
// If SaaS instance, include groupPath
groupPathQuery := ""
if len(r.client.BaseURL().Host) == 0 || r.client.BaseURL().Host == "gitlab.com" {
groupPathQuery = fmt.Sprintf(`groupPath: "%s",`, groupPath)
}
query := gitlab.GraphQLQuery{
Query: fmt.Sprintf(`
mutation {
memberRoleCreate(
input: {
%s
name: "%s",
description: "%s",
baseAccessLevel: %s,
permissions: %s
}
) {
memberRole {
baseAccessLevel {
stringValue
},
createdAt,
description,
editPath,
enabledPermissions {
nodes {
value
}
},
id,
name
}
errors
}
}`, groupPathQuery, name, description, baseAccessLevel, permissions),
}
tflog.Debug(ctx, "executing GraphQL Query to create custom member role", map[string]any{
"query": query.Query,
})
var response createMemberRoleResponse
if _, err := r.client.GraphQL.Do(ctx, query, &response); err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to create custom member role: %s", err.Error()))
return
}
// check response for errors
var allerr string
if len(response.Errors) > 0 {
for i, err := range response.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err.Message)
}
}
if len(response.Data.MemberRoleCreate.Errors) > 0 {
for i, err := range response.Data.MemberRoleCreate.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err)
}
}
if len(allerr) > 0 {
resp.Diagnostics.AddError("GitLab GraphQL error occurred", allerr)
return
}
// persist API response in state model
r.memberRoleToStateModel(&response.Data.MemberRoleCreate.MemberRole, groupPath, data)
// Log the creation of the resource
tflog.Debug(ctx, "created a custom member role", map[string]any{
"id": data.Id.ValueString(), "name": data.Name.ValueString(), "description": data.Description.ValueString(), "group_path": data.GroupPath.ValueString(),
})
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
// Delete removes the resource.
func (r *gitlabMemberRoleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data *gitlabMemberRoleResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
id := data.Id.ValueString()
query := gitlab.GraphQLQuery{
Query: fmt.Sprintf(`
mutation {
memberRoleDelete(
input: {
id: "%s"
}
) {
errors
}
}`, id),
}
tflog.Debug(ctx, "executing GraphQL Query to delete custom member role", map[string]any{
"query": query.Query,
})
var response deleteMemberRoleResponse
if _, err := r.client.GraphQL.Do(ctx, query, &response); err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to delete custom member role: %s", err.Error()))
return
}
// check response for errors
var allerr string
if len(response.Errors) > 0 {
for i, err := range response.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err.Message)
}
}
if len(response.Data.MemberRoleDelete.Errors) > 0 {
for i, err := range response.Data.MemberRoleDelete.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err)
}
}
if len(allerr) > 0 {
resp.Diagnostics.AddError("GitLab GraphQL error occurred", allerr)
return
}
}
// Update updates the resource in-place.
func (r *gitlabMemberRoleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data *gitlabMemberRoleResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
id := data.Id.ValueString()
groupPath := data.GroupPath.ValueString()
name := data.Name.ValueString()
description := data.Description.ValueString()
var permissions []string
for _, v := range data.EnabledPermissions {
permissions = append(permissions, v.ValueString())
}
query := gitlab.GraphQLQuery{
Query: fmt.Sprintf(`
mutation {
memberRoleUpdate(
input: {
id: "%s",
name: "%s",
description: "%s",
permissions: %v
}
) {
memberRole {
baseAccessLevel {
stringValue
},
createdAt,
description,
editPath,
enabledPermissions {
nodes {
value
}
},
id,
name
}
errors
}
}`, id, name, description, permissions),
}
tflog.Debug(ctx, "executing GraphQL Query to update custom member role", map[string]any{
"query": query.Query,
})
var response updateMemberRoleResponse
if _, err := r.client.GraphQL.Do(ctx, query, &response); err != nil {
resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to update custom member role: %s", err.Error()))
return
}
// check response for errors
var allerr string
if len(response.Errors) > 0 {
for i, err := range response.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err.Message)
}
}
if len(response.Data.MemberRoleUpdate.Errors) > 0 {
for i, err := range response.Data.MemberRoleUpdate.Errors {
allerr += fmt.Sprintf("Error %d Message: %s\n", i, err)
}
}
if len(allerr) > 0 {
resp.Diagnostics.AddError("GitLab GraphQL error occurred", allerr)
return
}
// persist API response in state model
r.memberRoleToStateModel(&response.Data.MemberRoleUpdate.MemberRole, groupPath, data)
// Log the update of the resource
tflog.Debug(ctx, "updated a custom member role", map[string]any{
"id": data.Id.ValueString(), "name": data.Name.ValueString(), "description": data.Description.ValueString(), "group_path": data.GroupPath.ValueString(),
})
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabMemberRoleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
type MemberRole struct {
BaseAccessLevel struct {
StringValue string `json:"stringValue"`
} `json:"baseAccessLevel"`
CreatedAt string `json:"createdAt"`
Description string `json:"description"`
EditPath string `json:"editPath"`
EnabledPermissions struct {
Nodes []struct {
Value string `json:"value"`
} `json:"nodes"`
} `json:"enabledPermissions"`
ID string `json:"id"`
Name string `json:"name"`
}
type MemberRoleResponse struct {
Data struct {
MemberRole MemberRole `json:"memberRole"`
} `json:"data"`
}
type createMemberRoleResponse struct {
Data struct {
MemberRoleCreate struct {
MemberRole MemberRole `json:"memberRole"`
Errors []string `json:"errors"`
} `json:"memberRoleCreate"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
Path []string `json:"path"`
} `json:"errors"`
}
type updateMemberRoleResponse struct {
Data struct {
MemberRoleUpdate struct {
MemberRole MemberRole `json:"memberRole"`
Errors []string `json:"errors"`
} `json:"memberRoleUpdate"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
Path []string `json:"path"`
} `json:"errors"`
}
type deleteMemberRoleResponse struct {
Data struct {
MemberRoleDelete struct {
MemberRole MemberRole `json:"memberRole"`
Errors []string `json:"errors"`
} `json:"memberRoleDelete"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
Path []string `json:"path"`
} `json:"errors"`
}