Skip to content

Commit ab08af2

Browse files
PushTheLimitclaudeethanndickson
authored
feat(coderd_user): add service account support (#357)
Adds an `is_service_account` attribute to the `coderd_user` resource and data source, closing the Terraform/API parity gap for service accounts. Service accounts are admin-managed, login-less accounts that — unlike a `login_type = "none"` user — carry no email and do not consume a licensed user seat. Changes: - **Resource**: add `is_service_account` (optional, defaults `false`, `ForceNew` since it is immutable server-side). When `true`, the user is created via `CreateUserWithOrgs` with `ServiceAccount: true` (no email, `login_type` `none`); the existing `CreateUser` path is unchanged for regular users. - **Plan-time validation** (`ValidateConfig`) mirrors the server rules: `email` is required unless `is_service_account` is `true`, in which case `email`, `password`, and a non-`none` `login_type` are rejected. - **Data source**: expose `is_service_account` (read-only). - `email` becomes `Optional + Computed` (was `Required`) so it can be omitted for service accounts; it remains effectively required for regular users via `ValidateConfig`. - Add an acceptance test (`TestAccUserResourceServiceAccount`, gated on a licensed deployment via `UseLicense` since service accounts are Premium) and regenerate docs/examples. The attribute name matches the API's `is_service_account` field, consistent with how `is_default` is named on the `coderd_organization` data source; on create it maps to the `service_account` field of `CreateUserRequestWithOrgs` (the create request uses the unprefixed name), the same attribute-name-to-request-field mapping already used for `suspended`. Verified locally: `go build ./...`, `golangci-lint run` (clean), `make gen` (idempotent). Acceptance tests require a licensed deployment and run in CI. Fixes #356 --------- Co-authored-by: PushTheLimit <591079+PushTheLimit@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ethan Dickson <ethanndickson@gmail.com>
1 parent d170626 commit ab08af2

7 files changed

Lines changed: 371 additions & 33 deletions

File tree

docs/data-sources/user.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ resource "coderd_group" "bosses" {
4747
- `avatar_url` (String) URL of the user's avatar.
4848
- `created_at` (Number) Unix timestamp of when the user was created.
4949
- `email` (String) Email of the user.
50+
- `is_service_account` (Boolean) Whether the user is a service account: an admin-managed account that cannot log in interactively and does not consume a licensed user seat.
5051
- `last_seen_at` (Number) Unix timestamp of when the user was last seen.
5152
- `login_type` (String) Type of login for the user. Valid types are `none`, `password', `github`, and `oidc`.
5253
- `name` (String) Display name of the user.

docs/resources/user.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,28 @@ resource "coderd_user" "admin" {
4141
suspended = true
4242
email = "admin@example.com"
4343
}
44+
45+
// Create a service account for automation (Premium). Unlike a `login_type =
46+
// none` user, a service account has no email and does not consume a user seat.
47+
resource "coderd_user" "automation" {
48+
username = "automation"
49+
name = "Automation"
50+
roles = ["template-admin"]
51+
is_service_account = true
52+
}
4453
```
4554

4655
<!-- schema generated by tfplugindocs -->
4756
## Schema
4857

4958
### Required
5059

51-
- `email` (String) Email address of the user.
5260
- `username` (String) Username of the user.
5361

5462
### Optional
5563

64+
- `email` (String) Email address of the user. Required unless `is_service_account` is `true`, in which case it must be omitted (service accounts have no email).
65+
- `is_service_account` (Boolean) Whether the user is a service account. Service accounts are admin-managed accounts that cannot log in interactively: they have no password or email and use `login_type` `none`. Unlike a regular `login_type = none` user, a service account does not consume a licensed user seat. Changing this attribute forces replacement.
5666
- `login_type` (String) Type of login for the user. Valid types are `none`, `password`, `github`, and `oidc`.
5767
- `name` (String) Display name of the user. Defaults to username.
5868
- `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.

examples/resources/coderd_user/resource.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,12 @@ resource "coderd_user" "admin" {
2626
suspended = true
2727
email = "admin@example.com"
2828
}
29+
30+
// Create a service account for automation (Premium). Unlike a `login_type =
31+
// none` user, a service account has no email and does not consume a user seat.
32+
resource "coderd_user" "automation" {
33+
username = "automation"
34+
name = "Automation"
35+
roles = ["template-admin"]
36+
is_service_account = true
37+
}

internal/provider/user_data_source.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,16 @@ type UserDataSourceModel struct {
3232
ID UUID `tfsdk:"id"`
3333
Username types.String `tfsdk:"username"`
3434

35-
Name types.String `tfsdk:"name"`
36-
Email types.String `tfsdk:"email"`
37-
Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit)
38-
LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc
39-
Suspended types.Bool `tfsdk:"suspended"`
40-
AvatarURL types.String `tfsdk:"avatar_url"`
41-
OrganizationIDs types.Set `tfsdk:"organization_ids"`
42-
CreatedAt types.Int64 `tfsdk:"created_at"` // Unix timestamp
43-
LastSeenAt types.Int64 `tfsdk:"last_seen_at"`
35+
Name types.String `tfsdk:"name"`
36+
Email types.String `tfsdk:"email"`
37+
Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit)
38+
LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc
39+
Suspended types.Bool `tfsdk:"suspended"`
40+
IsServiceAccount types.Bool `tfsdk:"is_service_account"`
41+
AvatarURL types.String `tfsdk:"avatar_url"`
42+
OrganizationIDs types.Set `tfsdk:"organization_ids"`
43+
CreatedAt types.Int64 `tfsdk:"created_at"` // Unix timestamp
44+
LastSeenAt types.Int64 `tfsdk:"last_seen_at"`
4445
}
4546

