From f555328f87928272ac15f3f3789e78c6d9f4ad47 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 16 Jul 2024 05:59:57 +0000
Subject: [PATCH] feat: add coderd_group resource

---
 docs/index.md                              |   1 +
 docs/resources/group.md                    |  32 ++
 integration/integration.go                 |   7 +-
 integration/integration_test.go            |   2 +-
 internal/provider/group_resource.go        | 357 +++++++++++++++++++++
 internal/provider/group_resource_test.go   | 151 +++++++++
 internal/provider/provider.go              |  21 +-
 internal/provider/user_data_source_test.go |  69 ++--
 internal/provider/user_resource_test.go    |  42 +--
 internal/provider/util.go                  |  43 +++
 10 files changed, 645 insertions(+), 80 deletions(-)
 create mode 100644 docs/resources/group.md
 create mode 100644 internal/provider/group_resource.go
 create mode 100644 internal/provider/group_resource_test.go

diff --git a/docs/index.md b/docs/index.md
index a5ea0f0..974962c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -23,5 +23,6 @@ provider "coderd" {
 
 ### Optional
 
+- `default_organization_id` (String) Default organization ID to use when creating resources. Defaults to the first organization the token has access to.
 - `token` (String) API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.
 - `url` (String) URL to the Coder deployment. Defaults to $CODER_URL.
diff --git a/docs/resources/group.md b/docs/resources/group.md
new file mode 100644
index 0000000..1972265
--- /dev/null
+++ b/docs/resources/group.md
@@ -0,0 +1,32 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "coderd_group Resource - coderd"
+subcategory: ""
+description: |-
+  A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the data.coderd_group data source.
+---
+
+# coderd_group (Resource)
+
+A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source.
+
+
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `name` (String) The unique name of the group.
+
+### Optional
+
+- `avatar_url` (String) The URL of the group's avatar.
+- `display_name` (String) The display name of the group. Defaults to the group name.
+- `members` (Set of String) Members of the group, by ID. If null, members will not be added or removed.
+- `organization_id` (String) The organization ID that the group belongs to. Defaults to the provider default organization ID.
+- `quota_allowance` (Number) The number of quota credits to allocate to each user in the group.
+
+### Read-Only
+
+- `id` (String) Group ID.
diff --git a/integration/integration.go b/integration/integration.go
index 08ea4ed..622b015 100644
--- a/integration/integration.go
+++ b/integration/integration.go
@@ -19,7 +19,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client {
+func StartCoder(ctx context.Context, t *testing.T, name string, useTrial bool) *codersdk.Client {
 	coderImg := os.Getenv("CODER_IMAGE")
 	if coderImg == "" {
 		coderImg = "ghcr.io/coder/coder"
@@ -75,9 +75,9 @@ func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client
 
 	// nolint:gosec // For testing only.
 	var (
-		testEmail    = "testing@coder.com"
+		testEmail    = "admin@coder.com"
 		testPassword = "InsecurePassw0rd!"
-		testUsername = "testing"
+		testUsername = "admin"
 	)
 
 	// Perform first time setup
@@ -96,6 +96,7 @@ func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client
 		Email:    testEmail,
 		Username: testUsername,
 		Password: testPassword,
+		Trial:    useTrial,
 	})
 	require.NoError(t, err, "create first user")
 	resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
diff --git a/integration/integration_test.go b/integration/integration_test.go
index fa58a5a..f9774c2 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -90,7 +90,7 @@ func TestIntegration(t *testing.T) {
 		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
-			client := StartCoder(ctx, t, tt.name)
+			client := StartCoder(ctx, t, tt.name, true)
 			wd, err := os.Getwd()
 			require.NoError(t, err)
 			srcDir := filepath.Join(wd, tt.name)
diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go
new file mode 100644
index 0000000..05d84d0
--- /dev/null
+++ b/internal/provider/group_resource.go
@@ -0,0 +1,357 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/coder/coder/v2/codersdk"
+	"github.com/google/uuid"
+	"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/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+// Ensure provider defined types fully satisfy framework interfaces.
+var _ resource.Resource = &GroupResource{}
+var _ resource.ResourceWithImportState = &GroupResource{}
+
+func NewGroupResource() resource.Resource {
+	return &GroupResource{}
+}
+
+// GroupResource defines the resource implementation.
+type GroupResource struct {
+	data *CoderdProviderData
+}
+
+// GroupResourceModel describes the resource data model.
+type GroupResourceModel struct {
+	ID types.String `tfsdk:"id"`
+
+	Name           types.String `tfsdk:"name"`
+	DisplayName    types.String `tfsdk:"display_name"`
+	AvatarURL      types.String `tfsdk:"avatar_url"`
+	QuotaAllowance types.Int32  `tfsdk:"quota_allowance"`
+	OrganizationID types.String `tfsdk:"organization_id"`
+	Members        types.Set    `tfsdk:"members"`
+}
+
+func (r *GroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_group"
+}
+
+func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		MarkdownDescription: "A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source.",
+
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				MarkdownDescription: "Group ID.",
+				Computed:            true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"name": schema.StringAttribute{
+				MarkdownDescription: "The unique name of the group.",
+				Required:            true,
+			},
+			"display_name": schema.StringAttribute{
+				MarkdownDescription: "The display name of the group. Defaults to the group name.",
+				Computed:            true,
+				Optional:            true,
+				// Defaulted in Create
+			},
+			"avatar_url": schema.StringAttribute{
+				MarkdownDescription: "The URL of the group's avatar.",
+				Computed:            true,
+				Optional:            true,
+				Default:             stringdefault.StaticString(""),
+			},
+			// Int32 in the db
+			"quota_allowance": schema.Int32Attribute{
+				MarkdownDescription: "The number of quota credits to allocate to each user in the group.",
+				Optional:            true,
+			},
+			"organization_id": schema.StringAttribute{
+				MarkdownDescription: "The organization ID that the group belongs to. Defaults to the provider default organization ID.",
+				Optional:            true,
+				Computed:            true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplaceIfConfigured(),
+				},
+			},
+			"members": schema.SetAttribute{
+				MarkdownDescription: "Members of the group, by ID. If null, members will not be added or removed.",
+				ElementType:         types.StringType,
+				Optional:            true,
+			},
+		},
+	}
+}
+
+func (r *GroupResource) 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 *GroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var data GroupResourceModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	client := r.data.Client
+
+	if data.OrganizationID.IsUnknown() {
+		data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID)
+	}
+
+	orgID, err := uuid.Parse(data.OrganizationID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err))
+		return
+	}
+
+	displayName := data.Name.ValueString()
+	if data.DisplayName.ValueString() != "" {
+		displayName = data.DisplayName.ValueString()
+	}
+
+	tflog.Trace(ctx, "creating group")
+	group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{
+		Name:           data.Name.ValueString(),
+		DisplayName:    displayName,
+		AvatarURL:      data.AvatarURL.ValueString(),
+		QuotaAllowance: int(data.QuotaAllowance.ValueInt32()),
+	})
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create group, got error: %s", err))
+		return
+	}
+	tflog.Trace(ctx, "successfully created group", map[string]any{
+		"id": group.ID.String(),
+	})
+	data.ID = types.StringValue(group.ID.String())
+	data.DisplayName = types.StringValue(group.DisplayName)
+
+	tflog.Trace(ctx, "setting group members")
+	var members []string
+	resp.Diagnostics.Append(
+		data.Members.ElementsAs(ctx, &members, false)...,
+	)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
+		AddUsers: members,
+	})
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add members to group, got error: %s", err))
+		return
+	}
+	tflog.Trace(ctx, "successfully set group members")
+
+	// Save data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var data GroupResourceModel
+
+	// 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
+
+	groupID, err := uuid.Parse(data.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
+		return
+	}
+
+	group, err := client.Group(ctx, groupID)
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err))
+		return
+	}
+
+	data.Name = types.StringValue(group.Name)
+	data.DisplayName = types.StringValue(group.DisplayName)
+	data.AvatarURL = types.StringValue(group.AvatarURL)
+	data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance))
+	data.OrganizationID = types.StringValue(group.OrganizationID.String())
+	if !data.Members.IsNull() {
+		members := make([]attr.Value, 0, len(group.Members))
+		for _, member := range group.Members {
+			members = append(members, types.StringValue(member.ID.String()))
+		}
+		data.Members = types.SetValueMust(types.StringType, members)
+	}
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var data GroupResourceModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	client := r.data.Client
+	if data.OrganizationID.IsUnknown() {
+		data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID)
+	}
+	groupID, err := uuid.Parse(data.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
+		return
+	}
+
+	group, err := client.Group(ctx, groupID)
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err))
+		return
+	}
+	var newMembers []string
+	resp.Diagnostics.Append(
+		data.Members.ElementsAs(ctx, &newMembers, false)...,
+	)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	var add []string
+	var remove []string
+	if !data.Members.IsNull() {
+		add, remove = memberDiff(group.Members, newMembers)
+	}
+	tflog.Trace(ctx, "updating group", map[string]any{
+		"new_members":     add,
+		"removed_members": remove,
+		"new_name":        data.Name,
+		"new_displayname": data.DisplayName,
+		"new_avatarurl":   data.AvatarURL,
+		"new_quota":       data.QuotaAllowance,
+	})
+
+	quotaAllowance := int(data.QuotaAllowance.ValueInt32())
+	_, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
+		AddUsers:       add,
+		RemoveUsers:    remove,
+		Name:           data.Name.ValueString(),
+		DisplayName:    data.DisplayName.ValueStringPointer(),
+		AvatarURL:      data.AvatarURL.ValueStringPointer(),
+		QuotaAllowance: &quotaAllowance,
+	})
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update group, got error: %s", err))
+		return
+	}
+	tflog.Trace(ctx, "successfully updated group")
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var data GroupResourceModel
+
+	// 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
+	groupID, err := uuid.Parse(data.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err))
+		return
+	}
+
+	tflog.Trace(ctx, "deleting group")
+	err = client.DeleteGroup(ctx, groupID)
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete group, got error: %s", err))
+		return
+	}
+	tflog.Trace(ctx, "successfully deleted group")
+}
+
+func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	client := r.data.Client
+	groupID, err := uuid.Parse(req.ID)
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse import group ID as UUID, got error: %s", err))
+		return
+	}
+	group, err := client.Group(ctx, groupID)
+	if err != nil {
+		resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get imported group, got error: %s", err))
+		return
+	}
+	if group.Source == "oidc" {
+		resp.Diagnostics.AddError("Client Error", "Cannot import groups created via OIDC")
+		return
+	}
+	resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
+}
+
+func memberDiff(curMembers []codersdk.ReducedUser, newMembers []string) (add, remove []string) {
+	curSet := make(map[string]struct{}, len(curMembers))
+	newSet := make(map[string]struct{}, len(newMembers))
+
+	for _, user := range curMembers {
+		curSet[user.ID.String()] = struct{}{}
+	}
+	for _, userID := range newMembers {
+		newSet[userID] = struct{}{}
+		if _, exists := curSet[userID]; !exists {
+			add = append(add, userID)
+		}
+	}
+	for _, user := range curMembers {
+		if _, exists := newSet[user.ID.String()]; !exists {
+			remove = append(remove, user.ID.String())
+		}
+	}
+	return add, remove
+}
diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go
new file mode 100644
index 0000000..2b90868
--- /dev/null
+++ b/internal/provider/group_resource_test.go
@@ -0,0 +1,151 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+import (
+	"context"
+	"os"
+	"strings"
+	"testing"
+	"text/template"
+
+	"github.com/coder/coder/v2/codersdk"
+	"github.com/coder/terraform-provider-coderd/integration"
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAccGroupResource(t *testing.T) {
+	if os.Getenv("TF_ACC") == "" {
+		t.Skip("Acceptance tests are disabled.")
+	}
+
+	ctx := context.Background()
+	client := integration.StartCoder(ctx, t, "group_acc", true)
+	firstUser, err := client.User(ctx, codersdk.Me)
+	require.NoError(t, err)
+
+	user1, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
+		Email:          "example@coder.com",
+		Username:       "example",
+		Password:       "SomeSecurePassword!",
+		UserLoginType:  "password",
+		OrganizationID: firstUser.OrganizationIDs[0],
+	})
+	require.NoError(t, err)
+
+	user2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
+		Email:          "example2@coder.com",
+		Username:       "example2",
+		Password:       "SomeSecurePassword!",
+		UserLoginType:  "password",
+		OrganizationID: firstUser.OrganizationIDs[0],
+	})
+	require.NoError(t, err)
+
+	cfg1 := testAccGroupResourceconfig{
+		URL:            client.URL.String(),
+		Token:          client.SessionToken(),
+		Name:           PtrTo("example-group"),
+		DisplayName:    PtrTo("Example Group"),
+		AvatarUrl:      PtrTo("https://google.com"),
+		QuotaAllowance: PtrTo(int32(100)),
+		Members:        PtrTo([]string{user1.ID.String()}),
+	}
+
+	cfg2 := cfg1
+	cfg2.Name = PtrTo("example-group-new")
+	cfg2.DisplayName = PtrTo("Example Group New")
+	cfg2.Members = PtrTo([]string{user2.ID.String()})
+
+	cfg3 := cfg2
+	cfg3.Members = nil
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 func() { testAccPreCheck(t) },
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			// Create and Read
+			{
+				Config: cfg1.String(t),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group"),
+					resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group"),
+					resource.TestCheckResourceAttr("coderd_group.test", "avatar_url", "https://google.com"),
+					resource.TestCheckResourceAttr("coderd_group.test", "quota_allowance", "100"),
+					resource.TestCheckResourceAttr("coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()),
+					resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"),
+					resource.TestCheckResourceAttr("coderd_group.test", "members.0", user1.ID.String()),
+				),
+			},
+			// Import
+			{
+				Config:                  cfg1.String(t),
+				ResourceName:            "coderd_group.test",
+				ImportState:             true,
+				ImportStateVerify:       true,
+				ImportStateVerifyIgnore: []string{"members"},
+			},
+			// Update and Read
+			{
+				Config: cfg2.String(t),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group-new"),
+					resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group New"),
+					resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"),
+					resource.TestCheckResourceAttr("coderd_group.test", "members.0", user2.ID.String()),
+				),
+			},
+			// Unmanaged members
+			{
+				Config: cfg3.String(t),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestCheckNoResourceAttr("coderd_group.test", "members"),
+				),
+			},
+		},
+	})
+}
+
+type testAccGroupResourceconfig struct {
+	URL   string
+	Token string
+
+	Name           *string
+	DisplayName    *string
+	AvatarUrl      *string
+	QuotaAllowance *int32
+	OrganizationID *string
+	Members        *[]string
+}
+
+func (c testAccGroupResourceconfig) String(t *testing.T) string {
+	t.Helper()
+	tpl := `
+provider coderd {
+	url   = "{{.URL}}"
+	token = "{{.Token}}"
+}
+
+resource "coderd_group" "test" {
+	name              = {{orNull .Name}}
+	display_name      = {{orNull .DisplayName}}
+	avatar_url        = {{orNull .AvatarUrl}}
+	quota_allowance   = {{orNull .QuotaAllowance}}
+	organization_id   = {{orNull .OrganizationID}}
+	members           = {{orNull .Members}}
+}
+`
+	funcMap := template.FuncMap{
+		"orNull": PrintOrNull,
+	}
+
+	buf := strings.Builder{}
+	tmpl, err := template.New("groupResource").Funcs(funcMap).Parse(tpl)
+	require.NoError(t, err)
+
+	err = tmpl.Execute(&buf, c)
+	require.NoError(t, err)
+	return buf.String()
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 40bdff3..8cfe4c7 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -32,12 +32,17 @@ type CoderdProvider struct {
 
 type CoderdProviderData struct {
 	Client *codersdk.Client
+	// TODO(ethanndickson): We should use a custom TFPF type for UUIDs everywhere
+	// possible, instead of `string` and `types.String`.
+	DefaultOrganizationID string
 }
 
 // CoderdProviderModel describes the provider data model.
 type CoderdProviderModel struct {
 	URL   types.String `tfsdk:"url"`
 	Token types.String `tfsdk:"token"`
+
+	DefaultOrganizationID types.String `tfsdk:"default_organization_id"`
 }
 
 func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -56,6 +61,10 @@ func (p *CoderdProvider) Schema(ctx context.Context, req provider.SchemaRequest,
 				MarkdownDescription: "API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.",
 				Optional:            true,
 			},
+			"default_organization_id": schema.StringAttribute{
+				MarkdownDescription: "Default organization ID to use when creating resources. Defaults to the first organization the token has access to.",
+				Optional:            true,
+			},
 		},
 	}
 }
