Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/labeler-issue-triage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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+|group_without_members\W+|groups\W+)((.|\n)*)###'
- '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_(group\W+|group_license\W+|group_member\W+|group_without_members\W+|groups\W+)((.|\n)*)###'

feature/identity-governance:
- '### (|New or )Affected Resource\(s\)\/Data Source\(s\)((.|\n)*)azuread_(access_package|privileged_access_group_)((.|\n)*)###'
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 3.10.0 (Unreleased)

FEATURES:

* **New Resource:** `azuread_group_license` [GH-1890]

## 3.9.0 (June 18, 2026)

ENHANCEMENTS:
Expand Down
75 changes: 75 additions & 0 deletions docs/resources/group_license.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
subcategory: "Groups"
---

# Resource: azuread_group_license

Manages a single license assignment for a group (group-based licensing).

~> **License availability** The SKU being assigned must be available in your tenant. You can find the available SKUs and their service plans in the [Microsoft 365 admin center](https://learn.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference), or by inspecting the `subscribedSkus` in Microsoft Graph.

-> **Group-based licensing** Licenses assigned to a group are inherited by all members of that group. Assignment to individual members happens asynchronously after the license is assigned to the group, and is managed by Microsoft Entra ID rather than by this resource. This resource only manages the license assignment on the group object itself.

## API Permissions

The following API permissions are required in order to use this resource.

When authenticated with a service principal, this resource requires one of the following application roles: `LicenseAssignment.ReadWrite.All`, `Group.ReadWrite.All` or `Directory.ReadWrite.All`

When authenticated with a user principal, this resource may require one of the following directory roles: `License Administrator`, `Groups Administrator`, `User Administrator` or `Global Administrator`

## Example Usage

```terraform
resource "azuread_group" "example" {
display_name = "Sales Team"
security_enabled = true
}

resource "azuread_group_license" "example" {
group_id = azuread_group.example.object_id
sku_id = "c7df2760-2c81-4ef7-b578-5b5392b571df"
}
```

### Disabling specific service plans

```terraform
resource "azuread_group_license" "example" {
group_id = azuread_group.example.object_id
sku_id = "c7df2760-2c81-4ef7-b578-5b5392b571df"
disabled_plans = ["a23b959c-7ce8-4e57-9140-b90eb88a9e97"]
}
```

-> **Tip** For assigning more licenses to a group, create additional instances of this resource.

## Argument Reference

The following arguments are supported:

* `disabled_plans` - (Optional) A set of unique identifiers (GUIDs) for the service plans to disable for this license. Changing this forces a new resource to be created.
* `group_id` - (Required) The object ID of the group to which the license should be assigned. Changing this forces a new resource to be created.
* `sku_id` - (Required) The unique identifier (GUID) for the SKU (license) to assign to the group. Changing this forces a new resource to be created.

## Attributes Reference

No additional attributes are exported.

## Timeouts

The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/language/resources/syntax#operation-timeouts) for certain actions:

* `create` - (Defaults to 10 minutes) Used when creating the resource.
* `read` - (Defaults to 5 minutes) Used when retrieving the resource.
* `delete` - (Defaults to 5 minutes) Used when deleting the resource.

## Import

Group Licenses can be imported using the object ID of the group and the SKU ID of the license, in the following format.

```shell
terraform import azuread_group_license.example 00000000-0000-0000-0000-000000000000/license/11111111-1111-1111-1111-111111111111
```

-> This ID format is unique to Terraform and is composed of the Azure AD Group Object ID and the license SKU ID in the format `{GroupObjectID}/license/{SkuID}`.
1 change: 1 addition & 0 deletions internal/provider/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func SupportedTypedServices() []sdk.TypedServiceRegistration {
policies.Registration{},
identitygovernance.Registration{},
serviceprincipals.Registration{},
groups.Registration{},
}
}