4647
func (d *UserDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
@@ -83,6 +84,10 @@ func (d *UserDataSource) Schema(ctx context.Context, req datasource.SchemaReques
8384
MarkdownDescription: "Whether the user is suspended.",
8485
Computed: true,
8586
},
87+
"is_service_account": schema.BoolAttribute{
88+
MarkdownDescription: "Whether the user is a service account: an admin-managed account that cannot log in interactively and does not consume a licensed user seat.",
89+
Computed: true,
90+
},
8691
"avatar_url": schema.StringAttribute{
8792
MarkdownDescription: "URL of the user's avatar.",
8893
Computed: true,
@@ -175,6 +180,7 @@ func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
175180
data.Roles = types.SetValueMust(types.StringType, roles)
176181
data.LoginType = types.StringValue(string(user.LoginType))
177182
data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended)
183+
data.IsServiceAccount = types.BoolValue(user.IsServiceAccount)
178184

179185
orgIDs := make([]attr.Value, 0, len(user.OrganizationIDs))
180186
for _, orgID := range user.OrganizationIDs {

internal/provider/user_data_source_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/coder/coder/v2/coderd/util/ptr"
1111
"github.com/coder/coder/v2/codersdk"
1212
"github.com/coder/terraform-provider-coderd/integration"
13+
"github.com/google/uuid"
1314
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
1415
"github.com/stretchr/testify/require"
1516
)
@@ -49,6 +50,7 @@ func TestAccUserDataSource(t *testing.T) {
4950
resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"),
5051
resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"),
5152
resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"),
53+
resource.TestCheckResourceAttr("data.coderd_user.test", "is_service_account", "false"),
5254
)
5355
t.Run("UserByUsernameOk", func(t *testing.T) {
5456
cfg := testAccUserDataSourceConfig{
@@ -127,6 +129,51 @@ func TestAccUserDataSource(t *testing.T) {
127129
})
128130
}
129131

132+
// TestAccUserDataSourceServiceAccount verifies the data source surfaces
133+
// is_service_account==true and an empty email for a service account. Service
134+
// accounts are a Premium feature, so a licensed deployment is required.
135+
func TestAccUserDataSourceServiceAccount(t *testing.T) {
136+
t.Parallel()
137+
if os.Getenv("TF_ACC") == "" {
138+
t.Skip("Acceptance tests are disabled.")
139+
}
140+
ctx := t.Context()
141+
client := integration.StartCoder(ctx, t, "user_data_service_account_acc", integration.UseLicense)
142+
firstUser, err := client.User(ctx, codersdk.Me)
143+
require.NoError(t, err)
144+
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
145+
Username: "service-account",
146+
Name: "Service Account",
147+
UserLoginType: "none",
148+
OrganizationIDs: []uuid.UUID{firstUser.OrganizationIDs[0]},
149+
ServiceAccount: true,
150+
})
151+
require.NoError(t, err)
152+
153+
cfg := testAccUserDataSourceConfig{
154+
URL: client.URL.String(),
155+
Token: client.SessionToken(),
156+
Username: ptr.Ref(user.Username),
157+
}
158+
resource.Test(t, resource.TestCase{
159+
IsUnitTest: true,
160+
PreCheck: func() { testAccPreCheck(t) },
161+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
162+
Steps: []resource.TestStep{
163+
{
164+
Config: cfg.String(t),
165+
Check: resource.ComposeAggregateTestCheckFunc(
166+
resource.TestCheckResourceAttr("data.coderd_user.test", "username", "service-account"),
167+
resource.TestCheckResourceAttr("data.coderd_user.test", "is_service_account", "true"),
168+
// Service accounts have no email.
169+
resource.TestCheckResourceAttr("data.coderd_user.test", "email", ""),
170+
resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "none"),
171+
),
172+
},
173+
},
174+
})
175+
}
176+
130177
type testAccUserDataSourceConfig struct {
131178
URL string
132179
Token string

internal/provider/user_resource.go

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/hashicorp/terraform-plugin-framework/resource"
1414
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
1515
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
1617
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
1718
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
1819
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
@@ -27,6 +28,7 @@ import (
2728
// Ensure provider defined types fully satisfy framework interfaces.
2829
var _ resource.Resource = &UserResource{}
2930
var _ resource.ResourceWithImportState = &UserResource{}
31+
var _ resource.ResourceWithValidateConfig = &UserResource{}
3032

3133
func NewUserResource() resource.Resource {
3234
return &UserResource{}
@@ -41,13 +43,14 @@ type UserResource struct {
4143
type UserResourceModel struct {
4244
ID UUID `tfsdk:"id"`
4345

44-
Username types.String `tfsdk:"username"`
45-
Name types.String `tfsdk:"name"`
46-
Email types.String `tfsdk:"email"`
47-
Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit)
48-
LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc
49-
Password types.String `tfsdk:"password"` // only when login_type is password
50-
Suspended types.Bool `tfsdk:"suspended"`
46+
Username types.String `tfsdk:"username"`
47+
Name types.String `tfsdk:"name"`
48+
Email types.String `tfsdk:"email"`
49+
Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit)
50+
LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc
51+
Password types.String `tfsdk:"password"` // only when login_type is password
52+
Suspended types.Bool `tfsdk:"suspended"`
53+
IsServiceAccount types.Bool `tfsdk:"is_service_account"`
5154
}
5255

5356
func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
@@ -83,8 +86,13 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r
8386
},
8487
},
8588
"email": schema.StringAttribute{
86-
MarkdownDescription: "Email address of the user.",
87-
Required: true,
89+
MarkdownDescription: "Email address of the user. Required unless `is_service_account` is `true`, in which case it must be omitted (service accounts have no email).",
90+
Optional: true,
91+
Computed: true,
92+
PlanModifiers: []planmodifier.String{
93+
stringplanmodifier.UseStateForUnknown(),
94+
stringplanmodifier.RequiresReplaceIfConfigured(),
95+
},
8896
},
8997
"roles": schema.SetAttribute{
9098
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",
@@ -119,6 +127,15 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r
119127
Optional: true,
120128
Default: booldefault.StaticBool(false),
121129
},
130+
"is_service_account": schema.BoolAttribute{
131+
MarkdownDescription: "Whether the user is a service account. Service accounts are admin-managed accounts that cannot log in interactively: they have no password or email and use `login_type` `none`. Unlike a regular `login_type = none` user, a service account does not consume a licensed user seat. Changing this attribute forces replacement.",
132+
Computed: true,
133+
Optional: true,
134+
Default: booldefault.StaticBool(false),
135+
PlanModifiers: []planmodifier.Bool{
136+
boolplanmodifier.RequiresReplaceIfConfigured(),
137+
},
138+
},
122139
},
123140
}
124141
}
@@ -143,6 +160,44 @@ func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequ
143160
r.data = data
144161
}
145162