@@ -94,8 +103,17 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
 	client := codersdk.New(url)
 	client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug))
 	client.SetSessionToken(data.Token.ValueString())
+	if data.DefaultOrganizationID.IsNull() {
+		user, err := client.User(ctx, codersdk.Me)
+		if err != nil {
+			resp.Diagnostics.AddError("default_organization_id", "failed to get default organization ID: "+err.Error())
+			return
+		}
+		data.DefaultOrganizationID = types.StringValue(user.OrganizationIDs[0].String())
+	}
 	providerData := &CoderdProviderData{
-		Client: client,
+		Client:                client,
+		DefaultOrganizationID: data.DefaultOrganizationID.ValueString(),
 	}
 	resp.DataSourceData = providerData
 	resp.ResourceData = providerData
@@ -104,6 +122,7 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
 func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource {
 	return []func() resource.Resource{
 		NewUserResource,
+		NewGroupResource,
 	}
 }
 
diff --git a/internal/provider/user_data_source_test.go b/internal/provider/user_data_source_test.go
index b3e3987..2d69d13 100644
--- a/internal/provider/user_data_source_test.go
+++ b/internal/provider/user_data_source_test.go
@@ -2,11 +2,11 @@ package provider
 
 import (
 	"context"
-	"html/template"
 	"os"
 	"regexp"
 	"strings"
 	"testing"
+	"text/template"
 
 	"github.com/coder/coder/v2/codersdk"
 	"github.com/coder/terraform-provider-coderd/integration"
@@ -19,7 +19,7 @@ func TestAccUserDataSource(t *testing.T) {
 		t.Skip("Acceptance tests are disabled.")
 	}
 	ctx := context.Background()
-	client := integration.StartCoder(ctx, t, "user_data_acc")
+	client := integration.StartCoder(ctx, t, "user_data_acc", false)
 	firstUser, err := client.User(ctx, codersdk.Me)
 	require.NoError(t, err)
 	user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
@@ -39,11 +39,21 @@ func TestAccUserDataSource(t *testing.T) {
 		Name:     "Example User",
 	})
 	require.NoError(t, err)
