Skip to content

feat: add org_sync_idp_groups attribute to coderd_organization resource #182

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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