From 98b1fdbf5910b46e6955c618848d674bd67ccdd0 Mon Sep 17 00:00:00 2001 From: Bernardo Pastorelli <13519917+randomswdev@users.noreply.github.com> Date: Mon, 8 Nov 2021 19:04:06 +0100 Subject: [PATCH 1/3] Add the group_member resource --- .../winrmhelper/winrm_group_membership.go | 10 +- ad/provider.go | 1 + ad/resource_ad_group_member.go | 131 +++++++++++ ad/resource_ad_group_member_test.go | 207 ++++++++++++++++++ ad/resource_ad_group_membership.go | 2 +- docs/index.md | 9 +- docs/resources/group_member.md | 69 ++++++ examples/resources/ad_group_member/import.sh | 3 + .../resources/ad_group_member/resource.tf | 31 +++ 9 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 ad/resource_ad_group_member.go create mode 100644 ad/resource_ad_group_member_test.go create mode 100644 docs/resources/group_member.md create mode 100644 examples/resources/ad_group_member/import.sh create mode 100644 examples/resources/ad_group_member/resource.tf diff --git a/ad/internal/winrmhelper/winrm_group_membership.go b/ad/internal/winrmhelper/winrm_group_membership.go index 6ee2ff18..c3d8a27e 100644 --- a/ad/internal/winrmhelper/winrm_group_membership.go +++ b/ad/internal/winrmhelper/winrm_group_membership.go @@ -128,27 +128,27 @@ func (g *GroupMembership) bulkGroupMembersOp(conf *config.ProviderConf, operatio return nil } -func (g *GroupMembership) addGroupMembers(conf *config.ProviderConf, members []*GroupMember) error { +func (g *GroupMembership) AddGroupMembers(conf *config.ProviderConf, members []*GroupMember) error { return g.bulkGroupMembersOp(conf, "Add-ADGroupMember", members) } -func (g *GroupMembership) removeGroupMembers(conf *config.ProviderConf, members []*GroupMember) error { +func (g *GroupMembership) RemoveGroupMembers(conf *config.ProviderConf, members []*GroupMember) error { return g.bulkGroupMembersOp(conf, "Remove-ADGroupMember", members) } -func (g *GroupMembership) Update(conf *config.ProviderConf, expected []*GroupMember) error { +func (g *GroupMembership) SetGroupMembers(conf *config.ProviderConf, expected []*GroupMember) error { existing, err := g.getGroupMembers(conf) if err != nil { return err } toAdd, toRemove := diffGroupMemberLists(expected, existing) - err = g.addGroupMembers(conf, toAdd) + err = g.AddGroupMembers(conf, toAdd) if err != nil { return err } - err = g.removeGroupMembers(conf, toRemove) + err = g.RemoveGroupMembers(conf, toRemove) if err != nil { return err } diff --git a/ad/provider.go b/ad/provider.go index aa803ca5..63808661 100644 --- a/ad/provider.go +++ b/ad/provider.go @@ -110,6 +110,7 @@ func Provider() *schema.Provider { "ad_user": resourceADUser(), "ad_group": resourceADGroup(), "ad_group_membership": resourceADGroupMembership(), + "ad_group_member": resourceADGroupMember(), "ad_gpo": resourceADGPO(), "ad_gpo_security": resourceADGPOSecurity(), "ad_computer": resourceADComputer(), diff --git a/ad/resource_ad_group_member.go b/ad/resource_ad_group_member.go new file mode 100644 index 00000000..4161e72d --- /dev/null +++ b/ad/resource_ad_group_member.go @@ -0,0 +1,131 @@ +package ad + +import ( + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-provider-ad/ad/internal/config" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-ad/ad/internal/winrmhelper" +) + +func resourceADGroupMember() *schema.Resource { + return &schema.Resource{ + Description: "`ad_group_member` manages a specific member of a given Active Directory group.", + Create: resourceADGroupMemberCreate, + Read: resourceADGroupMemberRead, + Delete: resourceADGroupMemberDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "group_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the group. This can be a GUID, a SID, a Distinguished Name, or the SAM Account Name of the group.", + ForceNew: true, + }, + "group_member": { + Type: schema.TypeString, + Required: true, + Description: "A member AD Principal. The principal can be identified by its GUID, SID, Distinguished Name, or SAM Account Name.", + ForceNew: true, + }, + }, + } +} + +func composeGroupMemberID(groupID, memberID string) string { + return groupID + "_" + memberID +} + +func parseGroupMemberID(groupMemberID string) (groupID, memberID string, err error) { + ids := strings.Split(groupID, "_") + + if len(ids) != 2 { + err = fmt.Errorf("invalid groupMemberID: %s", groupMemberID) + return + } + + groupID = ids[0] + memberID = ids[1] + + return +} + +func resourceADGroupMemberRead(d *schema.ResourceData, meta interface{}) error { + groupID, memberID, err := parseGroupMemberID(d.Id()) + if err != nil { + // This is a provider internal error. Let's return it. + return err + } + + gm, err := winrmhelper.NewGroupMembershipFromHost(meta.(*config.ProviderConf), groupID) + if err != nil { + return err + } + + for _, m := range gm.GroupMembers { + if memberID == m.GUID { + + _ = d.Set("group_member", memberID) + _ = d.Set("group_id", groupID) + + return nil + } + } + + log.Printf("error finding member %s in membership of group %s", memberID, groupID) + d.SetId("") + return nil +} + +func resourceADGroupMemberCreate(d *schema.ResourceData, meta interface{}) error { + groupID := d.Get("group_id").(string) + memberID := d.Get("group_member").(string) + + gm := &winrmhelper.GroupMembership{ + GroupGUID: groupID, + GroupMembers: []*winrmhelper.GroupMember{ + { + GUID: memberID, + }, + }, + } + + err := gm.Create(meta.(*config.ProviderConf)) + if err != nil { + return err + } + + d.SetId(composeGroupMemberID(groupID, memberID)) + + return nil +} + +func resourceADGroupMemberDelete(d *schema.ResourceData, meta interface{}) error { + groupID, memberID, err := parseGroupMemberID(d.Id()) + if err != nil { + // This is a provider internal error. Let's return it. + return err + } + + gm := &winrmhelper.GroupMembership{ + GroupGUID: groupID, + GroupMembers: []*winrmhelper.GroupMember{ + { + GUID: memberID, + }, + }, + } + + err = gm.RemoveGroupMembers(meta.(*config.ProviderConf), gm.GroupMembers) + if err != nil { + return err + } + + d.SetId("") + return nil +} diff --git a/ad/resource_ad_group_member_test.go b/ad/resource_ad_group_member_test.go new file mode 100644 index 00000000..81780cca --- /dev/null +++ b/ad/resource_ad_group_member_test.go @@ -0,0 +1,207 @@ +package ad + +import ( + "fmt" + "strings" + "testing" + "os" + + "github.com/hashicorp/terraform-provider-ad/ad/internal/config" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-ad/ad/internal/winrmhelper" +) + +func TestAccResourceADGroupMember_basic(t *testing.T) { + envVars := []string{ + "TF_VAR_ad_group_name", + "TF_VAR_ad_group_sam", + "TF_VAR_ad_group_container", + "TF_VAR_ad_group2_name", + "TF_VAR_ad_group2_sam", + "TF_VAR_ad_group2_container", + "TF_VAR_ad_user_display_name", + "TF_VAR_ad_user_sam", + "TF_VAR_ad_user_password", + "TF_VAR_ad_user_principal_name", + "TF_VAR_ad_user_container", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t, envVars) }, + Providers: testAccProviders, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccResourceADGroupMemberExists("ad_group_member.gm", false, ""), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceADGroupMemberConfigBasic(), + Check: resource.ComposeTestCheckFunc( + testAccResourceADGroupMemberExists("ad_group_member.gm", true, os.Getenv("TF_VAR_ad_user_principal_name")), + ), + }, + { + ResourceName: "ad_group_member.gm", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResourceADGroupMember_Update(t *testing.T) { + envVars := []string{ + "TF_VAR_ad_group_name", + "TF_VAR_ad_group_sam", + "TF_VAR_ad_group_container", + "TF_VAR_ad_group2_name", + "TF_VAR_ad_group2_sam", + "TF_VAR_ad_group2_container", + "TF_VAR_ad_group3_name", + "TF_VAR_ad_group3_sam", + "TF_VAR_ad_group3_container", + "TF_VAR_ad_user_display_name", + "TF_VAR_ad_user_sam", + "TF_VAR_ad_user_password", + "TF_VAR_ad_user_principal_name", + "TF_VAR_ad_user_container", + } + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t, envVars) }, + Providers: testAccProviders, + CheckDestroy: resource.ComposeTestCheckFunc( + testAccResourceADGroupMemberExists("ad_group_member.gm", false, ""), + ), + Steps: []resource.TestStep{ + { + Config: testAccResourceADGroupMemberUpdate(), + Check: resource.ComposeTestCheckFunc( + testAccResourceADGroupMemberExists("ad_group_member.gm", true, os.Getenv("TF_VAR_ad_group2_name")), + ), + }, + }, + }) +} +func testAccResourceADGroupMemberExists(resourceName string, expected bool, member string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("%s resource not found", resourceName) + } + + toks := strings.Split(rs.Primary.ID, "_") + gm, err := winrmhelper.NewGroupMembershipFromHost(testAccProvider.Meta().(*config.ProviderConf), toks[0]) + if err != nil { + if strings.Contains(err.Error(), "ADIdentityNotFoundException") && !expected { + return nil + } + return err + } + + if expected && gm.GroupMembers[0].Name!=member { + return fmt.Errorf("actual member (%s) does not match the expected member (%s)", gm.GroupMembers[0].Name, member) + } + + return nil + } +} + +func testAccResourceADGroupMemberConfigBasic() string { + return ` + + variable "ad_group_name" {} + variable "ad_group_sam" {} + variable "ad_group_container" {} + + variable "ad_group2_name" {} + variable "ad_group2_sam" {} + variable "ad_group2_container" {} + + variable "ad_user_display_name" {} + variable "ad_user_principal_name" {} + variable "ad_user_sam" {} + variable "ad_user_password" {} + variable "ad_user_container" {} + + resource ad_group "g" { + name = var.ad_group_name + sam_account_name = var.ad_group_sam + container = var.ad_group_container + } + + resource ad_group "g2" { + name = var.ad_group2_name + sam_account_name = var.ad_group2_sam + container = var.ad_group2_container + } + + resource ad_user "u" { + display_name = var.ad_user_display_name + principal_name = var.ad_user_principal_name + sam_account_name = var.ad_user_sam + initial_password = var.ad_user_password + container = var.ad_user_container + } + + resource ad_group_member "gm" { + group_id = ad_group.g.id + group_member = ad_user.u.id + } + ` +} + +func testAccResourceADGroupMemberUpdate() string { + return ` + variable "ad_group_name" {} + variable "ad_group_sam" {} + variable "ad_group_container" {} + + variable "ad_group2_name" {} + variable "ad_group2_sam" {} + variable "ad_group2_container" {} + + variable "ad_group3_name" {} + variable "ad_group3_sam" {} + variable "ad_group3_container" {} + + variable "ad_user_display_name" {} + variable "ad_user_principal_name" {} + variable "ad_user_sam" {} + variable "ad_user_password" {} + variable "ad_user_container" {} + + resource ad_group "g" { + name = var.ad_group_name + sam_account_name = var.ad_group_sam + container = var.ad_group_container + } + + resource ad_group "g2" { + name = var.ad_group2_name + sam_account_name = var.ad_group2_sam + container = var.ad_group2_container + } + + resource ad_group "g3" { + name = var.ad_group3_name + sam_account_name = var.ad_group3_sam + container = var.ad_group3_container + } + + + resource ad_user "u" { + display_name = var.ad_user_display_name + principal_name = var.ad_user_principal_name + sam_account_name = var.ad_user_sam + initial_password = var.ad_user_password + container = var.ad_user_container + } + + resource ad_group_member "gm" { + group_id = ad_group.g.id + group_member = ad_group.g2.id + } +` +} diff --git a/ad/resource_ad_group_membership.go b/ad/resource_ad_group_membership.go index f3cde0e9..3f1272bd 100644 --- a/ad/resource_ad_group_membership.go +++ b/ad/resource_ad_group_membership.go @@ -84,7 +84,7 @@ func resourceADGroupMembershipUpdate(d *schema.ResourceData, meta interface{}) e return err } - err = gm.Update(meta.(*config.ProviderConf), gm.GroupMembers) + err = gm.SetGroupMembers(meta.(*config.ProviderConf), gm.GroupMembers) if err != nil { return err } diff --git a/docs/index.md b/docs/index.md index 2668827c..3f26ad86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -169,16 +169,19 @@ provider "ad" { ## Schema +### Required + +- **winrm_hostname** (String) The hostname of the server we will use to run powershell scripts over WinRM. (Environment variable: AD_HOSTNAME) +- **winrm_password** (String) The password used to authenticate to the server's WinRM service. (Environment variable: AD_PASSWORD) +- **winrm_username** (String) The username used to authenticate to the server's WinRM service. (Environment variable: AD_USER) + ### Optional - **krb_conf** (String) Path to kerberos configuration file. (default: none, environment variable: AD_KRB_CONF) - **krb_realm** (String) The name of the kerberos realm (domain) we will use for authentication. (default: "", environment variable: AD_KRB_REALM) - **krb_spn** (String) Alternative Service Principal Name. (default: none, environment variable: AD_KRB_SPN) -- **winrm_hostname** (String) The hostname of the server we will use to run powershell scripts over WinRM. (Environment variable: AD_HOSTNAME) - **winrm_insecure** (Boolean) Trust unknown certificates. (default: false, environment variable: AD_WINRM_INSECURE) - **winrm_pass_credentials** (Boolean) Pass credentials in WinRM session to create a System.Management.Automation.PSCredential. (default: false, environment variable: AD_WINRM_PASS_CREDENTIALS) -- **winrm_password** (String) The password used to authenticate to the server's WinRM service. (Environment variable: AD_PASSWORD) - **winrm_port** (Number) The port WinRM is listening for connections. (default: 5985, environment variable: AD_PORT) - **winrm_proto** (String) The WinRM protocol we will use. (default: http, environment variable: AD_PROTO) - **winrm_use_ntlm** (Boolean) Use NTLM authentication. (default: false, environment variable: AD_WINRM_USE_NTLM) -- **winrm_username** (String) The username used to authenticate to the server's WinRM service. (Environment variable: AD_USER) diff --git a/docs/resources/group_member.md b/docs/resources/group_member.md new file mode 100644 index 00000000..b69d9df2 --- /dev/null +++ b/docs/resources/group_member.md @@ -0,0 +1,69 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "ad_group_member Resource - terraform-provider-ad" +subcategory: "" +description: |- + ad_group_member manages a specific member of a given Active Directory group. +--- + +# ad_group_member (Resource) + +`ad_group_member` manages a specific member of a given Active Directory group. + +## Example Usage + +```terraform +variable name { default = "TestOU" } +variable path { default = "dc=yourdomain,dc=com" } +variable description { default = "some description" } +variable protected { default = false } +variable container { default = "CN=Users,dc=yourdomain,dc=com" } + +variable name { default = "test group" } +variable sam_account_name { default = "TESTGROUP" } +variable scope { default = "global" } +variable category { default = "security" } + +resource "ad_group" "g" { + name = var.name + sam_account_name = var.sam_account_name + scope = var.scope + category = var.category + container = var.container +} + +resource ad_user "u" { + display_name = "test user" + principal_name = "testUser" + sam_account_name = "testUser" + initial_password = "SuperSecure1234!!" + container = var.container +} + +resource ad_group_member "gm" { + group_id = ad_group.g.id + group_member = ad_user.u.id +} +``` + + +## Schema + +### Required + +- **group_id** (String) The ID of the group. This can be a GUID, a SID, a Distinguished Name, or the SAM Account Name of the group. +- **group_member** (String) A member AD Principal. The principal can be identified by its GUID, SID, Distinguished Name, or SAM Account Name. + +### Optional + +- **id** (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +# The ID for this resource is the group's UUID plus the UUID of the group member, joined +# by an underscore `_`. +$ terraform import ad_group_member 9CB8219C-31FF-4A85-A7A3-9BCBB6A41D02_E9079B50-95C5-4101-8400-E01CC83CF53B +``` diff --git a/examples/resources/ad_group_member/import.sh b/examples/resources/ad_group_member/import.sh new file mode 100644 index 00000000..c4e36751 --- /dev/null +++ b/examples/resources/ad_group_member/import.sh @@ -0,0 +1,3 @@ +# The ID for this resource is the group's UUID plus the UUID of the group member, joined +# by an underscore `_`. +$ terraform import ad_group_member 9CB8219C-31FF-4A85-A7A3-9BCBB6A41D02_E9079B50-95C5-4101-8400-E01CC83CF53B diff --git a/examples/resources/ad_group_member/resource.tf b/examples/resources/ad_group_member/resource.tf new file mode 100644 index 00000000..99e47f39 --- /dev/null +++ b/examples/resources/ad_group_member/resource.tf @@ -0,0 +1,31 @@ +variable name { default = "TestOU" } +variable path { default = "dc=yourdomain,dc=com" } +variable description { default = "some description" } +variable protected { default = false } +variable container { default = "CN=Users,dc=yourdomain,dc=com" } + +variable name { default = "test group" } +variable sam_account_name { default = "TESTGROUP" } +variable scope { default = "global" } +variable category { default = "security" } + +resource "ad_group" "g" { + name = var.name + sam_account_name = var.sam_account_name + scope = var.scope + category = var.category + container = var.container +} + +resource ad_user "u" { + display_name = "test user" + principal_name = "testUser" + sam_account_name = "testUser" + initial_password = "SuperSecure1234!!" + container = var.container +} + +resource ad_group_member "gm" { + group_id = ad_group.g.id + group_member = ad_user.u.id +} From 6207cb1c71e56df871e09d81c450993ea131045e Mon Sep 17 00:00:00 2001 From: Bernardo Pastorelli <13519917+randomswdev@users.noreply.github.com> Date: Mon, 8 Nov 2021 22:03:42 +0100 Subject: [PATCH 2/3] Apply formatting --- ad/resource_ad_group_member_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ad/resource_ad_group_member_test.go b/ad/resource_ad_group_member_test.go index 81780cca..2ae85635 100644 --- a/ad/resource_ad_group_member_test.go +++ b/ad/resource_ad_group_member_test.go @@ -2,9 +2,9 @@ package ad import ( "fmt" + "os" "strings" "testing" - "os" "github.com/hashicorp/terraform-provider-ad/ad/internal/config" @@ -100,7 +100,7 @@ func testAccResourceADGroupMemberExists(resourceName string, expected bool, memb return err } - if expected && gm.GroupMembers[0].Name!=member { + if expected && gm.GroupMembers[0].Name != member { return fmt.Errorf("actual member (%s) does not match the expected member (%s)", gm.GroupMembers[0].Name, member) } From dec44acffdeb6d29fc97b54e435c97c32b75aafb Mon Sep 17 00:00:00 2001 From: Bernardo Pastorelli <13519917+randomswdev@users.noreply.github.com> Date: Tue, 9 Nov 2021 19:14:44 +0100 Subject: [PATCH 3/3] Fix the ID management --- ad/resource_ad_group_member.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ad/resource_ad_group_member.go b/ad/resource_ad_group_member.go index 4161e72d..01cb962e 100644 --- a/ad/resource_ad_group_member.go +++ b/ad/resource_ad_group_member.go @@ -42,7 +42,7 @@ func composeGroupMemberID(groupID, memberID string) string { } func parseGroupMemberID(groupMemberID string) (groupID, memberID string, err error) { - ids := strings.Split(groupID, "_") + ids := strings.Split(groupMemberID, "_") if len(ids) != 2 { err = fmt.Errorf("invalid groupMemberID: %s", groupMemberID)