From 905f8dedbec3f6c688d72c2a33350ad582bc7e41 Mon Sep 17 00:00:00 2001 From: Sebastian Claesson Date: Tue, 10 Nov 2026 16:47:26 +0100 Subject: [PATCH] fix: Add identity placeholders for existing principalId and clientId (#3972) When a template references an `existing` user-assigned identity that is not part of the deployment, `reference()` returns an unknown mock for its `principalId` and `clientId`. Placed inside an object or array literal, such as a module parameter, the unknown mock expands to `null`, so a nested `uniqueString()` or `guid()` over the value failed with "The argument 'additional parameters as needed' for 'UniqueString' is not a valid string", and the whole file was skipped. Add `principalId` and `clientId` of `Microsoft.ManagedIdentity/userAssignedIdentities` to the typed resource placeholder table added in #3908, so they resolve to a string. The placeholder is the same `ffffffff-ffff-ffff-ffff-ffffffffffff` GUID that is already used for identities deployed in the template. The pinned role assignment name in the symbolic name test changes because `guid(principalId)` now hashes the placeholder instead of an empty string. Fixes #3972 Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 2 + src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs | 6 + .../Arm/Expressions/FunctionTests.cs | 16 +++ .../BicepSymbolicNameTests.cs | 2 +- .../PSRule.Rules.Azure.Tests.csproj | 3 + .../TemplateVisitorTests.cs | 18 +++ .../Tests.Bicep.46.bicep | 24 ++++ .../Tests.Bicep.46.child.bicep | 13 ++ .../Tests.Bicep.46.grant.bicep | 15 +++ .../Tests.Bicep.46.json | 116 ++++++++++++++++++ 10 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.child.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.grant.bicep create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.json diff --git a/docs/changelog.md b/docs/changelog.md index 7a5c63810af..8c36ff44202 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -48,6 +48,8 @@ What's changed since pre-release v1.48.0-B0228: [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) - Fixed cross-scope `existing` resource reference via symbolic name resolving to a malformed mock, breaking `concat()`/`map()` during pre-flight expansion. [#3920](https://github.com/Azure/PSRule.Rules.Azure/issues/3920) + - Fixed `uniqueString()` and `guid()` failing on the `principalId` or `clientId` of an `existing` user-assigned identity during expansion. + [#3972](https://github.com/Azure/PSRule.Rules.Azure/issues/3972) - Engineering: - Bump YamlDotNet to 11.2.5. diff --git a/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs b/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs index 7a29e3cd206..eb76c7c35a4 100644 --- a/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs +++ b/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs @@ -15,11 +15,15 @@ internal sealed class Mock private const string RESOURCE_TYPE_VIRTUAL_NETWORK = "Microsoft.Network/virtualNetworks"; private const string RESOURCE_TYPE_SUBNET = "Microsoft.Network/virtualNetworks/subnets"; private const string RESOURCE_TYPE_IPAM_POOL = "Microsoft.Network/networkManagers/ipamPools"; + private const string RESOURCE_TYPE_USER_ASSIGNED_IDENTITY = "Microsoft.ManagedIdentity/userAssignedIdentities"; private const string PATH_VIRTUAL_NETWORK_ADDRESS_PREFIXES = "addressSpace.addressPrefixes"; private const string PATH_SUBNET_ADDRESS_PREFIX = "addressPrefix"; private const string PATH_SUBNET_ADDRESS_PREFIXES = "addressPrefixes"; + private const string PATH_PRINCIPAL_ID = "principalId"; + private const string PATH_CLIENT_ID = "clientId"; private const string PLACEHOLDER_VIRTUAL_NETWORK_CIDR = "192.0.2.0/24"; private const string PLACEHOLDER_SUBNET_CIDR = "192.0.2.0/28"; + private const string PLACEHOLDER_GUID = "ffffffff-ffff-ffff-ffff-ffffffffffff"; private static readonly MockPlaceholderDescriptor[] _ResourcePlaceholders = [ @@ -27,6 +31,8 @@ internal sealed class Mock new(RESOURCE_TYPE_SUBNET, PATH_SUBNET_ADDRESS_PREFIX, MockPlaceholderKind.String, [PLACEHOLDER_SUBNET_CIDR]), new(RESOURCE_TYPE_SUBNET, PATH_SUBNET_ADDRESS_PREFIXES, MockPlaceholderKind.Array, [PLACEHOLDER_SUBNET_CIDR]), new(RESOURCE_TYPE_IPAM_POOL, PATH_SUBNET_ADDRESS_PREFIXES, MockPlaceholderKind.Array, [PLACEHOLDER_VIRTUAL_NETWORK_CIDR]), + new(RESOURCE_TYPE_USER_ASSIGNED_IDENTITY, PATH_PRINCIPAL_ID, MockPlaceholderKind.String, [PLACEHOLDER_GUID]), + new(RESOURCE_TYPE_USER_ASSIGNED_IDENTITY, PATH_CLIENT_ID, MockPlaceholderKind.String, [PLACEHOLDER_GUID]), ]; private enum MockPlaceholderKind diff --git a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs index 7b8a5bb80ba..c9b627da022 100644 --- a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs @@ -1046,6 +1046,22 @@ public void ReferenceUsesConcreteNetworkAddressProperties() Assert.Equal("203.0.113.0/24", actual["addressSpace"]["addressPrefixes"][0].Value()); } + [Fact] + [Trait(TRAIT, TRAIT_RESOURCE)] + public void ReferenceWithIdentityPlaceholders() + { + var context = GetContext(); + var identityId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-001"; + + var identity = Functions.Reference(context, [identityId]) as Mock.MockObject; + Assert.NotNull(identity); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", identity["principalId"].Value()); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", identity["clientId"].Value()); + Assert.Equal(JTokenType.String, identity["principalId"].Type); + Assert.NotNull(Functions.UniqueString(context, ["vault", identity["principalId"]])); + Assert.NotNull(Functions.Guid(context, [identityId, identity["principalId"]])); + } + [Fact] [Trait(TRAIT, TRAIT_RESOURCE)] public void References() diff --git a/tests/PSRule.Rules.Azure.Tests/Bicep/SymbolicNameTestCases/BicepSymbolicNameTests.cs b/tests/PSRule.Rules.Azure.Tests/Bicep/SymbolicNameTestCases/BicepSymbolicNameTests.cs index 789b5e06d87..ae888c4ba59 100644 --- a/tests/PSRule.Rules.Azure.Tests/Bicep/SymbolicNameTestCases/BicepSymbolicNameTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/Bicep/SymbolicNameTestCases/BicepSymbolicNameTests.cs @@ -49,7 +49,7 @@ public void ProcessTemplate_WhenConditionalExistingReference_IgnoresExpand() actual = resources[2]; Assert.Equal("Microsoft.Authorization/roleAssignments", actual["type"].Value()); - Assert.Equal("02041802-66a9-0a85-7330-8186e16422c7", actual["name"].Value()); + Assert.Equal("2f5ec487-2ecb-1424-fd53-71891b2f5eac", actual["name"].Value()); } /// 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 66a4057764d..83e0b552096 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -326,6 +326,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs index 26d2b940cd7..ee3cc8df820 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs @@ -1444,4 +1444,22 @@ public void ProcessTemplate_WhenExistingResourceReferencedByResourceId_ShouldRet Assert.True(templateContext.RootDeployment.TryOutput("combinedAddressPrefixes", out JObject combinedAddressPrefixesOutput)); Assert.Equal(JTokenType.Array, combinedAddressPrefixesOutput["value"].Type); } + + /// + /// Test case for https://github.com/Azure/PSRule.Rules.Azure/issues/3972 + /// + [Fact] + public void ProcessTemplate_WhenExistingResourcePropertyPassedToModuleAndHashed_ShouldExpand() + { + var resources = ProcessTemplate(GetSourcePath("Tests.Bicep.46.json"), null, out _); + + var deployment = resources.FirstOrDefault(r => r["type"].Value() == "Microsoft.Resources/deployments" && r["name"].Value().StartsWith("ra-", System.StringComparison.Ordinal)); + Assert.NotNull(deployment); + Assert.Matches("^ra-[a-z0-9]{13}$", deployment["name"].Value()); + + var assignment = resources.FirstOrDefault(r => r["type"].Value() == "Microsoft.Authorization/roleAssignments"); + Assert.NotNull(assignment); + Assert.True(System.Guid.TryParse(assignment["name"].Value(), out _)); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", assignment["properties"]["principalId"].Value()); + } } diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.bicep b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.bicep new file mode 100644 index 00000000000..f55ed3c54e5 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.bicep @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Test case for https://github.com/Azure/PSRule.Rules.Azure/issues/3972 + +param identityResourceGroup string = 'rg-identity' +param identityName string = 'id-issue-3972' + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: identityName + scope: resourceGroup(identityResourceGroup) +} + +module child './Tests.Bicep.46.child.bicep' = { + name: 'child' + params: { + grants: [ + { + name: 'vault' + principalId: identity.properties.principalId + } + ] + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.child.bicep b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.child.bicep new file mode 100644 index 00000000000..8a3e75b4145 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.child.bicep @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param grants array + +module assignment './Tests.Bicep.46.grant.bicep' = [ + for grant in grants: { + name: 'ra-${uniqueString(grant.name, grant.principalId)}' + params: { + principalId: grant.principalId + } + } +] diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.grant.bicep b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.grant.bicep new file mode 100644 index 00000000000..10ac51fb8e0 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.grant.bicep @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param principalId string + +resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, principalId, '4633458b-17de-408a-b874-0445c86b69e6') + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') + } +} + +output principalId string = principalId diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.json b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.json new file mode 100644 index 00000000000..75bb5d12392 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.46.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "6919797167586006276" + } + }, + "parameters": { + "identityResourceGroup": { + "type": "string", + "defaultValue": "rg-identity" + }, + "identityName": { + "type": "string", + "defaultValue": "id-issue-3972" + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "child", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "grants": { + "value": [ + { + "name": "vault", + "principalId": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('identityResourceGroup')), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName')), '2023-01-31').principalId]" + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "5082234139662032612" + } + }, + "parameters": { + "grants": { + "type": "array" + } + }, + "resources": [ + { + "copy": { + "name": "assignment", + "count": "[length(parameters('grants'))]" + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('ra-{0}', uniqueString(parameters('grants')[copyIndex()].name, parameters('grants')[copyIndex()].principalId))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[parameters('grants')[copyIndex()].principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.48.1.52935", + "templateHash": "3551328931435798587" + } + }, + "parameters": { + "principalId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(resourceGroup().id, parameters('principalId'), '4633458b-17de-408a-b874-0445c86b69e6')]", + "properties": { + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]" + } + } + ], + "outputs": { + "principalId": { + "type": "string", + "value": "[parameters('principalId')]" + } + } + } + } + } + ] + } + } + } + ] +} \ No newline at end of file