Skip to content

fix: support unmanaged roles on user resource #250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/resources/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integration/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 45 additions & 36 deletions internal/provider/user_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -88,16 +87,14 @@ 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{
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`.",
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
41 changes: 39 additions & 2 deletions internal/provider/user_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand All @@ -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
{
Expand All @@ -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"},
Copy link
Member Author

@ethanndickson ethanndickson Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we're using null to signify that roles shouldn't be managed by Terraform, we have the same problem we have with the group resource: we don't know whether or not the null value was set in the config, or is null because it's an import. Frustratingly, the provider framework doesn't provide a way to differentiate the two.

},
// Update and Read testing
{
Expand Down Expand Up @@ -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 {
Expand Down
Loading