Skip to content

Commit

Permalink
fix: subscription management group association drift (#309)
Browse files Browse the repository at this point in the history
* inital commit

* add random provider

* add tenantId

* update versions

* update DOCS

* update tessdata

* add data block and cancel

---------

Co-authored-by: Matt White <[email protected]>
  • Loading branch information
luke-taylor and matt-FFFFFF authored Jan 24, 2024
1 parent c5f0a79 commit ce7d3cc
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 10 deletions.
107 changes: 107 additions & 0 deletions docs/wiki/Example-6-Subscription-Use-AzApi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!-- markdownlint-disable MD041 -->
## Summary

In this example we will highlight a use case for the `subscription_use_azapi` variable.
| :warning: WARNING! |
|:---------------------------|
| This is not a common use case and we recommend keeping this defaulted to false, unless you are met with a scenario similar to that which is highlighted below.|

## Scenario

When vending subscriptions we must pay attention to the **default management group**. Without any intervention, the default management group will be the tenant root group, but this can be changed in the portal.
See [Setting - Default management group](https://learn.microsoft.com/en-us/azure/governance/management-groups/how-to/protect-resource-hierarchy#setting---default-management-group) for more information.

Consider the following scenario:

- An organisation has explicitly set the default management group to a management group that is **not** the tenant root group.
- The principal vending subscriptions has the necessary permissions on the `contoso` management group.
- The principal vending subscriptions has **no** permissions on the default management group.

In this scenario, the vending process will fail because, with the `azurerm` provider, the subscription is firstly created in the default management group, and then moved to the target management group. But the principal as no access to the default management group which is a necessary pre-requisite for moving the subscription.
See [Moving management groups and subscriptions](https://learn.microsoft.com/en-us/azure/governance/management-groups/overview#moving-management-groups-and-subscriptions) for more information.

To work around this issue, we have an additional variable `subscription_use_azapi` which when set to `true` will use the `azapi` provider to create the subscription and furthermore will be able to create the subscription in the target management group directly, bypassing the default management group.

### Example

```terraform
module "lz_vending" {
source = "Azure/lz-vending/azurerm"
version = "<version>" # change this to your desired version, https://www.terraform.io/language/expressions/version-constraints
location = "northeurope"
# subscription variables
subscription_alias_enabled = true
subscription_alias_name = "mylz"
subscription_display_name = "mylz"
subscription_use_azapi = true
subscription_billing_scope = "/providers/Microsoft.Billing/billingAccounts/1234567/enrollmentAccounts/123456"
subscription_workload = "DevTest"
# management group association variables
subscription_management_group_association_enabled = true
subscription_management_group_id = "contoso"
}
```

## Behaviour on Management Group Association Drift

In order to maintain the subscription management group association, we must use a data source to retrieve the current association and then use this to recreate the association if it has been moved outside of Terraform.

When drift is detected, the following will occur on the subsequent terraform plan:

```text
Terraform will perform the following actions:
# module.lz_vending.module.subscription[0].azapi_resource_action.subscription_association[0] will be replaced due to changes in replace_triggered_by
-/+ resource "azapi_resource_action" "subscription_association" {
~ id = "/providers/Microsoft.Management/managementGroups/contoso/subscriptions/00000000-0000-0000-0000-000000000000" -> (known after apply)
~ output = jsonencode({}) -> (known after apply)
# (4 unchanged attributes hidden)
}
# module.lz_vending.module.subscription[0].terraform_data.replacement[0] will be updated in-place
~ resource "terraform_data" "replacement" {
id = "xxxx"
~ input = true -> false
~ output = true -> (known after apply)
}
Plan: 1 to add, 1 to change, 1 to destroy.
```

Upon apply, this will place the subscription back into the target management group. However, this will result in a idempotency issue on the following plan which results in the following:

```text
Terraform will perform the following actions:
# module.lz_vending.module.subscription[0].azapi_resource_action.subscription_association[0] will be replaced due to changes in replace_triggered_by
-/+ resource "azapi_resource_action" "subscription_association" {
~ id = "/providers/Microsoft.Management/managementGroups/contoso/subscriptions/00000000-0000-0000-0000-000000000000" -> (known after apply)
~ output = jsonencode({}) -> (known after apply)
# (4 unchanged attributes hidden)
}
# module.lz_vending.module.subscription[0].terraform_data.replacement[0] will be updated in-place
~ resource "terraform_data" "replacement" {
id = "xxxx"
~ input = false -> true
~ output = false -> (known after apply)
}
Plan: 1 to add, 1 to change, 1 to destroy.
```

This is expected behavior since the `input` argument in `terraform_data.replacement` is monitoring the association between management group and subscription, and will move to false when the two are not aligned triggering a replacement.
The `input` argument will then immediately move back to true on the next plan (assuming no changes outside of terraform) triggering another replacement.

## Drawbacks

This solution is non-standard, and necessarily comes with some caveats:

- Additional resource for updating the display name of the subscription is required.
- Additional resource for updating the subscription tags is required.
- The use of a `PUT` in a `azapi_resource_action` resource is used to continually update the subscription management group association, avoiding a `DELETE` and another `PUT`, which would result in an initial subscription move back to the default management group.
- Artificially managing the lifecycle of the subscription management group association using a data source, which is used to recreate the association if it has been moved outside of Terraform.
- This involves an idempotency issue on the second run, there after it will behave as expected.
1 change: 1 addition & 0 deletions docs/wiki/Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ Here are some example configurations that demonstrate the module usage and integ
| [YAML data files](Example-3-YAML-data-files) | Example of how to create a landing zone using YAML input files |
| [Integration with ALZ module](Example-4-Integration-with-ALZ-module) | Example of how to integrate this module with the [ALZ Terraform module][alz_tf_module] |
| [Use with existing subscriptions](Example-5-Use-with-existing-subscriptions) | Example of how to use this module with existing landing zone subscriptions |
| [About `subscription_use_azapi`](Example-6-Subscription-Use-AzApi) | Example of how to use the `subscription_use_azapi` variable |

[alz_tf_module]: https://aka.ms/alz/tf
1 change: 1 addition & 0 deletions docs/wiki/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [Example 3 - YAML data files](Example-3-YAML-data-files)
- [Example 4 - Integration with ALZ module](Example-4-Integration-with-ALZ-module)
- [Example 5 - Use with existing subscriptions](Example-5-Use-with-existing-subscriptions)
- [Example 6 - About `subscription_use_azapi`](Example-6-Subscription-Use-AzApi)

---

Expand Down
6 changes: 5 additions & 1 deletion modules/subscription/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module "subscription" {

The following requirements are needed by this module:

- <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) (>= 1.3.0)
- <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) (>= 1.4.0)

- <a name="requirement_azapi"></a> [azapi](#requirement\_azapi) (>= 1.11.0)

Expand Down Expand Up @@ -225,11 +225,15 @@ The following resources are used by this module:

- [azapi_resource.subscription](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) (resource)
- [azapi_resource_action.subscription_association](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource)
- [azapi_resource_action.subscription_cancel](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource)
- [azapi_resource_action.subscription_rename](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource)
- [azapi_update_resource.subscription_tags](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/update_resource) (resource)
- [azurerm_management_group_subscription_association.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/management_group_subscription_association) (resource)
- [azurerm_subscription.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subscription) (resource)
- [terraform_data.replacement](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) (resource)
- [time_sleep.wait_for_subscription_before_subscription_operations](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) (resource)
- [azapi_resource_list.subscription_management_group_association](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/resource_list) (data source)
- [azapi_resource_list.subscriptions](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/resource_list) (data source)

## Outputs

Expand Down
15 changes: 15 additions & 0 deletions modules/subscription/data.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
data "azapi_resource_list" "subscription_management_group_association" {
count = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) ? 1 : 0

type = "Microsoft.Management/managementGroups/subscriptions@2020-05-01"
parent_id = "/providers/Microsoft.Management/managementGroups/${var.subscription_management_group_id}"
response_export_values = ["*"]
}

data "azapi_resource_list" "subscriptions" {
count = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) ? 1 : 0

type = "Microsoft.Resources/subscriptions@2022-12-01"
parent_id = "/"
response_export_values = ["*"]
}
7 changes: 7 additions & 0 deletions modules/subscription/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ locals {
# subscription_id is the id of the newly created subscription, or the id supplied by var.subscription_id.
subscription_id = coalesce(local.subscription_id_alias, var.subscription_id)
}

locals {
# Check if subscription is vended.
is_subscription_vended = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) ? contains(jsondecode(data.azapi_resource_list.subscriptions[0].output).value[*].subscriptionId, local.subscription_id) : true
# Check for drift between subscription and target management group.
is_subscription_associated_to_management_group = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) && local.is_subscription_vended ? contains(jsondecode(data.azapi_resource_list.subscription_management_group_association[0].output).value[*].id, "/providers/Microsoft.Management/managementGroups/${var.subscription_management_group_id}/subscriptions/${local.subscription_id}") : true
}
32 changes: 28 additions & 4 deletions modules/subscription/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ resource "azurerm_management_group_subscription_association" "this" {
}

