Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/data-sources/organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
7 changes: 7 additions & 0 deletions docs/resources/organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions examples/resources/coderd_organization/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions internal/provider/organization_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down
119 changes: 115 additions & 4 deletions internal/provider/organization_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)...)
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion internal/provider/organization_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -222,6 +245,8 @@ type testAccOrganizationResourceConfig struct {
Icon *string
WorkspaceSharing *string

DefaultOrgMemberRoles *[]string

OrgSyncIdpGroups []string
GroupSync *codersdk.GroupSyncSettings
RoleSync *codersdk.RoleSyncSettings
Expand All @@ -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 }}
Expand Down