163+
func (r *UserResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
164+
var data UserResourceModel
165+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
166+
if resp.Diagnostics.HasError() || data.IsServiceAccount.IsUnknown() {
167+
return
168+
}
169+
170+
emailKnown := !data.Email.IsUnknown()
171+
emailSet := emailKnown && !data.Email.IsNull() && data.Email.ValueString() != ""
172+
173+
if data.IsServiceAccount.ValueBool() {
174+
// Service accounts cannot log in, so they carry no email or credentials
175+
// and must use login_type 'none' (enforced server-side).
176+
if emailSet {
177+
resp.Diagnostics.AddAttributeError(path.Root("email"),
178+
"Invalid Attribute Combination",
179+
"`email` must not be set when `is_service_account` is `true`.")
180+
}
181+
if !data.Password.IsNull() {
182+
resp.Diagnostics.AddAttributeError(path.Root("password"),
183+
"Invalid Attribute Combination",
184+
"`password` must not be set when `is_service_account` is `true`.")
185+
}
186+
// Compared as a string literal to match the schema default/validator and
187+
// avoid the deprecated codersdk.LoginTypeNone constant.
188+
if !data.LoginType.IsNull() && !data.LoginType.IsUnknown() &&
189+
data.LoginType.ValueString() != "none" {
190+
resp.Diagnostics.AddAttributeError(path.Root("login_type"),
191+
"Invalid Attribute Combination",
192+
"`login_type` must be `none` when `is_service_account` is `true`.")
193+
}
194+
} else if emailKnown && !emailSet {
195+
resp.Diagnostics.AddAttributeError(path.Root("email"),
196+
"Missing Required Attribute",
197+
"`email` is required when `is_service_account` is `false`.")
198+
}
199+
}
200+
146201
func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
147202
var data UserResourceModel
148203

