diff --git a/bicepconfig.json b/bicepconfig.json index 072e1e72649..19826bd0397 100644 --- a/bicepconfig.json +++ b/bicepconfig.json @@ -1,5 +1,6 @@ { "experimentalFeaturesEnabled": { - "userDefinedTypes": true + "userDefinedTypes": true, + "optionalModuleNames": true } } diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index e571edbc3a2..e599818867c 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -39,6 +39,8 @@ What's changed since v1.32.0: [#2593](https://github.com/Azure/PSRule.Rules.Azure/issues/2593) - Fixed failure to expand copy loop in a Azure Policy deployment by @BernieWhite. [#2605](https://github.com/Azure/PSRule.Rules.Azure/issues/2605) + - Fixed cast exception when expanding the union of an array and mock by @BernieWhite. + [#2614](https://github.com/Azure/PSRule.Rules.Azure/issues/2614) ## v1.32.0 diff --git a/src/PSRule.Rules.Azure/Data/Template/ExpressionHelpers.cs b/src/PSRule.Rules.Azure/Data/Template/ExpressionHelpers.cs index 04b001d2605..ff5a7f988c4 100644 --- a/src/PSRule.Rules.Azure/Data/Template/ExpressionHelpers.cs +++ b/src/PSRule.Rules.Azure/Data/Template/ExpressionHelpers.cs @@ -585,8 +585,8 @@ internal static object UnionArray(object[] o) { for (var j = 0; j < jArray.Count; j++) { - var element = jArray[j]; - if (!result.Contains(element)) + var element = GetBaseObject(jArray[j]); + if (element != null && !result.Contains(element)) result.Add(element); } } @@ -612,6 +612,28 @@ internal static object UnionArray(object[] o) return result.ToArray(); } + private static object GetBaseObject(JToken token) + { + object result = token; + if (token.Type == JTokenType.String) + { + result = token.Value(); + } + else if (token.Type == JTokenType.Integer) + { + result = token.Value(); + } + else if (token.Type == JTokenType.Boolean) + { + result = token.Value(); + } + else if (token.Type == JTokenType.Null) + { + result = null; + } + return result; + } + internal static bool IsObject(object o) { return o is JObject or diff --git a/src/PSRule.Rules.Azure/Data/Template/Functions.cs b/src/PSRule.Rules.Azure/Data/Template/Functions.cs index adbe69ab041..26402ae72a1 100644 --- a/src/PSRule.Rules.Azure/Data/Template/Functions.cs +++ b/src/PSRule.Rules.Azure/Data/Template/Functions.cs @@ -717,14 +717,30 @@ internal static object TryGet(ITemplateContext context, object[] args) return o; } + /// + /// union(arg1, arg2, arg3, ...) + /// + /// + /// Returns a single array or object with all elements from the parameters. For arrays, duplicate values are included once. + /// For objects, duplicate property names are only included once. + /// See . + /// internal static object Union(ITemplateContext context, object[] args) { if (CountArgs(args) < 2) throw ArgumentsOutOfRange(nameof(Union), args); - // Find first non-null case + var hasMocks = false; + + // Find first non-null case. for (var i = 0; i < args.Length; i++) { + if (args[i] is IMock) + { + hasMocks = true; + continue; + } + // Array if (ExpressionHelpers.IsArray(args[i])) return ExpressionHelpers.UnionArray(args); @@ -733,7 +749,9 @@ internal static object Union(ITemplateContext context, object[] args) if (ExpressionHelpers.IsObject(args[i])) return ExpressionHelpers.UnionObject(args); } - return null; + + // Handle mocks as objects if no other object or array is found. + return hasMocks ? ExpressionHelpers.UnionObject(args) : null; } #endregion Array and object diff --git a/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs b/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs index 81ef02c5f2b..0869d71eab2 100644 --- a/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs +++ b/src/PSRule.Rules.Azure/Data/Template/RuleDataExportVisitor.cs @@ -34,6 +34,7 @@ internal sealed class RuleDataExportVisitor : TemplateVisitor private const string PROPERTY_LOGINSERVER = "loginServer"; private const string PROPERTY_RULES = "rules"; private const string PROPERTY_RULEID = "ruleId"; + private const string PROPERTY_ACCESSPOLICIES = "accessPolicies"; private const string PLACEHOLDER_GUID = "ffffffff-ffff-ffff-ffff-ffffffffffff"; private const string IDENTITY_SYSTEMASSIGNED = "SystemAssigned"; @@ -50,6 +51,7 @@ internal sealed class RuleDataExportVisitor : TemplateVisitor private const string TYPE_NETWORKINTERFACE = "Microsoft.Network/networkInterfaces"; private const string TYPE_SUBSCRIPTIONALIAS = "Microsoft.Subscription/aliases"; private const string TYPE_CONTAINERREGISTRY = "Microsoft.ContainerRegistry/registries"; + private const string TYPE_KEYVAULT = "Microsoft.KeyVault/vaults"; private const string TYPE_STORAGE_OBJECTREPLICATIONPOLICIES = "Microsoft.Storage/storageAccounts/objectReplicationPolicies"; private static readonly JsonMergeSettings _MergeSettings = new() @@ -128,7 +130,8 @@ private static void ProjectRuntimeProperties(TemplateContext context, IResourceV ProjectContainerRegistry(context, resource) || ProjectPrivateEndpoints(context, resource) || ProjectSubscriptionAlias(context, resource) || - StorageObjectReplicationPolicies(context, resource) || + ProjectStorageObjectReplicationPolicies(context, resource) || + ProjectKeyVault(context, resource) || ProjectResource(context, resource); } @@ -246,10 +249,31 @@ private static bool ProjectContainerRegistry(TemplateContext context, IResourceV { properties[PROPERTY_LOGINSERVER] = $"{resource.Name}.azurecr.io"; } + return ProjectResource(context, resource); + } + + private static bool ProjectKeyVault(TemplateContext context, IResourceValue resource) + { + if (!resource.IsType(TYPE_KEYVAULT)) + return false; + + resource.Value.UseProperty(PROPERTY_PROPERTIES, out JObject properties); + + // Add properties.accessPolicies + if (!properties.ContainsKeyInsensitive(PROPERTY_ACCESSPOLICIES)) + { + properties[PROPERTY_ACCESSPOLICIES] = new JArray(); + } + + // Add properties.tenantId + if (!properties.ContainsKeyInsensitive(PROPERTY_TENANTID)) + { + properties[PROPERTY_TENANTID] = context.Tenant.TenantId; + } return true; } - private static bool StorageObjectReplicationPolicies(TemplateContext context, IResourceValue resource) + private static bool ProjectStorageObjectReplicationPolicies(TemplateContext context, IResourceValue resource) { if (!resource.IsType(TYPE_STORAGE_OBJECTREPLICATIONPOLICIES)) return false; diff --git a/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs b/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs index 87d59b6e3ce..91ebfcf7b29 100644 --- a/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/FunctionTests.cs @@ -575,10 +575,14 @@ public void Union() Assert.Equal(2, actual2.Length); actual2 = Functions.Union(context, new object[] { new string[] { "one", "two" }, null, new string[] { "one", "three" } }) as object[]; Assert.Equal(3, actual2.Length); + actual2 = Functions.Union(context, new object[] { new string[] { "one", "two" }, null, new JArray { "one", "three" } }) as object[]; + Assert.Equal(3, actual2.Length); actual2 = Functions.Union(context, new object[] { new string[] { "one", "two" }, new Mock.MockArray() }) as object[]; Assert.Equal(2, actual2.Length); actual2 = Functions.Union(context, new object[] { null, new string[] { "three", "four" } }) as object[]; Assert.Equal(2, actual2.Length); + actual2 = Functions.Union(context, new object[] { new Mock.MockUnknownObject(), new JArray { "one", "two" } }) as object[]; + Assert.Equal(2, actual2.Length); } [Fact] 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 e0848d79e3e..654f9437074 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -218,6 +218,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs index 34ff95d32b0..cbd44cdf86c 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs @@ -1060,10 +1060,25 @@ public void Quoting() [Fact] public void PolicyCopyLoop() { - var resources = ProcessTemplate(GetSourcePath("Template.Policy.WithDeployment.json"), null, out var templateContext); + var resources = ProcessTemplate(GetSourcePath("Template.Policy.WithDeployment.json"), null, out _); Assert.Equal(2, resources.Length); } + [Fact] + public void UnionMockWithArray() + { + var resources = ProcessTemplate(GetSourcePath("Tests.Bicep.34.json"), null, out _); + Assert.Equal(4, resources.Length); + + var actual = resources[1]; + Assert.Equal("Microsoft.KeyVault/vaults", actual["type"].Value()); + Assert.Equal("ffffffff-ffff-ffff-ffff-ffffffffffff", actual["properties"]["tenantId"].Value()); + Assert.Empty(actual["properties"]["accessPolicies"].Value()); + + actual = resources[3]; + Assert.Equal("Microsoft.KeyVault/vaults/accessPolicies", actual["type"].Value()); + } + #region Helper methods private static string GetSourcePath(string fileName) diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.bicep b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.bicep new file mode 100644 index 00000000000..403c4ef1b28 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.bicep @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param keyVaultName string = 'vault1' +param objectId string = newGuid() + +var newAccessPolicies = [ + { + tenantId: tenant().tenantId + objectId: objectId + permissions: { + keys: [ + 'Get' + 'List' + ] + secrets: [ + 'Get' + 'List' + ] + certificates: [] + } + } +] + +resource keyvault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { + name: keyVaultName +} + +#disable-next-line BCP035 +resource keyvault2 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: '${keyVaultName}-2' + + #disable-next-line BCP035 + properties: {} +} + +var existingAccessPolicies = keyvault.properties.accessPolicies +var allPolicies = union(existingAccessPolicies, newAccessPolicies) + +module addPolicies './Tests.Bicep.34.child.bicep' = { + params: { + accessPolicies: allPolicies + keyVaultName: keyVaultName + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.child.bicep b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.child.bicep new file mode 100644 index 00000000000..661f014b54b --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.child.bicep @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +param keyVaultName string +param accessPolicies array + +// Policies to add. +resource additionalPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = { + name: '${keyVaultName}/add' + properties: { + accessPolicies: accessPolicies + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.json b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.json new file mode 100644 index 00000000000..4db9a7f681a --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.34.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.24.24.22086", + "templateHash": "13351406772210248604" + } + }, + "parameters": { + "keyVaultName": { + "type": "string", + "defaultValue": "vault1" + }, + "objectId": { + "type": "string", + "defaultValue": "[newGuid()]" + } + }, + "variables": { + "newAccessPolicies": [ + { + "tenantId": "[tenant().tenantId]", + "objectId": "[parameters('objectId')]", + "permissions": { + "keys": [ + "Get", + "List" + ], + "secrets": [ + "Get", + "List" + ], + "certificates": [] + } + } + ] + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[format('{0}-2', parameters('keyVaultName'))]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('addPolicies-{0}', uniqueString('addPolicies', deployment().name))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "accessPolicies": { + "value": "[union(reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2021-11-01-preview').accessPolicies, variables('newAccessPolicies'))]" + }, + "keyVaultName": { + "value": "[parameters('keyVaultName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.24.24.22086", + "templateHash": "11282630508739439881" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "accessPolicies": { + "type": "array" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/accessPolicies", + "apiVersion": "2023-07-01", + "name": "[format('{0}/add', parameters('keyVaultName'))]", + "properties": { + "accessPolicies": "[parameters('accessPolicies')]" + } + } + ] + } + } + } + ] +} \ No newline at end of file