From e7bd4c1bf10d7d6152fcccfc18a959d83701c325 Mon Sep 17 00:00:00 2001 From: Daniel Kuschny Date: Tue, 27 Feb 2024 15:10:12 +0100 Subject: [PATCH] feat: New resource for individual project members --- CHANGELOG.md | 6 + docs/resources/member.md | 46 +++ examples/resources/project_member/import.sh | 1 + examples/resources/project_member/resource.tf | 5 + pkg/project/project_member.go | 46 +++ pkg/project/provider.go | 1 + pkg/project/resource_project_member.go | 156 +++++++++++ pkg/project/resource_project_member_test.go | 264 ++++++++++++++++++ sample.tf | 2 +- 9 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docs/resources/member.md create mode 100644 examples/resources/project_member/import.sh create mode 100644 examples/resources/project_member/resource.tf create mode 100644 pkg/project/project_member.go create mode 100644 pkg/project/resource_project_member.go create mode 100644 pkg/project/resource_project_member_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a27f0f4..56e58692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.3.6 (to be defined) + +FEATURES: + +* **New Resource:** `project_member` - Separate resource to project memberships. + ## 1.3.5 (Feburary 9, 2024) BUG FIXES: diff --git a/docs/resources/member.md b/docs/resources/member.md new file mode 100644 index 00000000..ccaa6a64 --- /dev/null +++ b/docs/resources/member.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "project_member Resource - terraform-provider-project" +subcategory: "" +description: |- + Create a project member. Element has one to one mapping with the JFrog Project Users API https://jfrog.com/help/r/jfrog-rest-apis/add-or-update-user-in-project. Requires a user assigned with the 'Administer the Platform' role or Project Admin permissions if admin_privileges.manage_resoures is enabled. +--- + +# project_member (Resource) + +Create a project member. Element has one to one mapping with the [JFrog Project Users API](https://jfrog.com/help/r/jfrog-rest-apis/add-or-update-user-in-project). Requires a user assigned with the 'Administer the Platform' role or Project Admin permissions if `admin_privileges.manage_resoures` is enabled. + +## Example Usage + +```terraform +resource "project_member" "myuser" { + project_key = "myproj" + name = "myuser" + roles = ["Viewer"] +} +``` + + +## Schema + +### Required + +- `name` (String) Must be existing Artifactory user +- `project_key` (String) The key of the project to which the member belongs. +- `roles` (Set of String) List of pre-defined Project or custom roles + +### Optional + +- `ignore_missing_user` (Boolean) When set to true, the resource will not fail if the user does not exist. Default to false. This is useful when the user is externally managed and the local account wasn't created yet. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import project_member.mymember project_key:username +``` diff --git a/examples/resources/project_member/import.sh b/examples/resources/project_member/import.sh new file mode 100644 index 00000000..be6c553d --- /dev/null +++ b/examples/resources/project_member/import.sh @@ -0,0 +1 @@ +terraform import project_member.mymember project_key:username \ No newline at end of file diff --git a/examples/resources/project_member/resource.tf b/examples/resources/project_member/resource.tf new file mode 100644 index 00000000..7b1ffd24 --- /dev/null +++ b/examples/resources/project_member/resource.tf @@ -0,0 +1,5 @@ +resource "project_member" "myuser" { + project_key = "myproj" + name = "myuser" + roles = ["Viewer"] +} \ No newline at end of file diff --git a/pkg/project/project_member.go b/pkg/project/project_member.go new file mode 100644 index 00000000..6e65d6f2 --- /dev/null +++ b/pkg/project/project_member.go @@ -0,0 +1,46 @@ +package project + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/jfrog/terraform-provider-shared/util" +) + +type ProjectMember struct { + ProjectKey string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + IgnoreMissingUser bool `json:"-"` +} + +func unpackProjectMember(d *schema.ResourceData) ProjectMember { + return ProjectMember{ + ProjectKey: d.Get("project_key").(string), + Name: d.Get("name").(string), + Roles: util.CastToStringArr(d.Get("roles").(*schema.Set).List()), + IgnoreMissingUser: d.Get("ignore_missing_user").(bool), + } +} + +func packProjectMember(ctx context.Context, data *schema.ResourceData, m ProjectMember) diag.Diagnostics { + setValue := util.MkLens(data) + + errors := []error{} + errors = append(errors, setValue("name", m.Name)...) + errors = append(errors, setValue("project_key", m.ProjectKey)...) + errors = append(errors, setValue("roles", m.Roles)...) + errors = append(errors, setValue("ignore_missing_user", m.IgnoreMissingUser)...) + + if len(errors) > 0 { + return diag.Errorf("failed to pack project member %q", errors) + } + + return nil +} + +func (m ProjectMember) Id() string { + return fmt.Sprintf(`%s:%s`, m.ProjectKey, m.Name) +} diff --git a/pkg/project/provider.go b/pkg/project/provider.go index 4af9710a..e49c4bd1 100644 --- a/pkg/project/provider.go +++ b/pkg/project/provider.go @@ -51,6 +51,7 @@ func Provider() *schema.Provider { "project": projectResource(), "project_environment": projectEnvironmentResource(), "project_role": projectRoleResource(), + "project_member": projectMemberResource(), }, ), } diff --git a/pkg/project/resource_project_member.go b/pkg/project/resource_project_member.go new file mode 100644 index 00000000..5b314958 --- /dev/null +++ b/pkg/project/resource_project_member.go @@ -0,0 +1,156 @@ +package project + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/jfrog/terraform-provider-shared/util" + "github.com/jfrog/terraform-provider-shared/validator" +) + +const projectMembersUrl = "access/api/v1/projects/{projectKey}/users/{name}" + +func projectMemberResource() *schema.Resource { + var projectMemberSchema = map[string]*schema.Schema{ + "project_key": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validator.ProjectKey, + Description: "The key of the project to which the member belongs.", + }, + "name": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "Must be existing Artifactory user", + }, + "roles": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "List of pre-defined Project or custom roles", + }, + "ignore_missing_user": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When set to true, the resource will not fail if the user does not exist. Default to false. This is useful when the user is externally managed and the local account wasn't created yet.", + }, + } + + var readProjectMember = func(ctx context.Context, data *schema.ResourceData, m interface{}) diag.Diagnostics { + projectMember := unpackProjectMember(data) + var loadedProjectMember ProjectMember + + resp, err := m.(util.ProvderMetadata).Client.R(). + SetPathParams(map[string]string{ + "projectKey": projectMember.ProjectKey, + "name": projectMember.Name, + }). + SetResult(&loadedProjectMember). + Get(projectMembersUrl) + + if resp != nil && resp.StatusCode() == http.StatusNotFound && projectMember.IgnoreMissingUser { + // ignore missing user, reuse local info for state + loadedProjectMember = projectMember + } else if err != nil { + return diag.FromErr(err) + } + + loadedProjectMember.ProjectKey = projectMember.ProjectKey + loadedProjectMember.IgnoreMissingUser = projectMember.IgnoreMissingUser + + return packProjectMember(ctx, data, loadedProjectMember) + } + + var upsertProjectMember = func(ctx context.Context, data *schema.ResourceData, m interface{}) diag.Diagnostics { + projectMember := unpackProjectMember(data) + + resp, err := m.(util.ProvderMetadata).Client.R(). + SetPathParams(map[string]string{ + "projectKey": projectMember.ProjectKey, + "name": projectMember.Name, + }). + SetBody(&projectMember). + Put(projectMembersUrl) + + // allow missing user? -> report warning and ignore error + diagnostics := diag.Diagnostics{} + + if resp != nil && resp.StatusCode() == http.StatusNotFound { + if projectMember.IgnoreMissingUser { + diagnostics = append(diagnostics, diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("user '%s' not found, but ignore_missing_user is set to true, project membership not created", projectMember.Name), + }) + } else { + return diag.Errorf("user '%s' not found, project membership not created", projectMember.Name) + } + } else if err != nil { + return diag.FromErr(err) + } + + data.SetId(projectMember.Id()) + + diagnostics = append(diagnostics, readProjectMember(ctx, data, m)...) + + if len(diagnostics) > 0 { + return diagnostics + } + + return nil + } + + var deleteProjectMember = func(ctx context.Context, data *schema.ResourceData, m interface{}) diag.Diagnostics { + projectMember := unpackProjectMember(data) + + _, err := m.(util.ProvderMetadata).Client.R(). + SetPathParams(map[string]string{ + "projectKey": projectMember.ProjectKey, + "name": projectMember.Name, + }). + Delete(projectMembersUrl) + + if err != nil { + return diag.FromErr(err) + } + + data.SetId("") + + return nil + } + + var importForProjectKeyMemberName = func(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + parts := strings.SplitN(d.Id(), ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("unexpected format of ID (%s), expected project_key:name", d.Id()) + } + + d.Set("project_key", parts[0]) + d.Set("name", parts[1]) + + return []*schema.ResourceData{d}, nil + } + + return &schema.Resource{ + CreateContext: upsertProjectMember, + ReadContext: readProjectMember, + UpdateContext: upsertProjectMember, + DeleteContext: deleteProjectMember, + + Importer: &schema.ResourceImporter{ + State: importForProjectKeyMemberName, + }, + + Schema: projectMemberSchema, + SchemaVersion: 1, + + Description: "Create a project member. Element has one to one mapping with the [JFrog Project Users API](https://jfrog.com/help/r/jfrog-rest-apis/add-or-update-user-in-project). Requires a user assigned with the 'Administer the Platform' role or Project Admin permissions if `admin_privileges.manage_resoures` is enabled.", + } +} diff --git a/pkg/project/resource_project_member_test.go b/pkg/project/resource_project_member_test.go new file mode 100644 index 00000000..d7a9718d --- /dev/null +++ b/pkg/project/resource_project_member_test.go @@ -0,0 +1,264 @@ +package project + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/jfrog/terraform-provider-shared/test" +) + +func TestAccProjectMember(t *testing.T) { + projectName := fmt.Sprintf("tftestprojects%s", randSeq(10)) + projectKey := strings.ToLower(randSeq(6)) + + username := "user1" + email := username + "@tempurl.org" + + resourceName := "project_member." + username + + params := map[string]interface{}{ + "project_name": projectName, + "project_key": projectKey, + "username": username, + "email": email, + "roles": `["Developer","Project Admin"]`, + } + + template := ` + resource "artifactory_managed_user" "{{ .username }}" { + name = "{{ .username }}" + email = "{{ .email }}" + password = "Password1!" + admin = false + } + + resource "project_member" "{{ .username }}" { + project_key = "{{ .project_key }}" + name = "{{ .username }}" + roles = ["Developer","Project Admin"] + } + + resource "project" "{{ .project_name }}" { + key = "{{ .project_key }}" + display_name = "{{ .project_name }}" + description = "test description" + admin_privileges { + manage_members = true + manage_resources = true + index_resources = true + } + max_storage_in_gibibytes = 1 + block_deployments_on_limit = true + email_notification = false + + lifecycle { + ignore_changes = ["member"] + } + } + ` + + config := test.ExecuteTemplate("TestAccProjectMember", template, params) + + updateParams := map[string]interface{}{ + "project_name": params["project_name"], + "project_key": params["project_key"], + "username": params["username"], + "email": params["email"], + "roles": `["Developer"]`, + } + + configUpdated := test.ExecuteTemplate("TestAccProjectMember", template, updateParams) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: verifyDeleted(resourceName, func(id string, request *resty.Request) (*resty.Response, error) { + return verifyProjectMember(username, projectKey, request) + }), + ProviderFactories: testAccProviders(), + ExternalProviders: map[string]resource.ExternalProvider{ + "artifactory": { + Source: "jfrog/artifactory", + VersionConstraint: "10.1.3", + }, + }, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "project_key", fmt.Sprintf("%s", params["project_key"])), + resource.TestCheckResourceAttr(resourceName, "name", username), + resource.TestCheckResourceAttr(resourceName, "ignore_missing_user", "false"), + resource.TestCheckResourceAttr(resourceName, "roles.#", "2"), + resource.TestCheckResourceAttr(resourceName, "roles.0", "Developer"), + resource.TestCheckResourceAttr(resourceName, "roles.1", "Project Admin"), + ), + }, + { + Config: configUpdated, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "project_key", fmt.Sprintf("%s", params["project_key"])), + resource.TestCheckResourceAttr(resourceName, "name", username), + resource.TestCheckResourceAttr(resourceName, "ignore_missing_user", "false"), + resource.TestCheckResourceAttr(resourceName, "roles.#", "1"), + resource.TestCheckResourceAttr(resourceName, "roles.0", "Developer"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccProjectMember_missing_user_fails(t *testing.T) { + projectName := fmt.Sprintf("tftestprojects%s", randSeq(10)) + projectKey := strings.ToLower(randSeq(6)) + + username := "not_existing" + email := username + "@tempurl.org" + + resourceName := "project_member." + username + + params := map[string]interface{}{ + "project_name": projectName, + "project_key": projectKey, + "username": username, + "email": email, + "roles": `["Developer","Project Admin"]`, + } + + template := ` + resource "project_member" "{{ .username }}" { + project_key = "{{ .project_key }}" + name = "{{ .username }}" + roles = ["Developer","Project Admin"] + ignore_missing_user = false + } + + resource "project" "{{ .project_name }}" { + key = "{{ .project_key }}" + display_name = "{{ .project_name }}" + description = "test description" + admin_privileges { + manage_members = true + manage_resources = true + index_resources = true + } + max_storage_in_gibibytes = 1 + block_deployments_on_limit = true + email_notification = false + + lifecycle { + ignore_changes = ["member"] + } + } + ` + + config := test.ExecuteTemplate("TestAccProjectMember", template, params) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: verifyDeleted(resourceName, func(id string, request *resty.Request) (*resty.Response, error) { + return verifyProjectMember(username, projectKey, request) + }), + ProviderFactories: testAccProviders(), + ExternalProviders: map[string]resource.ExternalProvider{ + "artifactory": { + Source: "jfrog/artifactory", + VersionConstraint: "10.1.3", + }, + }, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`user .* not found, project membership not created.*`), + }, + }, + }) +} + +func TestAccProjectMember_missing_user_ignored(t *testing.T) { + projectName := fmt.Sprintf("tftestprojects%s", randSeq(10)) + projectKey := strings.ToLower(randSeq(6)) + + username := "not_existing" + email := username + "@tempurl.org" + + resourceName := "project_member." + username + + params := map[string]interface{}{ + "project_name": projectName, + "project_key": projectKey, + "username": username, + "email": email, + "roles": `["Developer","Project Admin"]`, + } + + template := ` + resource "project_member" "{{ .username }}" { + project_key = "{{ .project_key }}" + name = "{{ .username }}" + roles = ["Developer","Project Admin"] + ignore_missing_user = true + } + + resource "project" "{{ .project_name }}" { + key = "{{ .project_key }}" + display_name = "{{ .project_name }}" + description = "test description" + admin_privileges { + manage_members = true + manage_resources = true + index_resources = true + } + max_storage_in_gibibytes = 1 + block_deployments_on_limit = true + email_notification = false + + lifecycle { + ignore_changes = ["member"] + } + } + ` + + config := test.ExecuteTemplate("TestAccProjectMember", template, params) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + CheckDestroy: verifyDeleted(resourceName, func(id string, request *resty.Request) (*resty.Response, error) { + return verifyProjectMember(username, projectKey, request) + }), + ProviderFactories: testAccProviders(), + ExternalProviders: map[string]resource.ExternalProvider{ + "artifactory": { + Source: "jfrog/artifactory", + VersionConstraint: "10.1.3", + }, + }, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "project_key", fmt.Sprintf("%s", params["project_key"])), + resource.TestCheckResourceAttr(resourceName, "name", username), + resource.TestCheckResourceAttr(resourceName, "ignore_missing_user", "true"), + resource.TestCheckResourceAttr(resourceName, "roles.#", "2"), + resource.TestCheckResourceAttr(resourceName, "roles.0", "Developer"), + resource.TestCheckResourceAttr(resourceName, "roles.1", "Project Admin"), + )}, + }, + }) +} + +func verifyProjectMember(name string, projectKey string, request *resty.Request) (*resty.Response, error) { + return request. + SetPathParams(map[string]string{ + "projectKey": projectKey, + "name": name, + }). + Get(projectMembersUrl) +} diff --git a/sample.tf b/sample.tf index f2ff85a1..bb5bd63d 100644 --- a/sample.tf +++ b/sample.tf @@ -3,7 +3,7 @@ terraform { required_providers { project = { source = "registry.terraform.io/jfrog/project" - version = "1.1.17" + version = "1.3.6" } } }