diff --git a/.github/labeler-issue-triage.yaml b/.github/labeler-issue-triage.yaml index 8a03033cf..daf101a97 100644 --- a/.github/labeler-issue-triage.yaml +++ b/.github/labeler-issue-triage.yaml @@ -25,7 +25,7 @@ feature/domains: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_domains((.|\n)*)###' feature/groups: - - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_(group\W+|group_member\W+|groups\W+)((.|\n)*)###' + - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_(group\W+|group_license_assignment\W+|group_member\W+|groups\W+)((.|\n)*)###' feature/identity-governance: - '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_(access_package|privileged_access_group_)((.|\n)*)###' diff --git a/examples/application/main.tf b/examples/application/main.tf index c75b56065..dc2650666 100644 --- a/examples/application/main.tf +++ b/examples/application/main.tf @@ -45,7 +45,7 @@ resource "azuread_application" "widgets_app" { resource_access { # User.Read - id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" + id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" type = "Scope" } } @@ -53,7 +53,7 @@ resource "azuread_application" "widgets_app" { required_resource_access { resource_app_id = azuread_application.widgets_service.application_id - dynamic resource_access { + dynamic "resource_access" { for_each = azuread_application.widgets_service.api.0.oauth2_permission_scope iterator = scope diff --git a/examples/create-for-rbac/outputs.tf b/examples/create-for-rbac/outputs.tf index 0b99a6adf..54e12de6a 100644 --- a/examples/create-for-rbac/outputs.tf +++ b/examples/create-for-rbac/outputs.tf @@ -14,7 +14,7 @@ output "client_certificate" { } output "client_key" { - value = tls_private_key.example.private_key_pem + value = tls_private_key.example.private_key_pem sensitive = true } diff --git a/internal/services/groups/group_license_assignment_resource.go b/internal/services/groups/group_license_assignment_resource.go new file mode 100644 index 000000000..a2a09cee5 --- /dev/null +++ b/internal/services/groups/group_license_assignment_resource.go @@ -0,0 +1,188 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package groups + +import ( + "context" + "strings" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-sdk/microsoft-graph/common-types/beta" + "github.com/hashicorp/go-azure-sdk/microsoft-graph/groups/beta/group" + "github.com/hashicorp/go-azure-sdk/sdk/nullable" + "github.com/hashicorp/terraform-provider-azuread/internal/clients" + "github.com/hashicorp/terraform-provider-azuread/internal/helpers/tf" + "github.com/hashicorp/terraform-provider-azuread/internal/helpers/tf/pluginsdk" + "github.com/hashicorp/terraform-provider-azuread/internal/helpers/tf/validation" + "github.com/hashicorp/terraform-provider-azuread/internal/services/groups/parse" +) + +func groupLicenseAssignmentResource() *pluginsdk.Resource { + return &pluginsdk.Resource{ + CreateContext: groupLicenseAssignmentResourceCreate, + ReadContext: groupLicenseAssignmentResourceRead, + DeleteContext: groupLicenseAssignmentResourceDelete, + + Timeouts: &pluginsdk.ResourceTimeout{ + Create: pluginsdk.DefaultTimeout(5 * time.Minute), + Read: pluginsdk.DefaultTimeout(5 * time.Minute), + Delete: pluginsdk.DefaultTimeout(5 * time.Minute), + }, + + Importer: pluginsdk.ImporterValidatingResourceId(func(id string) error { + _, err := parse.GroupLicenseAssignmentID(id) + return err + }), + + Schema: map[string]*pluginsdk.Schema{ + "group_object_id": { + Description: "The object ID of the group you want to add the member to", + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.IsUUID, + }, + + "sku_id": { + Description: "The unique identifier for the SKU. Corresponds to the skuId from subscribedSkus or companySubscription.", + Type: pluginsdk.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.IsUUID, + }, + + "disabled_plans": { + Description: "A collection of the unique identifiers for plans that have been disabled. IDs are available in servicePlans > servicePlanId in the tenant's subscribedSkus or serviceStatus > servicePlanId in the tenant's companySubscription.", + Type: pluginsdk.TypeSet, + Optional: true, + ForceNew: true, + Elem: &pluginsdk.Schema{ + Type: pluginsdk.TypeString, + ValidateFunc: validation.IsUUID, + }, + }, + }, + } +} + +func groupLicenseAssignmentResourceCreate(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(*clients.Client).Groups.GroupClientBeta + + groupId := beta.NewGroupID(d.Get("group_object_id").(string)) + resourceId := parse.NewGroupLicenseAssignmentID(groupId.GroupId, d.Get("sku_id").(string)) + + resp, err := client.GetGroup(ctx, groupId, group.GetGroupOperationOptions{ + Select: &[]string{ + "assignedLicenses", + }, + }) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return tf.ErrorDiagPathF(nil, "group_object_id", "%s was not found", groupId) + } + return tf.ErrorDiagPathF(err, "group_object_id", "Retrieving %s", groupId) + } + + license := getGroupLicense(resp.Model.AssignedLicenses, resourceId.SKUId) + if license != nil { + return tf.ImportAsExistsDiag("azuread_group_license_assignment", resourceId.String()) + } + + if _, err := client.AssignLicense(ctx, groupId, group.AssignLicenseRequest{ + AddLicenses: &[]beta.AssignedLicense{ + { + SkuId: nullable.Value(resourceId.SKUId), + DisabledPlans: tf.ExpandStringSlicePtr(d.Get("disabled_plans").(*pluginsdk.Set).List()), + }, + }, + }, group.DefaultAssignLicenseOperationOptions()); err != nil { + return tf.ErrorDiagF(err, "Assigning license to %s", groupId) + } + + d.SetId(resourceId.String()) + + return groupLicenseAssignmentResourceRead(ctx, d, meta) +} + +func groupLicenseAssignmentResourceRead(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(*clients.Client).Groups.GroupClientBeta + + resourceId, err := parse.GroupLicenseAssignmentID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing Group License Assignment ID %q", d.Id()) + } + + resp, err := client.GetGroup(ctx, beta.NewGroupID(resourceId.GroupId), group.GetGroupOperationOptions{ + Select: &[]string{ + "assignedLicenses", + }, + }) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return tf.ErrorDiagPathF(nil, "group_object_id", "%s was not found", resourceId.GroupId) + } + return tf.ErrorDiagPathF(err, "group_object_id", "Retrieving %s", resourceId.GroupId) + } + + license := getGroupLicense(resp.Model.AssignedLicenses, resourceId.SKUId) + + if license == nil { + return tf.ErrorDiagF(err, "Retrieving license %s for group with object ID: %s", resourceId.SKUId, resourceId.GroupId) + } + + tf.Set(d, "group_object_id", resourceId.GroupId) + tf.Set(d, "sku_id", resourceId.SKUId) + tf.Set(d, "disabled_plans", tf.FlattenStringSlicePtr(license.DisabledPlans)) + + return nil +} + +func groupLicenseAssignmentResourceDelete(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) pluginsdk.Diagnostics { + client := meta.(*clients.Client).Groups.GroupClientBeta + + resourceId, err := parse.GroupLicenseAssignmentID(d.Id()) + if err != nil { + return tf.ErrorDiagPathF(err, "id", "Parsing Group License Assignment ID %q", d.Id()) + } + + resp, err := client.GetGroup(ctx, beta.NewGroupID(resourceId.GroupId), group.GetGroupOperationOptions{ + Select: &[]string{ + "assignedLicenses", + }, + }) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + // Group is already deleted + return nil + } + return tf.ErrorDiagPathF(err, "group_object_id", "Retrieving %s", resourceId.GroupId) + } + license := getGroupLicense(resp.Model.AssignedLicenses, resourceId.SKUId) + + if license == nil { + // License is already removed + return nil + } + + if _, err := client.AssignLicense(ctx, beta.NewGroupID(resourceId.GroupId), group.AssignLicenseRequest{ + RemoveLicenses: &[]string{resourceId.SKUId}, + }, group.DefaultAssignLicenseOperationOptions()); err != nil { + return tf.ErrorDiagF(err, "Removing license %s to %s", resourceId.SKUId, resourceId.GroupId) + } + + return nil +} + +func getGroupLicense(licenses *[]beta.AssignedLicense, skuId string) *beta.AssignedLicense { + if licenses != nil { + for _, v := range *licenses { + if strings.EqualFold(v.SkuId.GetOrZero(), skuId) { + return &v + } + } + } + + return nil +} diff --git a/internal/services/groups/group_license_assignment_resource_test.go b/internal/services/groups/group_license_assignment_resource_test.go new file mode 100644 index 000000000..7b4ab72a2 --- /dev/null +++ b/internal/services/groups/group_license_assignment_resource_test.go @@ -0,0 +1,118 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package groups_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/lang/response" + "github.com/hashicorp/go-azure-sdk/microsoft-graph/common-types/beta" + "github.com/hashicorp/go-azure-sdk/microsoft-graph/groups/beta/group" + "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 GroupLicenseAssignmentResource struct{} + +func TestAccGrouplicenseassignment_license(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_license_assignment", "testA") + r := GroupLicenseAssignmentResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.license(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("group_object_id").IsUuid(), + check.That(data.ResourceName).Key("sku_id").IsUuid(), + check.That(data.ResourceName).Key("disabled_plans").IsEmpty(), + ), + }, + data.ImportStep(), + }) +} + +func TestAccGroupLicenseAssignment_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azuread_group_license_assignment", "test") + r := GroupLicenseAssignmentResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.license(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.RequiresImportErrorStep(r.requiresImport(data)), + }) +} + +func (r GroupLicenseAssignmentResource) Exists(ctx context.Context, clients *clients.Client, state *terraform.InstanceState) (*bool, error) { + client := clients.Groups.GroupClientBeta + + id, err := parse.GroupLicenseAssignmentID(state.ID) + if err != nil { + return nil, fmt.Errorf("parsing Group License Assignment ID: %v", err) + } + + resp, err := client.GetGroup(ctx, beta.NewGroupID(id.GroupId), group.GetGroupOperationOptions{ + Select: &[]string{ + "assignedLicenses", + }, + }) + if err != nil { + if response.WasNotFound(resp.HttpResponse) { + return pointer.To(false), nil + } + return nil, fmt.Errorf("failed to retrieve license %q (group ID: %q): %+v", id.SKUId, id.GroupId, err) + } + + if resp.Model != nil { + for _, license := range *resp.Model.AssignedLicenses { + if license.SkuId.GetOrZero() == id.SKUId { + return pointer.To(true), nil + } + } + } + + return pointer.To(false), nil +} + +func (GroupLicenseAssignmentResource) template(data acceptance.TestData) string { + return fmt.Sprintf(` +resource "azuread_group" "test" { + display_name = "acctestGroup-%[1]d" + security_enabled = true +} +`, data.RandomInteger) +} + +func (r GroupLicenseAssignmentResource) license(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_group_license_assignment" "test" { + group_object_id = azuread_group.test.object_id + sku_id = "90d8b3f8-712e-4f7b-aa1e-62e7ae6cbe96" # SMB_APPS +} +`, r.template(data)) +} + +func (r GroupLicenseAssignmentResource) requiresImport(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azuread_group_license_assignment" "import" { + group_object_id = azuread_group_license_assignment.test.group_object_id + sku_id = azuread_group_license_assignment.test.sku_id + disabled_plans = azuread_group_license_assignment.test.disabled_plans +} +`, r.license(data)) +} diff --git a/internal/services/groups/parse/group_license_assignment.go b/internal/services/groups/parse/group_license_assignment.go new file mode 100644 index 000000000..4132d391c --- /dev/null +++ b/internal/services/groups/parse/group_license_assignment.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package parse + +import "fmt" + +type GroupLicenseAssignmentId struct { + ObjectSubResourceId + GroupId string + SKUId string +} + +func NewGroupLicenseAssignmentID(groupId, skuId string) GroupLicenseAssignmentId { + return GroupLicenseAssignmentId{ + ObjectSubResourceId: NewObjectSubResourceID(groupId, "license", skuId), + GroupId: groupId, + SKUId: skuId, + } +} + +func GroupLicenseAssignmentID(idString string) (*GroupLicenseAssignmentId, error) { + id, err := ObjectSubResourceID(idString, "license") + if err != nil { + return nil, fmt.Errorf("unable to parse Group License Assignment ID: %v", err) + } + + return &GroupLicenseAssignmentId{ + ObjectSubResourceId: *id, + GroupId: id.objectId, + SKUId: id.subId, + }, nil +} diff --git a/internal/services/groups/registration.go b/internal/services/groups/registration.go index 9a1dfe832..6544edada 100644 --- a/internal/services/groups/registration.go +++ b/internal/services/groups/registration.go @@ -35,7 +35,8 @@ func (r Registration) SupportedDataSources() map[string]*pluginsdk.Resource { // SupportedResources returns the supported Resources supported by this Service func (r Registration) SupportedResources() map[string]*pluginsdk.Resource { return map[string]*pluginsdk.Resource{ - "azuread_group": groupResource(), - "azuread_group_member": groupMemberResource(), + "azuread_group": groupResource(), + "azuread_group_member": groupMemberResource(), + "azuread_group_license_assignment": groupLicenseAssignmentResource(), } }