From ed8270c8d2e24c36693644104330d3943f4aa6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Fri, 21 Feb 2025 14:12:16 -0700 Subject: [PATCH] feat: add `org_sync_idp_groups` attribute to `coderd_organization` resource (#182) --- docs/resources/organization.md | 29 ++++ .../resources/coderd_organization/resource.tf | 25 ++++ internal/provider/organization_resource.go | 127 +++++++++++++++--- .../provider/organization_resource_test.go | 41 +++++- .../organization_sync_settings_resource.go | 2 +- internal/provider/util.go | 6 +- 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 examples/resources/coderd_organization/resource.tf diff --git a/docs/resources/organization.md b/docs/resources/organization.md index 6dbc5d1..88246dd 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -15,7 +15,35 @@ An organization on the Coder deployment. ~> **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" "blueberry" { + name = "blueberry" + display_name = "Blueberry" + description = "The organization for blueberries" + icon = "/emojis/1fad0.png" + + org_sync_idp_groups = [ + "wibble", + "wobble", + ] + + group_sync { + field = "coder_groups" + mapping = { + toast = [coderd_group.bread.id] + } + } + + role_sync { + field = "coder_roles" + mapping = { + manager = ["organization-user-admin"] + } + } +} +``` ## Schema @@ -30,6 +58,7 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/ - `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)) - `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)) ### Read-Only diff --git a/examples/resources/coderd_organization/resource.tf b/examples/resources/coderd_organization/resource.tf new file mode 100644 index 0000000..cb26a86 --- /dev/null +++ b/examples/resources/coderd_organization/resource.tf @@ -0,0 +1,25 @@ +resource "coderd_organization" "blueberry" { + name = "blueberry" + display_name = "Blueberry" + description = "The organization for blueberries" + icon = "/emojis/1fad0.png" + + org_sync_idp_groups = [ + "wibble", + "wobble", + ] + + group_sync { + field = "coder_groups" + mapping = { + toast = [coderd_group.bread.id] + } + } + + role_sync { + field = "coder_roles" + mapping = { + manager = ["organization-user-admin"] + } + } +} diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 2c39f0c..a83ff25 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" "github.com/google/uuid" @@ -40,8 +41,9 @@ type OrganizationResourceModel struct { Description types.String `tfsdk:"description"` Icon types.String `tfsdk:"icon"` - GroupSync types.Object `tfsdk:"group_sync"` - RoleSync types.Object `tfsdk:"role_sync"` + OrgSyncIdpGroups types.Set `tfsdk:"org_sync_idp_groups"` + GroupSync types.Object `tfsdk:"group_sync"` + RoleSync types.Object `tfsdk:"role_sync"` } type GroupSyncModel struct { @@ -134,6 +136,12 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/ Computed: true, Default: stringdefault.StaticString(""), }, + + "org_sync_idp_groups": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + MarkdownDescription: "Claims from the IdP provider that will give users access to this organization.", + }, }, Blocks: map[string]schema.Block{ @@ -361,21 +369,38 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe // default it. data.DisplayName = types.StringValue(org.DisplayName) - // Now apply group and role sync settings, if specified orgID := data.ID.ValueUUID() - tflog.Trace(ctx, "updating group sync", map[string]any{ - "orgID": orgID, - }) + + // Apply org sync patches, if specified + if !data.OrgSyncIdpGroups.IsNull() { + tflog.Trace(ctx, "updating org sync", map[string]any{ + "orgID": orgID, + }) + + var claims []string + resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &claims, false)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, []string{}, claims)...) + } + + // Apply group and role sync settings, if specified if !data.GroupSync.IsNull() { + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) + resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) if resp.Diagnostics.HasError() { return } } - tflog.Trace(ctx, "updating role sync", map[string]any{ - "orgID": orgID, - }) if !data.RoleSync.IsNull() { + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) if resp.Diagnostics.HasError() { return @@ -423,19 +448,42 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe "icon": org.Icon, }) - tflog.Trace(ctx, "updating group sync", map[string]any{ - "orgID": orgID, - }) + // Apply org sync patches, if specified + if !data.OrgSyncIdpGroups.IsNull() { + tflog.Trace(ctx, "updating org sync mappings", map[string]any{ + "orgID": orgID, + }) + + var state OrganizationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + var currentClaims []string + resp.Diagnostics.Append(state.OrgSyncIdpGroups.ElementsAs(ctx, ¤tClaims, false)...) + + var plannedClaims []string + resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &plannedClaims, false)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, currentClaims, plannedClaims)...) + if resp.Diagnostics.HasError() { + return + } + } + if !data.GroupSync.IsNull() { + tflog.Trace(ctx, "updating group sync", map[string]any{ + "orgID": orgID, + }) resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...) if resp.Diagnostics.HasError() { return } } - tflog.Trace(ctx, "updating role sync", map[string]any{ - "orgID": orgID, - }) if !data.RoleSync.IsNull() { + tflog.Trace(ctx, "updating role sync", map[string]any{ + "orgID": orgID, + }) resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...) if resp.Diagnostics.HasError() { return @@ -456,6 +504,21 @@ func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRe orgID := data.ID.ValueUUID() + // Remove org sync mappings, if we were managing them + if !data.OrgSyncIdpGroups.IsNull() { + tflog.Trace(ctx, "deleting org sync mappings", map[string]any{ + "orgID": orgID, + }) + + var claims []string + resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &claims, false)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, claims, []string{})...) + } + tflog.Trace(ctx, "deleting organization", map[string]any{ "id": orgID, "name": data.Name.ValueString(), @@ -554,3 +617,37 @@ func (r *OrganizationResource) patchRoleSync( return diags } + +func (r *OrganizationResource) patchOrgSyncMapping( + ctx context.Context, + orgID uuid.UUID, + currentClaims, plannedClaims []string, +) diag.Diagnostics { + var diags diag.Diagnostics + + add, remove := slice.SymmetricDifference(currentClaims, plannedClaims) + var addMappings []codersdk.IDPSyncMapping[uuid.UUID] + for _, claim := range add { + addMappings = append(addMappings, codersdk.IDPSyncMapping[uuid.UUID]{ + Given: claim, + Gets: orgID, + }) + } + var removeMappings []codersdk.IDPSyncMapping[uuid.UUID] + for _, claim := range remove { + removeMappings = append(removeMappings, codersdk.IDPSyncMapping[uuid.UUID]{ + Given: claim, + Gets: orgID, + }) + } + + _, err := r.Client.PatchOrganizationIDPSyncMapping(ctx, codersdk.PatchOrganizationIDPSyncMappingRequest{ + Add: addMappings, + Remove: removeMappings, + }) + if err != nil { + diags.AddError("Org Sync Update error", err.Error()) + } + + return diags +} diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index 0a755c4..40a7465 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -42,13 +42,19 @@ func TestAccOrganizationResource(t *testing.T) { cfg2.DisplayName = ptr.Ref("Example Organization New") cfg3 := cfg2 - cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ + cfg3.OrgSyncIdpGroups = []string{"wibble", "wobble"} + + cfg4 := cfg3 + cfg4.OrgSyncIdpGroups = []string{"wibbley", "wobbley"} + + cfg5 := cfg4 + cfg5.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{ Field: "wibble", Mapping: map[string][]uuid.UUID{ "wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")}, }, }) - cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ + cfg5.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{ Field: "wobble", Mapping: map[string][]string{ "wobble": {"wobbly"}, @@ -86,9 +92,25 @@ func TestAccOrganizationResource(t *testing.T) { statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")), }, }, - // Add group and role sync + // Add org sync { Config: cfg3.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(0), knownvalue.StringExact("wibble")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(1), knownvalue.StringExact("wobble")), + }, + }, + // Patch org sync + { + Config: cfg4.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(0), knownvalue.StringExact("wibbley")), + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(1), knownvalue.StringExact("wobbley")), + }, + }, + // Add group and role sync + { + Config: cfg5.String(t), ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")), statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("mapping").AtMapKey("wibble").AtSliceIndex(0), knownvalue.StringExact("6e57187f-6543-46ab-a62c-a10065dd4314")), @@ -110,8 +132,9 @@ type testAccOrganizationResourceConfig struct { Description *string Icon *string - GroupSync *codersdk.GroupSyncSettings - RoleSync *codersdk.RoleSyncSettings + OrgSyncIdpGroups []string + GroupSync *codersdk.GroupSyncSettings + RoleSync *codersdk.RoleSyncSettings } func (c testAccOrganizationResourceConfig) String(t *testing.T) string { @@ -128,6 +151,14 @@ resource "coderd_organization" "test" { description = {{orNull .Description}} icon = {{orNull .Icon}} + {{- if .OrgSyncIdpGroups}} + org_sync_idp_groups = [ + {{- range $name := .OrgSyncIdpGroups }} + "{{$name}}", + {{- end}} + ] + {{- end}} + {{- if .GroupSync}} group_sync { field = "{{.GroupSync.Field}}" diff --git a/internal/provider/organization_sync_settings_resource.go b/internal/provider/organization_sync_settings_resource.go index 0283c40..d492d3d 100644 --- a/internal/provider/organization_sync_settings_resource.go +++ b/internal/provider/organization_sync_settings_resource.go @@ -244,7 +244,7 @@ func (r *OrganizationSyncSettingsResource) Delete(ctx context.Context, req resou tflog.Trace(ctx, "deleting organization sync", map[string]any{}) _, err := r.Client.PatchOrganizationIDPSyncConfig(ctx, codersdk.PatchOrganizationIDPSyncConfigRequest{ // This disables organization sync without causing state conflicts for - // organization resources that might still specify `sync_mapping`. + // organization resources that might still specify `org_sync_idp_groups`. Field: "", }) if err != nil { diff --git a/internal/provider/util.go b/internal/provider/util.go index 169286f..e409738 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -83,8 +83,10 @@ func computeDirectoryHash(directory string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } -// memberDiff returns the members to add and remove from the group, given the current members and the planned members. -// plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set. +// memberDiff returns the members to add and remove from the group, given the +// current members and the planned members. plannedMembers is deliberately our +// custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a +// set. func memberDiff(currentMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) { curSet := make(map[uuid.UUID]struct{}, len(currentMembers)) planSet := make(map[uuid.UUID]struct{}, len(plannedMembers))