From b262d580e8d04466cb30d588287bc163258e9d66 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Mar 2026 02:01:19 -0700 Subject: [PATCH 1/3] Pass parent AKV options to child config deserialization When loading child data-source-files, the parent's AzureKeyVault options were not passed to the DeserializationVariableReplacementSettings, causing @akv() references in child configs to go unresolved. Fix: pass this.AzureKeyVault instead of null so child configs inherit the parent's Key Vault configuration for secret resolution. Regression test uses a local .akv file to verify end-to-end resolution. Fixes part of #3271 --- src/Config/ObjectModel/RuntimeConfig.cs | 5 +- .../Configuration/RuntimeConfigLoaderTests.cs | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 659914a7ac..e81c8de253 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -355,8 +355,9 @@ public RuntimeConfig( foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { - // Use default replacement settings for environment variable replacement - DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); + // Pass the parent's AKV options so @akv() references in child configs can + // be resolved using the parent's Key Vault configuration. + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true); if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index d6f19ec65f..0598bdf001 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json.Linq; @@ -131,4 +132,108 @@ public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPat Assert.IsTrue(runtimeConfig.SqlDataSourceUsed, "Should have Sql data source"); Assert.AreEqual(expectedEntities, runtimeConfig.Entities.Entities.Count, "Number of entities is not what is expected."); } + + /// + /// Validates that when a parent config has azure-key-vault options configured, + /// child configs can resolve @akv('...') references using the parent's AKV configuration. + /// Uses a local .akv file to simulate Azure Key Vault without requiring a real vault. + /// Regression test for https://github.com/Azure/data-api-builder/issues/3271 + /// + [TestMethod] + public async Task ChildConfigResolvesAkvReferencesFromParentAkvOptions() + { + string akvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + + try + { + // Create a local .akv secrets file with test secrets. + await File.WriteAllTextAsync(akvFilePath, "my-connection-secret=Server=tcp:127.0.0.1,1433;Trusted_Connection=True;\n"); + + // Parent config with azure-key-vault pointing to the local .akv file. + string parentConfig = $@"{{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{akvFilePath.Replace("\\", "\\\\")}"" + }}, + ""data-source-files"": [""{childFilePath.Replace("\\", "\\\\")}""], + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{}} + }}"; + + // Child config with @akv('...') reference in its connection string. + string childConfig = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""@akv('my-connection-secret')"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true }, + ""graphql"": { ""enabled"": true }, + ""host"": { + ""cors"": { ""origins"": [] }, + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": { + ""AkvChildEntity"": { + ""source"": ""dbo.AkvTable"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""read""] }] + } + } + }"; + + await File.WriteAllTextAsync(childFilePath, childConfig); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentConfig) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: new AzureKeyVaultOptions() { Endpoint = akvFilePath, UserProvidedEndpoint = true }, + doReplaceEnvVar: true, + doReplaceAkvVar: true, + envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); + + Assert.IsTrue( + loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings), + "Config should load successfully when child config has @akv() references resolvable via parent AKV options."); + + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("AkvChildEntity"), "Child config entity should be merged into the parent config."); + + // Verify the child's connection string was resolved from the .akv file. + string childDataSourceName = runtimeConfig.GetDataSourceNameFromEntityName("AkvChildEntity"); + DataSource childDataSource = runtimeConfig.GetDataSourceFromDataSourceName(childDataSourceName); + Assert.IsTrue( + childDataSource.ConnectionString.Contains("127.0.0.1"), + "Child config connection string should have the AKV secret resolved."); + } + finally + { + if (File.Exists(akvFilePath)) + { + File.Delete(akvFilePath); + } + + if (File.Exists(childFilePath)) + { + File.Delete(childFilePath); + } + } + } } From 28f3245b141c238b06a7d82c6e4987ece0746cb5 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Mar 2026 02:50:04 -0700 Subject: [PATCH 2/3] fix comment, improve performance --- src/Config/ObjectModel/RuntimeConfig.cs | 7 ++++--- .../Configuration/RuntimeConfigLoaderTests.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index e81c8de253..19598d16b4 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -353,11 +353,12 @@ public RuntimeConfig( // This loader is not used as a part of hot reload and therefore does not need a handler. FileSystemRuntimeConfigLoader loader = new(fileSystem, handler: null); + // Pass the parent's AKV options so @akv() references in child configs can + // be resolved using the parent's Key Vault configuration. + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true); + foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { - // Pass the parent's AKV options so @akv() references in child configs can - // be resolved using the parent's Key Vault configuration. - DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true); if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index 0598bdf001..d7b67eb23d 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -137,7 +137,7 @@ public async Task CanLoadValidMultiSourceConfigWithAutoentities(string configPat /// Validates that when a parent config has azure-key-vault options configured, /// child configs can resolve @akv('...') references using the parent's AKV configuration. /// Uses a local .akv file to simulate Azure Key Vault without requiring a real vault. - /// Regression test for https://github.com/Azure/data-api-builder/issues/3271 + /// Regression test for https://github.com/Azure/data-api-builder/issues/3322 /// [TestMethod] public async Task ChildConfigResolvesAkvReferencesFromParentAkvOptions() From 4b2e598a91b1e2f56cca05969ac0ebc43ec3abf2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Mon, 13 Apr 2026 11:37:46 -0700 Subject: [PATCH 3/3] add test --- src/Config/ObjectModel/RuntimeConfig.cs | 5 +- .../Configuration/RuntimeConfigLoaderTests.cs | 117 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 19598d16b4..c41c9d1126 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -6,6 +6,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Extensions.Logging; @@ -355,7 +356,9 @@ public RuntimeConfig( // Pass the parent's AKV options so @akv() references in child configs can // be resolved using the parent's Key Vault configuration. - DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true); + // If a child config defines its own azure-key-vault section, TryParseConfig's + // ExtractAzureKeyVaultOptions will detect it and override these parent options. + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: this.AzureKeyVault, doReplaceEnvVar: true, doReplaceAkvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index d7b67eb23d..31561f40f9 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -236,4 +236,121 @@ public async Task ChildConfigResolvesAkvReferencesFromParentAkvOptions() } } } + + /// + /// Validates that when both the parent and child configs define azure-key-vault options, + /// the child's AKV settings take precedence over the parent's. + /// The child config references a secret that only exists in the child's .akv file, + /// proving the child's AKV endpoint was used instead of the parent's. + /// + [TestMethod] + public async Task ChildAkvOptionsOverrideParentAkvOptions() + { + string parentAkvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childAkvFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".akv"); + string childFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json"); + + try + { + // Parent's .akv file does NOT contain the secret the child references. + await File.WriteAllTextAsync(parentAkvFilePath, "parent-only-secret=ParentValue\n"); + + // Child's .akv file contains the secret the child references. + await File.WriteAllTextAsync(childAkvFilePath, "child-connection-secret=Server=tcp:10.0.0.1,1433;Trusted_Connection=True;\n"); + + // Parent config with azure-key-vault pointing to the parent's .akv file. + string parentConfig = $@"{{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{parentAkvFilePath.Replace("\\", "\\\\")}"" + }}, + ""data-source-files"": [""{childFilePath.Replace("\\", "\\\\")}""], + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{}} + }}"; + + // Child config with its own azure-key-vault pointing to the child's .akv file, + // and a connection string referencing a secret only in the child's vault. + string childConfig = $@"{{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""@akv('child-connection-secret')"" + }}, + ""azure-key-vault"": {{ + ""endpoint"": ""{childAkvFilePath.Replace("\\", "\\\\")}"" + }}, + ""runtime"": {{ + ""rest"": {{ ""enabled"": true }}, + ""graphql"": {{ ""enabled"": true }}, + ""host"": {{ + ""cors"": {{ ""origins"": [] }}, + ""authentication"": {{ ""provider"": ""StaticWebApps"" }} + }} + }}, + ""entities"": {{ + ""ChildOverrideEntity"": {{ + ""source"": ""dbo.ChildTable"", + ""permissions"": [{{ ""role"": ""anonymous"", ""actions"": [""read""] }}] + }} + }} + }}"; + + await File.WriteAllTextAsync(childFilePath, childConfig); + + MockFileSystem fs = new(new Dictionary() + { + { "dab-config.json", new MockFileData(parentConfig) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: new AzureKeyVaultOptions() { Endpoint = parentAkvFilePath, UserProvidedEndpoint = true }, + doReplaceEnvVar: true, + doReplaceAkvVar: true, + envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore); + + Assert.IsTrue( + loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig, replacementSettings: replacementSettings), + "Config should load successfully when child config has its own AKV options."); + + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("ChildOverrideEntity"), "Child config entity should be merged into the parent config."); + + // Verify the child's connection string was resolved using the child's AKV file, not the parent's. + string childDataSourceName = runtimeConfig.GetDataSourceNameFromEntityName("ChildOverrideEntity"); + DataSource childDataSource = runtimeConfig.GetDataSourceFromDataSourceName(childDataSourceName); + Assert.IsTrue( + childDataSource.ConnectionString.Contains("10.0.0.1"), + "Child config connection string should be resolved from the child's own AKV file, not the parent's."); + } + finally + { + if (File.Exists(parentAkvFilePath)) + { + File.Delete(parentAkvFilePath); + } + + if (File.Exists(childAkvFilePath)) + { + File.Delete(childAkvFilePath); + } + + if (File.Exists(childFilePath)) + { + File.Delete(childFilePath); + } + } + } }