-	t.Run("UserByUsername", func(t *testing.T) {
+
+	checkFn := resource.ComposeAggregateTestCheckFunc(
+		resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"),
+		resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"),
+	)
+	t.Run("UserByUsernameOk", func(t *testing.T) {
 		cfg := testAccUserDataSourceConfig{
 			URL:      client.URL.String(),
 			Token:    client.SessionToken(),
-			Username: user.Username,
+			Username: PtrTo(user.Username),
 		}
 		resource.Test(t, resource.TestCase{
 			PreCheck:                 func() { testAccPreCheck(t) },
@@ -51,25 +61,17 @@ func TestAccUserDataSource(t *testing.T) {
 			Steps: []resource.TestStep{
 				{
 					Config: cfg.String(t),
-					Check: resource.ComposeAggregateTestCheckFunc(
-						resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"),
-					),
+					Check:  checkFn,
 				},
 			},
 		})
 	})
 
-	t.Run("UserByID", func(t *testing.T) {
+	t.Run("UserByIDOk", func(t *testing.T) {
 		cfg := testAccUserDataSourceConfig{
 			URL:   client.URL.String(),
 			Token: client.SessionToken(),
-			ID:    user.ID.String(),
+			ID:    PtrTo(user.ID.String()),
 		}
 		resource.Test(t, resource.TestCase{
 			PreCheck:                 func() { testAccPreCheck(t) },
@@ -78,20 +80,12 @@ func TestAccUserDataSource(t *testing.T) {
 			Steps: []resource.TestStep{
 				{
 					Config: cfg.String(t),
-					Check: resource.ComposeAggregateTestCheckFunc(
-						resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"),
-						resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"),
-					),
+					Check:  checkFn,
 				},
 			},
 		})
 	})
-	t.Run("NeitherIDNorUsername", func(t *testing.T) {
+	t.Run("NeitherIDNorUsernameError", func(t *testing.T) {
 		cfg := testAccUserDataSourceConfig{
 			URL:   client.URL.String(),
 			Token: client.SessionToken(),
@@ -115,11 +109,12 @@ type testAccUserDataSourceConfig struct {
 	URL   string
 	Token string
 
-	ID       string
-	Username string
+	ID       *string
+	Username *string
 }
 
 func (c testAccUserDataSourceConfig) String(t *testing.T) string {
+	t.Helper()
 	tpl := `
 provider coderd {
 	url = "{{.URL}}"
@@ -127,21 +122,19 @@ provider coderd {
 }
 
 data "coderd_user" "test" {
-{{- if .ID }}
-  id = "{{ .ID }}"
-{{- end }}
-{{- if .Username }}
-  username = "{{ .Username }}"
-{{- end }}
+	id       = {{orNull .ID}}
+	username = {{orNull .Username}}
 }`
 
-	tmpl := template.Must(template.New("userDataSource").Parse(tpl))
+	funcMap := template.FuncMap{
+		"orNull": PrintOrNull,
+	}
 
 	buf := strings.Builder{}
-	err := tmpl.Execute(&buf, c)
-	if err != nil {
-		panic(err)
-	}
+	tmpl, err := template.New("userDataSource").Funcs(funcMap).Parse(tpl)
+	require.NoError(t, err)
 
+	err = tmpl.Execute(&buf, c)
+	require.NoError(t, err)
 	return buf.String()
 }
diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go
index f955310..27f09a8 100644
--- a/internal/provider/user_resource_test.go
+++ b/internal/provider/user_resource_test.go
@@ -2,7 +2,6 @@ package provider
 
 import (
 	"context"
-	"fmt"
 	"os"
 	"strings"
 	"testing"
@@ -18,7 +17,7 @@ func TestAccUserResource(t *testing.T) {
 		t.Skip("Acceptance tests are disabled.")
 	}
 	ctx := context.Background()
-	client := integration.StartCoder(ctx, t, "user_acc")
+	client := integration.StartCoder(ctx, t, "user_acc", false)
 
 	cfg1 := testAccUserResourceConfig{
 		URL:       client.URL.String(),
@@ -47,8 +46,8 @@ func TestAccUserResource(t *testing.T) {
 					resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User"),
 					resource.TestCheckResourceAttr("coderd_user.test", "email", "example@coder.com"),
 					resource.TestCheckResourceAttr("coderd_user.test", "roles.#", "2"),
-					resource.TestCheckResourceAttr("coderd_user.test", "roles.0", "auditor"),
-					resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "owner"),
+					resource.TestCheckTypeSetElemAttr("coderd_user.test", "roles.*", "auditor"),
+					resource.TestCheckTypeSetElemAttr("coderd_user.test", "roles.*", "owner"),
 					resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"),
 					resource.TestCheckResourceAttr("coderd_user.test", "password", "SomeSecurePassword!"),
 					resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"),
@@ -89,6 +88,7 @@ type testAccUserResourceConfig struct {
 }
 
 func (c testAccUserResourceConfig) String(t *testing.T) string {
+	t.Helper()
 	tpl := `
 provider coderd {
 	url = "{{.URL}}"
@@ -107,39 +107,7 @@ resource "coderd_user" "test" {
 `
 	// Define template functions
 	funcMap := template.FuncMap{
-		"orNull": func(v interface{}) string {
-			if v == nil {
-				return "null"
-			}
-			switch value := v.(type) {
-			case *string:
-				if value == nil {
-					return "null"
-				}
-				return fmt.Sprintf("%q", *value)
-			case *bool:
-				if value == nil {
-					return "null"
-				}
-				return fmt.Sprintf(`%t`, *value)
-			case *[]string:
-				if value == nil {
-					return "null"
-				}
-				var result string
-				for i, role := range *value {
-					if i > 0 {
-						result += ", "
-					}
-					result += fmt.Sprintf("%q", role)
-				}
-				return fmt.Sprintf("[%s]", result)
-
-			default:
-				require.NoError(t, fmt.Errorf("unknown type in template: %T", value))
-				return ""
-			}
-		},
+		"orNull": PrintOrNull,
 	}
 
 	buf := strings.Builder{}
diff --git a/internal/provider/util.go b/internal/provider/util.go
index c9fbc9f..c0c8161 100644
--- a/internal/provider/util.go
+++ b/internal/provider/util.go
@@ -1,5 +1,48 @@
 package provider
 
+import (
+	"fmt"
+)
+
 func PtrTo[T any](v T) *T {
 	return &v
 }
+
+func PrintOrNull(v any) string {
+	if v == nil {
+		return "null"
+	}
+	switch value := v.(type) {
+	case *int32:
+		if value == nil {
+			return "null"
+		}
+		return fmt.Sprintf("%d", *value)
+	case *string:
+		if value == nil {
+			return "null"
+		}
+		out := fmt.Sprintf("%q", *value)
+		return out
+	case *bool:
+		if value == nil {
+			return "null"
+		}
+		return fmt.Sprintf(`%t`, *value)
+	case *[]string:
+		if value == nil {
+			return "null"
+		}
+		var result string
+		for i, role := range *value {
+			if i > 0 {
+				result += ", "
+			}
+			result += fmt.Sprintf("%q", role)
+		}
+		return fmt.Sprintf("[%s]", result)
+
+	default:
+		panic(fmt.Errorf("unknown type in template: %T", value))
+	}
+}