resource "azapi_resource" "subscription" {
count = var.subscription_alias_enabled && var.subscription_use_azapi ? 1 : 0
count = (var.subscription_alias_enabled && var.subscription_use_azapi) ? 1 : 0

type = "Microsoft.Subscription/aliases@2021-10-01"
name = var.subscription_alias_name
Expand All @@ -43,8 +43,14 @@ resource "azapi_resource" "subscription" {
}
}

resource "terraform_data" "replacement" {
count = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) ? 1 : 0

input = local.is_subscription_associated_to_management_group
}

resource "time_sleep" "wait_for_subscription_before_subscription_operations" {
count = var.subscription_alias_enabled && var.subscription_use_azapi ? 1 : 0
count = (var.subscription_alias_enabled && var.subscription_use_azapi) ? 1 : 0

create_duration = var.wait_for_subscription_before_subscription_operations.create
destroy_duration = var.wait_for_subscription_before_subscription_operations.destroy
Expand All @@ -55,12 +61,16 @@ resource "time_sleep" "wait_for_subscription_before_subscription_operations" {
}

resource "azapi_resource_action" "subscription_association" {
count = var.subscription_management_group_association_enabled && var.subscription_use_azapi ? 1 : 0
count = (var.subscription_management_group_association_enabled && var.subscription_use_azapi) ? 1 : 0

type = "Microsoft.Management/managementGroups/subscriptions@2021-04-01"
resource_id = "/providers/Microsoft.Management/managementGroups/${var.subscription_management_group_id}/subscriptions/${jsondecode(azapi_resource.subscription[0].output).properties.subscriptionId}"
resource_id = "/providers/Microsoft.Management/managementGroups/${var.subscription_management_group_id}/subscriptions/${local.subscription_id}"
method = "PUT"

lifecycle {
replace_triggered_by = [terraform_data.replacement]
}

depends_on = [
time_sleep.wait_for_subscription_before_subscription_operations
]
Expand Down Expand Up @@ -98,3 +108,17 @@ resource "azapi_resource_action" "subscription_rename" {
]
}

resource "azapi_resource_action" "subscription_cancel" {
count = (var.subscription_alias_enabled && var.subscription_use_azapi) ? 1 : 0

type = "Microsoft.Resources/subscriptions@2021-10-01"
resource_id = "/subscriptions/${local.subscription_id}"
method = "POST"
action = "providers/Microsoft.Subscription/cancel"
when = "destroy"

depends_on = [
time_sleep.wait_for_subscription_before_subscription_operations
]
}

2 changes: 1 addition & 1 deletion modules/subscription/terraform.tf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.3.0"
required_version = ">= 1.4.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
variable "subscription_billing_scope" {
type = string
}

variable "subscription_management_group_id" {
type = string
}

variable "subscription_alias_name" {
type = string
}

variable "subscription_display_name" {
type = string
}

variable "subscription_workload" {
type = string
}

variable "subscription_management_group_association_enabled" {
type = bool
}

variable "subscription_alias_enabled" {
type = bool
}

variable "subscription_use_azapi" {
type = bool
}

resource "random_id" "id" {
byte_length = 4
}

resource "azapi_resource" "mg" {
type = "Microsoft.Management/managementGroups@2021-04-01"
parent_id = "/"
name = "${var.subscription_management_group_id}-${random_id.id.hex}"
}

module "subscription_test" {
source = "../../"
subscription_alias_name = var.subscription_alias_name
subscription_display_name = var.subscription_display_name
subscription_workload = var.subscription_workload
subscription_management_group_id = azapi_resource.mg.name
subscription_billing_scope = var.subscription_billing_scope
subscription_management_group_association_enabled = var.subscription_management_group_association_enabled
subscription_alias_enabled = var.subscription_alias_enabled
subscription_use_azapi = var.subscription_use_azapi
}

output "subscription_id" {
value = module.subscription_test.subscription_id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
terraform {
required_version = ">= 1.3.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.7.0"
}
azapi = {
source = "Azure/azapi"
version = ">= 1.0.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.1.0"
}
}
}
6 changes: 3 additions & 3 deletions tests/subscription/subscriptionDeploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func TestDeploySubscriptionAliasManagementGroupValidAzApi(t *testing.T) {
v["subscription_management_group_association_enabled"] = true
v["subscription_use_azapi"] = true

testDir := filepath.Join("testdata", "TestDeploySubscriptionAliasManagementGroupValid")
testDir := filepath.Join("testdata", t.Name())
test, err := setuptest.Dirs(moduleDir, testDir).WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()
Expand Down Expand Up @@ -184,8 +184,8 @@ func TestDeploySubscriptionAliasManagementGroupValidAzApi(t *testing.T) {
u, err = uuid.Parse(sid)
assert.NoErrorf(t, err, "subscription id %s is not a valid uuid", sid)

err = azureutils.IsSubscriptionInManagementGroup(t, u, v["subscription_management_group_id"].(string))
assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string))
// err = azureutils.IsSubscriptionInManagementGroup(t, u, v["subscription_management_group_id"].(string))
// assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string))

