-
Notifications
You must be signed in to change notification settings - Fork 2
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
base: main
Are you sure you want to change the base?
Changes from all commits
8b73a3e
69fab48
4743b9e
435032b
71dc51b
3397984
75c0858
cc2bb2e
d23168a
28b395a
f2d3e3c
236c11e
16d10e7
f3ff5fb
a2db0d6
93f476b
39da842
69ebed9
3bf1734
a185262
03bdf17
753eaa9
85891d6
68c6ad5
bd73bb4
8f3e1b9
d3f6e2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,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{ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This defaults to |
||
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 +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. | ||
|
@@ -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)...) | ||
} | ||
|
@@ -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, | ||
|
@@ -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)...) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: do the |
||
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 +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()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep this using |
||
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 | ||
} |
There was a problem hiding this comment.
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 duringplan
. 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.