diff --git a/docs/resources/user.md b/docs/resources/user.md index c8d8db3..d6d11b9 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -56,7 +56,7 @@ resource "coderd_user" "admin" { - `login_type` (String) Type of login for the user. Valid types are `none`, `password`, `github`, and `oidc`. - `name` (String) Display name of the user. Defaults to username. - `password` (String, Sensitive) 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. -- `roles` (Set of String) Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`. +- `roles` (Set of String) Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`. If `null`, roles will not be managed by Terraform. This attribute must be null if the user is an OIDC user and role sync is configured - `suspended` (Boolean) Whether the user is suspended. ### Read-Only diff --git a/integration/integration.go b/integration/integration.go index 9f7120f..391d864 100644 --- a/integration/integration.go +++ b/integration/integration.go @@ -96,7 +96,7 @@ func StartCoder(ctx context.Context, t *testing.T, name string, useLicense bool) t.Logf("not ready yet: %s", err.Error()) } return err == nil - }, 20*time.Second, time.Second, "coder failed to become ready in time") + }, 30*time.Second, time.Second, "coder failed to become ready in time") _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ Email: testEmail, Username: testUsername, diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index 79d248d..db605c0 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -14,7 +14,6 @@ import ( "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" @@ -88,8 +87,7 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r Required: true, }, "roles": schema.SetAttribute{ - MarkdownDescription: "Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`.", - Computed: true, + MarkdownDescription: "Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`. If `null`, roles will not be managed by Terraform. This attribute must be null if the user is an OIDC user and role sync is configured", Optional: true, ElementType: types.StringType, Validators: []validator.Set{ @@ -97,7 +95,6 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r 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`.", @@ -209,21 +206,26 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r 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 + if !data.Roles.IsNull() { + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) + if resp.Diagnostics.HasError() { + return + } + 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") } - tflog.Info(ctx, "successfully updated user roles") if data.Suspended.ValueBool() { _, err = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) @@ -267,11 +269,13 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp 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)) + if !data.Roles.IsNull() { + 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.Roles = types.SetValueMust(types.StringType, roles) data.LoginType = types.StringValue(string(user.LoginType)) data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended) @@ -344,21 +348,26 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r 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 + if !data.Roles.IsNull() { + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) + if resp.Diagnostics.HasError() { + return + } + 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") } - tflog.Info(ctx, "successfully updated user roles") if data.LoginType.ValueString() == string(codersdk.LoginTypePassword) && !data.Password.IsNull() { tflog.Info(ctx, "updating password") diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go index 8c78acc..9e47df3 100644 --- a/internal/provider/user_resource_test.go +++ b/internal/provider/user_resource_test.go @@ -42,6 +42,9 @@ func TestAccUserResource(t *testing.T) { cfg4.LoginType = ptr.Ref("github") cfg4.Password = nil + cfg5 := cfg4 + cfg5.Roles = nil + resource.Test(t, resource.TestCase{ IsUnitTest: true, PreCheck: func() { testAccPreCheck(t) }, @@ -68,7 +71,7 @@ func TestAccUserResource(t *testing.T) { ImportState: true, ImportStateVerify: true, // We can't pull the password from the API. - ImportStateVerifyIgnore: []string{"password"}, + ImportStateVerifyIgnore: []string{"password", "roles"}, }, // ImportState by username { @@ -77,7 +80,7 @@ func TestAccUserResource(t *testing.T) { ImportStateVerify: true, ImportStateId: "example", // We can't pull the password from the API. - ImportStateVerifyIgnore: []string{"password"}, + ImportStateVerifyIgnore: []string{"password", "roles"}, }, // Update and Read testing { @@ -114,8 +117,42 @@ func TestAccUserResource(t *testing.T) { // The Plan should be to create the entire resource ExpectNonEmptyPlan: true, }, + // Unmanaged roles + { + Config: cfg5.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_user.test", "roles"), + ), + }, }, }) + + t.Run("CreateUnmanagedRolesOk", func(t *testing.T) { + cfg := testAccUserResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Username: ptr.Ref("unmanaged"), + Name: ptr.Ref("Unmanaged User"), + Email: ptr.Ref("unmanaged@coder.com"), + Roles: nil, // Start with unmanaged roles + LoginType: ptr.Ref("password"), + Password: ptr.Ref("SomeSecurePassword!"), + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_user.test", "roles"), + ), + }, + }, + }) + }) } type testAccUserResourceConfig struct {