diff --git a/docs/data-sources/organization.md b/docs/data-sources/organization.md index 7209b450..e5de7cf3 100644 --- a/docs/data-sources/organization.md +++ b/docs/data-sources/organization.md @@ -52,6 +52,7 @@ resource "coderd_group" "example" { ### Read-Only - `created_at` (Number) Unix timestamp when the organization was created. +- `default_org_member_roles` (Set of String) Built-in organization role names unioned into every member's effective roles in this organization. Null when the Coder deployment does not support default org member roles. - `members` (Set of String) Members of the organization, by ID - `updated_at` (Number) Unix timestamp when the organization was last updated. - `workspace_sharing` (String) Workspace sharing setting for the organization. Valid values are `everyone` and `none`. diff --git a/docs/resources/organization.md b/docs/resources/organization.md index 28d717e1..b6bd360b 100644 --- a/docs/resources/organization.md +++ b/docs/resources/organization.md @@ -24,6 +24,12 @@ resource "coderd_organization" "blueberry" { description = "The organization for blueberries" icon = "/emojis/1fad0.png" + # Requires the `minimum-implicit-member` experiment when set to + # anything other than the deployment default. + default_org_member_roles = [ + "organization-workspace-access", + ] + org_sync_idp_groups = [ "wibble", "wobble", @@ -54,6 +60,7 @@ resource "coderd_organization" "blueberry" { ### Optional +- `default_org_member_roles` (Set of String) Built-in organization role names unioned into every member's effective roles in this organization (e.g. `organization-workspace-access`, `organization-auditor`). New organizations default to `["organization-workspace-access"]`. When null, this facet is left unmanaged by Terraform. Setting any value other than the deployment default requires the `minimum-implicit-member` experiment to be enabled on the Coder deployment. - `description` (String) - `display_name` (String) Display name of the organization. Defaults to name. - `group_sync` (Block, Optional, Deprecated) Group sync settings to sync groups from an IdP. diff --git a/examples/resources/coderd_organization/resource.tf b/examples/resources/coderd_organization/resource.tf index cb26a868..f3137fa9 100644 --- a/examples/resources/coderd_organization/resource.tf +++ b/examples/resources/coderd_organization/resource.tf @@ -4,6 +4,12 @@ resource "coderd_organization" "blueberry" { description = "The organization for blueberries" icon = "/emojis/1fad0.png" + # Requires the `minimum-implicit-member` experiment when set to + # anything other than the deployment default. + default_org_member_roles = [ + "organization-workspace-access", + ] + org_sync_idp_groups = [ "wibble", "wobble", diff --git a/internal/provider/organization_data_source.go b/internal/provider/organization_data_source.go index 1a8fa58a..cdf2a20e 100644 --- a/internal/provider/organization_data_source.go +++ b/internal/provider/organization_data_source.go @@ -33,9 +33,10 @@ type OrganizationDataSourceModel struct { IsDefault types.Bool `tfsdk:"is_default"` Name types.String `tfsdk:"name"` - CreatedAt types.Int64 `tfsdk:"created_at"` - UpdatedAt types.Int64 `tfsdk:"updated_at"` - WorkspaceSharing types.String `tfsdk:"workspace_sharing"` + CreatedAt types.Int64 `tfsdk:"created_at"` + UpdatedAt types.Int64 `tfsdk:"updated_at"` + WorkspaceSharing types.String `tfsdk:"workspace_sharing"` + DefaultOrgMemberRoles types.Set `tfsdk:"default_org_member_roles"` // TODO: This could reasonably store some User object - though we may need to make additional queries depending on what fields we // want, or to have one consistent user type for all data sources. Members types.Set `tfsdk:"members"` @@ -83,6 +84,13 @@ This data source is only compatible with Coder version [2.13.0](https://github.c "Valid values are `everyone` and `none`.", Computed: true, }, + "default_org_member_roles": schema.SetAttribute{ + MarkdownDescription: "Built-in organization role names unioned into every member's " + + "effective roles in this organization. Null when the Coder deployment does not " + + "support default org member roles.", + Computed: true, + ElementType: types.StringType, + }, "members": schema.SetAttribute{ MarkdownDescription: "Members of the organization, by ID", @@ -188,6 +196,12 @@ func (d *OrganizationDataSource) Read(ctx context.Context, req datasource.ReadRe return } data.WorkspaceSharing = workspaceSharing + defaultOrgMemberRoles, diags := defaultOrgMemberRolesValue(ctx, org.DefaultOrgMemberRoles) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.DefaultOrgMemberRoles = defaultOrgMemberRoles members, err := client.OrganizationMembers(ctx, org.ID) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get organization members, got error: %s", err)) diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go index 568809eb..8c731388 100644 --- a/internal/provider/organization_resource.go +++ b/internal/provider/organization_resource.go @@ -12,6 +12,7 @@ import ( "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/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -45,6 +46,8 @@ type OrganizationResourceModel struct { Icon types.String `tfsdk:"icon"` WorkspaceSharing types.String `tfsdk:"workspace_sharing"` + DefaultOrgMemberRoles types.Set `tfsdk:"default_org_member_roles"` + OrgSyncIdpGroups types.Set `tfsdk:"org_sync_idp_groups"` GroupSync types.Object `tfsdk:"group_sync"` RoleSync types.Object `tfsdk:"role_sync"` @@ -149,6 +152,20 @@ This resource is only compatible with Coder version [2.16.0](https://github.com/ }, }, + "default_org_member_roles": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + MarkdownDescription: "Built-in organization role names unioned into every member's " + + "effective roles in this organization (e.g. `organization-workspace-access`, " + + "`organization-auditor`). New organizations default to `[\"organization-workspace-access\"]`. " + + "When null, this facet is left unmanaged by Terraform. Setting any value other than the " + + "deployment default requires the `minimum-implicit-member` experiment to be enabled on " + + "the Coder deployment.", + Validators: []validator.Set{ + setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), + }, + }, + "org_sync_idp_groups": schema.SetAttribute{ ElementType: types.StringType, Optional: true, @@ -352,6 +369,17 @@ func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadReques data.Icon = types.StringValue(org.Icon) data.WorkspaceSharing = workspaceSharing + // `default_org_member_roles` is null when unmanaged; only sync it from the + // backend when it is managed by this resource. + if !data.DefaultOrgMemberRoles.IsNull() { + roles, diags := defaultOrgMemberRolesValue(ctx, org.DefaultOrgMemberRoles) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.DefaultOrgMemberRoles = roles + } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -396,6 +424,18 @@ func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRe orgID := data.ID.ValueUUID() + // Apply default org member roles, if specified. CreateOrganizationRequest + // has no field for them, so they are set with a follow-up PATCH. + if !data.DefaultOrgMemberRoles.IsNull() && !data.DefaultOrgMemberRoles.IsUnknown() { + tflog.Trace(ctx, "updating default org member roles", map[string]any{ + "orgID": orgID, + }) + resp.Diagnostics.Append(r.patchDefaultOrgMemberRoles(ctx, orgID, data.DefaultOrgMemberRoles)...) + if resp.Diagnostics.HasError() { + return + } + } + // Apply org sync patches, if specified if !data.OrgSyncIdpGroups.IsNull() { tflog.Trace(ctx, "updating org sync", map[string]any{ @@ -488,16 +528,25 @@ func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRe "new_description": data.Description.ValueString(), "new_icon": data.Icon.ValueString(), }) + defaultOrgMemberRoles := defaultOrgMemberRolesPtr(ctx, data.DefaultOrgMemberRoles, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } org, err := r.Client.UpdateOrganization(ctx, orgID.String(), codersdk.UpdateOrganizationRequest{ - Name: data.Name.ValueString(), - DisplayName: data.DisplayName.ValueString(), - Description: data.Description.ValueStringPointer(), - Icon: data.Icon.ValueStringPointer(), + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueStringPointer(), + Icon: data.Icon.ValueStringPointer(), + DefaultOrgMemberRoles: defaultOrgMemberRoles, }) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update organization %s, got error: %s", orgID, err)) return } + if !data.DefaultOrgMemberRoles.IsNull() && org.DefaultOrgMemberRoles == nil { + resp.Diagnostics.AddError(defaultOrgMemberRolesNotFoundSummary, defaultOrgMemberRolesNotFoundDetail) + return + } tflog.Trace(ctx, "successfully updated organization", map[string]any{ "id": orgID, @@ -751,6 +800,68 @@ const ( workspaceSharingNotFoundDetail = "Workspace sharing is not available on this Coder deployment. Remove the workspace_sharing attribute or upgrade the deployment." ) +const ( + defaultOrgMemberRolesNotFoundSummary = "Default Org Member Roles Unsupported" + defaultOrgMemberRolesNotFoundDetail = "Default org member roles are not available on this Coder deployment. Remove the default_org_member_roles attribute or upgrade the deployment." +) + +// defaultOrgMemberRolesPtr converts the Terraform set into the pointer +// used by UpdateOrganizationRequest. A null or unknown set returns nil, +// which omits the field from the PATCH so the server preserves the +// current value. +func defaultOrgMemberRolesPtr(ctx context.Context, set types.Set, diags *diag.Diagnostics) *[]string { + if set.IsNull() || set.IsUnknown() { + return nil + } + roles := []string{} + diags.Append(set.ElementsAs(ctx, &roles, false)...) + if diags.HasError() { + return nil + } + return &roles +} + +// defaultOrgMemberRolesValue converts the roles returned by the server +// into a Terraform set. A nil slice means the deployment does not +// return the field (it predates the feature) and maps to null. +func defaultOrgMemberRolesValue(ctx context.Context, roles []string) (types.Set, diag.Diagnostics) { + if roles == nil { + return types.SetNull(types.StringType), nil + } + return types.SetValueFrom(ctx, types.StringType, roles) +} + +func (r *OrganizationResource) patchDefaultOrgMemberRoles( + ctx context.Context, + orgID uuid.UUID, + rolesSet types.Set, +) diag.Diagnostics { + var diags diag.Diagnostics + + roles := defaultOrgMemberRolesPtr(ctx, rolesSet, &diags) + if diags.HasError() { + return diags + } + + org, err := r.Client.UpdateOrganization(ctx, orgID.String(), codersdk.UpdateOrganizationRequest{ + DefaultOrgMemberRoles: roles, + }) + if err != nil { + // If the minimum-implicit-member experiment is not enabled, the + // endpoint returns a user-friendly 403 message that we show as is. + diags.AddError("Default Org Member Roles Update error", err.Error()) + return diags + } + // An older deployment ignores the field entirely and returns no + // value for it. + if org.DefaultOrgMemberRoles == nil { + diags.AddError(defaultOrgMemberRolesNotFoundSummary, defaultOrgMemberRolesNotFoundDetail) + return diags + } + + return diags +} + func workspaceSharingValueFromSettings(settings codersdk.WorkspaceSharingSettings) string { if settings.SharingDisabled { return workspaceSharingNone diff --git a/internal/provider/organization_resource_test.go b/internal/provider/organization_resource_test.go index c850beb4..c7f5f2d3 100644 --- a/internal/provider/organization_resource_test.go +++ b/internal/provider/organization_resource_test.go @@ -26,7 +26,7 @@ func TestAccOrganizationResource(t *testing.T) { } ctx := t.Context() - client := integration.StartCoder(ctx, t, "organization_acc", integration.UseLicense, integration.CoderExperiments("workspace-sharing")) + client := integration.StartCoder(ctx, t, "organization_acc", integration.UseLicense, integration.CoderExperiments("workspace-sharing,minimum-implicit-member")) _, err := client.User(ctx, codersdk.Me) require.NoError(t, err) runOrganizationResourceTest(t, client, true) @@ -166,6 +166,12 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx cfg7 := cfg6 cfg7.WorkspaceSharing = ptr.Ref("everyone") + cfg8 := cfg7 + cfg8.DefaultOrgMemberRoles = ptr.Ref([]string{"organization-workspace-access", "organization-auditor"}) + + cfg9 := cfg8 + cfg9.DefaultOrgMemberRoles = ptr.Ref([]string{}) + steps = append(steps, // Disable workspace sharing for org resource.TestStep{ @@ -181,6 +187,23 @@ func runOrganizationResourceTest(t *testing.T, client *codersdk.Client, enableEx statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("workspace_sharing"), knownvalue.StringExact("everyone")), }, }, + // Set default org member roles + resource.TestStep{ + Config: cfg8.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("default_org_member_roles"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("organization-workspace-access"), + knownvalue.StringExact("organization-auditor"), + })), + }, + }, + // Clear default org member roles + resource.TestStep{ + Config: cfg9.String(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("coderd_organization.test", tfjsonpath.New("default_org_member_roles"), knownvalue.SetExact([]knownvalue.Check{})), + }, + }, ) } return steps @@ -222,6 +245,8 @@ type testAccOrganizationResourceConfig struct { Icon *string WorkspaceSharing *string + DefaultOrgMemberRoles *[]string + OrgSyncIdpGroups []string GroupSync *codersdk.GroupSyncSettings RoleSync *codersdk.RoleSyncSettings @@ -242,6 +267,14 @@ resource "coderd_organization" "test" { icon = {{orNull .Icon}} workspace_sharing = {{orNull .WorkspaceSharing}} + {{- if .DefaultOrgMemberRoles}} + default_org_member_roles = [ + {{- range $role := .DefaultOrgMemberRoles }} + "{{$role}}", + {{- end}} + ] + {{- end}} + {{- if .OrgSyncIdpGroups}} org_sync_idp_groups = [ {{- range $name := .OrgSyncIdpGroups }}