package provider import ( "context" "fmt" "strings" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "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/coder/coder/v2/codersdk" ) // Ensure provider defined types fully satisfy framework interfaces. var _ resource.Resource = &UserResource{} var _ resource.ResourceWithImportState = &UserResource{} func NewUserResource() resource.Resource { return &UserResource{} } // UserResource defines the resource implementation. type UserResource struct { data *CoderdProviderData } // UserResourceModel describes the resource data model. type UserResourceModel struct { ID UUID `tfsdk:"id"` Username types.String `tfsdk:"username"` Name types.String `tfsdk:"name"` Email types.String `tfsdk:"email"` Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit) LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc Password types.String `tfsdk:"password"` // only when login_type is password Suspended types.Bool `tfsdk:"suspended"` } func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_user" } func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "A user on the Coder deployment.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ CustomType: UUIDType, Computed: true, MarkdownDescription: "User ID", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "username": schema.StringAttribute{ MarkdownDescription: "Username of the user.", Required: true, Validators: []validator.String{ stringvalidator.LengthBetween(1, 32), stringvalidator.RegexMatches(nameValidRegex, "Username must be alphanumeric with hyphens."), }, }, "name": schema.StringAttribute{ MarkdownDescription: "Display name of the user. Defaults to username.", Computed: true, Optional: true, Validators: []validator.String{ stringvalidator.LengthBetween(1, 128), }, }, "email": schema.StringAttribute{ MarkdownDescription: "Email address of the user.", Required: true, }, "roles": schema.SetAttribute{ MarkdownDescription: "Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`.", Computed: true, Optional: true, ElementType: types.StringType, Validators: []validator.Set{ setvalidator.ValueStringsAre( stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"), ), }, Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})), }, "login_type": schema.StringAttribute{ MarkdownDescription: "Type of login for the user. Valid types are `none`, `password`, `github`, and `oidc`.", Computed: true, Optional: true, Validators: []validator.String{ stringvalidator.OneOf("none", "password", "github", "oidc"), }, Default: stringdefault.StaticString("none"), PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplaceIfConfigured(), }, }, "password": schema.StringAttribute{ MarkdownDescription: "Password for the user. Required when `login_type` is `password`. Passwords are saved into the state as plain text and should only be used for testing purposes.", Optional: true, Sensitive: true, }, "suspended": schema.BoolAttribute{ MarkdownDescription: "Whether the user is suspended.", Computed: true, Optional: true, Default: booldefault.StaticBool(false), }, }, } } func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return } data, ok := req.ProviderData.(*CoderdProviderData) if !ok { resp.Diagnostics.AddError( "Unexpected Resource Configure Type", fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return } r.data = data } func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data UserResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } client := r.data.Client me, err := client.User(ctx, codersdk.Me) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return } if len(me.OrganizationIDs) < 1 { resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") return } tflog.Info(ctx, "creating user") loginType := codersdk.LoginType(data.LoginType.ValueString()) if loginType == codersdk.LoginTypePassword && data.Password.IsNull() { resp.Diagnostics.AddError("Data Error", "Password is required when login_type is 'password'") return } if loginType != codersdk.LoginTypePassword && !data.Password.IsNull() { resp.Diagnostics.AddError("Data Error", "Password is only allowed when login_type is 'password'") return } user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ Email: data.Email.ValueString(), Username: data.Username.ValueString(), Password: data.Password.ValueString(), UserLoginType: loginType, OrganizationID: me.OrganizationIDs[0], }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create user, got error: %s", err)) return } tflog.Info(ctx, "successfully created user", map[string]any{ "id": user.ID.String(), }) data.ID = UUIDValue(user.ID) tflog.Info(ctx, "updating user profile") name := data.Username if data.Name.ValueString() != "" { name = data.Name } user, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ Username: data.Username.ValueString(), Name: name.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user profile, got error: %s", err)) return } tflog.Info(ctx, "successfully updated user profile") data.Name = types.StringValue(user.Name) var roles []string resp.Diagnostics.Append( data.Roles.ElementsAs(ctx, &roles, false)..., ) tflog.Info(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) user, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ Roles: roles, }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user roles, got error: %s", err)) return } tflog.Info(ctx, "successfully updated user roles") if data.Suspended.ValueBool() { _, err = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) } if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) return } // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data UserResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } client := r.data.Client user, err := client.User(ctx, data.ID.ValueString()) if err != nil { if isNotFound(err) { resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("User with ID %q not found. Marking as deleted.", data.ID.ValueString())) resp.State.RemoveResource(ctx) return } resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return } if len(user.OrganizationIDs) < 1 { resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") return } data.Email = types.StringValue(user.Email) data.Name = types.StringValue(user.Name) data.Username = types.StringValue(user.Username) roles := make([]attr.Value, 0, len(user.Roles)) for _, role := range user.Roles { roles = append(roles, types.StringValue(role.Name)) } data.Roles = types.SetValueMust(types.StringType, roles) data.LoginType = types.StringValue(string(user.LoginType)) data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended) // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data UserResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } client := r.data.Client user, err := client.User(ctx, data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return } if len(user.OrganizationIDs) < 1 { resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") return } name := data.Username if data.Name.ValueString() != "" { name = data.Name } tflog.Info(ctx, "updating user", map[string]any{ "new_username": data.Username.ValueString(), "new_name": name.ValueString(), }) _, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ Username: data.Username.ValueString(), Name: name.ValueString(), }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user profile, got error: %s", err)) return } data.Name = name tflog.Info(ctx, "successfully updated user profile") var roles []string resp.Diagnostics.Append( data.Roles.ElementsAs(ctx, &roles, false)..., ) tflog.Info(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) _, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ Roles: roles, }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user roles, got error: %s", err)) return } tflog.Info(ctx, "successfully updated user roles") if data.LoginType.ValueString() == string(codersdk.LoginTypePassword) && !data.Password.IsNull() { tflog.Info(ctx, "updating password") err = client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{ Password: data.Password.ValueString(), }) if err != nil && !strings.Contains(err.Error(), "New password cannot match old password.") { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update password, got error: %s", err)) return } tflog.Info(ctx, "successfully updated password") } var statusErr error if data.Suspended.ValueBool() { _, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) } if !data.Suspended.ValueBool() && user.Status == codersdk.UserStatusSuspended { _, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("active")) } if statusErr != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) return } // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data UserResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } client := r.data.Client tflog.Info(ctx, "deleting user") err := client.DeleteUser(ctx, data.ID.ValueUUID()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete user, got error: %s", err)) return } tflog.Info(ctx, "successfully deleted user") } // Req.ID can be either a UUID or a username. func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { _, err := uuid.Parse(req.ID) if err == nil { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) return } client := r.data.Client user, err := client.User(ctx, req.ID) if err != nil { resp.Diagnostics.AddError("Client Error", "Invalid import ID format, expected a single UUID or a valid username") return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), user.ID.String())...) }