diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md
index 9ded8654ae..7fca3743ed 100644
--- a/docs/CHANGELOG-v1.md
+++ b/docs/CHANGELOG-v1.md
@@ -29,6 +29,12 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers
## Unreleased
+What's changed since v1.41.0:
+
+- Bug fixes:
+ - Fixed incorrect generation of resource ID for tenant scoped deployments by @BernieWhite.
+ [#3237](https://github.com/Azure/PSRule.Rules.Azure/issues/3237)
+
## v1.41.0
What's changed since v1.40.0:
diff --git a/src/PSRule.Rules.Azure/Common/ResourceHelper.cs b/src/PSRule.Rules.Azure/Common/ResourceHelper.cs
index 2868096a4b..29d25c05db 100644
--- a/src/PSRule.Rules.Azure/Common/ResourceHelper.cs
+++ b/src/PSRule.Rules.Azure/Common/ResourceHelper.cs
@@ -120,6 +120,20 @@ internal static bool TryResourceGroup(string resourceId, out string? subscriptio
TryConsumeResourceGroupPart(idParts, ref i, out resourceGroupName);
}
+ ///
+ /// Get the name of the management group from the specified resource Id.
+ ///
+ internal static bool TryManagementGroup(string resourceId, out string? managementGroup)
+ {
+ managementGroup = null;
+ if (string.IsNullOrEmpty(resourceId))
+ return false;
+
+ var idParts = resourceId.Split(SLASH_C);
+ var i = 0;
+ return TryConsumeManagementGroupPart(idParts, ref i, out managementGroup);
+ }
+
///
/// Combines Id fragments to form a resource Id.
///
diff --git a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs
index b53e761e16..2bfb77972a 100644
--- a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs
+++ b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs
@@ -39,7 +39,7 @@ internal abstract class TemplateVisitor : ResourceManagerVisitor
private const string PROPERTY_TYPE = "type";
private const string PROPERTY_PROPERTIES = "properties";
private const string PROPERTY_TEMPLATE = "template";
- private const string PROPERTY_TEMPLATELINK = "templateLink";
+ private const string PROPERTY_TEMPLATE_LINK = "templateLink";
private const string PROPERTY_LOCATION = "location";
private const string PROPERTY_COPY = "copy";
private const string PROPERTY_NAME = "name";
@@ -49,15 +49,15 @@ internal abstract class TemplateVisitor : ResourceManagerVisitor
private const string PROPERTY_MODE = "mode";
private const string PROPERTY_DEFAULTVALUE = "defaultValue";
private const string PROPERTY_SECRETNAME = "secretName";
- private const string PROPERTY_PROVISIONINGSTATE = "provisioningState";
+ private const string PROPERTY_PROVISIONING_STATE = "provisioningState";
private const string PROPERTY_ID = "id";
private const string PROPERTY_URI = "uri";
private const string PROPERTY_TEMPLATEHASH = "templateHash";
private const string PROPERTY_EXPRESSIONEVALUATIONOPTIONS = "expressionEvaluationOptions";
private const string PROPERTY_SCOPE = "scope";
- private const string PROPERTY_RESOURCEGROUP = "resourceGroup";
- private const string PROPERTY_SUBSCRIPTIONID = "subscriptionId";
- private const string PROPERTY_MANAGEMENTGROUP = "managementGroup";
+ private const string PROPERTY_RESOURCE_GROUP = "resourceGroup";
+ private const string PROPERTY_SUBSCRIPTION_ID = "subscriptionId";
+ private const string PROPERTY_MANAGEMENT_GROUP = "managementGroup";
private const string PROPERTY_NAMESPACE = "namespace";
private const string PROPERTY_MEMBERS = "members";
private const string PROPERTY_OUTPUT = "output";
@@ -481,7 +481,7 @@ internal void EnterDeployment(string deploymentName, JObject template, bool isNe
TryObjectProperty(template, PROPERTY_METADATA, out var metadata);
TryStringProperty(template, PROPERTY_SCHEMA, out var schema);
var scope = GetDeploymentScope(schema, out var deploymentScope);
- var id = string.Concat(scope, "/providers/", RESOURCE_TYPE_DEPLOYMENT, "/", deploymentName);
+ var id = string.Concat(scope == "/" ? string.Empty : scope, "/providers/", RESOURCE_TYPE_DEPLOYMENT, "/", deploymentName);
var location = ResourceGroup.Location;
var templateLink = new JObject
@@ -493,10 +493,10 @@ internal void EnterDeployment(string deploymentName, JObject template, bool isNe
var properties = new JObject
{
{ PROPERTY_TEMPLATE, template.CloneTemplateJToken() },
- { PROPERTY_TEMPLATELINK, templateLink },
+ { PROPERTY_TEMPLATE_LINK, templateLink },
{ PROPERTY_PARAMETERS, _Parameters },
{ PROPERTY_MODE, "Incremental" },
- { PROPERTY_PROVISIONINGSTATE, "Accepted" },
+ { PROPERTY_PROVISIONING_STATE, "Accepted" },
{ PROPERTY_TEMPLATEHASH, templateHash },
{ PROPERTY_OUTPUTS, new JObject() }
};
@@ -673,7 +673,7 @@ internal bool TryParameterDefault(string parameterName, ParameterType type, out
return true;
}
break;
- };
+ }
return false;
}
@@ -1406,14 +1406,14 @@ private static DeploymentScope GetDeploymentScope(TemplateContext context, JObje
}
// Handle special case for cross-scope deployments which may have an alternative subscription or resource group set.
- subscriptionId = ResolveDeploymentScopeProperty(context, resource, PROPERTY_SUBSCRIPTIONID, contextValue:
+ subscriptionId = ResolveDeploymentScopeProperty(context, resource, PROPERTY_SUBSCRIPTION_ID, contextValue:
context.Deployment.DeploymentScope == DeploymentScope.Subscription ||
context.Deployment.DeploymentScope == DeploymentScope.ResourceGroup ? context.Subscription.SubscriptionId : null);
- resourceGroupName = ResolveDeploymentScopeProperty(context, resource, PROPERTY_RESOURCEGROUP, contextValue:
+ resourceGroupName = ResolveDeploymentScopeProperty(context, resource, PROPERTY_RESOURCE_GROUP, contextValue:
context.Deployment.DeploymentScope == DeploymentScope.ResourceGroup ? context.ResourceGroup.Name : null);
- managementGroup = ResolveDeploymentScopeProperty(context, resource, PROPERTY_MANAGEMENTGROUP, contextValue:
+ managementGroup = ResolveDeploymentScopeProperty(context, resource, PROPERTY_MANAGEMENT_GROUP, contextValue:
context.Deployment.DeploymentScope == DeploymentScope.ManagementGroup ? context.ManagementGroup.Name : null);
// Update the deployment scope.
@@ -1541,20 +1541,32 @@ private TemplateContext GetDeploymentContext(TemplateContext context, string dep
var resourceGroup = new ResourceGroupOption(context.ResourceGroup);
var tenant = new TenantOption(context.Tenant);
var managementGroup = new ManagementGroupOption(context.ManagementGroup);
- if (TryStringProperty(resource, PROPERTY_SUBSCRIPTIONID, out var subscriptionId))
+ if (TryStringProperty(resource, PROPERTY_SUBSCRIPTION_ID, out var subscriptionId))
{
var targetSubscriptionId = ExpandString(context, subscriptionId);
if (!string.IsNullOrEmpty(subscriptionId))
subscription.SubscriptionId = targetSubscriptionId;
}
- if (TryStringProperty(resource, PROPERTY_RESOURCEGROUP, out var resourceGroupName))
+ if (TryStringProperty(resource, PROPERTY_RESOURCE_GROUP, out var resourceGroupName))
{
var targetResourceGroup = ExpandString(context, resourceGroupName);
if (!string.IsNullOrEmpty(targetResourceGroup))
resourceGroup.Name = targetResourceGroup;
}
+ if (TryStringProperty(resource, PROPERTY_SCOPE, out var scopeId) && ResourceHelper.ResourceIdComponents(scopeId, out _, out var managementGroupName, out subscriptionId, out resourceGroupName, out _, out _))
+ {
+ if (!string.IsNullOrEmpty(managementGroupName))
+ managementGroup.Name = managementGroupName;
+
+ if (!string.IsNullOrEmpty(subscriptionId))
+ subscription.SubscriptionId = subscriptionId;
+
+ if (!string.IsNullOrEmpty(resourceGroupName))
+ resourceGroup.Name = resourceGroupName;
+ }
+
resourceGroup.SubscriptionId = subscription.SubscriptionId;
TryObjectProperty(template, PROPERTY_PARAMETERS, out var templateParameters);
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs
index a30c944bb1..8916279f1d 100644
--- a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs
@@ -57,4 +57,20 @@ public void ProcessTemplate_WhenResourceGroupFromSubscriptionScope_ShouldReturnC
var property = actual["identity"]["userAssignedIdentities"].Value().Properties().FirstOrDefault();
Assert.Equal("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/rg-1/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-1", property.Name);
}
+
+ [Fact]
+ public void ProcessTemplate_WhenTenantScope_ShouldReturnCorrectIdentifiers()
+ {
+ var resources = ProcessTemplate(GetSourcePath("Bicep/ScopeTestCases/Tests.Bicep.3.json"), null, out _);
+
+ Assert.NotNull(resources);
+
+ var actual = resources.Where(r => r["name"].Value() == "mg-test").FirstOrDefault();
+ Assert.Equal("Microsoft.Management/managementGroups", actual["type"].Value());
+ Assert.Equal("/providers/Microsoft.Management/managementGroups/mg-test", actual["id"].Value());
+
+ actual = resources.Where(r => r["name"].Value() == "mg-test/00000000-0000-0000-0000-000000000000").FirstOrDefault();
+ Assert.Equal("Microsoft.Management/managementGroups/subscriptions", actual["type"].Value());
+ Assert.Equal("/providers/Microsoft.Management/managementGroups/mg-test/subscriptions/00000000-0000-0000-0000-000000000000", actual["id"].Value());
+ }
}
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.bicep b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.bicep
new file mode 100644
index 0000000000..f49f8c1f76
--- /dev/null
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.bicep
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// Test case for https://github.com/Azure/PSRule.Rules.Azure/issues/3237
+
+// Based on AVM subscription placement module.
+
+targetScope = 'tenant'
+
+module mg './Tests.Bicep.3.child1.bicep' = {
+ name: 'mg'
+ scope: tenant()
+}
+
+module child './Tests.Bicep.3.child2.bicep' = {
+ name: 'child'
+ params: {
+ managementGroups: [
+ {
+ id: mg.outputs.managementGroupId
+ subscriptionId: mg.outputs.subscriptionId
+ }
+ ]
+ }
+}
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child1.bicep b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child1.bicep
new file mode 100644
index 0000000000..d321604c86
--- /dev/null
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child1.bicep
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+targetScope = 'tenant'
+
+resource managementGroup 'Microsoft.Management/managementGroups@2023-04-01' = {
+ name: 'mg-test'
+ properties: {
+ displayName: 'Test Management Group'
+ details: {
+ parent: {
+ id: ''
+ }
+ }
+ }
+}
+
+output managementGroupId string = managementGroup.id
+output subscriptionId string = '00000000-0000-0000-0000-000000000000'
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child2.bicep b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child2.bicep
new file mode 100644
index 0000000000..1c560651e4
--- /dev/null
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child2.bicep
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+targetScope = 'tenant'
+
+param managementGroups managementGroup[]
+
+type managementGroup = {
+ id: string
+ subscriptionId: string
+}
+
+module customSubscriptionPlacement './Tests.Bicep.3.child3.bicep' = [
+ for (place, index) in managementGroups: {
+ name: 'placement-${uniqueString(place.id)}${index}'
+ params: {
+ managementGroupId: place.id
+ subscriptionId: place.subscriptionId
+ }
+ }
+]
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child3.bicep b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child3.bicep
new file mode 100644
index 0000000000..8360e201c8
--- /dev/null
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child3.bicep
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+targetScope = 'tenant'
+
+param managementGroupId string
+param subscriptionId string
+
+resource customSubscriptionPlacement 'Microsoft.Management/managementGroups/subscriptions@2023-04-01' = {
+ name: '${last(split(managementGroupId, '/'))}/${subscriptionId}'
+}
diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.json b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.json
new file mode 100644
index 0000000000..8d6648ab2c
--- /dev/null
+++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.json
@@ -0,0 +1,171 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "metadata": {
+ "_generator": {
+ "name": "bicep",
+ "version": "0.33.93.31351",
+ "templateHash": "2473504024471157297"
+ }
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Resources/deployments",
+ "apiVersion": "2022-09-01",
+ "name": "mg",
+ "location": "[deployment().location]",
+ "properties": {
+ "expressionEvaluationOptions": {
+ "scope": "inner"
+ },
+ "mode": "Incremental",
+ "template": {
+ "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "metadata": {
+ "_generator": {
+ "name": "bicep",
+ "version": "0.33.93.31351",
+ "templateHash": "17955590497928865384"
+ }
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Management/managementGroups",
+ "apiVersion": "2023-04-01",
+ "name": "mg-test",
+ "properties": {
+ "displayName": "Test Management Group",
+ "details": {
+ "parent": {
+ "id": ""
+ }
+ }
+ }
+ }
+ ],
+ "outputs": {
+ "managementGroupId": {
+ "type": "string",
+ "value": "[tenantResourceId('Microsoft.Management/managementGroups', 'mg-test')]"
+ },
+ "subscriptionId": {
+ "type": "string",
+ "value": "00000000-0000-0000-0000-000000000000"
+ }
+ }
+ }
+ }
+ },
+ {
+ "type": "Microsoft.Resources/deployments",
+ "apiVersion": "2022-09-01",
+ "name": "child",
+ "location": "[deployment().location]",
+ "properties": {
+ "expressionEvaluationOptions": {
+ "scope": "inner"
+ },
+ "mode": "Incremental",
+ "parameters": {
+ "managementGroups": {
+ "value": [
+ {
+ "id": "[reference(tenantResourceId('Microsoft.Resources/deployments', 'mg'), '2022-09-01').outputs.managementGroupId.value]",
+ "subscriptionId": "[reference(tenantResourceId('Microsoft.Resources/deployments', 'mg'), '2022-09-01').outputs.subscriptionId.value]"
+ }
+ ]
+ }
+ },
+ "template": {
+ "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#",
+ "languageVersion": "2.0",
+ "contentVersion": "1.0.0.0",
+ "metadata": {
+ "_generator": {
+ "name": "bicep",
+ "version": "0.33.93.31351",
+ "templateHash": "8581646487883214838"
+ }
+ },
+ "definitions": {
+ "managementGroup": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "subscriptionId": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "parameters": {
+ "managementGroups": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/managementGroup"
+ }
+ }
+ },
+ "resources": {
+ "customSubscriptionPlacement": {
+ "copy": {
+ "name": "customSubscriptionPlacement",
+ "count": "[length(parameters('managementGroups'))]"
+ },
+ "type": "Microsoft.Resources/deployments",
+ "apiVersion": "2022-09-01",
+ "name": "[format('placement-{0}{1}', uniqueString(parameters('managementGroups')[copyIndex()].id), copyIndex())]",
+ "location": "[deployment().location]",
+ "properties": {
+ "expressionEvaluationOptions": {
+ "scope": "inner"
+ },
+ "mode": "Incremental",
+ "parameters": {
+ "managementGroupId": {
+ "value": "[parameters('managementGroups')[copyIndex()].id]"
+ },
+ "subscriptionId": {
+ "value": "[parameters('managementGroups')[copyIndex()].subscriptionId]"
+ }
+ },
+ "template": {
+ "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "metadata": {
+ "_generator": {
+ "name": "bicep",
+ "version": "0.33.93.31351",
+ "templateHash": "2385326936979591180"
+ }
+ },
+ "parameters": {
+ "managementGroupId": {
+ "type": "string"
+ },
+ "subscriptionId": {
+ "type": "string"
+ }
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Management/managementGroups/subscriptions",
+ "apiVersion": "2023-04-01",
+ "name": "[format('{0}/{1}', last(split(parameters('managementGroupId'), '/')), parameters('subscriptionId'))]"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "dependsOn": [
+ "[tenantResourceId('Microsoft.Resources/deployments', 'mg')]"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj
index 663b010e78..8ac0b17572 100644
--- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj
+++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj
@@ -320,6 +320,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs b/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs
index ca0fc66f50..dbb33127e8 100644
--- a/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs
+++ b/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs
@@ -5,6 +5,9 @@ namespace PSRule.Rules.Azure;
#nullable enable
+///
+/// Unit tests for .
+///
public sealed class ResourceHelperTests
{
[Fact]
@@ -171,6 +174,25 @@ public void ResourceIdDepth(string[] resourceType, string[] resourceName, int de
{
Assert.Equal(depth, ResourceHelper.ResourceIdDepth(null, null, null, null, resourceType, resourceName));
}
+
+ [Theory]
+ [InlineData("/providers/Microsoft.Management/managementGroups/mg-1", "mg-1")]
+ [InlineData("/providers/Microsoft.Management/managementGroups/mg-1/providers/Microsoft.Authorization/policyAssignments/assignment-1", "mg-1")]
+ [InlineData("Microsoft.Management/managementGroups/mg-1", "mg-1")]
+ public void TryManagementGroup_WithValidResourceId_ShouldReturnManagementGroup(string resourceId, string managementGroup)
+ {
+ Assert.True(ResourceHelper.TryManagementGroup(resourceId, out var actualManagementGroup));
+ Assert.Equal(managementGroup, actualManagementGroup);
+ }
+
+ [Theory]
+ [InlineData("/")]
+ [InlineData("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/rg-4")]
+ public void TryManagementGroup_WithInvalidResourceId_ShouldReturnFalse(string resourceId)
+ {
+ Assert.False(ResourceHelper.TryManagementGroup(resourceId, out var actualManagementGroup));
+ Assert.Null(actualManagementGroup);
+ }
}
#nullable restore