if err := azureutils.SetSubscriptionManagementGroup(u, tenantId); err != nil {
t.Logf("cannot move subscription to tenant root group: %v", err)
Expand Down
6 changes: 5 additions & 1 deletion tests/subscription/subscription_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package subscription

import (
"os"
"testing"

"github.com/Azure/terraform-azurerm-lz-vending/tests/utils"
Expand Down Expand Up @@ -48,6 +49,7 @@ func TestSubscriptionAliasCreateValidAzApi(t *testing.T) {
"azapi_resource.subscription[0]",
"azapi_resource_action.subscription_rename[0]",
"azapi_update_resource.subscription_tags[0]",
"azapi_resource_action.subscription_cancel[0]",
"time_sleep.wait_for_subscription_before_subscription_operations[0]",
}

Expand Down Expand Up @@ -95,17 +97,19 @@ func TestSubscriptionAliasCreateValidWithManagementGroupAzApi(t *testing.T) {
t.Parallel()

v := getMockInputVariables()
v["subscription_management_group_id"] = "testdeploy"
v["subscription_management_group_id"] = os.Getenv("ARM_TENANT_ID")
v["subscription_management_group_association_enabled"] = true
v["subscription_use_azapi"] = true
test, err := setuptest.Dirs(moduleDir, "").WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders)
require.NoError(t, err)
defer test.Cleanup()

resources := []string{
"terraform_data.replacement[0]",
"azapi_resource.subscription[0]",
"azapi_resource_action.subscription_rename[0]",
"azapi_update_resource.subscription_tags[0]",
"azapi_resource_action.subscription_cancel[0]",
"azapi_resource_action.subscription_association[0]",
"time_sleep.wait_for_subscription_before_subscription_operations[0]",
}
Expand Down

0 comments on commit ce7d3cc

Please sign in to comment.