-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: add support for managing user mapping Signed-off-by: Michal Wasilewski <[email protected]>
- Loading branch information
Showing
6 changed files
with
366 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`), | ||
}, | ||
}) | ||
}) | ||
|
||
} |