Skip to content

Commit ed8270c

Browse files
authored
feat: add org_sync_idp_groups attribute to coderd_organization resource (#182)
1 parent 5fa9117 commit ed8270c

File tree

6 files changed

+207
-23
lines changed

6 files changed

+207
-23
lines changed

docs/resources/organization.md

+29
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,35 @@ An organization on the Coder deployment.
1515
~> **Warning**
1616
This resource is only compatible with Coder version [2.16.0](https://github.com/coder/coder/releases/tag/v2.16.0) and later.
1717

18+
## Example Usage
1819

20+
```terraform
21+
resource "coderd_organization" "blueberry" {
22+
name = "blueberry"
23+
display_name = "Blueberry"
24+
description = "The organization for blueberries"
25+
icon = "/emojis/1fad0.png"
26+
27+
org_sync_idp_groups = [
28+
"wibble",
29+
"wobble",
30+
]
31+
32+
group_sync {
33+
field = "coder_groups"
34+
mapping = {
35+
toast = [coderd_group.bread.id]
36+
}
37+
}
38+
39+
role_sync {
40+
field = "coder_roles"
41+
mapping = {
42+
manager = ["organization-user-admin"]
43+
}
44+
}
45+
}
46+
```
1947

2048
<!-- schema generated by tfplugindocs -->
2149
## Schema
@@ -30,6 +58,7 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/
3058
- `display_name` (String) Display name of the organization. Defaults to name.
3159
- `group_sync` (Block, Optional) Group sync settings to sync groups from an IdP. (see [below for nested schema](#nestedblock--group_sync))
3260
- `icon` (String)
61+
- `org_sync_idp_groups` (Set of String) Claims from the IdP provider that will give users access to this organization.
3362
- `role_sync` (Block, Optional) Role sync settings to sync organization roles from an IdP. (see [below for nested schema](#nestedblock--role_sync))
3463

3564
### Read-Only
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
resource "coderd_organization" "blueberry" {
2+
name = "blueberry"
3+
display_name = "Blueberry"
4+
description = "The organization for blueberries"
5+
icon = "/emojis/1fad0.png"
6+
7+
org_sync_idp_groups = [
8+
"wibble",
9+
"wobble",
10+
]
11+
12+
group_sync {
13+
field = "coder_groups"
14+
mapping = {
15+
toast = [coderd_group.bread.id]
16+
}
17+
}
18+
19+
role_sync {
20+
field = "coder_roles"
21+
mapping = {
22+
manager = ["organization-user-admin"]
23+
}
24+
}
25+
}

internal/provider/organization_resource.go

+112-15
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"regexp"
77

8+
"github.com/coder/coder/v2/coderd/util/slice"
89
"github.com/coder/coder/v2/codersdk"
910
"github.com/coder/terraform-provider-coderd/internal/codersdkvalidator"
1011
"github.com/google/uuid"
@@ -40,8 +41,9 @@ type OrganizationResourceModel struct {
4041
Description types.String `tfsdk:"description"`
4142
Icon types.String `tfsdk:"icon"`
4243

43-
GroupSync types.Object `tfsdk:"group_sync"`
44-
RoleSync types.Object `tfsdk:"role_sync"`
44+
OrgSyncIdpGroups types.Set `tfsdk:"org_sync_idp_groups"`
45+
GroupSync types.Object `tfsdk:"group_sync"`
46+
RoleSync types.Object `tfsdk:"role_sync"`
4547
}
4648

4749
type GroupSyncModel struct {
@@ -134,6 +136,12 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/
134136
Computed: true,
135137
Default: stringdefault.StaticString(""),
136138
},
139+
140+
"org_sync_idp_groups": schema.SetAttribute{
141+
ElementType: types.StringType,
142+
Optional: true,
143+
MarkdownDescription: "Claims from the IdP provider that will give users access to this organization.",
144+
},
137145
},
138146

139147
Blocks: map[string]schema.Block{
@@ -361,21 +369,38 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe
361369
// default it.
362370
data.DisplayName = types.StringValue(org.DisplayName)
363371

364-
// Now apply group and role sync settings, if specified
365372
orgID := data.ID.ValueUUID()
366-
tflog.Trace(ctx, "updating group sync", map[string]any{
367-
"orgID": orgID,
368-
})
373+
374+
// Apply org sync patches, if specified
375+
if !data.OrgSyncIdpGroups.IsNull() {
376+
tflog.Trace(ctx, "updating org sync", map[string]any{
377+
"orgID": orgID,
378+
})
379+
380+
var claims []string
381+
resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &claims, false)...)
382+
if resp.Diagnostics.HasError() {
383+
return
384+
}
385+
386+
resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, []string{}, claims)...)
387+
}
388+
389+
// Apply group and role sync settings, if specified
369390
if !data.GroupSync.IsNull() {
391+
tflog.Trace(ctx, "updating group sync", map[string]any{
392+
"orgID": orgID,
393+
})
394+
370395
resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...)
371396
if resp.Diagnostics.HasError() {
372397
return
373398
}
374399
}
375-
tflog.Trace(ctx, "updating role sync", map[string]any{
376-
"orgID": orgID,
377-
})
378400
if !data.RoleSync.IsNull() {
401+
tflog.Trace(ctx, "updating role sync", map[string]any{
402+
"orgID": orgID,
403+
})
379404
resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...)
380405
if resp.Diagnostics.HasError() {
381406
return
@@ -423,19 +448,42 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe
423448
"icon": org.Icon,
424449
})
425450

