From ca8ce6cc6c930dea117b12fb6351e46c0dfef88f Mon Sep 17 00:00:00 2001 From: Bernie White Date: Fri, 7 Feb 2025 22:32:10 +1000 Subject: [PATCH] Fixes tenant scope resource ID #3237 (#3246) --- docs/CHANGELOG-v1.md | 6 + .../Common/ResourceHelper.cs | 14 ++ .../Data/Template/TemplateVisitor.cs | 40 ++-- .../Bicep/ScopeTestCases/BicepScopeTests.cs | 16 ++ .../Bicep/ScopeTestCases/Tests.Bicep.3.bicep | 25 +++ .../ScopeTestCases/Tests.Bicep.3.child1.bicep | 19 ++ .../ScopeTestCases/Tests.Bicep.3.child2.bicep | 21 +++ .../ScopeTestCases/Tests.Bicep.3.child3.bicep | 11 ++ .../Bicep/ScopeTestCases/Tests.Bicep.3.json | 171 ++++++++++++++++++ .../PSRule.Rules.Azure.Tests.csproj | 3 + .../ResourceHelperTests.cs | 22 +++ 11 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child1.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child2.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.child3.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.3.json diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index 9ded8654ae5..7fca3743edc 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 2868096a4bb..29d25c05db2 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 b53e761e166..2bfb77972a6 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 a30c944bb19..8916279f1d6 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 00000000000..f49f8c1f768 --- /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 00000000000..d321604c862 --- /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 00000000000..1c560651e4c --- /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 00000000000..8360e201c8f --- /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 00000000000..8d6648ab2c4 --- /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 663b010e785..8ac0b17572d 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 ca0fc66f501..dbb33127e87 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