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"])
+ }
+}