diff --git a/docs/resources/organization.md b/docs/resources/organization.md
index a5e2402..9556be4 100644
--- a/docs/resources/organization.md
+++ b/docs/resources/organization.md
@@ -23,12 +23,33 @@ An organization on the Coder deployment
- `description` (String)
- `display_name` (String) Display name of the organization. Defaults to name.
+- `group_sync` (Block, Optional) (see [below for nested schema](#nestedblock--group_sync))
- `icon` (String)
+- `role_sync` (Block, Optional) (see [below for nested schema](#nestedblock--role_sync))
### Read-Only
- `id` (String) Organization ID
+
+### Nested Schema for `group_sync`
+
+Optional:
+
+- `auto_create_missing` (Boolean) Controls whether groups will be created if they are missing.
+- `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.
+- `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.
+
+
+
+### Nested Schema for `role_sync`
+
+Optional:
+
+- `field` (String) The claim field that specifies what organization roles a user should be given.
+- `mapping` (Map of List of String) A map from OIDC group name to Coder organization role.
+
## Import
Import is supported using the following syntax:
diff --git a/internal/codersdkvalidator/regex.go b/internal/codersdkvalidator/regex.go
new file mode 100644
index 0000000..0077616
--- /dev/null
+++ b/internal/codersdkvalidator/regex.go
@@ -0,0 +1,16 @@
+package codersdkvalidator
+
+import (
+ "regexp"
+
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+)
+
+func checkRegexp(it string) error {
+ _, err := regexp.Compile("")
+ return err
+}
+
+func Regexp() validator.String {
+ return validatorFromFunc(checkRegexp, "value must be a valid regexp")
+}
diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go
index 1575ce3..397f92e 100644
--- a/internal/provider/organization_resource.go
+++ b/internal/provider/organization_resource.go
@@ -3,9 +3,14 @@ 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/attr"
+ "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"
@@ -14,6 +19,7 @@ import (
"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-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
@@ -33,6 +39,49 @@ type OrganizationResourceModel struct {
DisplayName types.String `tfsdk:"display_name"`
Description types.String `tfsdk:"description"`
Icon types.String `tfsdk:"icon"`
+
+ GroupSync types.Object `tfsdk:"group_sync"`
+ RoleSync types.Object `tfsdk:"role_sync"`
+}
+
+type GroupSyncModel struct {
+ Field types.String `tfsdk:"field"`
+ RegexFilter types.String `tfsdk:"regex_filter"`
+ AutoCreateMissing types.Bool `tfsdk:"auto_create_missing"`
+ Mapping types.Map `tfsdk:"mapping"`
+}
+
+var groupSyncAttrTypes = map[string]attr.Type{
+ "field": types.StringType,
+ "regex_filter": types.StringType,
+ "auto_create_missing": types.BoolType,
+ "mapping": types.MapType{ElemType: types.ListType{ElemType: UUIDType}},
+}
+
+func (m GroupSyncModel) ValueObject() types.Object {
+ return types.ObjectValueMust(groupSyncAttrTypes, map[string]attr.Value{
+ "field": m.Field,
+ "regex_filter": m.RegexFilter,
+ "auto_create_missing": m.AutoCreateMissing,
+ "mapping": m.Mapping,
+ })
+}
+
+type RoleSyncModel struct {
+ Field types.String `tfsdk:"field"`
+ Mapping types.Map `tfsdk:"mapping"`
+}
+
+var roleSyncAttrTypes = map[string]attr.Type{
+ "field": types.StringType,
+ "mapping": types.MapType{ElemType: types.ListType{ElemType: types.StringType}},
+}
+
+func (m RoleSyncModel) ValueObject() types.Object {
+ return types.ObjectValueMust(roleSyncAttrTypes, map[string]attr.Value{
+ "field": m.Field,
+ "mapping": m.Mapping,
+ })
}
func NewOrganizationResource() resource.Resource {
@@ -83,6 +132,59 @@ func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRe
Default: stringdefault.StaticString(""),
},
},
+
+ Blocks: map[string]schema.Block{
+ "group_sync": schema.SingleNestedBlock{
+ Attributes: map[string]schema.Attribute{
+ "field": schema.StringAttribute{
+ Optional: 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,
+ MarkdownDescription: "Controls whether groups will be created if " +
+ "they are missing.",
+ },
+ "mapping": schema.MapAttribute{
+ ElementType: types.ListType{ElemType: UUIDType},
+ Optional: true,
+ MarkdownDescription: "A map from OIDC group name to Coder group ID.",
+ },
+ },
+ },
+ "role_sync": schema.SingleNestedBlock{
+ Attributes: map[string]schema.Attribute{
+ "field": schema.StringAttribute{
+ Optional: true,
+ MarkdownDescription: "The claim field that specifies what " +
+ "organization roles a user should be given.",
+ Validators: []validator.String{
+ stringvalidator.LengthAtLeast(1),
+ },
+ },
+ "mapping": schema.MapAttribute{
+ ElementType: types.ListType{ElemType: types.StringType},
+ Optional: true,
+ MarkdownDescription: "A map from OIDC group name to Coder " +
+ "organization role.",
+ },
+ },
+ },
+ },
}
}
@@ -133,6 +235,77 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques
}
}
+ if !data.GroupSync.IsNull() {
+ groupSync, err := r.Client.GroupIDPSyncSettings(ctx, data.ID.ValueUUID().String())
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization group sync settings, got error: %s", err))
+ return
+ }
+
+ // Read values from Terraform
+ var groupSyncData GroupSyncModel
+ resp.Diagnostics.Append(data.GroupSync.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if !groupSyncData.Field.IsNull() {
+ groupSyncData.Field = types.StringValue(groupSync.Field)
+ }
+ if !groupSyncData.RegexFilter.IsNull() {
+ groupSyncData.RegexFilter = types.StringValue(groupSync.RegexFilter.String())
+ }
+ if !groupSyncData.AutoCreateMissing.IsNull() {
+ groupSyncData.AutoCreateMissing = types.BoolValue(groupSync.AutoCreateMissing)
+ }
+ if !groupSyncData.Mapping.IsNull() {
+ 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
+ }
+ groupSyncData.Mapping = mapping
+ }
+
+ data.GroupSync = groupSyncData.ValueObject()
+ }
+
+ if !data.RoleSync.IsNull() {
+ roleSync, err := r.Client.RoleIDPSyncSettings(ctx, data.ID.ValueUUID().String())
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unable to get organization role sync settings, got error: %s", err))
+ return
+ }
+
+ // Read values from Terraform
+ var roleSyncData RoleSyncModel
+ resp.Diagnostics.Append(data.RoleSync.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if !roleSyncData.Field.IsNull() {
+ roleSyncData.Field = types.StringValue(roleSync.Field)
+ }
+ if !roleSyncData.Mapping.IsNull() {
+ mapping, diags := types.MapValueFrom(ctx, types.ListType{ElemType: types.StringType}, roleSync.Mapping)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ roleSyncData.Mapping = mapping
+ }
+
+ data.RoleSync = roleSyncData.ValueObject()
+ }
+
// We've fetched the organization ID from state, and the latest values for
// everything else from the backend. Ensure that any mutable data is synced
// with the backend.
@@ -183,6 +356,27 @@ 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,
+ })
+ if !data.GroupSync.IsNull() {
+ 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() {
+ resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ }
+
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
@@ -215,6 +409,7 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update organization %s, got error: %s", orgID, err))
return
}
+
tflog.Trace(ctx, "successfully updated organization", map[string]any{
"id": orgID,
"name": org.Name,
@@ -223,6 +418,25 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe
"icon": org.Icon,
})
+ tflog.Trace(ctx, "updating group sync", map[string]any{
+ "orgID": orgID,
+ })
+ if !data.GroupSync.IsNull() {
+ 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() {
+ resp.Diagnostics.Append(r.patchRoleSync(ctx, orgID, data.RoleSync)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ }
+
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
@@ -260,3 +474,78 @@ func (r *OrganizationResource) ImportState(ctx context.Context, req resource.Imp
// set the `name` attribute.
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
+
+func (r *OrganizationResource) patchGroupSync(
+ ctx context.Context,
+ orgID uuid.UUID,
+ groupSyncObject types.Object,
+) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ // Read values from Terraform
+ var groupSyncData GroupSyncModel
+ diags.Append(groupSyncObject.As(ctx, &groupSyncData, basetypes.ObjectAsOptions{})...)
+ if diags.HasError() {
+ return diags
+ }
+
+ // Convert that into the type used to send the PATCH to the backend
+ var groupSync codersdk.GroupSyncSettings
+ groupSync.Field = groupSyncData.Field.ValueString()
+ groupSync.RegexFilter = regexp.MustCompile(groupSyncData.RegexFilter.ValueString())
+ groupSync.AutoCreateMissing = groupSyncData.AutoCreateMissing.ValueBool()
+ groupSync.Mapping = make(map[string][]uuid.UUID)
+ // 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(groupSyncData.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
+}
+
+func (r *OrganizationResource) patchRoleSync(
+ ctx context.Context,
+ orgID uuid.UUID,
+ roleSyncObject types.Object,
+) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ // Read values from Terraform
+ var roleSyncData RoleSyncModel
+ diags.Append(roleSyncObject.As(ctx, &roleSyncData, basetypes.ObjectAsOptions{})...)
+ if diags.HasError() {
+ return diags
+ }
+
+ // Convert that into the type used to send the PATCH to the backend
+ var roleSync codersdk.RoleSyncSettings
+ roleSync.Field = roleSyncData.Field.ValueString()
+ diags.Append(roleSyncData.Mapping.ElementsAs(ctx, &roleSync.Mapping, false)...)
+ if diags.HasError() {
+ return diags
+ }
+
+ // Perform the PATCH
+ _, err := r.Client.PatchRoleIDPSyncSettings(ctx, orgID.String(), roleSync)
+ if err != nil {
+ diags.AddError("Role Sync Update error", err.Error())
+ return diags
+ }
+
+ return diags
+}
diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go
index b633265..0a755c4 100644
--- a/internal/provider/organization_resource_test.go
+++ b/internal/provider/organization_resource_test.go
@@ -10,6 +10,7 @@ import (
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/terraform-provider-coderd/integration"
+ "github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
@@ -23,7 +24,7 @@ func TestAccOrganizationResource(t *testing.T) {
}
ctx := context.Background()
- client := integration.StartCoder(ctx, t, "group_acc", true)
+ client := integration.StartCoder(ctx, t, "organization_acc", true)
_, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
@@ -40,6 +41,20 @@ func TestAccOrganizationResource(t *testing.T) {
cfg2.Name = ptr.Ref("example-org-new")
cfg2.DisplayName = ptr.Ref("Example Organization New")
+ cfg3 := cfg2
+ cfg3.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{
+ Field: "wobble",
+ Mapping: map[string][]string{
+ "wobble": {"wobbly"},
+ },
+ })
+
t.Run("CreateImportUpdateReadOk", func(t *testing.T) {
resource.Test(t, resource.TestCase{
IsUnitTest: true,
@@ -71,6 +86,16 @@ func TestAccOrganizationResource(t *testing.T) {
statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("display_name"), knownvalue.StringExact("Example Organization New")),
},
},
+ // Add group and role sync
+ {
+ Config: cfg3.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")),
+ statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("field"), knownvalue.StringExact("wobble")),
+ statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("role_sync").AtMapKey("mapping").AtMapKey("wobble").AtSliceIndex(0), knownvalue.StringExact("wobbly")),
+ },
+ },
},
})
})
@@ -84,6 +109,9 @@ type testAccOrganizationResourceConfig struct {
DisplayName *string
Description *string
Icon *string
+
+ GroupSync *codersdk.GroupSyncSettings
+ RoleSync *codersdk.RoleSyncSettings
}
func (c testAccOrganizationResourceConfig) String(t *testing.T) string {
@@ -99,6 +127,28 @@ resource "coderd_organization" "test" {
display_name = {{orNull .DisplayName}}
description = {{orNull .Description}}
icon = {{orNull .Icon}}
+
+ {{- if .GroupSync}}
+ group_sync {
+ field = "{{.GroupSync.Field}}"
+ mapping = {
+ {{- range $key, $value := .GroupSync.Mapping}}
+ {{$key}} = {{printf "%q" $value}}
+ {{- end}}
+ }
+ }
+ {{- end}}
+
+ {{- if .RoleSync}}
+ role_sync {
+ field = "{{.RoleSync.Field}}"
+ mapping = {
+ {{- range $key, $value := .RoleSync.Mapping}}
+ {{$key}} = {{printf "%q" $value}}
+ {{- end}}
+ }
+ }
+ {{- end}}
}
`
funcMap := template.FuncMap{