diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index 9e0f85db7c..18ec8db867 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -34,6 +34,8 @@ What's changed since pre-release v1.40.0-B0103: - Bug fixes: - Fixed object to hashtable conversion for default parameter values by @BernieWhite. [#3033](https://github.com/Azure/PSRule.Rules.Azure/issues/3033) + - Fixed deployments with more than one module at tenant scope by @BernieWhite. + [#3167](https://github.com/Azure/PSRule.Rules.Azure/issues/3167) ## v1.40.0-B0103 (pre-release) diff --git a/src/PSRule.Rules.Azure/Common/ResourceHelper.cs b/src/PSRule.Rules.Azure/Common/ResourceHelper.cs index 0cd1ea8ad7..945a265925 100644 --- a/src/PSRule.Rules.Azure/Common/ResourceHelper.cs +++ b/src/PSRule.Rules.Azure/Common/ResourceHelper.cs @@ -18,6 +18,7 @@ internal static class ResourceHelper private const string MANAGEMENT_GROUPS = "managementGroups"; private const string PROVIDERS = "providers"; private const string MANAGEMENT_GROUP_TYPE = "/providers/Microsoft.Management/managementGroups/"; + private const string PROVIDER_MICROSOFT_MANAGEMENT = "Microsoft.Management"; private const char SLASH_C = '/'; @@ -319,7 +320,7 @@ internal static string ResourceId(string? scopeTenant, string? scopeManagementGr result[i++] = SLASH; result[i++] = PROVIDERS; result[i++] = SLASH; - result[i++] = "Microsoft.Management"; + result[i++] = PROVIDER_MICROSOFT_MANAGEMENT; result[i++] = SLASH; result[i++] = MANAGEMENT_GROUPS; result[i++] = SLASH; @@ -648,12 +649,20 @@ private static bool TryConsumeTenantPart(string[] idParts, ref int start, out st private static bool TryConsumeManagementGroupPart(string[] idParts, ref int start, out string? managementGroup) { managementGroup = null; - if (start == 0 && idParts.Length >= 5 && idParts[0] == string.Empty && StringComparer.OrdinalIgnoreCase.Equals(idParts[1], PROVIDERS) && idParts[2] == "Microsoft.Management" && idParts[3] == MANAGEMENT_GROUPS) + // Handle ID form: /providers/Microsoft.Management/managementGroups/ + if (start == 0 && idParts.Length >= 5 && idParts[0] == string.Empty && StringComparer.OrdinalIgnoreCase.Equals(idParts[1], PROVIDERS) && idParts[2] == PROVIDER_MICROSOFT_MANAGEMENT && idParts[3] == MANAGEMENT_GROUPS) { managementGroup = idParts[4]; start += 5; return true; } + // Handle scope form: Microsoft.Management/managementGroups/ + else if (start == 0 && idParts.Length >= 3 && idParts[0] == PROVIDER_MICROSOFT_MANAGEMENT && idParts[1] == MANAGEMENT_GROUPS) + { + managementGroup = idParts[2]; + start += 3; + return true; + } return false; } diff --git a/src/PSRule.Rules.Azure/Data/Template/Functions.cs b/src/PSRule.Rules.Azure/Data/Template/Functions.cs index a84a0e259d..c0480fd3e2 100644 --- a/src/PSRule.Rules.Azure/Data/Template/Functions.cs +++ b/src/PSRule.Rules.Azure/Data/Template/Functions.cs @@ -1127,6 +1127,7 @@ internal static object SubscriptionResourceId(ITemplateContext context, object[] /// /// tenantResourceId(resourceType, resourceName1, [resourceName2], ...) + /// See . /// /// /// /providers/{resourceProviderNamespace}/{resourceType}/{resourceName} diff --git a/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs b/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs index 8aa01f4c0f..9802917cc9 100644 --- a/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs +++ b/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Management.Automation.Language; using Newtonsoft.Json.Linq; namespace PSRule.Rules.Azure.Data.Template; @@ -58,6 +57,7 @@ internal sealed class RuleDataExportVisitor : TemplateVisitor private const string TYPE_KEYVAULT = "Microsoft.KeyVault/vaults"; private const string TYPE_STORAGE_OBJECTREPLICATIONPOLICIES = "Microsoft.Storage/storageAccounts/objectReplicationPolicies"; private const string TYPE_AUTHORIZATION_ROLE_ASSIGNMENTS = "Microsoft.Authorization/roleAssignments"; + private const string TYPE_MANAGEMENT_GROUPS = "Microsoft.Management/managementGroups"; private static readonly JsonMergeSettings _MergeSettings = new() { @@ -138,6 +138,7 @@ private static void ProjectRuntimeProperties(TemplateContext context, IResourceV ProjectStorageObjectReplicationPolicies(context, resource) || ProjectKeyVault(context, resource) || ProjectRoleAssignments(context, resource) || + ProjectManagementGroup(context, resource) || ProjectResource(context, resource); } @@ -157,6 +158,22 @@ private static bool ProjectResource(TemplateContext context, IResourceValue reso return true; } + private static bool ProjectManagementGroup(TemplateContext context, IResourceValue resource) + { + if (!resource.IsType(TYPE_MANAGEMENT_GROUPS)) + return false; + + resource.Value.UseProperty(PROPERTY_PROPERTIES, out JObject properties); + + // Add properties.tenantId + if (!properties.ContainsKeyInsensitive(PROPERTY_TENANT_ID)) + { + properties[PROPERTY_TENANT_ID] = context.Tenant.TenantId; + } + + return true; + } + private static bool ProjectRoleAssignments(TemplateContext context, IResourceValue resource) { if (!resource.IsType(TYPE_AUTHORIZATION_ROLE_ASSIGNMENTS)) diff --git a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs index bf9be3e2d9..60bf4fdfce 100644 --- a/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs +++ b/src/PSRule.Rules.Azure/Data/Template/TemplateVisitor.cs @@ -549,7 +549,7 @@ private string GetDeploymentScope(string schema, out DeploymentScope deploymentS if (string.Equals(template, "tenantDeploymentTemplate.json", StringComparison.OrdinalIgnoreCase)) { deploymentScope = DeploymentScope.Tenant; - return Tenant.Id; + return "/"; } } diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs new file mode 100644 index 0000000000..6f5f8784d3 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/BicepScopeTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace PSRule.Rules.Azure.Bicep.ScopeTestCases; + +/// +/// Tests for validating resource scopes and IDs are generated correctly. +/// +public sealed class BicepScopeTests : TemplateVisitorTestsBase +{ + [Fact] + public void ProcessTemplate_WhenManagementGroupAtTenant_ShouldReturnCompleteProperties() + { + var resources = ProcessTemplate(GetSourcePath("Bicep/ScopeTestCases/Tests.Bicep.1.json"), null, out _); + + Assert.NotNull(resources); + + var actual = resources.Where(r => r["name"].Value() == "mg-01").FirstOrDefault(); + Assert.Equal("Microsoft.Management/managementGroups", actual["type"].Value()); + Assert.Equal("/providers/Microsoft.Management/managementGroups/mg-01", actual["id"].Value()); + Assert.Equal("/", actual["scope"].Value()); + Assert.Equal("mg-01", actual["properties"]["displayName"].Value()); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", actual["properties"]["tenantId"].Value()); + + actual = resources.Where(r => r["name"].Value() == "mg-02").FirstOrDefault(); + Assert.Equal("Microsoft.Management/managementGroups", actual["type"].Value()); + Assert.Equal("/providers/Microsoft.Management/managementGroups/mg-02", actual["id"].Value()); + Assert.Equal("/", actual["scope"].Value()); + Assert.Equal("mg-02", actual["properties"]["displayName"].Value()); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", actual["properties"]["tenantId"].Value()); + Assert.Equal("/providers/Microsoft.Management/managementGroups/mg-01", actual["properties"]["details"]["parent"]["id"].Value()); + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.bicep b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.bicep new file mode 100644 index 0000000000..c1efa83b9e --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.bicep @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +targetScope = 'tenant' + +resource mg_2 'Microsoft.Management/managementGroups@2023-04-01' = { + name: 'mg-02' + properties: { + displayName: 'mg-02' + details: { + parent: mg_1 + } + } +} + +resource mg_1 'Microsoft.Management/managementGroups@2023-04-01' = { + name: 'mg-01' + properties: { + displayName: 'mg-01' + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.json b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.json new file mode 100644 index 0000000000..4d9f14e557 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Bicep/ScopeTestCases/Tests.Bicep.1.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.31.34.60546", + "templateHash": "7600444971325533016" + } + }, + "resources": [ + { + "type": "Microsoft.Management/managementGroups", + "apiVersion": "2023-04-01", + "name": "mg-02", + "properties": { + "displayName": "mg-02", + "details": { + "parent": "[reference(tenantResourceId('Microsoft.Management/managementGroups', 'mg-01'), '2023-04-01', 'full')]" + } + }, + "dependsOn": [ + "[tenantResourceId('Microsoft.Management/managementGroups', 'mg-01')]" + ] + }, + { + "type": "Microsoft.Management/managementGroups", + "apiVersion": "2023-04-01", + "name": "mg-01", + "properties": { + "displayName": "mg-01" + } + } + ] +} \ 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 5d9f485130..5305f35f83 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -314,6 +314,9 @@ PreserveNewest + + PreserveNewest + diff --git a/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs b/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs index 4fc40422fe..ca0fc66f50 100644 --- a/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/ResourceHelperTests.cs @@ -151,6 +151,7 @@ public void ResourceId(string resourceType, string resourceName, string scopeId, [InlineData("/subscriptions/ffffffff-ffff-ffff-ffff-ffffffffffff/resourceGroups/rg-5/providers/Microsoft.KeyVault/vaults/keyvault-1/secrets/secret-1", null, null, "ffffffff-ffff-ffff-ffff-ffffffffffff", "rg-5", new string[] { "Microsoft.KeyVault/vaults", "secrets" }, new string[] { "keyvault-1", "secret-1" })] [InlineData("Microsoft.Network/virtualNetworks/vnet-A", null, null, null, null, new string[] { "Microsoft.Network/virtualNetworks" }, new string[] { "vnet-A" })] [InlineData("Microsoft.Network/virtualNetworks/vnet-A/subnets/GatewaySubnet", null, null, null, null, new string[] { "Microsoft.Network/virtualNetworks", "subnets" }, new string[] { "vnet-A", "GatewaySubnet" })] + [InlineData("Microsoft.Management/managementGroups/mg-1", null, "mg-1", null, null, null, null)] public void ResourceIdComponents(string id, string? tenant, string? managementGroup, string? subscriptionId, string? resourceGroup, string[]? resourceType, string[]? resourceName) { Assert.True(ResourceHelper.ResourceIdComponents(id, out var actualTenant, out var actualManagementGroup, out var actualSubscriptionId, out var actualResourceGroup, out var actualResourceType, out var actualResourceName)); diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs index 21d96dc3eb..1e8d8ea303 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs @@ -5,7 +5,6 @@ using Newtonsoft.Json.Linq; using PSRule.Rules.Azure.Configuration; using PSRule.Rules.Azure.Data.Template; -using PSRule.Rules.Azure.Pipeline; using static PSRule.Rules.Azure.Data.Template.TemplateVisitor; namespace PSRule.Rules.Azure