426-
tflog.Trace(ctx, "updating group sync", map[string]any{
427-
"orgID": orgID,
428-
})
451+
// Apply org sync patches, if specified
452+
if !data.OrgSyncIdpGroups.IsNull() {
453+
tflog.Trace(ctx, "updating org sync mappings", map[string]any{
454+
"orgID": orgID,
455+
})
456+
457+
var state OrganizationResourceModel
458+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
459+
var currentClaims []string
460+
resp.Diagnostics.Append(state.OrgSyncIdpGroups.ElementsAs(ctx, &currentClaims, false)...)
461+
462+
var plannedClaims []string
463+
resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &plannedClaims, false)...)
464+
if resp.Diagnostics.HasError() {
465+
return
466+
}
467+
468+
resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, currentClaims, plannedClaims)...)
469+
if resp.Diagnostics.HasError() {
470+
return
471+
}
472+
}
473+
429474
if !data.GroupSync.IsNull() {
475+
tflog.Trace(ctx, "updating group sync", map[string]any{
476+
"orgID": orgID,
477+
})
430478
resp.Diagnostics.Append(r.patchGroupSync(ctx, orgID, data.GroupSync)...)
431479
if resp.Diagnostics.HasError() {
432480
return
433481
}
434482
}
435-
tflog.Trace(ctx, "updating role sync", map[string]any{
436-
"orgID": orgID,
437-
})
438483
if !data.RoleSync.IsNull() {
484+
tflog.Trace(ctx, "updating role sync", map[string]any{
485+
"orgID": orgID,
486+
})
439487
resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...)
440488
if resp.Diagnostics.HasError() {
441489
return
@@ -456,6 +504,21 @@ func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRe
456504

457505
orgID := data.ID.ValueUUID()
458506

507+
// Remove org sync mappings, if we were managing them
508+
if !data.OrgSyncIdpGroups.IsNull() {
509+
tflog.Trace(ctx, "deleting org sync mappings", map[string]any{
510+
"orgID": orgID,
511+
})
512+
513+
var claims []string
514+
resp.Diagnostics.Append(data.OrgSyncIdpGroups.ElementsAs(ctx, &claims, false)...)
515+
if resp.Diagnostics.HasError() {
516+
return
517+
}
518+
519+
resp.Diagnostics.Append(r.patchOrgSyncMapping(ctx, orgID, claims, []string{})...)
520+
}
521+
459522
tflog.Trace(ctx, "deleting organization", map[string]any{
460523
"id": orgID,
461524
"name": data.Name.ValueString(),
@@ -554,3 +617,37 @@ func (r *OrganizationResource) patchRoleSync(
554617

555618
return diags
556619
}
620+
621+
func (r *OrganizationResource) patchOrgSyncMapping(
622+
ctx context.Context,
623+
orgID uuid.UUID,
624+
currentClaims, plannedClaims []string,
625+
) diag.Diagnostics {
626+
var diags diag.Diagnostics
627+
628+
add, remove := slice.SymmetricDifference(currentClaims, plannedClaims)
629+
var addMappings []codersdk.IDPSyncMapping[uuid.UUID]
630+
for _, claim := range add {
631+
addMappings = append(addMappings, codersdk.IDPSyncMapping[uuid.UUID]{
632+
Given: claim,
633+
Gets: orgID,
634+
})
635+
}
636+
var removeMappings []codersdk.IDPSyncMapping[uuid.UUID]
637+
for _, claim := range remove {
638+
removeMappings = append(removeMappings, codersdk.IDPSyncMapping[uuid.UUID]{
639+
Given: claim,
640+
Gets: orgID,
641+
})
642+
}
643+
644+
_, err := r.Client.PatchOrganizationIDPSyncMapping(ctx, codersdk.PatchOrganizationIDPSyncMappingRequest{
645+
Add: addMappings,
646+
Remove: removeMappings,
647+
})
648+
if err != nil {
649+
diags.AddError("Org Sync Update error", err.Error())
650+
}
651+
652+
return diags
653+
}

internal/provider/organization_resource_test.go

+36-5
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,19 @@ func TestAccOrganizationResource(t *testing.T) {
4242
cfg2.DisplayName = ptr.Ref("Example Organization New")
4343

4444
cfg3 := cfg2
45-
cfg3.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{
45+
cfg3.OrgSyncIdpGroups = []string{"wibble", "wobble"}
46+
47+
cfg4 := cfg3
48+
cfg4.OrgSyncIdpGroups = []string{"wibbley", "wobbley"}
49+
50+
cfg5 := cfg4
51+
cfg5.GroupSync = ptr.Ref(codersdk.GroupSyncSettings{
4652
Field: "wibble",
4753
Mapping: map[string][]uuid.UUID{
4854
"wibble": {uuid.MustParse("6e57187f-6543-46ab-a62c-a10065dd4314")},
4955
},
5056
})
51-
cfg3.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{
57+
cfg5.RoleSync = ptr.Ref(codersdk.RoleSyncSettings{
5258
Field: "wobble",
5359
Mapping: map[string][]string{
5460
"wobble": {"wobbly"},
@@ -86,9 +92,25 @@ func TestAccOrganizationResource(t *testing.T) {
8692
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")),
8793
},
8894
},
89-
// Add group and role sync
95+
// Add org sync
9096
{
9197
Config: cfg3.String(t),
98+
ConfigStateChecks: []statecheck.StateCheck{
99+
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(0), knownvalue.StringExact("wibble")),
100+
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(1), knownvalue.StringExact("wobble")),
101+
},
102+
},
103+
// Patch org sync
104+
{
105+
Config: cfg4.String(t),
106+
ConfigStateChecks: []statecheck.StateCheck{
107+
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(0), knownvalue.StringExact("wibbley")),
108+
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("org_sync_idp_groups").AtSliceIndex(1), knownvalue.StringExact("wobbley")),
109+
},
110+
},
111+
// Add group and role sync
112+
{
113+
Config: cfg5.String(t),
92114
ConfigStateChecks: []statecheck.StateCheck{
93115
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("group_sync").AtMapKey("field"), knownvalue.StringExact("wibble")),
94116
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 {
110132
Description *string
111133
Icon *string
112134

113-
GroupSync *codersdk.GroupSyncSettings
114-
RoleSync *codersdk.RoleSyncSettings
135+
OrgSyncIdpGroups []string
136+
GroupSync *codersdk.GroupSyncSettings
137+
RoleSync *codersdk.RoleSyncSettings
115138
}
116139

117140
func (c testAccOrganizationResourceConfig) String(t *testing.T) string {
@@ -128,6 +151,14 @@ resource "coderd_organization" "test" {
128151
description = {{orNull .Description}}
129152
icon = {{orNull .Icon}}
130153
154+
{{- if .OrgSyncIdpGroups}}
155+
org_sync_idp_groups = [
156+
{{- range $name := .OrgSyncIdpGroups }}
157+
"{{$name}}",
158+
{{- end}}
159+
]
160+
{{- end}}
161+
131162
{{- if .GroupSync}}
132163
group_sync {
133164
field = "{{.GroupSync.Field}}"

internal/provider/organization_sync_settings_resource.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ func (r *OrganizationSyncSettingsResource) Delete(ctx context.Context, req resou
244244
tflog.Trace(ctx, "deleting organization sync", map[string]any{})
245245
_, err := r.Client.PatchOrganizationIDPSyncConfig(ctx, codersdk.PatchOrganizationIDPSyncConfigRequest{
246246
// This disables organization sync without causing state conflicts for
247-
// organization resources that might still specify `sync_mapping`.
247+
// organization resources that might still specify `org_sync_idp_groups`.
248248
Field: "",
249249
})
250250
if err != nil {

internal/provider/util.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ func computeDirectoryHash(directory string) (string, error) {
8383
return hex.EncodeToString(hash.Sum(nil)), nil
8484
}
8585

86-
// memberDiff returns the members to add and remove from the group, given the current members and the planned members.
87-
// plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set.
86+
// memberDiff returns the members to add and remove from the group, given the
87+
// current members and the planned members. plannedMembers is deliberately our
88+
// custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a
89+
// set.
8890
func memberDiff(currentMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) {
8991
curSet := make(map[uuid.UUID]struct{}, len(currentMembers))
9092
planSet := make(map[uuid.UUID]struct{}, len(plannedMembers))

0 commit comments

Comments
 (0)