From 2fb0bd45d750b6265274abdf457a452542154397 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 15 Aug 2025 06:30:12 +0000 Subject: [PATCH 1/3] feat: add `coderd_organization_group_sync_resource` --- docs/resources/organization.md | 4 +- docs/resources/organization_group_sync.md | 68 ++++ .../coderd_organization_group_sync/import.sh | 10 + .../resource.tf | 12 + integration/integration_test.go | 39 +++ integration/org-group-sync-test/main.tf | 45 +++ internal/codersdkvalidator/regex.go | 2 +- .../organization_group_sync_resource.go | 291 ++++++++++++++++++ .../organization_group_sync_resource_test.go | 196 ++++++++++++ internal/provider/organization_resource.go | 5 +- internal/provider/provider.go | 1 + 11 files changed, 670 insertions(+), 3 deletions(-) create mode 100644 docs/resources/organization_group_sync.md create mode 100644 examples/resources/coderd_organization_group_sync/import.sh create mode 100644 examples/resources/coderd_organization_group_sync/resource.tf create mode 100644 integration/org-group-sync-test/main.tf create mode 100644 internal/provider/organization_group_sync_resource.go create mode 100644 internal/provider/organization_group_sync_resource_test.go diff --git a/docs/resources/organization.md b/docs/resources/organization.md index 53969bf..2848cbe 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -56,7 +56,9 @@ resource "coderd_organization" "blueberry" { - `description` (String) - `display_name` (String) Display name of the organization. Defaults to name. -- `group_sync` (Block, Optional) Group sync settings to sync groups from an IdP. (see [below for nested schema](#nestedblock--group_sync)) +- `group_sync` (Block, Optional, Deprecated) Group sync settings to sync groups from an IdP. + +~> **Deprecated** This block is deprecated. Use the `coderd_organization_group_sync` resource instead. (see [below for nested schema](#nestedblock--group_sync)) - `icon` (String) - `org_sync_idp_groups` (Set of String) Claims from the IdP provider that will give users access to this organization. - `role_sync` (Block, Optional) Role sync settings to sync organization roles from an IdP. (see [below for nested schema](#nestedblock--role_sync)) diff --git a/docs/resources/organization_group_sync.md b/docs/resources/organization_group_sync.md new file mode 100644 index 0000000..c372633 --- /dev/null +++ b/docs/resources/organization_group_sync.md @@ -0,0 +1,68 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_organization_group_sync Resource - terraform-provider-coderd" +subcategory: "" +description: |- + Group sync settings for an organization on the Coder deployment. + Multiple instances of this resource for a single organization will conflict. + ~> Warning + This resource is only compatible with Coder version 2.16.0 https://github.com/coder/coder/releases/tag/v2.16.0 and later. +--- + +# coderd_organization_group_sync (Resource) + +Group sync settings for an organization on the Coder deployment. +Multiple instances of this resource for a single organization will conflict. + +~> **Warning** +This resource is only compatible with Coder version [2.16.0](https://github.com/coder/coder/releases/tag/v2.16.0) and later. + +## Example Usage + +```terraform +resource "coderd_organization_group_sync" "test" { + organization_id = coderd_organization.test.id + field = "groups" + regex_filter = "test_.*|admin_.*" + auto_create_missing = false + + mapping = { + "test_developers" = [coderd_group.test.id] + "admin_users" = [coderd_group.admins.id] + "mixed_group" = [coderd_group.test.id, coderd_group.admins.id] + } +} +``` + + +## Schema + +### Required + +- `field` (String) The claim field that specifies what groups a user should be in. +- `mapping` (Map of List of String) A map from OIDC group name to Coder group ID. +- `organization_id` (String) The ID of the organization to configure group sync for. + +### Optional + +- `auto_create_missing` (Boolean) Controls whether groups will be created if they are missing. Defaults to false. +- `regex_filter` (String) A regular expression that will be used to filter the groups returned by the OIDC provider. Any group not matched will be ignored. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID supplied must be an organization UUID +$ terraform import coderd_organization_group_sync.main_group_sync +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_organization_group_sync.main_group_sync + id = "" +} +``` diff --git a/examples/resources/coderd_organization_group_sync/import.sh b/examples/resources/coderd_organization_group_sync/import.sh new file mode 100644 index 0000000..e6fa126 --- /dev/null +++ b/examples/resources/coderd_organization_group_sync/import.sh @@ -0,0 +1,10 @@ +# The ID supplied must be an organization UUID +$ terraform import coderd_organization_group_sync.main_group_sync +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_organization_group_sync.main_group_sync + id = "" +} diff --git a/examples/resources/coderd_organization_group_sync/resource.tf b/examples/resources/coderd_organization_group_sync/resource.tf new file mode 100644 index 0000000..acce998 --- /dev/null +++ b/examples/resources/coderd_organization_group_sync/resource.tf @@ -0,0 +1,12 @@ +resource "coderd_organization_group_sync" "test" { + organization_id = coderd_organization.test.id + field = "groups" + regex_filter = "test_.*|admin_.*" + auto_create_missing = false + + mapping = { + "test_developers" = [coderd_group.test.id] + "admin_users" = [coderd_group.admins.id] + "mixed_group" = [coderd_group.test.id, coderd_group.admins.id] + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 79834a8..ad2f212 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -106,6 +106,45 @@ func TestIntegration(t *testing.T) { assert.Equal(t, group.QuotaAllowance, 100) }, }, + { + name: "org-group-sync-test", + preF: func(t testing.TB, c *codersdk.Client) {}, + assertF: func(t testing.TB, c *codersdk.Client) { + org, err := c.OrganizationByName(ctx, "test-org-group-sync") + assert.NoError(t, err) + assert.Equal(t, "test-org-group-sync", org.Name) + assert.Equal(t, "Test Organization for Group Sync", org.DisplayName) + + testGroup, err := c.GroupByOrgAndName(ctx, org.ID, "test-group") + assert.NoError(t, err) + assert.Equal(t, "test-group", testGroup.Name) + assert.Equal(t, "Test Group", testGroup.DisplayName) + assert.Equal(t, 50, testGroup.QuotaAllowance) + + adminGroup, err := c.GroupByOrgAndName(ctx, org.ID, "admin-group") + assert.NoError(t, err) + assert.Equal(t, "admin-group", adminGroup.Name) + assert.Equal(t, "Admin Group", adminGroup.DisplayName) + assert.Equal(t, 100, adminGroup.QuotaAllowance) + + // Verify group sync settings + groupSync, err := c.GroupIDPSyncSettings(ctx, org.ID.String()) + assert.NoError(t, err) + assert.Equal(t, "groups", groupSync.Field) + assert.NotNil(t, groupSync.RegexFilter) + assert.Equal(t, "test_.*|admin_.*", groupSync.RegexFilter.String()) + assert.False(t, groupSync.AutoCreateMissing) + + assert.Contains(t, groupSync.Mapping, "test_developers") + assert.Contains(t, groupSync.Mapping, "admin_users") + assert.Contains(t, groupSync.Mapping, "mixed_group") + + assert.Contains(t, groupSync.Mapping["test_developers"], testGroup.ID) + assert.Contains(t, groupSync.Mapping["admin_users"], adminGroup.ID) + assert.Contains(t, groupSync.Mapping["mixed_group"], testGroup.ID) + assert.Contains(t, groupSync.Mapping["mixed_group"], adminGroup.ID) + }, + }, { name: "template-test", preF: func(t testing.TB, c *codersdk.Client) {}, diff --git a/integration/org-group-sync-test/main.tf b/integration/org-group-sync-test/main.tf new file mode 100644 index 0000000..80d701f --- /dev/null +++ b/integration/org-group-sync-test/main.tf @@ -0,0 +1,45 @@ +terraform { + required_providers { + coderd = { + source = "coder/coderd" + version = ">=0.0.0" + } + } +} + +resource "coderd_organization" "test" { + name = "test-org-group-sync" + display_name = "Test Organization for Group Sync" + description = "Organization created for testing group sync functionality" +} + +resource "coderd_group" "test" { + organization_id = coderd_organization.test.id + name = "test-group" + display_name = "Test Group" + quota_allowance = 50 +} + +resource "coderd_group" "admins" { + organization_id = coderd_organization.test.id + name = "admin-group" + display_name = "Admin Group" + quota_allowance = 100 +} + +resource "coderd_organization_group_sync" "test" { + organization_id = coderd_organization.test.id + field = "groups" + regex_filter = "test_.*|admin_.*" + auto_create_missing = false + + mapping = { + "test_developers" = [coderd_group.test.id] + "admin_users" = [coderd_group.admins.id] + "mixed_group" = [coderd_group.test.id, coderd_group.admins.id] + } +} + +data "coderd_organization" "test_data" { + id = coderd_organization.test.id +} \ No newline at end of file diff --git a/internal/codersdkvalidator/regex.go b/internal/codersdkvalidator/regex.go index 0077616..7ad2120 100644 --- a/internal/codersdkvalidator/regex.go +++ b/internal/codersdkvalidator/regex.go @@ -7,7 +7,7 @@ import ( ) func checkRegexp(it string) error { - _, err := regexp.Compile("") + _, err := regexp.Compile(it) return err } diff --git a/internal/provider/organization_group_sync_resource.go b/internal/provider/organization_group_sync_resource.go new file mode 100644 index 0000000..36ca841 --- /dev/null +++ b/internal/provider/organization_group_sync_resource.go @@ -0,0 +1,291 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "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 = &OrganizationGroupSyncResource{} +var _ resource.ResourceWithImportState = &OrganizationGroupSyncResource{} + +type OrganizationGroupSyncResource struct { + *CoderdProviderData +} + +// OrganizationGroupSyncResourceModel describes the resource data model. +type OrganizationGroupSyncResourceModel struct { + OrganizationID UUID `tfsdk:"organization_id"` + Field types.String `tfsdk:"field"` + RegexFilter types.String `tfsdk:"regex_filter"` + AutoCreateMissing types.Bool `tfsdk:"auto_create_missing"` + Mapping types.Map `tfsdk:"mapping"` +} + +func NewOrganizationGroupSyncResource() resource.Resource { + return &OrganizationGroupSyncResource{} +} + +func (r *OrganizationGroupSyncResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization_group_sync" +} + +func (r *OrganizationGroupSyncResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `Group sync settings for an organization on the Coder deployment. +Multiple instances of this resource for a single organization will conflict. + +~> **Warning** +This resource is only compatible with Coder version [2.16.0](https://github.com/coder/coder/releases/tag/v2.16.0) and later. +`, + Attributes: map[string]schema.Attribute{ + "organization_id": schema.StringAttribute{ + CustomType: UUIDType, + Required: true, + MarkdownDescription: "The ID of the organization to configure group sync for.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "field": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The claim field that specifies what groups a user should be in.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "regex_filter": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A regular expression that will be used to filter the groups " + + "returned by the OIDC provider. Any group not matched will be ignored.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + codersdkvalidator.Regexp(), + }, + }, + "auto_create_missing": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Controls whether groups will be created if they are missing. Defaults to false.", + }, + "mapping": schema.MapAttribute{ + ElementType: types.ListType{ElemType: UUIDType}, + Required: true, + MarkdownDescription: "A map from OIDC group name to Coder group ID.", + }, + }, + } +} + +func (r *OrganizationGroupSyncResource) 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( + "Unable to configure provider data", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.CoderdProviderData = data +} + +func (r *OrganizationGroupSyncResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Read Terraform prior state data into the model + var data OrganizationGroupSyncResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.OrganizationID.ValueUUID() + + groupSync, err := r.Client.GroupIDPSyncSettings(ctx, orgID.String()) + if err != nil { + if isNotFound(err) { + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Organization with ID %q not found. Marking resource as deleted.", orgID.String())) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization group sync settings, got error: %s", err)) + return + } + + data.Field = types.StringValue(groupSync.Field) + + if groupSync.RegexFilter != nil { + data.RegexFilter = types.StringValue(groupSync.RegexFilter.String()) + } else { + data.RegexFilter = types.StringNull() + } + + data.AutoCreateMissing = types.BoolValue(groupSync.AutoCreateMissing) + + elements := make(map[string][]string) + for key, ids := range groupSync.Mapping { + for _, id := range ids { + elements[key] = append(elements[key], id.String()) + } + } + + mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: UUIDType}, elements) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.Mapping = mapping + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationGroupSyncResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Read Terraform plan data into the model + var data OrganizationGroupSyncResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.OrganizationID.ValueUUID() + + tflog.Trace(ctx, "creating organization group sync", map[string]any{ + "organization_id": orgID, + "field": data.Field.ValueString(), + }) + + // Apply group sync settings + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationGroupSyncResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Read Terraform plan data into the model + var data OrganizationGroupSyncResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.OrganizationID.ValueUUID() + + tflog.Trace(ctx, "updating organization group sync", map[string]any{ + "organization_id": orgID, + "field": data.Field.ValueString(), + }) + + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data)...) + if resp.Diagnostics.HasError() { + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationGroupSyncResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Read Terraform prior state data into the model + var data OrganizationGroupSyncResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + orgID := data.OrganizationID.ValueUUID() + + tflog.Trace(ctx, "deleting organization group sync", map[string]any{ + "organization_id": orgID, + }) + + // Disable group sync by setting field to empty string + var groupSync codersdk.GroupSyncSettings + groupSync.Field = "" // Empty field disables group sync + groupSync.AutoCreateMissing = false + groupSync.Mapping = make(map[string][]uuid.UUID) + groupSync.RegexFilter = nil + + // Perform the PATCH to disable group sync + _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) + if err != nil { + if isNotFound(err) { + // Organization doesn't exist, so group sync is already "deleted" + return + } + resp.Diagnostics.AddError("Group Sync Delete error", err.Error()) + return + } +} + +func (r *OrganizationGroupSyncResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Import using organization ID + resource.ImportStatePassthroughID(ctx, path.Root("organization_id"), req, resp) +} + +func (r *OrganizationGroupSyncResource) patchGroupSync( + ctx context.Context, + orgID uuid.UUID, + data OrganizationGroupSyncResourceModel, +) diag.Diagnostics { + var diags diag.Diagnostics + + var groupSync codersdk.GroupSyncSettings + groupSync.Field = data.Field.ValueString() + + if !data.RegexFilter.IsNull() { + groupSync.RegexFilter = regexp.MustCompile(data.RegexFilter.ValueString()) + } + + groupSync.AutoCreateMissing = data.AutoCreateMissing.ValueBool() + groupSync.Mapping = make(map[string][]uuid.UUID) + + // Mapping is required, so always process it (can be empty) + // Terraform doesn't know how to turn one our `UUID` Terraform values into a + // `uuid.UUID`, so we have to do the unwrapping manually here. + var mapping map[string][]UUID + diags.Append(data.Mapping.ElementsAs(ctx, &mapping, false)...) + if diags.HasError() { + return diags + } + for key, ids := range mapping { + for _, id := range ids { + groupSync.Mapping[key] = append(groupSync.Mapping[key], id.ValueUUID()) + } + } + + // Perform the PATCH + _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) + if err != nil { + diags.AddError("Group Sync Update error", err.Error()) + return diags + } + + return diags +} diff --git a/internal/provider/organization_group_sync_resource_test.go b/internal/provider/organization_group_sync_resource_test.go new file mode 100644 index 0000000..bd0a88c --- /dev/null +++ b/internal/provider/organization_group_sync_resource_test.go @@ -0,0 +1,196 @@ +package provider + +import ( + "os" + "regexp" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/require" +) + +func TestAccOrganizationGroupSyncResource(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + ctx := t.Context() + client := integration.StartCoder(ctx, t, "organization_group_sync_acc", true) + _, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + // Create an organization first + org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "test-org", + DisplayName: "Test Organization", + }) + require.NoError(t, err) + + cfg1 := testAccOrganizationGroupSyncResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OrganizationID: org.ID.String(), + Field: "groups", + Mapping: map[string][]string{}, // Empty mapping + } + + cfg2 := cfg1 + cfg2.Field = "updated_groups" + cfg2.RegexFilter = ptr.Ref(".*test.*") + cfg2.AutoCreateMissing = ptr.Ref(true) + cfg2.Mapping = map[string][]string{ + "test_group": {"6e57187f-6543-46ab-a62c-a10065dd4314"}, + } + + cfg3 := cfg2 + cfg3.Mapping = map[string][]string{ + "new_group": {"6e57187f-6543-46ab-a62c-a10065dd4314"}, + } + + t.Run("CreateImportUpdateReadOk", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read + { + Config: cfg1.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("organization_id"), knownvalue.StringExact(org.ID.String())), + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("field"), knownvalue.StringExact("groups")), + }, + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_organization_group_sync.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: org.ID.String(), + ImportStateVerifyIdentifierAttribute: "organization_id", + }, + // Update and Read + { + Config: cfg2.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("field"), knownvalue.StringExact("updated_groups")), + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("regex_filter"), knownvalue.StringExact(".*test.*")), + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("auto_create_missing"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("mapping").AtMapKey("test_group").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), + }, + }, + // Update mapping + { + Config: cfg3.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("mapping").AtMapKey("new_group").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), + }, + }, + }, + }) + }) + + t.Run("MinimalConfig", func(t *testing.T) { + minimalCfg := testAccOrganizationGroupSyncResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OrganizationID: org.ID.String(), + Field: "minimal", + Mapping: map[string][]string{}, // Empty mapping + } + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: minimalCfg.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("field"), knownvalue.StringExact("minimal")), + statecheck.ExpectKnownValue("coderd_organization_group_sync.test", tfjsonpath.New("organization_id"), knownvalue.StringExact(org.ID.String())), + }, + }, + }, + }) + }) + + t.Run("InvalidRegexFilter", func(t *testing.T) { + invalidRegexCfg := testAccOrganizationGroupSyncResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + OrganizationID: org.ID.String(), + Field: "invalid_regex", + RegexFilter: ptr.Ref("[invalid"), + Mapping: map[string][]string{}, + } + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: invalidRegexCfg.String(t), + ExpectError: regexp.MustCompile("error parsing regexp"), + }, + }, + }) + }) +} + +type testAccOrganizationGroupSyncResourceConfig struct { + URL string + Token string + OrganizationID string + + Field string + RegexFilter *string + AutoCreateMissing *bool + Mapping map[string][]string +} + +func (c testAccOrganizationGroupSyncResourceConfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_organization_group_sync" "test" { + organization_id = "{{.OrganizationID}}" + field = {{printf "%q" .Field}} + {{- if .RegexFilter}} + regex_filter = {{orNull .RegexFilter}} + {{- end}} + {{- if .AutoCreateMissing}} + auto_create_missing = {{.AutoCreateMissing}} + {{- end}} + mapping = { + {{- range $key, $value := .Mapping}} + {{$key}} = {{printf "%q" $value}} + {{- end}} + } +} +` + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("organizationGroupSyncResource").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/organization_resource.go b/internal/provider/organization_resource.go index 8fbaba5..c1a40ae 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -145,7 +145,10 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/ Blocks: map[string]schema.Block{ "group_sync": schema.SingleNestedBlock{ - MarkdownDescription: `Group sync settings to sync groups from an IdP.`, + MarkdownDescription: `Group sync settings to sync groups from an IdP. + +~> **Deprecated** This block is deprecated. Use the ` + "`coderd_organization_group_sync`" + ` resource instead.`, + DeprecationMessage: "Use the coderd_organization_group_sync resource instead.", Attributes: map[string]schema.Attribute{ "field": schema.StringAttribute{ Optional: true, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8c65385..6e7f796 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -151,6 +151,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewOrganizationResource, NewProvisionerKeyResource, NewOrganizationSyncSettingsResource, + NewOrganizationGroupSyncResource, } } From 1941bfd0ac937d81e52abbfce431e96a4e551c6b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 15 Aug 2025 07:09:03 +0000 Subject: [PATCH 2/3] review --- .../organization_group_sync_resource.go | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/internal/provider/organization_group_sync_resource.go b/internal/provider/organization_group_sync_resource.go index 36ca841..9bb16ed 100644 --- a/internal/provider/organization_group_sync_resource.go +++ b/internal/provider/organization_group_sync_resource.go @@ -225,15 +225,8 @@ func (r *OrganizationGroupSyncResource) Delete(ctx context.Context, req resource "organization_id": orgID, }) - // Disable group sync by setting field to empty string - var groupSync codersdk.GroupSyncSettings - groupSync.Field = "" // Empty field disables group sync - groupSync.AutoCreateMissing = false - groupSync.Mapping = make(map[string][]uuid.UUID) - groupSync.RegexFilter = nil - - // Perform the PATCH to disable group sync - _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), groupSync) + // Sending all zero-values will delete the group sync configuration + _, err := r.Client.PatchGroupIDPSyncSettings(ctx, orgID.String(), codersdk.GroupSyncSettings{}) if err != nil { if isNotFound(err) { // Organization doesn't exist, so group sync is already "deleted" @@ -256,16 +249,16 @@ func (r *OrganizationGroupSyncResource) patchGroupSync( ) diag.Diagnostics { var diags diag.Diagnostics - var groupSync codersdk.GroupSyncSettings - groupSync.Field = data.Field.ValueString() + groupSync := codersdk.GroupSyncSettings{ + Field: data.Field.ValueString(), + AutoCreateMissing: data.AutoCreateMissing.ValueBool(), + Mapping: make(map[string][]uuid.UUID), + } if !data.RegexFilter.IsNull() { groupSync.RegexFilter = regexp.MustCompile(data.RegexFilter.ValueString()) } - groupSync.AutoCreateMissing = data.AutoCreateMissing.ValueBool() - groupSync.Mapping = make(map[string][]uuid.UUID) - // Mapping is required, so always process it (can be empty) // Terraform doesn't know how to turn one our `UUID` Terraform values into a // `uuid.UUID`, so we have to do the unwrapping manually here. From dfd24b5dab98b9400153c2498f80cc5382c049d7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 18 Aug 2025 03:35:03 +0000 Subject: [PATCH 3/3] review --- integration/org-group-sync-test/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/org-group-sync-test/main.tf b/integration/org-group-sync-test/main.tf index 80d701f..8fc4a92 100644 --- a/integration/org-group-sync-test/main.tf +++ b/integration/org-group-sync-test/main.tf @@ -42,4 +42,4 @@ resource "coderd_organization_group_sync" "test" { data "coderd_organization" "test_data" { id = coderd_organization.test.id -} \ No newline at end of file +}