Skip to content
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

feat: add group_sync and role_sync for coderd_organization_resource #147

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
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
21 changes: 21 additions & 0 deletions docs/resources/organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<a id="nestedblock--group_sync"></a>
### 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.


<a id="nestedblock--role_sync"></a>
### 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:
Expand Down
288 changes: 288 additions & 0 deletions internal/provider/organization_resource.go
Copy link
Member

@ethanndickson ethanndickson Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realised we're missing enterprise/premium entitlement checks on this and the provisioner key resource. We have them on the workspace proxy and the enterprise features of template resources, but they run during apply, when they should really run during plan. Let's not worry about it for now, and at some point I'll see if I can get them working at plan-time using a plan modifier (validators don't have the provider config we need).

If that doesn't work we can just copy the ones at apply-time to these two new resources.

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -83,6 +132,58 @@ 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),
},
},
"auto_create_missing": schema.BoolAttribute{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This defaults to false on coder right? Should just have a default set in the schema, and then you can remove the null checks on it.

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.",
},
},
},
},
}
}

Expand Down Expand Up @@ -133,6 +234,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.
Expand Down Expand Up @@ -183,6 +355,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)...)
}
Expand Down Expand Up @@ -215,6 +408,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,
Expand All @@ -223,6 +417,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)...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: do the IsNull checks in each patch* function

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)...)
}
Expand Down Expand Up @@ -260,3 +473,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())
Copy link
Member

@ethanndickson ethanndickson Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this using MustCompile, but add a custom regex validator on each regex_filter attribute so it can be caught by terraform validate (the provider should never panic).

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
}
Loading
Loading