Skip to content

Commit 499cd24

Browse files
Support for .akv files to AKV variable replacement. (#2957)
## Why make this change? Closes #2748 ## What is this change? Adds the option to use a local .akv file instead of Azure Key Vault for @akv('') replacement in the config file during deserialization. Similar to how we handle .env files. ## How was this tested? A new test was added that verifies we are able to do the replacement and get the correct resultant configuration. --------- Co-authored-by: Aniruddh Munde <[email protected]>
1 parent dd72498 commit 499cd24

File tree

2 files changed

+254
-3
lines changed

2 files changed

+254
-3
lines changed

src/Config/DeserializationVariableReplacementSettings.cs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Azure.DataApiBuilder.Service.Exceptions;
99
using Azure.Identity;
1010
using Azure.Security.KeyVault.Secrets;
11+
using Microsoft.Extensions.Logging;
1112

1213
namespace Azure.DataApiBuilder.Config
1314
{
@@ -43,19 +44,23 @@ public class DeserializationVariableReplacementSettings
4344

4445
private readonly AzureKeyVaultOptions? _azureKeyVaultOptions;
4546
private readonly SecretClient? _akvClient;
47+
private readonly Dictionary<string, string>? _akvFileSecrets;
48+
private readonly ILogger? _logger;
4649

4750
public Dictionary<Regex, Func<Match, string>> ReplacementStrategies { get; private set; } = new();
4851

4952
public DeserializationVariableReplacementSettings(
5053
AzureKeyVaultOptions? azureKeyVaultOptions = null,
5154
bool doReplaceEnvVar = false,
5255
bool doReplaceAkvVar = false,
53-
EnvironmentVariableReplacementFailureMode envFailureMode = EnvironmentVariableReplacementFailureMode.Throw)
56+
EnvironmentVariableReplacementFailureMode envFailureMode = EnvironmentVariableReplacementFailureMode.Throw,
57+
ILogger? logger = null)
5458
{
5559
_azureKeyVaultOptions = azureKeyVaultOptions;
5660
DoReplaceEnvVar = doReplaceEnvVar;
5761
DoReplaceAkvVar = doReplaceAkvVar;
5862
EnvFailureMode = envFailureMode;
63+
_logger = logger;
5964

6065
if (DoReplaceEnvVar)
6166
{
@@ -66,13 +71,68 @@ public DeserializationVariableReplacementSettings(
6671

6772
if (DoReplaceAkvVar && _azureKeyVaultOptions is not null)
6873
{
69-
_akvClient = CreateSecretClient(_azureKeyVaultOptions);
74+
// Determine if endpoint points to a local .akv file. If so, load secrets from file; otherwise, use remote AKV.
75+
if (IsLocalAkvFileEndpoint(_azureKeyVaultOptions.Endpoint))
76+
{
77+
_akvFileSecrets = LoadAkvFileSecrets(_azureKeyVaultOptions.Endpoint!, _logger);
78+
}
79+
else
80+
{
81+
_akvClient = CreateSecretClient(_azureKeyVaultOptions);
82+
}
83+
7084
ReplacementStrategies.Add(
7185
new Regex(OUTER_AKV_PATTERN, RegexOptions.Compiled),
7286
ReplaceAkvVariable);
7387
}
7488
}
7589

90+
// Checks if the endpoint is a path to a local .akv file.
91+
private static bool IsLocalAkvFileEndpoint(string? endpoint)
92+
=> !string.IsNullOrWhiteSpace(endpoint)
93+
&& endpoint.EndsWith(".akv", StringComparison.OrdinalIgnoreCase)
94+
&& File.Exists(endpoint);
95+
96+
// Loads key=value pairs from a .akv file, similar to .env style. Lines starting with '#' are comments.
97+
private static Dictionary<string, string> LoadAkvFileSecrets(string filePath, ILogger? logger = null)
98+
{
99+
Dictionary<string, string> secrets = new(StringComparer.OrdinalIgnoreCase);
100+
foreach (string rawLine in File.ReadAllLines(filePath))
101+
{
102+
string line = rawLine.Trim();
103+
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
104+
{
105+
continue;
106+
}
107+
108+
int eqIndex = line.IndexOf('=');
109+
if (eqIndex <= 0)
110+
{
111+
logger?.LogDebug("Ignoring malformed line in AKV secrets file {FilePath}: {Line}", filePath, rawLine);
112+
continue;
113+
}
114+
115+
string key = line.Substring(0, eqIndex).Trim();
116+
string value = line[(eqIndex + 1)..].Trim();
117+
118+
// Remove optional surrounding quotes
119+
if (value.Length >= 2 && ((value.StartsWith('"') && value.EndsWith('"')) || (value.StartsWith('\'') && value.EndsWith('\''))))
120+
{
121+
value = value[1..^1];
122+
}
123+
124+
if (!string.IsNullOrEmpty(key))
125+
{
126+
if (!secrets.TryAdd(key, value))
127+
{
128+
logger?.LogDebug("Duplicate key '{Key}' encountered in AKV secrets file {FilePath}. Skipping later value.", key, filePath);
129+
}
130+
}
131+
}
132+
133+
return secrets;
134+
}
135+
76136
private string ReplaceEnvVariable(Match match)
77137
{
78138
// strips first and last characters, ie: '''hello'' --> ''hello'
@@ -170,6 +230,15 @@ private static SecretClient CreateSecretClient(AzureKeyVaultOptions options)
170230
DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
171231
}
172232

233+
// If endpoint is a local .akv file, we should not create a SecretClient.
234+
if (IsLocalAkvFileEndpoint(options.Endpoint))
235+
{
236+
throw new DataApiBuilderException(
237+
"Attempted to create Azure Key Vault client for local .akv file endpoint.",
238+
System.Net.HttpStatusCode.InternalServerError,
239+
DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
240+
}
241+
173242
SecretClientOptions clientOptions = new();
174243

175244
if (options.RetryPolicy is not null)
@@ -195,6 +264,12 @@ private static SecretClient CreateSecretClient(AzureKeyVaultOptions options)
195264

196265
private string? GetAkvVariable(string name)
197266
{
267+
// If using local .akv file secrets, return from dictionary.
268+
if (_akvFileSecrets is not null)
269+
{
270+
return _akvFileSecrets.TryGetValue(name, out string? value) ? value : null;
271+
}
272+
198273
if (_akvClient is null)
199274
{
200275
throw new InvalidOperationException("Azure Key Vault client is not initialized.");

src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Azure.DataApiBuilder.Config.Converters;
1414
using Azure.DataApiBuilder.Config.ObjectModel;
1515
using Azure.DataApiBuilder.Service.Exceptions;
16+
using Microsoft.Data.SqlClient;
1617
using Microsoft.VisualStudio.TestTools.UnitTesting;
1718

1819
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
@@ -240,6 +241,7 @@ public void CheckCommentParsingInConfigFile()
240241
/// but have the effect of default values when deserialized.
241242
/// It starts with a minimal config and incrementally
242243
/// adds the optional subproperties. At each step, tests for valid deserialization.
244+
/// </summary>
243245
[TestMethod]
244246
public void TestNullableOptionalProps()
245247
{
@@ -431,7 +433,7 @@ public static string GetModifiedJsonString(string[] reps, string enumString)
431433
""host"": {
432434
""mode"": ""development"",
433435
""cors"": {
434-
""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @""" ],
436+
""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @"""],
435437
""allow-credentials"": true
436438
},
437439
""authentication"": {
@@ -671,5 +673,179 @@ private static bool TryParseAndAssertOnDefaults(string json, out RuntimeConfig p
671673
#endregion Helper Functions
672674

673675
record StubJsonType(string Foo);
676+
677+
/// <summary>
678+
/// Test to verify Azure Key Vault variable replacement from local .akv file.
679+
/// </summary>
680+
[TestMethod]
681+
public void TestAkvVariableReplacementFromLocalFile()
682+
{
683+
// Arrange: create a temporary .akv secrets file
684+
string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv");
685+
string secretConnectionString = "Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;";
686+
File.WriteAllText(akvFilePath, $"DBCONN={secretConnectionString}\nAPI_KEY=abcd\n# Comment line should be ignored\n MALFORMEDLINE \n");
687+
688+
// Escape backslashes for JSON
689+
string escapedPath = akvFilePath.Replace("\\", "\\\\");
690+
691+
string jsonConfig = $$"""
692+
{
693+
"$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json",
694+
"data-source": {
695+
"database-type": "mssql",
696+
"connection-string": "@akv('DBCONN')"
697+
},
698+
"azure-key-vault": {
699+
"endpoint": "{{escapedPath}}"
700+
},
701+
"entities": { }
702+
}
703+
""";
704+
705+
try
706+
{
707+
// Act
708+
DeserializationVariableReplacementSettings replacementSettings = new(
709+
azureKeyVaultOptions: null,
710+
doReplaceEnvVar: false,
711+
doReplaceAkvVar: true);
712+
bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings);
713+
714+
// Assert
715+
Assert.IsTrue(parsed, "Config should parse successfully with local AKV file replacement.");
716+
Assert.IsNotNull(config, "Config should not be null.");
717+
Assert.AreEqual(secretConnectionString, config.DataSource.ConnectionString, "Connection string should be replaced from AKV local file secret.");
718+
}
719+
finally
720+
{
721+
// Cleanup
722+
if (File.Exists(akvFilePath))
723+
{
724+
File.Delete(akvFilePath);
725+
}
726+
}
727+
}
728+
729+
/// <summary>
730+
/// Validates that when an AKV secret's value itself contains an @env('...') pattern, it is NOT further resolved
731+
/// because replacement only runs once per original JSON token. Demonstrates that nested env patterns inside
732+
/// AKV secret values are left intact.
733+
/// </summary>
734+
[TestMethod]
735+
public void TestAkvSecretValueContainingEnvPatternIsNotEnvExpanded()
736+
{
737+
string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv");
738+
// Valid MSSQL connection string which embeds an @env('env') pattern in the Database value.
739+
// This pattern should NOT be expanded because replacement only runs once on the original JSON token (@akv('DBCONN')).
740+
string secretValueWithEnvPattern = "Server=localhost;Database=@env('env');User Id=sa;Password=XXXX;";
741+
File.WriteAllText(akvFilePath, $"DBCONN={secretValueWithEnvPattern}\n");
742+
string escapedPath = akvFilePath.Replace("\\", "\\\\");
743+
744+
// Set env variable to prove it would be different if expansion occurred.
745+
Environment.SetEnvironmentVariable("env", "SHOULD_NOT_APPEAR");
746+
747+
string jsonConfig = $$"""
748+
{
749+
"$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json",
750+
"data-source": {
751+
"database-type": "mssql",
752+
"connection-string": "@akv('DBCONN')"
753+
},
754+
"azure-key-vault": {
755+
"endpoint": "{{escapedPath}}"
756+
},
757+
"entities": { }
758+
}
759+
""";
760+
761+
try
762+
{
763+
DeserializationVariableReplacementSettings replacementSettings = new(
764+
azureKeyVaultOptions: null,
765+
doReplaceEnvVar: true,
766+
doReplaceAkvVar: true);
767+
bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings);
768+
Assert.IsTrue(parsed, "Config should parse successfully.");
769+
Assert.IsNotNull(config);
770+
771+
string actual = config.DataSource.ConnectionString;
772+
Assert.IsTrue(actual.Contains("@env('env')"), "Nested @env pattern inside AKV secret should remain unexpanded.");
773+
Assert.IsFalse(actual.Contains("SHOULD_NOT_APPEAR"), "Env var value should not be expanded inside AKV secret.");
774+
Assert.IsTrue(actual.Contains("Application Name="), "Application Name should be appended for MSSQL when env replacement is enabled.");
775+
776+
var builderOriginal = new SqlConnectionStringBuilder(secretValueWithEnvPattern.Replace("Server=", "Data Source=").Replace("Database=", "Initial Catalog="));
777+
var builderActual = new SqlConnectionStringBuilder(actual);
778+
Assert.AreEqual(builderOriginal["Data Source"], builderActual["Data Source"], "Server/Data Source should match.");
779+
Assert.AreEqual(builderOriginal["Initial Catalog"], builderActual["Initial Catalog"], "Database/Initial Catalog should match (with env pattern retained).");
780+
Assert.AreEqual(builderOriginal["User ID"], builderActual["User ID"], "User Id should match.");
781+
Assert.AreEqual(builderOriginal["Password"], builderActual["Password"], "Password should match.");
782+
}
783+
finally
784+
{
785+
if (File.Exists(akvFilePath))
786+
{
787+
File.Delete(akvFilePath);
788+
}
789+
790+
Environment.SetEnvironmentVariable("env", null);
791+
}
792+
}
793+
794+
/// <summary>
795+
/// Validates two-pass replacement where an env var resolves to an AKV pattern which then resolves to the secret value.
796+
/// connection-string = @env('env_variable'), env_variable value = @akv('DBCONN'), AKV secret DBCONN holds the final connection string.
797+
/// </summary>
798+
[TestMethod]
799+
public void TestEnvVariableResolvingToAkvPatternIsExpandedInSecondPass()
800+
{
801+
string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv");
802+
string finalSecretValue = "Server=localhost;Database=Test;User Id=sa;Password=XXXX;";
803+
File.WriteAllText(akvFilePath, $"DBCONN={finalSecretValue}\n");
804+
string escapedPath = akvFilePath.Replace("\\", "\\\\");
805+
Environment.SetEnvironmentVariable("env_variable", "@akv('DBCONN')");
806+
807+
string jsonConfig = $$"""
808+
{
809+
"$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json",
810+
"data-source": {
811+
"database-type": "mssql",
812+
"connection-string": "@env('env_variable')"
813+
},
814+
"azure-key-vault": {
815+
"endpoint": "{{escapedPath}}"
816+
},
817+
"entities": { }
818+
}
819+
""";
820+
821+
try
822+
{
823+
DeserializationVariableReplacementSettings replacementSettings = new(
824+
azureKeyVaultOptions: null,
825+
doReplaceEnvVar: true,
826+
doReplaceAkvVar: true);
827+
bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings);
828+
Assert.IsTrue(parsed, "Config should parse successfully.");
829+
Assert.IsNotNull(config);
830+
831+
string expected = RuntimeConfigLoader.GetConnectionStringWithApplicationName(finalSecretValue);
832+
var builderExpected = new SqlConnectionStringBuilder(expected);
833+
var builderActual = new SqlConnectionStringBuilder(config.DataSource.ConnectionString);
834+
Assert.AreEqual(builderExpected["Data Source"], builderActual["Data Source"], "Data Source should match.");
835+
Assert.AreEqual(builderExpected["Initial Catalog"], builderActual["Initial Catalog"], "Initial Catalog should match.");
836+
Assert.AreEqual(builderExpected["User ID"], builderActual["User ID"], "User ID should match.");
837+
Assert.AreEqual(builderExpected["Password"], builderActual["Password"], "Password should match.");
838+
Assert.IsTrue(builderActual.ApplicationName?.Contains("dab_"), "Application Name should be appended including product identifier.");
839+
}
840+
finally
841+
{
842+
if (File.Exists(akvFilePath))
843+
{
844+
File.Delete(akvFilePath);
845+
}
846+
847+
Environment.SetEnvironmentVariable("env_variable", null);
848+
}
849+
}
674850
}
675851
}

0 commit comments

Comments
 (0)