Expand Down
267 changes: 267 additions & 0 deletions internal/services/groups/group_license_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// Copyright IBM Corp. 2014, 2025
// SPDX-License-Identifier: MPL-2.0

package groups

import (
"context"
"fmt"
"net/http"
"strings"
"time"

"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/go-azure-sdk/sdk/nullable"
"github.com/hashicorp/go-azure-sdk/sdk/odata"
"github.com/hashicorp/terraform-provider-azuread/internal/helpers/consistency"
"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/sdk"
"github.com/hashicorp/terraform-provider-azuread/internal/services/groups/parse"
)

type GroupLicenseResourceModel struct {
GroupId string `tfschema:"group_id"`
SkuId string `tfschema:"sku_id"`
DisabledPlans []string `tfschema:"disabled_plans"`
}

var _ sdk.Resource = GroupLicenseResource{}

type GroupLicenseResource struct{}

func (r GroupLicenseResource) IDValidationFunc() pluginsdk.SchemaValidateFunc {
return parse.ValidateGroupLicenseID
}

func (r GroupLicenseResource) ResourceType() string {
return "azuread_group_license"
}

func (r GroupLicenseResource) ModelObject() interface{} {
return &GroupLicenseResourceModel{}
}

func (r GroupLicenseResource) Arguments() map[string]*pluginsdk.Schema {
return map[string]*pluginsdk.Schema{
"group_id": {
Description: "The object ID of the group to which the license should be assigned",
Type: pluginsdk.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.IsUUID,
},

"sku_id": {
Description: "The unique identifier (GUID) for the SKU (license) to assign to the group",
Type: pluginsdk.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.IsUUID,
},

"disabled_plans": {
Description: "A set of unique identifiers (GUIDs) for the service plans to disable for this license",
Type: pluginsdk.TypeSet,
Optional: true,
ForceNew: true,
Elem: &pluginsdk.Schema{
Type: pluginsdk.TypeString,
ValidateFunc: validation.IsUUID,
},
},
}
}

func (r GroupLicenseResource) Attributes() map[string]*pluginsdk.Schema {
return map[string]*pluginsdk.Schema{}
}

func (r GroupLicenseResource) Create() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 10 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Groups.GroupClientBeta

var model GroupLicenseResourceModel
if err := metadata.Decode(&model); err != nil {
return fmt.Errorf("decoding: %+v", err)
}

groupId := beta.NewGroupID(model.GroupId)
id := parse.NewGroupLicenseID(model.GroupId, model.SkuId)

tf.LockByName(groupResourceName, model.GroupId)
defer tf.UnlockByName(groupResourceName, model.GroupId)

resp, err := client.GetGroup(ctx, groupId, group.GetGroupOperationOptions{
Select: &[]string{"id", "assignedLicenses"},
})
if err != nil {
if response.WasNotFound(resp.HttpResponse) {
return fmt.Errorf("assigning %s: group was not found", id)
}
return fmt.Errorf("retrieving %s: %+v", groupId, err)
}

g := resp.Model
if g == nil {
return fmt.Errorf("retrieving %s: model was nil", groupId)
}

if existing := findGroupLicense(g, model.SkuId); existing != nil {
return metadata.ResourceRequiresImport(r.ResourceType(), id)
}

// Microsoft Graph requires disabledPlans to be a non-null collection, so default a nil slice
// (when disabled_plans is unset) to an empty slice rather than sending a null value.
disabledPlans := model.DisabledPlans
if disabledPlans == nil {
disabledPlans = []string{}
}

properties := group.AssignLicenseRequest{
AddLicenses: &[]beta.AssignedLicense{
{
SkuId: nullable.Value(model.SkuId),
DisabledPlans: &disabledPlans,
},
},
RemoveLicenses: &[]string{},
}

options := group.AssignLicenseOperationOptions{
RetryFunc: func(resp *http.Response, _ *odata.OData) (bool, error) {
return response.WasNotFound(resp), nil
},
}