@@ -174,17 +229,21 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r
174229
resp.Diagnostics.AddError("Data Error", "Password is only allowed when login_type is 'password'")
175230
return
176231
}
177-
user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
178-
Email: data.Email.ValueString(),
179-
Username: data.Username.ValueString(),
180-
Password: data.Password.ValueString(),
181-
UserLoginType: loginType,
182-
OrganizationID: me.OrganizationIDs[0],
232+
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
233+
Email: data.Email.ValueString(),
234+
Username: data.Username.ValueString(),
235+
Name: data.Name.ValueString(),
236+
Password: data.Password.ValueString(),
237+
UserLoginType: loginType,
238+
OrganizationIDs: []uuid.UUID{me.OrganizationIDs[0]},
239+
ServiceAccount: data.IsServiceAccount.ValueBool(),
183240
})
184241
if err != nil {
185242
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create user, got error: %s", err))
186243
return
187244
}
245+
data.Email = types.StringValue(user.Email)
246+
data.IsServiceAccount = types.BoolValue(user.IsServiceAccount)
188247
tflog.Info(ctx, "successfully created user", map[string]any{
189248
"id": user.ID.String(),
190249
})
@@ -273,6 +332,7 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp
273332
}
274333
data.LoginType = types.StringValue(string(user.LoginType))
275334
data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended)
335+
data.IsServiceAccount = types.BoolValue(user.IsServiceAccount)
276336

277337
// The user-by-ID API returns deleted users if the authorized user has
278338
// permission. It does not indicate whether the user is deleted or not.

0 commit comments

Comments
 (0)