diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bfad0..2780018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ -## 1.11.0 (August 9, 2024). Tested on Artifactory 7.90.7 with Terraform 1.9.4 and OpenTofu 1.8.1 +## 1.11.0 (August 12, 2024). Tested on Artifactory 7.90.7 with Terraform 1.9.4 and OpenTofu 1.8.1 FEATURES: -**New Resource:** `platform_saml_settings` - Resource to manage SAML SSO settings. PR: [#118](https://github.com/jfrog/terraform-provider-platform/pull/118) +**New Resource:** +* `platform_saml_settings` - Resource to manage SAML SSO settings. PR: [#118](https://github.com/jfrog/terraform-provider-platform/pull/118) +* `platform_scim_user` - Resource to manage SCIM user. PR: [#120](https://github.com/jfrog/terraform-provider-platform/pull/120) +* `platform_scim_group` - Resource to manage SCIM group. PR: [#120](https://github.com/jfrog/terraform-provider-platform/pull/120) ## 1.10.0 (July 21, 2024). Tested on Artifactory 7.84.17 with Terraform 1.9.2 and OpenTofu 1.7.3 diff --git a/docs/resources/saml_settings.md b/docs/resources/saml_settings.md index aad3120..e283ee2 100644 --- a/docs/resources/saml_settings.md +++ b/docs/resources/saml_settings.md @@ -50,7 +50,7 @@ resource "platform_saml_settings" "my-okta-saml-settings" { - `allow_user_to_access_profile` (Boolean) When set, auto created users will have access to their profile page and will be able to perform actions such as generating an API key. Default value is `false`. - `auto_redirect` (Boolean) When set, clicking on the login link will direct users to the configured SAML login URL. Default value is `false`. -- `email_attribute` (String) If `no_auto_user_creation` is diabled or an internal user exists, the system will set the user's email to the value in this attribute that is returned by the SAML login XML response.. +- `email_attribute` (String) If `no_auto_user_creation` is diabled or an internal user exists, the system will set the user's email to the value in this attribute that is returned by the SAML login XML response. - `enable` (Boolean) When set, SAML integration is enabled and users may be authenticated via a SAML server. Default value is `true`. - `group_attribute` (String) The group attribute in the SAML login XML response. Note that the system will search for a case-sensitive match to an existing group.. - `name_id_attribute` (String) The username attribute used to configure the SSO URL for the identity provider. diff --git a/docs/resources/scim_group.md b/docs/resources/scim_group.md new file mode 100644 index 0000000..fdedb1e --- /dev/null +++ b/docs/resources/scim_group.md @@ -0,0 +1,56 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "platform_scim_group Resource - terraform-provider-platform" +subcategory: "" +description: |- + Provides a JFrog SCIM Group https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim resource to manage groups with the SCIM protocol. +--- + +# platform_scim_group (Resource) + +Provides a JFrog [SCIM Group](https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim) resource to manage groups with the SCIM protocol. + +## Example Usage + +```terraform +resource "platform_scim_group" "my-scim-group" { + id = "my-scim-group" + display_name = "my-scim-group" + members = [{ + value = "test@tempurl.org" + display = "test@tempurl.org" + }, { + value = "anonymous" + display = "anonymous" + }] +} +``` + + +## Schema + +### Required + +- `display_name` (String) +- `id` (String) Group ID +- `members` (Attributes Set) (see [below for nested schema](#nestedatt--members)) + +### Read-Only + +- `meta` (Map of String) + + +### Nested Schema for `members` + +Required: + +- `display` (String) +- `value` (String) + +## Import + +Import is supported using the following syntax: + +```shell +terraform import platform_scim_group.my-scim-group my-scim-group +``` diff --git a/docs/resources/scim_user.md b/docs/resources/scim_user.md new file mode 100644 index 0000000..0c2df07 --- /dev/null +++ b/docs/resources/scim_user.md @@ -0,0 +1,65 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "platform_scim_user Resource - terraform-provider-platform" +subcategory: "" +description: |- + Provides a JFrog SCIM User https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim resource to manage users with the SCIM protocol. +--- + +# platform_scim_user (Resource) + +Provides a JFrog [SCIM User](https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim) resource to manage users with the SCIM protocol. + +## Example Usage + +```terraform +resource "platform_scim_user" "my-scim-user" { + username = "test@tempurl.org" + active = true + emails = [{ + value = "test@tempurl.org" + primary = true + }] +} +``` + + +## Schema + +### Required + +- `emails` (Attributes Set) (see [below for nested schema](#nestedatt--emails)) +- `username` (String) + +### Optional + +- `active` (Boolean) + +### Read-Only + +- `groups` (Attributes Set) (see [below for nested schema](#nestedatt--groups)) +- `meta` (Map of String) + + +### Nested Schema for `emails` + +Required: + +- `primary` (Boolean) +- `value` (String) + + + +### Nested Schema for `groups` + +Required: + +- `value` (String) + +## Import + +Import is supported using the following syntax: + +```shell +terraform import platform_scim_user.my-scim-user test@tempurl.org +``` diff --git a/examples/resources/platform_scim_group/import.sh b/examples/resources/platform_scim_group/import.sh new file mode 100644 index 0000000..9508d83 --- /dev/null +++ b/examples/resources/platform_scim_group/import.sh @@ -0,0 +1 @@ +terraform import platform_scim_group.my-scim-group my-scim-group \ No newline at end of file diff --git a/examples/resources/platform_scim_group/resource.tf b/examples/resources/platform_scim_group/resource.tf new file mode 100644 index 0000000..8697762 --- /dev/null +++ b/examples/resources/platform_scim_group/resource.tf @@ -0,0 +1,11 @@ +resource "platform_scim_group" "my-scim-group" { + id = "my-scim-group" + display_name = "my-scim-group" + members = [{ + value = "test@tempurl.org" + display = "test@tempurl.org" + }, { + value = "anonymous" + display = "anonymous" + }] +} \ No newline at end of file diff --git a/examples/resources/platform_scim_user/import.sh b/examples/resources/platform_scim_user/import.sh new file mode 100644 index 0000000..89f5b5c --- /dev/null +++ b/examples/resources/platform_scim_user/import.sh @@ -0,0 +1 @@ +terraform import platform_scim_user.my-scim-user test@tempurl.org \ No newline at end of file diff --git a/examples/resources/platform_scim_user/resource.tf b/examples/resources/platform_scim_user/resource.tf new file mode 100644 index 0000000..4cec2c5 --- /dev/null +++ b/examples/resources/platform_scim_user/resource.tf @@ -0,0 +1,8 @@ +resource "platform_scim_user" "my-scim-user" { + username = "test@tempurl.org" + active = true + emails = [{ + value = "test@tempurl.org" + primary = true + }] +} \ No newline at end of file diff --git a/pkg/platform/provider.go b/pkg/platform/provider.go index aa85466..640f2f7 100644 --- a/pkg/platform/provider.go +++ b/pkg/platform/provider.go @@ -210,6 +210,8 @@ func (p *PlatformProvider) Resources(ctx context.Context) []func() resource.Reso NewPermissionResource, NewReverseProxyResource, NewSAMLSettingsResource, + NewSCIMUserResource, + NewSCIMGroupResource, NewWorkerServiceResource, } } diff --git a/pkg/platform/resource_saml_settings.go b/pkg/platform/resource_saml_settings.go index b1ccb2a..1cd79a7 100644 --- a/pkg/platform/resource_saml_settings.go +++ b/pkg/platform/resource_saml_settings.go @@ -158,7 +158,7 @@ func (r *SAMLSettingsResource) Schema(ctx context.Context, req resource.SchemaRe Validators: []validator.String{ stringvalidator.LengthAtLeast(1), }, - MarkdownDescription: "If `no_auto_user_creation` is diabled or an internal user exists, the system will set the user's email to the value in this attribute that is returned by the SAML login XML response..", + MarkdownDescription: "If `no_auto_user_creation` is diabled or an internal user exists, the system will set the user's email to the value in this attribute that is returned by the SAML login XML response.", }, "group_attribute": schema.StringAttribute{ Optional: true, diff --git a/pkg/platform/resource_scim_group.go b/pkg/platform/resource_scim_group.go new file mode 100644 index 0000000..0c5f468 --- /dev/null +++ b/pkg/platform/resource_scim_group.go @@ -0,0 +1,372 @@ +package platform + +import ( + "context" + "net/http" + + "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" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "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/jfrog/terraform-provider-shared/util" + utilfw "github.com/jfrog/terraform-provider-shared/util/fw" + "github.com/samber/lo" +) + +const ( + SCIMGroupsEndpoint = "access/api/v1/scim/v2/Groups" + SCIMGroupEndpoint = "access/api/v1/scim/v2/Groups/{name}" +) + +func NewSCIMGroupResource() resource.Resource { + return &SCIMGroupResource{ + TypeName: "platform_scim_group", + } +} + +type SCIMGroupResource struct { + ProviderData PlatformProviderMetadata + TypeName string +} + +type SCIMGroupResourceModel struct { + ID types.String `tfsdk:"id"` + DisplayName types.String `tfsdk:"display_name"` + Members types.Set `tfsdk:"members"` + Meta types.Map `tfsdk:"meta"` +} + +type SCIMGroupAPIModel struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMemberAPIModel `json:"members"` + Meta map[string]string `json:"meta,omitempty"` +} + +type SCIMGroupMemberAPIModel struct { + Value string `json:"value"` + Display string `json:"display"` +} + +func (r *SCIMGroupResourceModel) toAPIModel(ctx context.Context, apiModel *SCIMGroupAPIModel) (ds diag.Diagnostics) { + apiModel.Schemas = []string{"urn:ietf:params:scim:schemas:core:2.0:Group"} + + apiModel.ID = r.ID.ValueString() + apiModel.DisplayName = r.DisplayName.ValueString() + + members := lo.Map[attr.Value]( + r.Members.Elements(), + func(elem attr.Value, index int) SCIMGroupMemberAPIModel { + attr := elem.(types.Object).Attributes() + + return SCIMGroupMemberAPIModel{ + Value: attr["value"].(types.String).ValueString(), + Display: attr["display"].(types.String).ValueString(), + } + }, + ) + apiModel.Members = members + + return +} + +var SCIMGroupMemberResourceModelAttributeType map[string]attr.Type = map[string]attr.Type{ + "value": types.StringType, + "display": types.StringType, +} + +func (r *SCIMGroupResourceModel) fromAPIModel(ctx context.Context, apiModel *SCIMGroupAPIModel) (ds diag.Diagnostics) { + r.ID = types.StringValue(apiModel.ID) + r.DisplayName = types.StringValue(apiModel.DisplayName) + + members := lo.Map( + apiModel.Members, + func(member SCIMGroupMemberAPIModel, _ int) attr.Value { + e, d := types.ObjectValue( + SCIMGroupMemberResourceModelAttributeType, + map[string]attr.Value{ + "value": types.StringValue(member.Value), + "display": types.StringValue(member.Display), + }, + ) + if d.HasError() { + ds.Append(d...) + } + + return e + }, + ) + membersSet, d := types.SetValue( + types.ObjectType{AttrTypes: SCIMGroupMemberResourceModelAttributeType}, + members, + ) + if d.HasError() { + ds.Append(d...) + } + r.Members = membersSet + + metas := lo.MapEntries( + apiModel.Meta, + func(k, v string) (string, attr.Value) { + return k, types.StringValue(v) + }, + ) + + metasMap, d := types.MapValue( + types.StringType, + metas, + ) + if d.HasError() { + ds.Append(d...) + } + r.Meta = metasMap + + return +} + +func (r *SCIMGroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = r.TypeName +} + +func (r *SCIMGroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Description: "Group ID", + }, + "display_name": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "members": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "value": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "display": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + }, + }, + Required: true, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "meta": schema.MapAttribute{ + ElementType: types.StringType, + Computed: true, + }, + }, + MarkdownDescription: "Provides a JFrog [SCIM Group](https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim) resource to manage groups with the SCIM protocol.", + } +} + +func (r *SCIMGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + r.ProviderData = req.ProviderData.(PlatformProviderMetadata) +} + +func (r *SCIMGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + go util.SendUsageResourceCreate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan SCIMGroupResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var group SCIMGroupAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &group)...) + if resp.Diagnostics.HasError() { + return + } + + var result SCIMGroupAPIModel + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetBody(group). + SetResult(&result). + SetError(&scimErr). + Post(SCIMGroupsEndpoint) + + if err != nil { + utilfw.UnableToCreateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToCreateResourceError(resp, scimErr.Detail) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(plan.fromAPIModel(ctx, &result)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *SCIMGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + go util.SendUsageResourceRead(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state SCIMGroupResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var group SCIMGroupAPIModel + var scimErr SCIMErrorAPIModel + + response, err := r.ProviderData.Client.R(). + SetPathParam("name", state.ID.ValueString()). + SetResult(&group). + SetError(&scimErr). + Get(SCIMGroupEndpoint) + + if err != nil { + utilfw.UnableToRefreshResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToRefreshResourceError(resp, scimErr.Detail) + return + } + + // Treat HTTP 404 Not Found status as a signal to recreate resource + // and return early + if response.StatusCode() == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(state.fromAPIModel(ctx, &group)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *SCIMGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + go util.SendUsageResourceUpdate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan SCIMGroupResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var group SCIMGroupAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &group)...) + if resp.Diagnostics.HasError() { + return + } + + var result SCIMGroupAPIModel + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetPathParam("name", plan.ID.ValueString()). + SetBody(group). + SetResult(&result). + SetError(&scimErr). + Put(SCIMGroupEndpoint) + + if err != nil { + utilfw.UnableToUpdateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToUpdateResourceError(resp, scimErr.Detail) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(plan.fromAPIModel(ctx, &result)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *SCIMGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + go util.SendUsageResourceDelete(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state SCIMGroupResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetPathParam("name", state.ID.ValueString()). + SetError(&scimErr). + Delete(SCIMGroupEndpoint) + + if err != nil { + utilfw.UnableToDeleteResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToDeleteResourceError(resp, scimErr.Detail) + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +func (r *SCIMGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/pkg/platform/resource_scim_group_test.go b/pkg/platform/resource_scim_group_test.go new file mode 100644 index 0000000..1542ea8 --- /dev/null +++ b/pkg/platform/resource_scim_group_test.go @@ -0,0 +1,146 @@ +package platform_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/jfrog/terraform-provider-platform/pkg/platform" + "github.com/jfrog/terraform-provider-shared/testutil" + "github.com/jfrog/terraform-provider-shared/util" +) + +func TestAccSCIMGroup_full(t *testing.T) { + _, _, username := testutil.MkNames("test-scim-user", "platform_scim_user") + _, fqrn, name := testutil.MkNames("test-scim-group", "platform_scim_group") + + temp := ` + resource "platform_scim_user" "{{ .username }}" { + username = "{{ .email }}" + active = true + emails = [{ + value = "{{ .email }}" + primary = true + }] + } + + resource "platform_scim_group" "{{ .name }}" { + id = "{{ .name }}" + display_name = "{{ .name }}" + members = [{ + value = platform_scim_user.{{ .username }}.username + display = platform_scim_user.{{ .username }}.username + }] + }` + + testData := map[string]string{ + "username": username, + "email": "test@tempurl.org", + "name": name, + } + + config := util.ExecuteTemplate(name, temp, testData) + + updatedTemp := ` + resource "platform_scim_user" "{{ .username }}" { + username = "{{ .email }}" + active = true + emails = [{ + value = "{{ .email }}" + primary = true + }] + } + + resource "platform_scim_group" "{{ .name }}" { + id = "{{ .name }}" + display_name = "{{ .name }}" + members = [{ + value = platform_scim_user.{{ .username }}.username + display = platform_scim_user.{{ .username }}.username + }, { + value = "anonymous" + display = "anonymous" + }] + }` + + updatedTestData := map[string]string{ + "username": username, + "email": "test@tempurl.org", + "name": name, + } + + updatedConfig := util.ExecuteTemplate(name, updatedTemp, updatedTestData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + CheckDestroy: testAccSCIMGroupDestroy(fqrn), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "id", testData["name"]), + resource.TestCheckResourceAttr(fqrn, "display_name", testData["name"]), + resource.TestCheckResourceAttr(fqrn, "members.#", "1"), + resource.TestCheckResourceAttr(fqrn, "members.0.value", testData["email"]), + resource.TestCheckResourceAttr(fqrn, "members.0.display", testData["email"]), + resource.TestCheckResourceAttr(fqrn, "meta.%", "1"), + resource.TestCheckResourceAttr(fqrn, "meta.resourceType", "Group"), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "id", testData["name"]), + resource.TestCheckResourceAttr(fqrn, "display_name", testData["name"]), + resource.TestCheckResourceAttr(fqrn, "members.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(fqrn, "members.*", map[string]string{ + "value": testData["email"], + "display": testData["email"], + }), + resource.TestCheckTypeSetElemNestedAttrs(fqrn, "members.*", map[string]string{ + "value": "anonymous", + "display": "anonymous", + }), + resource.TestCheckResourceAttr(fqrn, "meta.%", "1"), + resource.TestCheckResourceAttr(fqrn, "meta.resourceType", "Group"), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateVerify: true, + ImportStateId: updatedTestData["name"], + ImportStateVerifyIdentifierAttribute: "id", + }, + }, + }) +} + +func testAccSCIMGroupDestroy(id string) func(*terraform.State) error { + return func(s *terraform.State) error { + c := TestProvider.(*platform.PlatformProvider).Meta.Client + + rs, ok := s.RootModule().Resources[id] + if !ok { + return fmt.Errorf("error: resource id [%s] not found", id) + } + + var group platform.SCIMGroupAPIModel + resp, err := c.R(). + SetPathParam("name", rs.Primary.Attributes["id"]). + SetResult(&group). + Get(platform.SCIMGroupEndpoint) + if err != nil { + return err + } + + if resp != nil && resp.StatusCode() == http.StatusNotFound { + return nil + } + + return fmt.Errorf("error: SCIM group %s still exists", rs.Primary.Attributes["id"]) + } +} diff --git a/pkg/platform/resource_scim_user.go b/pkg/platform/resource_scim_user.go new file mode 100644 index 0000000..d38c248 --- /dev/null +++ b/pkg/platform/resource_scim_user.go @@ -0,0 +1,423 @@ +package platform + +import ( + "context" + "net/http" + + "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" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "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/jfrog/terraform-provider-shared/util" + utilfw "github.com/jfrog/terraform-provider-shared/util/fw" + "github.com/samber/lo" +) + +const ( + SCIMUsersEndpoint = "access/api/v1/scim/v2/Users" + SCIMUserEndpoint = "access/api/v1/scim/v2/Users/{id}" +) + +func NewSCIMUserResource() resource.Resource { + return &SCIMUserResource{ + TypeName: "platform_scim_user", + } +} + +type SCIMUserResource struct { + ProviderData PlatformProviderMetadata + TypeName string +} + +type SCIMUserResourceModel struct { + Username types.String `tfsdk:"username"` + Active types.Bool `tfsdk:"active"` + Emails types.Set `tfsdk:"emails"` + Groups types.Set `tfsdk:"groups"` + Meta types.Map `tfsdk:"meta"` +} + +func (r *SCIMUserResourceModel) toAPIModel(ctx context.Context, apiModel *SCIMUserAPIModel) (ds diag.Diagnostics) { + apiModel.Schemas = []string{"urn:ietf:params:scim:schemas:core:2.0:User"} + + apiModel.Username = r.Username.ValueString() + apiModel.Active = r.Active.ValueBool() + + emails := lo.Map[attr.Value]( + r.Emails.Elements(), + func(elem attr.Value, index int) SCIMUserEmailAPIModel { + attr := elem.(types.Object).Attributes() + + return SCIMUserEmailAPIModel{ + Value: attr["value"].(types.String).ValueString(), + Primary: attr["primary"].(types.Bool).ValueBool(), + } + }, + ) + apiModel.Emails = emails + + return +} + +var SCIMUserEmailResourceModelAttributeType map[string]attr.Type = map[string]attr.Type{ + "value": types.StringType, + "primary": types.BoolType, +} + +var SCIMUserGroupResourceModelAttributeType map[string]attr.Type = map[string]attr.Type{ + "value": types.StringType, +} + +func (r *SCIMUserResourceModel) fromAPIModel(ctx context.Context, apiModel *SCIMUserAPIModel) (ds diag.Diagnostics) { + r.Username = types.StringValue(apiModel.Username) + r.Active = types.BoolValue(apiModel.Active) + + emails := lo.Map( + apiModel.Emails, + func(email SCIMUserEmailAPIModel, _ int) attr.Value { + e, d := types.ObjectValue( + SCIMUserEmailResourceModelAttributeType, + map[string]attr.Value{ + "value": types.StringValue(email.Value), + "primary": types.BoolValue(email.Primary), + }, + ) + if d.HasError() { + ds.Append(d...) + } + + return e + }, + ) + emailsSet, d := types.SetValue( + types.ObjectType{AttrTypes: SCIMUserEmailResourceModelAttributeType}, + emails, + ) + if d.HasError() { + ds.Append(d...) + } + r.Emails = emailsSet + + groups := lo.Map( + apiModel.Groups, + func(group SCIMUserGroupAPIModel, _ int) attr.Value { + e, d := types.ObjectValue( + SCIMUserGroupResourceModelAttributeType, + map[string]attr.Value{ + "value": types.StringValue(group.Value), + }, + ) + if d.HasError() { + ds.Append(d...) + } + + return e + }, + ) + groupsSet, d := types.SetValue( + types.ObjectType{AttrTypes: SCIMUserGroupResourceModelAttributeType}, + groups, + ) + if d.HasError() { + ds.Append(d...) + } + r.Groups = groupsSet + + metas := lo.MapEntries( + apiModel.Meta, + func(k, v string) (string, attr.Value) { + return k, types.StringValue(v) + }, + ) + + metasMap, d := types.MapValue( + types.StringType, + metas, + ) + if d.HasError() { + ds.Append(d...) + } + r.Meta = metasMap + + return +} + +type SCIMUserAPIModel struct { + Schemas []string `json:"schemas"` + Username string `json:"userName"` + Active bool `json:"active"` + Emails []SCIMUserEmailAPIModel `json:"emails"` + Groups []SCIMUserGroupAPIModel `json:"groups,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +type SCIMUserEmailAPIModel struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +type SCIMUserGroupAPIModel struct { + Value string `json:"value"` +} + +type SCIMErrorAPIModel struct { + Status int `json:"status"` + Detail string `json:"detail"` + Schemas []string `json:"schemas"` +} + +func (r *SCIMUserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = r.TypeName +} + +func (r *SCIMUserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "active": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "emails": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "value": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "primary": schema.BoolAttribute{ + Required: true, + }, + }, + }, + Required: true, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.RequiresReplace(), + }, + }, + "groups": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "value": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + }, + }, + Computed: true, + }, + "meta": schema.MapAttribute{ + ElementType: types.StringType, + Computed: true, + }, + }, + MarkdownDescription: "Provides a JFrog [SCIM User](https://jfrog.com/help/r/jfrog-platform-administration-documentation/scim) resource to manage users with the SCIM protocol.", + } +} + +func (r *SCIMUserResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + r.ProviderData = req.ProviderData.(PlatformProviderMetadata) +} + +func (r *SCIMUserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + go util.SendUsageResourceCreate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan SCIMUserResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var user SCIMUserAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &user)...) + if resp.Diagnostics.HasError() { + return + } + + var result SCIMUserAPIModel + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetBody(user). + SetResult(&result). + SetError(&scimErr). + Post(SCIMUsersEndpoint) + + if err != nil { + utilfw.UnableToCreateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToCreateResourceError(resp, scimErr.Detail) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(plan.fromAPIModel(ctx, &result)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *SCIMUserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + go util.SendUsageResourceRead(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state SCIMUserResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var user SCIMUserAPIModel + var scimErr SCIMErrorAPIModel + + response, err := r.ProviderData.Client.R(). + SetPathParam("id", state.Username.ValueString()). + SetResult(&user). + SetError(&scimErr). + Get(SCIMUserEndpoint) + + if err != nil { + utilfw.UnableToRefreshResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToRefreshResourceError(resp, scimErr.Detail) + return + } + + // Treat HTTP 404 Not Found status as a signal to recreate resource + // and return early + if response.StatusCode() == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(state.fromAPIModel(ctx, &user)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *SCIMUserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + go util.SendUsageResourceUpdate(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var plan SCIMUserResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var user SCIMUserAPIModel + resp.Diagnostics.Append(plan.toAPIModel(ctx, &user)...) + if resp.Diagnostics.HasError() { + return + } + + var result SCIMUserAPIModel + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetPathParam("id", plan.Username.ValueString()). + SetBody(user). + SetResult(&result). + SetError(&scimErr). + Put(SCIMUserEndpoint) + + if err != nil { + utilfw.UnableToUpdateResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToUpdateResourceError(resp, scimErr.Detail) + return + } + + // Convert from the API data model to the Terraform data model + // and refresh any attribute values. + resp.Diagnostics.Append(plan.fromAPIModel(ctx, &result)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *SCIMUserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + go util.SendUsageResourceDelete(ctx, r.ProviderData.Client.R(), r.ProviderData.ProductId, r.TypeName) + + var state SCIMUserResourceModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var scimErr SCIMErrorAPIModel + response, err := r.ProviderData.Client.R(). + SetPathParam("id", state.Username.ValueString()). + SetError(&scimErr). + Delete(SCIMUserEndpoint) + + if err != nil { + utilfw.UnableToDeleteResourceError(resp, err.Error()) + return + } + + if response.IsError() { + utilfw.UnableToDeleteResourceError(resp, scimErr.Detail) + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +func (r *SCIMUserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("username"), req, resp) +} diff --git a/pkg/platform/resource_scim_user_test.go b/pkg/platform/resource_scim_user_test.go new file mode 100644 index 0000000..4d926e4 --- /dev/null +++ b/pkg/platform/resource_scim_user_test.go @@ -0,0 +1,112 @@ +package platform_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/jfrog/terraform-provider-platform/pkg/platform" + "github.com/jfrog/terraform-provider-shared/testutil" + "github.com/jfrog/terraform-provider-shared/util" +) + +func TestAccSCIMUser_full(t *testing.T) { + _, fqrn, name := testutil.MkNames("test-scim-user", "platform_scim_user") + + temp := ` + resource "platform_scim_user" "{{ .name }}" { + username = "{{ .email }}" + active = {{ .active }} + emails = [{ + value = "{{ .email }}" + primary = true + }] + }` + + testData := map[string]string{ + "name": name, + "email": "test@tempurl.org", + "active": "true", + } + + config := util.ExecuteTemplate(name, temp, testData) + + updatedTestData := map[string]string{ + "name": name, + "email": "test@tempurl.org", + "active": "false", + } + + updatedConfig := util.ExecuteTemplate(name, temp, updatedTestData) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders(), + CheckDestroy: testAccSCIMUserDestroy(fqrn), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "username", testData["email"]), + resource.TestCheckResourceAttr(fqrn, "active", "true"), + resource.TestCheckResourceAttr(fqrn, "emails.#", "1"), + resource.TestCheckResourceAttr(fqrn, "emails.0.value", testData["email"]), + resource.TestCheckResourceAttr(fqrn, "emails.0.primary", "true"), + resource.TestCheckResourceAttr(fqrn, "groups.#", "1"), + resource.TestCheckResourceAttr(fqrn, "groups.0.value", "readers"), + resource.TestCheckResourceAttr(fqrn, "meta.%", "1"), + resource.TestCheckResourceAttr(fqrn, "meta.resourceType", "User"), + ), + }, + { + Config: updatedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(fqrn, "username", updatedTestData["email"]), + resource.TestCheckResourceAttr(fqrn, "active", "false"), + resource.TestCheckResourceAttr(fqrn, "emails.#", "1"), + resource.TestCheckResourceAttr(fqrn, "emails.0.value", updatedTestData["email"]), + resource.TestCheckResourceAttr(fqrn, "emails.0.primary", "true"), + resource.TestCheckResourceAttr(fqrn, "groups.#", "1"), + resource.TestCheckResourceAttr(fqrn, "groups.0.value", "readers"), + resource.TestCheckResourceAttr(fqrn, "meta.%", "1"), + resource.TestCheckResourceAttr(fqrn, "meta.resourceType", "User"), + ), + }, + { + ResourceName: fqrn, + ImportState: true, + ImportStateVerify: true, + ImportStateId: updatedTestData["email"], + ImportStateVerifyIdentifierAttribute: "username", + }, + }, + }) +} + +func testAccSCIMUserDestroy(id string) func(*terraform.State) error { + return func(s *terraform.State) error { + c := TestProvider.(*platform.PlatformProvider).Meta.Client + + rs, ok := s.RootModule().Resources[id] + if !ok { + return fmt.Errorf("error: resource id [%s] not found", id) + } + + var user platform.SCIMUserAPIModel + resp, err := c.R(). + SetPathParam("id", rs.Primary.Attributes["username"]). + SetResult(&user). + Get(platform.SCIMUserEndpoint) + if err != nil { + return err + } + + if resp != nil && resp.StatusCode() == http.StatusNotFound { + return nil + } + + return fmt.Errorf("error: SCIM user %s still exists", rs.Primary.Attributes["username"]) + } +}