internal/provider/sdk/resource_gitlab_user.go (285 lines of code) (raw):
package sdk
import (
"context"
"fmt"
"strconv"
"time"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
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"
)
var validUserStateValues = []string{
"active",
"deactivated",
"blocked",
}
var _ = registerResource("gitlab_user", func() *schema.Resource {
return &schema.Resource{
Description: `The ` + "`gitlab_user`" + ` resource allows to manage the lifecycle of a user.
-> the provider needs to be configured with admin-level access for this resource to work.
-> You must specify either password or reset_password.
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/users/)`,
CreateContext: resourceGitlabUserCreate,
ReadContext: resourceGitlabUserRead,
UpdateContext: resourceGitlabUserUpdate,
DeleteContext: resourceGitlabUserDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"username": {
Description: "The username of the user.",
Type: schema.TypeString,
Required: true,
},
"password": {
Description: "The password of the user.",
Type: schema.TypeString,
Optional: true,
Sensitive: true,
ForceNew: true,
ConflictsWith: []string{
"force_random_password",
},
},
"force_random_password": {
Description: "Set user password to a random value",
Type: schema.TypeBool,
Optional: true,
ForceNew: true,
ConflictsWith: []string{
"password",
},
},
"email": {
Description: "The e-mail address of the user.",
Type: schema.TypeString,
Required: true,
},
"name": {
Description: "The name of the user.",
Type: schema.TypeString,
Required: true,
},
"is_admin": {
Description: "Boolean, defaults to false. Whether to enable administrative privileges",
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"can_create_group": {
Description: "Boolean, defaults to false. Whether to allow the user to create groups.",
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"skip_confirmation": {
Description: "Boolean, defaults to true. Whether to skip confirmation.",
Type: schema.TypeBool,
Optional: true,
Default: true,
ForceNew: true,
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
// Skip Confirmation doesn't come back from the API, so this will always return a diff unless this skips.
// Should only return true on non-create actions (I.e., after the ID is set). Otherwise the schema always
// gets "false" instead of whatever is set.
return d.Id() != ""
},
},
"projects_limit": {
Description: "Integer, defaults to 0. Number of projects user can create.",
Type: schema.TypeInt,
Optional: true,
Default: 0,
},
"is_external": {
Description: "Boolean, defaults to false. Whether a user has access only to some internal or private projects. External users can only access projects to which they are explicitly granted access.",
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"reset_password": {
Description: "Boolean, defaults to false. Send user password reset link.",
Type: schema.TypeBool,
Optional: true,
ForceNew: true,
},
"note": {
Description: "The note associated to the user.",
Type: schema.TypeString,
Optional: true,
},
"state": {
Description: fmt.Sprintf("String, defaults to 'active'. The state of the user account. Valid values are %s.", utils.RenderValueListForDocs(validUserStateValues)),
Type: schema.TypeString,
Optional: true,
Default: "active",
ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(validUserStateValues, false)),
},
"namespace_id": {
Description: "The ID of the user's namespace.",
Type: schema.TypeInt,
Optional: true,
Computed: true,
},
},
}
})
func resourceGitlabUserSetToState(d *schema.ResourceData, user *gitlab.User) {
d.Set("username", user.Username)
d.Set("name", user.Name)
d.Set("can_create_group", user.CanCreateGroup)
d.Set("projects_limit", user.ProjectsLimit)
d.Set("email", user.Email)
d.Set("is_admin", user.IsAdmin)
d.Set("is_external", user.External)
d.Set("note", user.Note)
d.Set("state", user.State)
d.Set("namespace_id", user.NamespaceID)
}
func resourceGitlabUserCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*gitlab.Client)
options := &gitlab.CreateUserOptions{
Email: gitlab.Ptr(d.Get("email").(string)),
Username: gitlab.Ptr(d.Get("username").(string)),
Name: gitlab.Ptr(d.Get("name").(string)),
ProjectsLimit: gitlab.Ptr(d.Get("projects_limit").(int)),
Admin: gitlab.Ptr(d.Get("is_admin").(bool)),
CanCreateGroup: gitlab.Ptr(d.Get("can_create_group").(bool)),
SkipConfirmation: gitlab.Ptr(d.Get("skip_confirmation").(bool)),
External: gitlab.Ptr(d.Get("is_external").(bool)),
ResetPassword: gitlab.Ptr(d.Get("reset_password").(bool)),
ForceRandomPassword: gitlab.Ptr(d.Get("force_random_password").(bool)),
Note: gitlab.Ptr(d.Get("note").(string)),
}
if len(d.Get("password").(string)) != 0 {
options.Password = gitlab.Ptr(d.Get("password").(string))
}
// Validate the options set
if (options.Password == nil || *options.Password == "") &&
(options.ResetPassword == nil || !*options.ResetPassword) &&
(options.ForceRandomPassword == nil || !*options.ForceRandomPassword) {
return diag.Errorf(`At least one of "password", "reset_password", or "force_random_password" must be set`)
}
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] create gitlab user %q", *options.Username))
user, _, err := client.Users.CreateUser(options, gitlab.WithContext(ctx))
if err != nil {
return diag.FromErr(err)
}
d.SetId(fmt.Sprintf("%d", user.ID))
if d.Get("state") == "blocked" {
err := client.Users.BlockUser(user.ID, gitlab.WithContext(ctx))
if err != nil {
return diag.FromErr(err)
}
} else if d.Get("state") == "deactivated" {
err := client.Users.DeactivateUser(user.ID, gitlab.WithContext(ctx))
if err != nil {
return diag.FromErr(err)
}
}
return resourceGitlabUserRead(ctx, d, meta)
}
func resourceGitlabUserRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*gitlab.Client)
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] import -- read gitlab user %s", d.Id()))
id, _ := strconv.Atoi(d.Id())
user, _, err := client.Users.GetUser(id, gitlab.GetUsersOptions{}, gitlab.WithContext(ctx))
if err != nil {
if api.Is404(err) {
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] gitlab user not found %d", id))
d.SetId("")
return nil
}
return diag.FromErr(err)
}
resourceGitlabUserSetToState(d, user)
return nil
}
func resourceGitlabUserUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*gitlab.Client)
options := &gitlab.ModifyUserOptions{}
if d.HasChange("name") {
options.Name = gitlab.Ptr(d.Get("name").(string))
}
if d.HasChange("username") {
options.Username = gitlab.Ptr(d.Get("username").(string))
}
if d.HasChange("email") {
options.Email = gitlab.Ptr(d.Get("email").(string))
options.SkipReconfirmation = gitlab.Ptr(true)
}
if d.HasChange("is_admin") {
options.Admin = gitlab.Ptr(d.Get("is_admin").(bool))
}
if d.HasChange("can_create_group") {
options.CanCreateGroup = gitlab.Ptr(d.Get("can_create_group").(bool))
}
if d.HasChange("projects_limit") {
options.ProjectsLimit = gitlab.Ptr(d.Get("projects_limit").(int))
}
if d.HasChange("is_external") {
options.External = gitlab.Ptr(d.Get("is_external").(bool))
}
if d.HasChange("note") {
options.Note = gitlab.Ptr(d.Get("note").(string))
}
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] update gitlab user %s", d.Id()))
id, _ := strconv.Atoi(d.Id())
_, _, err := client.Users.ModifyUser(id, options, gitlab.WithContext(ctx))
if err != nil {
return diag.FromErr(err)
}
if d.HasChange("state") {
oldState, newState := d.GetChange("state")
var err error
// NOTE: yes, this can be written much more consice, however, for the sake of understanding the behavior,
// of the API and the allowed state transitions of GitLab, let's keep it as-is and enjoy the readability.
if newState == "active" && oldState == "blocked" {
err = client.Users.UnblockUser(id, gitlab.WithContext(ctx))
} else if newState == "active" && oldState == "deactivated" {
err = client.Users.ActivateUser(id, gitlab.WithContext(ctx))
} else if newState == "blocked" && oldState == "active" {
err = client.Users.BlockUser(id, gitlab.WithContext(ctx))
} else if newState == "blocked" && oldState == "deactivated" {
err = client.Users.BlockUser(id, gitlab.WithContext(ctx))
} else if newState == "deactivated" && oldState == "active" {
err = client.Users.DeactivateUser(id, gitlab.WithContext(ctx))
} else if newState == "deactivated" && oldState == "blocked" {
// a blocked user cannot be deactivated, GitLab will return an error, like:
// `403 Forbidden - A blocked user cannot be deactivated by the API`
// we have to unblock the user first
err = client.Users.UnblockUser(id, gitlab.WithContext(ctx))
if err != nil {
return diag.FromErr(err)
}
err = client.Users.DeactivateUser(id, gitlab.WithContext(ctx))
}
if err != nil {
return diag.FromErr(err)
}
}
return resourceGitlabUserRead(ctx, d, meta)
}
func resourceGitlabUserDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
client := meta.(*gitlab.Client)
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Delete gitlab user %s", d.Id()))
id, _ := strconv.Atoi(d.Id())
if _, err := client.Users.DeleteUser(id, gitlab.WithContext(ctx)); err != nil {
return diag.FromErr(err)
}
stateConf := &retry.StateChangeConf{
Timeout: 10 * time.Minute,
Target: []string{"Deleted"},
Refresh: func() (any, string, error) {
user, resp, err := client.Users.GetUser(id, gitlab.GetUsersOptions{}, gitlab.WithContext(ctx))
if resp != nil && resp.StatusCode == 404 {
return user, "Deleted", nil
}
if err != nil {
return user, "Error", err
}
return user, "Deleting", nil
},
}
if _, err := stateConf.WaitForStateContext(ctx); err != nil {
return diag.Errorf("Could not finish deleting user %d: %s", id, err)
}
return nil
}