diff --git a/docs/resources/user.md b/docs/resources/user.md new file mode 100644 index 00000000..0db7c8bb --- /dev/null +++ b/docs/resources/user.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "spacelift_user Resource - terraform-provider-spacelift" +subcategory: "" +description: |- + spacelift_user represents a mapping between a Spacelift user (managed using an Identity Provider) and a Policy. A Policy defines what access rights the user has to a given Space. +--- + +# spacelift_user (Resource) + +`spacelift_user` represents a mapping between a Spacelift user (managed using an Identity Provider) and a Policy. A Policy defines what access rights the user has to a given Space. + + + + +## Schema + +### Required + +- `invitation_email` (String) Email of the user. Used for sending an invitation. +- `policy` (Block List, Min: 1) (see [below for nested schema](#nestedblock--policy)) +- `username` (String) Username of the user + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `policy` + +Required: + +- `role` (String) Type of access to the space. Possible values are: READ, WRITE, ADMIN +- `space_id` (String) ID (slug) of the space the user has access to diff --git a/spacelift/internal/structs/user.go b/spacelift/internal/structs/user.go new file mode 100644 index 00000000..6754bcf3 --- /dev/null +++ b/spacelift/internal/structs/user.go @@ -0,0 +1,8 @@ +package structs + +type User struct { + ID string `graphql:"id"` + InvitationEmail string `graphql:"invitationEmail"` + Username string `graphql:"username"` + AccessRules []SpaceAccessRule `graphql:"accessRules"` +} diff --git a/spacelift/internal/structs/user_input.go b/spacelift/internal/structs/user_input.go new file mode 100644 index 00000000..2edfceed --- /dev/null +++ b/spacelift/internal/structs/user_input.go @@ -0,0 +1,14 @@ +package structs + +import "github.com/shurcooL/graphql" + +type ManagedUserInviteInput struct { + InvitationEmail graphql.String `json:"invitationEmail"` + Username graphql.String `json:"username"` + AccessRules []SpaceAccessRuleInput `json:"accessRules"` +} + +type ManagedUserUpdateInput struct { + ID graphql.ID `json:"id"` + AccessRules []SpaceAccessRuleInput `json:"accessRules"` +} diff --git a/spacelift/provider.go b/spacelift/provider.go index e8353422..ebedbf06 100644 --- a/spacelift/provider.go +++ b/spacelift/provider.go @@ -109,6 +109,7 @@ func Provider(commit, version string) plugin.ProviderFunc { "spacelift_drift_detection": resourceDriftDetection(), "spacelift_environment_variable": resourceEnvironmentVariable(), "spacelift_gcp_service_account": resourceGCPServiceAccount(), + "spacelift_idp_group_mapping": resourceIdpGroupMapping(), "spacelift_module": resourceModule(), "spacelift_mounted_file": resourceMountedFile(), "spacelift_policy_attachment": resourcePolicyAttachment(), @@ -125,7 +126,7 @@ func Provider(commit, version string) plugin.ProviderFunc { "spacelift_stack_aws_role": resourceStackAWSRole(), // deprecated "spacelift_stack_gcp_service_account": resourceStackGCPServiceAccount(), // deprecated "spacelift_terraform_provider": resourceTerraformProvider(), - "spacelift_idp_group_mapping": resourceIdpGroupMapping(), + "spacelift_user": resourceUser(), "spacelift_vcs_agent_pool": resourceVCSAgentPool(), "spacelift_webhook": resourceWebhook(), "spacelift_named_webhook": resourceNamedWebhook(), diff --git a/spacelift/resource_user.go b/spacelift/resource_user.go new file mode 100644 index 00000000..b0afaab9 --- /dev/null +++ b/spacelift/resource_user.go @@ -0,0 +1,166 @@ +package spacelift + +import ( + "context" + + "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/spacelift-io/terraform-provider-spacelift/spacelift/internal" + "github.com/spacelift-io/terraform-provider-spacelift/spacelift/internal/structs" + "github.com/spacelift-io/terraform-provider-spacelift/spacelift/internal/validations" +) + +func resourceUser() *schema.Resource { + return &schema.Resource{ + Description: "" + + "`spacelift_user` represents a mapping between a Spacelift user " + + "(managed using an Identity Provider) and a Policy. A Policy defines " + + "what access rights the user has to a given Space.", + CreateContext: resourceUserCreate, + ReadContext: resourceUserRead, + UpdateContext: resourceUserUpdate, + DeleteContext: resourceUserDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "invitation_email": { + Type: schema.TypeString, + Description: "Email of the user. Used for sending an invitation.", + Required: true, + }, + "username": { + Type: schema.TypeString, + Description: "Username of the user", + Required: true, + }, + "policy": { + Type: schema.TypeList, + MinItems: 1, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "space_id": { + Type: schema.TypeString, + Description: "ID (slug) of the space the user has access to", + Required: true, + ValidateDiagFunc: validations.DisallowEmptyString, + }, + "role": { + Type: schema.TypeString, + Description: "Type of access to the space. Possible values are: " + + "READ, WRITE, ADMIN", + Required: true, + ValidateFunc: validation.StringInSlice(validAccessLevels, false), + }, + }, + }, + }, + }, + } +} + +func resourceUserCreate(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + // send an Invite (create) mutation to the API + var mutation struct { + User *structs.User `graphql:"managedUserInvite(input: $input)"` + } + variables := map[string]interface{}{ + "input": structs.ManagedUserInviteInput{ + InvitationEmail: toString(d.Get("invitation_email")), + Username: toString(d.Get("username")), + AccessRules: getAccessRules(d), + }, + } + if err := i.(*internal.Client).Mutate(ctx, "ManagedUserInvite", &mutation, variables); err != nil { + return diag.Errorf("could not create user mapping %s: %v", toString(d.Get("username")), internal.FromSpaceliftError(err)) + } + + // set the ID in TF state + d.SetId(mutation.User.ID) + + // fetch state from remote and write to TF state + return resourceUserRead(ctx, d, i) +} + +func resourceUserRead(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + // send a read query to the API + var query struct { + User *structs.User `graphql:"managedUser(id: $id)"` + } + variables := map[string]interface{}{"id": toID(d.Id())} + if err := i.(*internal.Client).Query(ctx, "ManagedUser", &query, variables); err != nil { + return diag.Errorf("could not query for user mapping: %v", err) + } + + // if the mapping is not found on the remote side, delete it from the TF state + if query.User == nil { + d.SetId("") + return nil + } + + // if found, update the TF state + d.Set("invitation_email", query.User.InvitationEmail) + d.Set("username", query.User.Username) + var accessList []interface{} + for _, a := range query.User.AccessRules { + accessList = append(accessList, map[string]interface{}{ + "space_id": a.Space, + "role": a.SpaceAccessLevel, + }) + } + d.Set("policy", accessList) + + return nil +} + +func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + // input validation + if d.HasChange("invitation_email") { + return diag.Errorf("invitation_email cannot be changed") + } + if d.HasChange("username") { + return diag.Errorf("username cannot be changed") + } + + var ret diag.Diagnostics + + // send an update query to the API + var mutation struct { + User *structs.User `graphql:"managedUserUpdate(input: $input)"` + } + variables := map[string]interface{}{ + "input": structs.ManagedUserUpdateInput{ + ID: toID(d.Id()), + AccessRules: getAccessRules(d), + }, + } + if err := i.(*internal.Client).Mutate(ctx, "ManagedUserUpdate", &mutation, variables); err != nil { + ret = append(ret, diag.Errorf("could not update user mapping %s: %v", d.Id(), internal.FromSpaceliftError(err))...) + } + + // fetch from remote and write to TF state + ret = append(ret, resourceUserRead(ctx, d, i)...) + + return ret +} + +func resourceUserDelete(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + // send a delete query to the API + var mutation struct { + User *structs.User `graphql:"managedUserDelete(id: $id)"` + } + variables := map[string]interface{}{"id": toID(d.Id())} + if err := i.(*internal.Client).Mutate(ctx, "ManagedUserDelete", &mutation, variables); err != nil { + return diag.Errorf("could not delete user mapping %s: %v", d.Id(), internal.FromSpaceliftError(err)) + } + + // if the user was deleted, remove it from the TF state as well + d.SetId("") + + return nil +} diff --git a/spacelift/resource_user_test.go b/spacelift/resource_user_test.go new file mode 100644 index 00000000..fcac520e --- /dev/null +++ b/spacelift/resource_user_test.go @@ -0,0 +1,142 @@ +package spacelift + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + + . "github.com/spacelift-io/terraform-provider-spacelift/spacelift/internal/testhelpers" +) + +var userWithOneAccess = ` +resource "spacelift_user" "test" { + invitation_email = "%s" + username = "%s" + policy { + space_id = "root" + role = "ADMIN" + } +} +` + +var userWithTwoAccesses = ` +resource "spacelift_user" "test" { + invitation_email = "%s" + username = "%s" + policy { + space_id = "root" + role = "ADMIN" + } + policy { + space_id = "legacy" + role = "READ" + } +} +` + +func TestUserResource(t *testing.T) { + const resourceName = "spacelift_user.test" + + t.Run("creates a user without an error", func(t *testing.T) { + randomUsername := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + exampleEmail := fmt.Sprintf("%s@example.com", randomUsername) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail, randomUsername), + Check: Resource( + resourceName, + Attribute("invitation_email", Equals(exampleEmail)), + Attribute("username", Equals(randomUsername)), + SetContains("policy", "root"), + SetContains("policy", "ADMIN"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }) + }) + + t.Run("can edit access list", func(t *testing.T) { + randomUsername := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + exampleEmail := fmt.Sprintf("%s@example.com", randomUsername) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(userWithTwoAccesses, exampleEmail, randomUsername), + Check: Resource( + resourceName, + Attribute("invitation_email", Equals(exampleEmail)), + Attribute("username", Equals(randomUsername)), + SetContains("policy", "root", "ADMIN"), + SetContains("policy", "legacy", "READ")), + }, + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail, randomUsername), + Check: Resource( + resourceName, + Attribute("invitation_email", Equals(exampleEmail)), + Attribute("username", Equals(randomUsername)), + SetContains("policy", "root", "ADMIN"), + SetDoesNotContain("policy", "legacy", "READ")), + }, + }) + + }) + + t.Run("cannot change email address", func(t *testing.T) { + randomUsername := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + exampleEmail := fmt.Sprintf("%s@example.com", randomUsername) + + randomUsername2 := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + exampleEmail2 := fmt.Sprintf("%s@example.com", randomUsername2) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail, randomUsername), + Check: Resource( + resourceName, + Attribute("invitation_email", Equals(exampleEmail)), + Attribute("username", Equals(randomUsername)), + SetContains("policy", "root", "ADMIN"), + SetDoesNotContain("policy", "legacy"), + ), + }, + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail2, randomUsername), + ExpectError: regexp.MustCompile(`invitation_email cannot be changed`), + }, + }) + }) + + t.Run("cannot change username", func(t *testing.T) { + randomUsername := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + exampleEmail := fmt.Sprintf("%s@example.com", randomUsername) + + randomUsername2 := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail, randomUsername), + Check: Resource( + resourceName, + Attribute("invitation_email", Equals(exampleEmail)), + Attribute("username", Equals(randomUsername)), + SetContains("policy", "root", "ADMIN"), + SetDoesNotContain("policy", "legacy"), + ), + }, + { + Config: fmt.Sprintf(userWithOneAccess, exampleEmail, randomUsername2), + ExpectError: regexp.MustCompile(`username cannot be changed`), + }, + }) + }) + +}