Skip to content

Commit

Permalink
Add support for users (#474)
Browse files Browse the repository at this point in the history
* feat: add support for managing user mapping

Signed-off-by: Michal Wasilewski <[email protected]>
  • Loading branch information
mwasilew2 authored Oct 23, 2023
1 parent 1e0ed0c commit 23aa28a
Show file tree
Hide file tree
Showing 6 changed files with 366 additions and 1 deletion.
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`),
},
})
})

}

0 comments on commit 23aa28a

Please sign in to comment.