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{