if _, err = client.AssignLicense(ctx, groupId, properties, options); err != nil {
return fmt.Errorf("assigning %s: %+v", id, err)
}

// The assignLicense action for a group is asynchronous, so wait for the license to appear on the
// group's own assignedLicenses. This only reflects the assignment to the group object itself.
// Propagation to group members happens separately and is not managed by this resource.
if err = consistency.WaitForUpdate(ctx, func(ctx context.Context) (*bool, error) {
resp, err := client.GetGroup(ctx, groupId, group.GetGroupOperationOptions{
Select: &[]string{"id", "assignedLicenses"},
})
if err != nil {
return nil, err
}
return pointer.To(findGroupLicense(resp.Model, model.SkuId) != nil), nil
}); err != nil {
return fmt.Errorf("waiting for assignment of %s: %+v", id, err)
}

metadata.SetID(id)
return nil
},
}
}

func (r GroupLicenseResource) Read() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Groups.GroupClientBeta

id, err := parse.GroupLicenseID(metadata.ResourceData.Id())
if err != nil {
return err
}

groupId := beta.NewGroupID(id.GroupId)

resp, err := client.GetGroup(ctx, groupId, group.GetGroupOperationOptions{
Select: &[]string{"id", "assignedLicenses"},
})
if err != nil {
if response.WasNotFound(resp.HttpResponse) {
return metadata.MarkAsGone(id)
}
return fmt.Errorf("retrieving %s: %+v", groupId, err)
}

assignment := findGroupLicense(resp.Model, id.SkuId)
if assignment == nil {
return metadata.MarkAsGone(id)
}

state := GroupLicenseResourceModel{
GroupId: id.GroupId,
SkuId: id.SkuId,
DisabledPlans: pointer.From(assignment.DisabledPlans),
}

return metadata.Encode(&state)
},
}
}

func (r GroupLicenseResource) Delete() sdk.ResourceFunc {
return sdk.ResourceFunc{
Timeout: 5 * time.Minute,
Func: func(ctx context.Context, metadata sdk.ResourceMetaData) error {
client := metadata.Client.Groups.GroupClientBeta

id, err := parse.GroupLicenseID(metadata.ResourceData.Id())
if err != nil {
return err
}

groupId := beta.NewGroupID(id.GroupId)

tf.LockByName(groupResourceName, id.GroupId)
defer tf.UnlockByName(groupResourceName, id.GroupId)

properties := group.AssignLicenseRequest{
AddLicenses: &[]beta.AssignedLicense{},
RemoveLicenses: &[]string{id.SkuId},
}

if _, err = client.AssignLicense(ctx, groupId, properties, group.DefaultAssignLicenseOperationOptions()); err != nil {
return fmt.Errorf("removing %s: %+v", id, err)
}

if err = consistency.WaitForDeletion(ctx, func(ctx context.Context) (*bool, error) {
resp, err := client.GetGroup(ctx, groupId, group.GetGroupOperationOptions{
Select: &[]string{"id", "assignedLicenses"},
})
if err != nil {
if response.WasNotFound(resp.HttpResponse) {
return pointer.To(false), nil
}
return nil, err
}
return pointer.To(findGroupLicense(resp.Model, id.SkuId) != nil), nil
}); err != nil {
return fmt.Errorf("waiting for removal of %s: %+v", id, err)
}

return nil
},
}
}

func findGroupLicense(g *beta.Group, skuId string) *beta.AssignedLicense {
if g == nil || g.AssignedLicenses == nil {
return nil
}

for _, license := range *g.AssignedLicenses {
// SKU IDs are UUIDs and therefore case-insensitive; Microsoft Graph returns them lowercased but a
// user may supply an uppercase GUID, so compare case-insensitively to avoid spurious diffs.
if strings.EqualFold(license.SkuId.GetOrZero(), skuId) {
assignedLicense := license
return &assignedLicense
}
}

return nil
}
Loading
Loading