Skip to content

Commit

Permalink
feat: add org_sync_idp_groups attribute to coderd_organization re…
Browse files Browse the repository at this point in the history
…source (#182)
  • Loading branch information
aslilac authored Feb 21, 2025
1 parent 5fa9117 commit ed8270c
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 23 deletions.
29 changes: 29 additions & 0 deletions docs/resources/organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 generated by tfplugindocs -->
## Schema
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions examples/resources/coderd_organization/resource.tf
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
127 changes: 112 additions & 15 deletions internal/provider/organization_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, &currentClaims, 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
Expand All @@ -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(),
Expand Down Expand Up @@ -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
}
41 changes: 36 additions & 5 deletions internal/provider/organization_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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")),
Expand All @@ -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 {
Expand All @@ -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}}"
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/organization_sync_settings_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions internal/provider/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down

0 comments on commit ed8270c

Please sign in to comment.