Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for users #474

Merged
merged 13 commits into from
Oct 23, 2023
34 changes: 34 additions & 0 deletions docs/resources/user.md
Original file line number Diff line number Diff line change
@@ -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 generated by tfplugindocs -->
## 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.

<a id="nestedblock--policy"></a>
### 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
8 changes: 8 additions & 0 deletions spacelift/internal/structs/user.go
Original file line number Diff line number Diff line change
@@ -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"`
}
14 changes: 14 additions & 0 deletions spacelift/internal/structs/user_input.go
Original file line number Diff line number Diff line change
@@ -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"`
}
3 changes: 2 additions & 1 deletion spacelift/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
166 changes: 166 additions & 0 deletions spacelift/resource_user.go
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 142 additions & 0 deletions spacelift/resource_user_test.go
Original file line number Diff line number Diff line change
@@ -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("%[email protected]", 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("%[email protected]", 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("%[email protected]", randomUsername)

randomUsername2 := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
exampleEmail2 := fmt.Sprintf("%[email protected]", 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("%[email protected]", 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`),
},
})
})

}
Loading