From 1e0ed0cc57e361fe5002b64c4a6d3c6abf51800b Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 9 Oct 2023 15:05:45 +0200 Subject: [PATCH] Add support for user group resources (#473) * feat: add support for user group resources Signed-off-by: Michal Wasilewski --- docs/resources/idp_group_mapping.md | 47 +++++ .../spacelift_idp_group_mapping/resource.tf | 11 ++ spacelift/internal/structs/user_group.go | 12 ++ .../internal/structs/user_group_input.go | 20 ++ spacelift/provider.go | 1 + spacelift/resource_idp_group_mapping.go | 174 ++++++++++++++++++ spacelift/resource_idp_group_mapping_test.go | 97 ++++++++++ 7 files changed, 362 insertions(+) create mode 100644 docs/resources/idp_group_mapping.md create mode 100644 examples/resources/spacelift_idp_group_mapping/resource.tf create mode 100644 spacelift/internal/structs/user_group.go create mode 100644 spacelift/internal/structs/user_group_input.go create mode 100644 spacelift/resource_idp_group_mapping.go create mode 100644 spacelift/resource_idp_group_mapping_test.go diff --git a/docs/resources/idp_group_mapping.md b/docs/resources/idp_group_mapping.md new file mode 100644 index 00000000..28e6c1f3 --- /dev/null +++ b/docs/resources/idp_group_mapping.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "spacelift_idp_group_mapping Resource - terraform-provider-spacelift" +subcategory: "" +description: |- + spacelift_idp_group_mapping represents a mapping (binding) between a user group (as provided by IdP) and a Spacelift User Management Policy. If you assign permissions (a Policy) to a user group, all users in the group will have those permissions unless the user's permissions are higher than the group's permissions. +--- + +# spacelift_idp_group_mapping (Resource) + +`spacelift_idp_group_mapping` represents a mapping (binding) between a user group (as provided by IdP) and a Spacelift User Management Policy. If you assign permissions (a Policy) to a user group, all users in the group will have those permissions unless the user's permissions are higher than the group's permissions. + +## Example Usage + +```terraform +resource "spacelift_idp_group_mapping" "test" { + name = "test" + policy { + space_id = "root" + role = "ADMIN" + } + policy { + space_id = "legacy" + role = "ADMIN" + } +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the user group - should be unique in one account +- `policy` (Block List, Min: 1) (see [below for nested schema](#nestedblock--policy)) + +### 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 group has access to diff --git a/examples/resources/spacelift_idp_group_mapping/resource.tf b/examples/resources/spacelift_idp_group_mapping/resource.tf new file mode 100644 index 00000000..d9f9098b --- /dev/null +++ b/examples/resources/spacelift_idp_group_mapping/resource.tf @@ -0,0 +1,11 @@ +resource "spacelift_idp_group_mapping" "test" { + name = "test" + policy { + space_id = "root" + role = "ADMIN" + } + policy { + space_id = "legacy" + role = "ADMIN" + } +} diff --git a/spacelift/internal/structs/user_group.go b/spacelift/internal/structs/user_group.go new file mode 100644 index 00000000..054951d4 --- /dev/null +++ b/spacelift/internal/structs/user_group.go @@ -0,0 +1,12 @@ +package structs + +type SpaceAccessRule struct { + Space string `graphql:"space"` + SpaceAccessLevel string `graphql:"spaceAccessLevel"` +} + +type UserGroup struct { + ID string `graphql:"id"` + Name string `graphql:"groupName"` + AccessRules []SpaceAccessRule `graphql:"accessRules"` +} diff --git a/spacelift/internal/structs/user_group_input.go b/spacelift/internal/structs/user_group_input.go new file mode 100644 index 00000000..78d2ac4f --- /dev/null +++ b/spacelift/internal/structs/user_group_input.go @@ -0,0 +1,20 @@ +package structs + +import "github.com/shurcooL/graphql" + +type SpaceAccessLevel string + +type SpaceAccessRuleInput struct { + Space graphql.ID `json:"space"` + SpaceAccessLevel SpaceAccessLevel `json:"spaceAccessLevel"` +} + +type ManagedUserGroupCreateInput struct { + Name graphql.String `json:"groupName"` + AccessRules []SpaceAccessRuleInput `json:"accessRules"` +} + +type ManagedUserGroupUpdateInput struct { + ID graphql.ID `json:"id"` + AccessRules []SpaceAccessRuleInput `json:"accessRules"` +} diff --git a/spacelift/provider.go b/spacelift/provider.go index b38c1025..e8353422 100644 --- a/spacelift/provider.go +++ b/spacelift/provider.go @@ -125,6 +125,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_vcs_agent_pool": resourceVCSAgentPool(), "spacelift_webhook": resourceWebhook(), "spacelift_named_webhook": resourceNamedWebhook(), diff --git a/spacelift/resource_idp_group_mapping.go b/spacelift/resource_idp_group_mapping.go new file mode 100644 index 00000000..9d72226f --- /dev/null +++ b/spacelift/resource_idp_group_mapping.go @@ -0,0 +1,174 @@ +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/shurcooL/graphql" + + "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" +) + +var validAccessLevels = []string{ + "READ", + "WRITE", + "ADMIN", +} + +func resourceIdpGroupMapping() *schema.Resource { + return &schema.Resource{ + Description: "" + + "`spacelift_idp_group_mapping` represents a mapping (binding) between a user group (as provided by IdP) " + + "and a Spacelift User Management Policy. If you assign permissions (a Policy) to a user group, all users in the group " + + "will have those permissions unless the user's permissions are higher than the group's permissions.", + CreateContext: resourceIdpGroupMappingCreate, + ReadContext: resourceIdpGroupMappingRead, + UpdateContext: resourceIdpGroupMappingUpdate, + DeleteContext: resourceIdpGroupMappingDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "Name of the user group - should be unique in one account", + Required: true, + ForceNew: true, + ValidateDiagFunc: validations.DisallowEmptyString, + }, + "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 group 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 resourceIdpGroupMappingCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // send a create query to the API + var mutation struct { + UserGroup *structs.UserGroup `graphql:"managedUserGroupCreate(input: $input)"` + } + variables := map[string]interface{}{ + "input": structs.ManagedUserGroupCreateInput{ + Name: toString(d.Get("name")), + AccessRules: getAccessRules(d), + }, + } + if err := meta.(*internal.Client).Mutate(ctx, "ManagedUserGroupCreate", &mutation, variables); err != nil { + return diag.Errorf("could not create user group mapping %v: %v", toString(d.Get("name")), internal.FromSpaceliftError(err)) + } + + // set the ID in TF state + d.SetId(mutation.UserGroup.ID) + + // fetch from remote and write to TF state + return resourceIdpGroupMappingRead(ctx, d, meta) +} + +func resourceIdpGroupMappingRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // send a read query to the API + var query struct { + UserGroup *structs.UserGroup `graphql:"managedUserGroup(id: $id)"` + } + variables := map[string]interface{}{"id": graphql.ID(d.Id())} + if err := meta.(*internal.Client).Query(ctx, "ManagedUserGroupRead", &query, variables); err != nil { + return diag.Errorf("could not query for user group mapping: %v", err) + } + + // if the mapping is not found on the Spacelift side, delete it from the TF state + userGroup := query.UserGroup + if userGroup == nil { + d.SetId("") + return nil + } + + // if found, update the TF state + d.Set("name", userGroup.Name) + var accessList []interface{} + for _, a := range userGroup.AccessRules { + accessList = append(accessList, map[string]interface{}{ + "space_id": a.Space, + "role": a.SpaceAccessLevel, + }) + } + d.Set("policy", accessList) + + return nil + +} + +func resourceIdpGroupMappingUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var ret diag.Diagnostics + + // send an update query to the API + var mutation struct { + UserGroup *structs.UserGroup `graphql:"managedUserGroupUpdate(input: $input)"` + } + variables := map[string]interface{}{ + "input": structs.ManagedUserGroupUpdateInput{ + ID: toID(d.Id()), + AccessRules: getAccessRules(d), + }, + } + if err := meta.(*internal.Client).Mutate(ctx, "ManagedUserGroupUpdate", &mutation, variables); err != nil { + ret = append(ret, diag.Errorf("could not update user group mapping: %v", internal.FromSpaceliftError(err))...) + } + + // send a read query to the API + ret = append(ret, resourceIdpGroupMappingRead(ctx, d, meta)...) + + return ret +} + +func resourceIdpGroupMappingDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // send a delete query to the API + var mutation struct { + UserGroup *structs.UserGroup `graphql:"managedUserGroupDelete(id: $id)"` + } + variables := map[string]interface{}{"id": toID(d.Id())} + if err := meta.(*internal.Client).Mutate(ctx, "ManagedUserGroupDelete", &mutation, variables); err != nil { + return diag.Errorf("could not delete user group mapping: %v", internal.FromSpaceliftError(err)) + } + + // if the mapping was successfully removed from the Spacelift side, delete it from the TF state + d.SetId("") + + return nil +} + +func getAccessRules(d *schema.ResourceData) []structs.SpaceAccessRuleInput { + var accessRules []structs.SpaceAccessRuleInput + for _, a := range d.Get("policy").([]interface{}) { + access := a.(map[string]interface{}) + accessRules = append(accessRules, structs.SpaceAccessRuleInput{ + Space: toID(access["space_id"]), + SpaceAccessLevel: structs.SpaceAccessLevel(access["role"].(string)), + }) + } + return accessRules +} diff --git a/spacelift/resource_idp_group_mapping_test.go b/spacelift/resource_idp_group_mapping_test.go new file mode 100644 index 00000000..6e371e71 --- /dev/null +++ b/spacelift/resource_idp_group_mapping_test.go @@ -0,0 +1,97 @@ +package spacelift + +import ( + "fmt" + "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 withOneAccess = ` +resource "spacelift_idp_group_mapping" "test" { + name = "%s" + policy { + space_id = "root" + role = "ADMIN" + } +} +` + +var withTwoAccesses = ` +resource "spacelift_idp_group_mapping" "test" { + name = "%s" + policy { + space_id = "root" + role = "ADMIN" + } + policy { + space_id = "legacy" + role = "READ" + } +} +` + +func TestIdpGroupMappingResource(t *testing.T) { + const resourceName = "spacelift_idp_group_mapping.test" + + t.Run("creates and updates a user group mapping without an error", func(t *testing.T) { + oldName := "old name" + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(withOneAccess, oldName), + Check: Resource( + resourceName, + Attribute("name", Equals(oldName)), + SetContains("policy", "root"), + SetContains("policy", "ADMIN"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: fmt.Sprintf(withOneAccess, randomID), + Check: Resource( + resourceName, + Attribute("name", Equals(randomID)), + ), + }, + }) + }) + + t.Run("can remove one access", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + testSteps(t, []resource.TestStep{ + { + Config: fmt.Sprintf(withTwoAccesses, randomID), + Check: Resource( + resourceName, + Attribute("name", Equals(randomID)), + SetContains("policy", "root"), + SetContains("policy", "ADMIN"), + SetContains("policy", "legacy"), + SetContains("policy", "READ"), + ), + }, + { + Config: fmt.Sprintf(withOneAccess, randomID), + Check: Resource( + resourceName, + SetContains("policy", "root"), + SetContains("policy", "ADMIN"), + SetDoesNotContain("policy", "legacy"), + SetDoesNotContain("policy", "READ"), + ), + }, + }) + }) + +}