From 43b692bbdf1ebb1e9f0defbc657363f3f401f5ba Mon Sep 17 00:00:00 2001 From: Theo Andresier <49570057+Threpio@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:27:22 +0100 Subject: [PATCH 1/2] Initial Commit --- docs/resources/group_owner.md | 43 ++++ .../services/groups/group_owner_resource.go | 185 ++++++++++++++++++ .../services/groups/parse/group_member.go | 27 +++ internal/services/groups/registration.go | 1 + 4 files changed, 256 insertions(+) create mode 100644 docs/resources/group_owner.md create mode 100644 internal/services/groups/group_owner_resource.go diff --git a/docs/resources/group_owner.md b/docs/resources/group_owner.md new file mode 100644 index 0000000000..3b2a8a929b --- /dev/null +++ b/docs/resources/group_owner.md @@ -0,0 +1,43 @@ +--- +subcategory: "Groups" +--- + +# Resource: azuread_group_owner + +Manages a single group ownership within Azure Active Directory. + +~> **Warning** Do not use this resource at the same time as the `owner_object_id` property of the `azuread_group` resource for the same group. Doing so will cause a conflict and group members will be removed. + +## API Permissions + +## Example Usages + +```terraform + +resource "azuread_group_owner" "exa" { + group_object_id = azuread_group.example.id + owner_object_id = data.azuread_user.exa.id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `group_object_id` - (Required) The object ID of the group you want to add the member to. Changing this forces a new resource to be created. +* `member_object_id` - (Required) The object ID of the principal you want to add as a member to the group. Supported object types are Users, Groups or Service Principals. Changing this forces a new resource to be created. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +*No additional attributes are exported* + +## Import + +Group owners can be imported using the object ID of the group and the object ID of the owner, e.g. + +```shell +terraform import azuread_group_owner.example 00000000-0000-0000-0000-000000000000/member/11111111-1111-1111-1111-111111111111 +``` + diff --git a/internal/services/groups/group_owner_resource.go b/internal/services/groups/group_owner_resource.go new file mode 100644 index 0000000000..dd9790049f --- /dev/null +++ b/internal/services/groups/group_owner_resource.go @@ -0,0 +1,185 @@ +package groups + +import ( + "context" + "errors" + "fmt" + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-sdk/sdk/odata" + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/helpers" + "github.com/hashicorp/terraform-provider-azuread/internal/tf" + "github.com/manicminer/hamilton/msgraph" + "log" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-provider-azuread/internal/services/groups/parse" + "github.com/hashicorp/terraform-provider-azuread/internal/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azuread/internal/tf/validation" +) + +func groupOwnerResource() *pluginsdk.Resource { + return &pluginsdk.Resource{ + CreateContext: groupOwnerResourceCreate, + ReadContext: groupOwnerResourceRead, + DeleteContext: groupOwnerResourceDelete, + + Timeouts: &pluginsdk.ResourceTimeout{ + Create: pluginsdk.DefaultTimeout(5 * time.Minute), + Read: pluginsdk.DefaultTimeout(5 * time.Minute), + Update: pluginsdk.DefaultTimeout(5 * time.Minute), + Delete: pluginsdk.DefaultTimeout(5 * time.Minute), + }, + + Importer: pluginsdk.ImporterValidatingResourceId(func(id string) error { + _, err := parse.GroupMemberID(id) + return err + }), + + Schema: map[string]*pluginsdk.Schema{ + "group_object_id": { + Description: "The object ID of the group you want to add the owner to", + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ValidateDiag(validation.IsUUID), + }, + + "owner_object_id": { + Description: "The object ID of the principal you want to add as an owner to the group. Supported object types are Users, Groups or Service Principals", + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ValidateDiag(validation.IsUUID), + }, + }, + } +} + +func groupOwnerResourceCreate(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(*clients.Client).Groups.GroupsClient + directoryObjectsClient := meta.(*clients.Client).Groups.DirectoryObjectsClient + tenantId := meta.(*clients.Client).TenantID + groupId := d.Get("group_object_id").(string) + ownerId := d.Get("owner_object_id").(string) + + id := parse.NewGroupOwnerID(groupId, ownerId) + + tf.LockByName(groupResourceName, id.GroupId) + defer tf.UnlockByName(groupResourceName, id.GroupId) + + group, status, err := client.Get(ctx, groupId, odata.Query{}) + if err != nil { + if status == http.StatusNotFound { + return tf.ErrorDiagPathF(nil, "object_id", "Group with object ID %q not found", groupId) + } + return tf.ErrorDiagPathF(err, "object_id", "Error reading group with object ID %q", groupId) + } + + existingOwners, _, err := client.ListOwners(ctx, id.GroupId) + if err != nil { + return tf.ErrorDiagF(err, "Listing existing owners for group with object ID: %q", id.GroupId) + } + if existingOwners != nil { + for _, v := range *existingOwners { + if strings.EqualFold(v, ownerId) { + return tf.ImportAsExistsDiag("azuread_group_owner", id.String()) + } + } + } + + ownerObject, _, err := directoryObjectsClient.Get(ctx, ownerId, odata.Query{}) + if err != nil { + return tf.ErrorDiagF(err, "Could not retrieve principal object %q", ownerId) + } + if ownerObject == nil { + return tf.ErrorDiagF(errors.New("returned ownerObject was nil"), "Could not retrieve owner principal object %q", ownerId) + } + + ownerObject.ODataId = (*odata.Id)(pointer.To(fmt.Sprintf("%s/v1.0/%s/directoryObjects/%s", + client.BaseClient.Endpoint, tenantId, ownerId))) + + group.Owners = &msgraph.Owners{*ownerObject} + + if _, err := client.AddOwners(ctx, group); err != nil { + return tf.ErrorDiagF(err, "Adding group owner %q to group %q", ownerId, groupId) + } + + d.SetId(id.String()) + return groupOwnerResourceRead(ctx, d, meta) +} + +func groupOwnerResourceRead(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(*clients.Client).Groups.GroupsClient + + id, err := parse.GroupOwnerID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Error parsing Group Owner ID %q", d.Id()) + } + + owners, status, err := client.ListOwners(ctx, id.GroupId) + if err != nil { + if status == http.StatusNotFound { + log.Printf("[DEBUG] Group with ID %q was not found - removing group owner with ID %q from state", id.GroupId, d.Id()) + d.SetId("") + return nil + } + return tf.ErrorDiagF(err, "Retrieving owners for group with object ID: %q", id.GroupId) + } + + var ownerObjectId string + if owners != nil { + for _, objectId := range *owners { + if strings.EqualFold(objectId, id.OwnerId) { + ownerObjectId = objectId + break + } + } + } + + if ownerObjectId == "" { + log.Printf("[DEBUG] Owner with ID %q was not found in Group %q - removing from state", id.OwnerId, id.GroupId) + d.SetId("") + return nil + } + + tf.Set(d, "group_object_id", id.GroupId) + tf.Set(d, "owner_object_id", ownerObjectId) + + return nil +} + +func groupOwnerResourceDelete(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(clients.Client).Groups.GroupsClient + + id, err := parse.GroupOwnerID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing Group Owner ID %q", d.Id()) + } + + tf.LockByName(groupResourceName, id.GroupId) + defer tf.UnlockByName(groupResourceName, id.GroupId) + + if _, err := client.RemoveMembers(ctx, id.GroupId, &[]string{id.OwnerId}); err != nil { + return tf.ErrorDiagF(err, "Removing group owner %q from group %q", id.OwnerId, id.GroupId) + } + + // wait for owner link to be deleted + if err := helpers.WaitForDeletion(ctx, func(ctx context.Context) (*bool, error) { + defer func() { client.BaseClient.DisableRetries = false }() + client.BaseClient.DisableRetries = true + if _, status, err := client.GetOwner(ctx, id.GroupId, id.OwnerId); err != nil { + if status == http.StatusNotFound { + return pointer.To(false), nil + } + return nil, err + } + return pointer.To(true), nil + }); err != nil { + return tf.ErrorDiagF(err, "Waiting for removal of Owner %q from group with object ID %q", id.OwnerId, id.GroupId) + } + + return nil +} diff --git a/internal/services/groups/parse/group_member.go b/internal/services/groups/parse/group_member.go index 68288f941e..15aed0fd2b 100644 --- a/internal/services/groups/parse/group_member.go +++ b/internal/services/groups/parse/group_member.go @@ -31,3 +31,30 @@ func GroupMemberID(idString string) (*GroupMemberId, error) { MemberId: id.subId, }, nil } + +type GroupOwnerId struct { + ObjectSubResourceId + GroupId string + OwnerId string +} + +func NewGroupOwnerID(groupId, ownerId string) GroupOwnerId { + return GroupOwnerId{ + ObjectSubResourceId: NewObjectSubResourceID(groupId, "owner", ownerId), + GroupId: groupId, + OwnerId: ownerId, + } +} + +func GroupOwnerID(idString string) (*GroupOwnerId, error) { + id, err := ObjectSubResourceID(idString, "owner") + if err != nil { + return nil, fmt.Errorf("unable to parse Owner ID: %v", err) + } + + return &GroupOwnerId{ + ObjectSubResourceId: *id, + GroupId: id.objectId, + OwnerId: id.subId, + }, nil +} diff --git a/internal/services/groups/registration.go b/internal/services/groups/registration.go index 76e9ba770d..ff0d615de2 100644 --- a/internal/services/groups/registration.go +++ b/internal/services/groups/registration.go @@ -37,5 +37,6 @@ func (r Registration) SupportedResources() map[string]*pluginsdk.Resource { return map[string]*pluginsdk.Resource{ "azuread_group": groupResource(), "azuread_group_member": groupMemberResource(), + "azuread_group_owner": groupOwnerResource(), } } From a9b406d7bfdf111494b7b207a7614f5e61e081bc Mon Sep 17 00:00:00 2001 From: Theo Andresier <49570057+Threpio@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:33:00 +0100 Subject: [PATCH 2/2] Tests? --- .../groups/group_owner_resource_test.go | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 internal/services/groups/group_owner_resource_test.go diff --git a/internal/services/groups/group_owner_resource_test.go b/internal/services/groups/group_owner_resource_test.go new file mode 100644 index 0000000000..5e00c7c31b --- /dev/null +++ b/internal/services/groups/group_owner_resource_test.go @@ -0,0 +1,272 @@ +package groups_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance" + "github.com/hashicorp/terraform-provider-azuread/internal/acceptance/check" + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/services/groups/parse" +) + +type GroupOwnerResource struct{} + +func TestAccGroupOwner_group(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_owner", "test") + r := GroupOwnerResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.group(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("group_object_id").IsUuid(), + check.That(data.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccGroupOwner_servicePrincipal(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_owner", "test") + r := GroupOwnerResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.servicePrincipal(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("group_object_id").IsUuid(), + check.That(data.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccGroupOwner_user(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_owner", "testA") + r := GroupOwnerResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.oneUser(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("group_object_id").IsUuid(), + check.That(data.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccGroupOwner_multipleUser(t *testing.T) { + dataA := acceptance.BuildTestData(t, "azuread_group_owner", "testA") + dataB := acceptance.BuildTestData(t, "azuread_group_owner", "testB") + r := GroupOwnerResource{} + + dataA.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.oneUser(dataA), + Check: acceptance.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("group_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + dataA.ImportStep(), + { + Config: r.twoUsers(dataA), + Check: acceptance.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("group_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("owner_object_id").IsUuid(), + check.That(dataB.ResourceName).ExistsInAzure(r), + check.That(dataB.ResourceName).Key("group_object_id").IsUuid(), + check.That(dataB.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + // we rerun the config so the group resource updates with the number of owners + { + Config: r.twoUsers(dataA), + Check: acceptance.ComposeTestCheckFunc( + check.That("azuread_group.test").Key("owners.#").HasValue("2"), + ), + }, + dataA.ImportStep(), + { + Config: r.oneUser(dataA), + Check: acceptance.ComposeTestCheckFunc( + check.That(dataA.ResourceName).ExistsInAzure(r), + check.That(dataA.ResourceName).Key("group_object_id").IsUuid(), + check.That(dataA.ResourceName).Key("owner_object_id").IsUuid(), + ), + }, + // we rerun the config so the group resource updates with the number of owners + { + Config: r.oneUser(dataA), + Check: acceptance.ComposeTestCheckFunc( + check.That("azuread_group.test").Key("owners.#").HasValue("1"), + ), + }, + }) +} + +func TestAccGroupMember_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_owner", "test") + r := GroupOwnerResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.group(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport(data)), + }) +} + +func (r GroupOwnerResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.Groups.GroupsClient + client.BaseClient.DisableRetries = true + defer func() { client.BaseClient.DisableRetries = false }() + + id, err := parse.GroupMemberID(state.ID) + if err != nil { + return nil, fmt.Errorf("parsing Group Member ID: %v", err) + } + + owners, _, err := client.ListMembers(ctx, id.GroupId) + if err != nil { + return nil, fmt.Errorf("failed to retrieve Group owners (groupId: %q): %+v", id.GroupId, err) + } + + if owners != nil { + for _, objectId := range *owners { + if strings.EqualFold(objectId, id.MemberId) { + return pointer.To(true), nil + } + } + } + + return nil, fmt.Errorf("Member %q was not found in Group %q", id.MemberId, id.GroupId) +} + +func (GroupOwnerResource) template(data acceptance.TestData) string { + return fmt.Sprintf(` +resource "azuread_group" "test" { + display_name = "acctestGroup-%[1]d" + security_enabled = true +} +`, data.RandomInteger) +} + +func (GroupOwnerResource) templateThreeUsers(data acceptance.TestData) string { + return fmt.Sprintf(` +data "azuread_domains" "test" { + only_initial = true +} + +resource "azuread_user" "testA" { + user_principal_name = "acctestUser.%[1]d.A@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-A" + password = "%[2]s" +} + +resource "azuread_user" "testB" { + user_principal_name = "acctestUser.%[1]d.B@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-B" + mail_nickname = "acctestUser-%[1]d-B" + password = "%[2]s" +} + +resource "azuread_user" "testC" { + user_principal_name = "acctestUser.%[1]d.C@${data.azuread_domains.test.domains.0.domain_name}" + display_name = "acctestUser-%[1]d-C" + password = "%[2]s" +} +`, data.RandomInteger, data.RandomPassword) +} + +func (r GroupOwnerResource) group(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_group" "owner" { + display_name = "acctestGroup-%[2]d-Member" + security_enabled = true +} + +resource "azuread_group_owner" "test" { + group_object_id = azuread_group.test.object_id + owner_object_id = azuread_group.owner.object_id +} +`, r.template(data), data.RandomInteger) +} + +func (r GroupOwnerResource) servicePrincipal(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_application" "test" { + display_name = "acctestServicePrincipal-%[2]d" +} + +resource "azuread_service_principal" "test" { + application_id = azuread_application.test.application_id +} + +resource "azuread_group_owner" "test" { + group_object_id = azuread_group.test.object_id + owner_object_id = azuread_service_principal.test.object_id +} +`, r.template(data), data.RandomInteger) +} + +func (r GroupOwnerResource) oneUser(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s +%[2]s + +resource "azuread_group_owner" "testA" { + group_object_id = azuread_group.test.object_id + owner_object_id = azuread_user.testA.object_id +} +`, r.template(data), r.templateThreeUsers(data)) +} + +func (r GroupOwnerResource) twoUsers(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s +%[2]s + +resource "azuread_group_owner" "testA" { + group_object_id = azuread_group.test.object_id + owner_object_id = azuread_user.testA.object_id +} + +resource "azuread_group_owner" "testB" { + group_object_id = azuread_group.test.object_id + owner_object_id = azuread_user.testB.object_id +} +`, r.template(data), r.templateThreeUsers(data)) +} + +func (r GroupOwnerResource) requiresImport(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_group_owner" "import" { + group_object_id = azuread_group_owner.test.group_object_id + owner_object_id = azuread_group_owner.test.owner_object_id +} +`, r.group(data)) +}