diff --git a/src/WebJobs.Script.WebHost/Security/KeyManagement/ContainerAppsSecretsRepository.cs b/src/WebJobs.Script.WebHost/Security/KeyManagement/ContainerAppsSecretsRepository.cs new file mode 100644 index 0000000000..139f7e72e7 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Security/KeyManagement/ContainerAppsSecretsRepository.cs @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Script.WebHost; + +public class ContainerAppsSecretsRepository : ISecretsRepository +{ + internal const string ContainerAppsSecretsDir = "/run/secrets/functions-keys"; + + // host.master = value + private const string MasterKey = "host.master"; + // host.function.{keyName} = value + private const string HostFunctionKeyPrefix = "host.function."; + // host.systemKey.{keyName} = value + private const string SystemKeyPrefix = "host.systemKey."; + // functions.{functionName}.{keyName} = value + private const string FunctionKeyPrefix = "functions."; + + private readonly ILogger _logger; + + public ContainerAppsSecretsRepository(ILogger logger) + { + _logger = logger; + } + + // explicitly implementing this to avoid "unused" warnings on build + event EventHandler ISecretsRepository.SecretsChanged + { + add { } + remove { } + } + + public bool IsEncryptionSupported => false; + + public string Name => nameof(ContainerAppsSecretsRepository); + + public async Task ReadAsync(ScriptSecretsType type, string functionName) + { + if (type == ScriptSecretsType.Function && string.IsNullOrEmpty(functionName)) + { + throw new ArgumentNullException(nameof(functionName), $"{nameof(functionName)} cannot be null or empty with {nameof(type)} = {nameof(ScriptSecretsType.Function)}"); + } + + return type == ScriptSecretsType.Host ? await ReadHostSecretsAsync() : await ReadFunctionSecretsAsync(functionName.ToLowerInvariant()); + } + + public Task WriteAsync(ScriptSecretsType type, string functionName, ScriptSecrets secrets) + => throw new NotImplementedException(); + + private async Task ReadHostSecretsAsync() + { + var secrets = await GetFromFilesAsync(ContainerAppsSecretsDir); + + HostSecrets hostSecrets = new HostSecrets() + { + FunctionKeys = [], + SystemKeys = [] + }; + + foreach (var pair in secrets) + { + if (pair.Key.StartsWith(MasterKey, StringComparison.OrdinalIgnoreCase)) + { + hostSecrets.MasterKey = new Key("master", pair.Value); + } + else if (pair.Key.StartsWith(HostFunctionKeyPrefix, StringComparison.OrdinalIgnoreCase)) + { + hostSecrets.FunctionKeys.Add(ParseKeyWithPrefix(HostFunctionKeyPrefix, pair.Key, pair.Value)); + } + else if (pair.Key.StartsWith(SystemKeyPrefix)) + { + hostSecrets.SystemKeys.Add(ParseKeyWithPrefix(SystemKeyPrefix, pair.Key, pair.Value)); + } + } + + // Always return a HostSecrets object, even if empty. This will prevent the SecretManager from thinking + // it needs to create and persist new secrets, which is not supported in Container Apps. + return hostSecrets; + } + + private async Task ReadFunctionSecretsAsync(string functionName) + { + var secrets = await GetFromFilesAsync(ContainerAppsSecretsDir); + + var prefix = $"{FunctionKeyPrefix}{functionName}."; + + var functionSecrets = new FunctionSecrets() + { + Keys = secrets + .Where(p => p.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(p => ParseKeyWithPrefix(prefix, p.Key, p.Value)) + .ToList() + }; + + // Always return a FunctionSecrets object, even if empty. This will prevent the SecretManager from thinking + // it needs to create and persist new secrets, which is not supported in Container Apps. + return functionSecrets; + } + + private async Task> GetFromFilesAsync(string path) + { + string[] files = await FileUtility.GetFilesAsync(path, "*"); + var secrets = new Dictionary(files.Length); + + StringBuilder sb = new StringBuilder("Loaded secrets from files:"); + + foreach (var file in files) + { + secrets.Add(Path.GetFileName(file), await FileUtility.ReadAsync(file)); + sb.AppendLine($" {file}"); + } + + _logger.LogDebug(sb.ToString()); + return secrets; + } + + /// + /// no-op - allow stale secrets to remain. + /// + public async Task PurgeOldSecretsAsync(IList currentFunctions, ILogger logger) + => await Task.Yield(); + + /// + /// Runtime is not responsible for encryption so this code will never be executed. + /// + public Task WriteSnapshotAsync(ScriptSecretsType type, string functionName, ScriptSecrets secrets) + => throw new NotSupportedException(); + + /// + /// Runtime is not responsible for encryption so this code will never be executed. + /// + public Task GetSecretSnapshots(ScriptSecretsType type, string functionName) + => throw new NotSupportedException(); + + private static Key ParseKeyWithPrefix(string prefix, string key, string value) + => new(key.Substring(prefix.Length), value); +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/Security/KeyManagement/DefaultSecretManagerProvider.cs b/src/WebJobs.Script.WebHost/Security/KeyManagement/DefaultSecretManagerProvider.cs index e35055341f..568806922b 100644 --- a/src/WebJobs.Script.WebHost/Security/KeyManagement/DefaultSecretManagerProvider.cs +++ b/src/WebJobs.Script.WebHost/Security/KeyManagement/DefaultSecretManagerProvider.cs @@ -123,6 +123,10 @@ internal ISecretsRepository CreateSecretsRepository() _environment, _azureBlobStorageProvider); } + else if (repositoryType == typeof(ContainerAppsSecretsRepository)) + { + repository = new ContainerAppsSecretsRepository(_loggerFactory.CreateLogger()); + } } if (repository == null) @@ -166,6 +170,11 @@ internal bool TryGetSecretsRepositoryType(out Type repositoryType) repositoryType = typeof(KubernetesSecretsRepository); return true; } + else if (secretStorageType != null && secretStorageType.Equals("containerapps", StringComparison.OrdinalIgnoreCase)) + { + repositoryType = typeof(ContainerAppsSecretsRepository); + return true; + } else if (secretStorageSas != null) { repositoryType = typeof(BlobStorageSasSecretsRepository); diff --git a/src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs b/src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs index 8b3adf01e7..274b162dcb 100644 --- a/src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs +++ b/src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs @@ -17,7 +17,6 @@ using Microsoft.Azure.WebJobs.Script.WebHost.Properties; using Microsoft.Azure.WebJobs.Script.WebHost.Security; using Microsoft.Extensions.Logging; -using static Microsoft.Azure.WebJobs.Script.WebHost.Models.FunctionAppSecrets; using DataProtectionConstants = Microsoft.Azure.Web.DataProtection.Constants; namespace Microsoft.Azure.WebJobs.Script.WebHost @@ -140,7 +139,7 @@ public async virtual Task GetHostSecretsAsync() } // before caching any secrets, validate them - string masterKeyValue = hostSecrets.MasterKey.Value; + string masterKeyValue = hostSecrets.MasterKey?.Value; var functionKeys = hostSecrets.FunctionKeys.ToDictionary(p => p.Name, p => p.Value); var systemKeys = hostSecrets.SystemKeys.ToDictionary(p => p.Name, p => p.Value); ValidateHostSecrets(masterKeyValue, functionKeys, systemKeys); @@ -740,7 +739,7 @@ private HostSecrets ReadHostSecrets(HostSecrets hostSecrets) { return new HostSecrets { - MasterKey = _keyValueConverterFactory.ReadKey(hostSecrets.MasterKey), + MasterKey = hostSecrets.MasterKey is null ? null : _keyValueConverterFactory.ReadKey(hostSecrets.MasterKey), FunctionKeys = hostSecrets.FunctionKeys.Select(k => _keyValueConverterFactory.ReadKey(k)).ToList(), SystemKeys = hostSecrets.SystemKeys?.Select(k => _keyValueConverterFactory.ReadKey(k)).ToList() ?? new List() }; diff --git a/test/WebJobs.Script.Tests/Security/ContainerAppsSecretsRepositoryTests.cs b/test/WebJobs.Script.Tests/Security/ContainerAppsSecretsRepositoryTests.cs new file mode 100644 index 0000000000..de9a802bbb --- /dev/null +++ b/test/WebJobs.Script.Tests/Security/ContainerAppsSecretsRepositoryTests.cs @@ -0,0 +1,174 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Script.WebHost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests; + +public class ContainerAppsSecretsRepositoryTests : IDisposable +{ + private Dictionary> _fileContentMap; + private ContainerAppsSecretsRepository _repo; + + public ContainerAppsSecretsRepositoryTests() + { + // Mock the file system to return predefined secrets + var mockFileSystem = new Mock(MockBehavior.Strict); + var mockFile = new Mock(MockBehavior.Strict); + var mockDirectory = new Mock(MockBehavior.Strict); + + // Setup directory and file existence + mockDirectory.Setup(d => d.Exists(It.IsAny())).Returns(true); + mockFileSystem.SetupGet(fs => fs.Directory).Returns(mockDirectory.Object); + mockFileSystem.SetupGet(fs => fs.File).Returns(mockFile.Object); + + // Return all files when asked + mockDirectory + .Setup(d => d.GetFiles(ContainerAppsSecretsRepository.ContainerAppsSecretsDir, "*")) + .Returns(() => _fileContentMap.Keys.ToArray()); + + // Setup file existence checks + mockFile + .Setup(f => f.Exists(It.IsAny())) + .Returns((string f) => _fileContentMap.ContainsKey(f)); + + // Return content when asked + mockFile + .Setup(f => f.Open(It.IsAny(), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + .Returns((string f, FileMode _, FileAccess _, FileShare _) => _fileContentMap[f]()); + + FileUtility.Instance = mockFileSystem.Object; + + _repo = new ContainerAppsSecretsRepository(NullLogger.Instance); + } + + [Fact] + public async Task Read_Host_Secrets() + { + _fileContentMap = new() + { + { "/run/secrets/functions-keys/host.master", () => GetStream("mk") }, + { "/run/secrets/functions-keys/host.function.default", () => GetStream("hfd") }, + { "/run/secrets/functions-keys/host.function.key1", () => GetStream("hfk1") }, + { "/run/secrets/functions-keys/host.systemKey.key1", () => GetStream("hsk1") }, + }; + + var result = await _repo.ReadAsync(ScriptSecretsType.Host, null); + + var hostSecrets = result as HostSecrets; + Assert.NotNull(hostSecrets); + Assert.Equal("mk", hostSecrets.MasterKey.Value); + Assert.Equal("hfd", hostSecrets.GetFunctionKey("default", HostKeyScopes.FunctionKeys).Value); + Assert.Equal("hfk1", hostSecrets.GetFunctionKey("Key1", HostKeyScopes.FunctionKeys).Value); + Assert.Equal("hsk1", hostSecrets.GetFunctionKey("Key1", HostKeyScopes.SystemKeys).Value); + } + + [Fact] + public async Task Read_Function_Secrets() + { + _fileContentMap = new() + { + { "/run/secrets/functions-keys/functions.function1.key1", () => GetStream("f1k1") }, + { "/run/secrets/functions-keys/functions.function1.key2", () => GetStream("f1k2") }, + { "/run/secrets/functions-keys/functions.function2.key1", () => GetStream("f2k1") }, + { "/run/secrets/functions-keys/functions.function2.key2", () => GetStream("f2k2") } + }; + + var result = await _repo.ReadAsync(ScriptSecretsType.Function, "function1"); + + var functionSecrets = result as FunctionSecrets; + Assert.NotNull(functionSecrets); + Assert.Equal("f1k1", functionSecrets.GetFunctionKey("Key1", "function1").Value); + Assert.Equal("f1k2", functionSecrets.GetFunctionKey("key2", "Function1").Value); + + result = await _repo.ReadAsync(ScriptSecretsType.Function, "function2"); + functionSecrets = result as FunctionSecrets; + Assert.NotNull(functionSecrets); + Assert.Equal("f2k1", functionSecrets.GetFunctionKey("Key1", "funcTion2").Value); + Assert.Equal("f2k2", functionSecrets.GetFunctionKey("key2", "function2").Value); + } + + [Fact] + public async Task No_HostKeys_Returns_Empty_HostSecrets() + { + _fileContentMap = []; + + var result = await _repo.ReadAsync(ScriptSecretsType.Host, null); + + var hostSecrets = result as HostSecrets; + Assert.NotNull(hostSecrets); + Assert.Null(hostSecrets.MasterKey); + Assert.Empty(hostSecrets.FunctionKeys); + Assert.Empty(hostSecrets.SystemKeys); + } + + [Fact] + public async Task No_FunctionKeys_Returns_Empty_FunctionSecrets() + { + _fileContentMap = []; + + var result = await _repo.ReadAsync(ScriptSecretsType.Function, "function1"); + + var hostSecrets = result as FunctionSecrets; + Assert.NotNull(hostSecrets); + Assert.Empty(hostSecrets.Keys); + } + + [Fact] + public async Task SecretManager_DoesNotCreate_HostSecrets() + { + // no keys; we don't want the SecretManager to try to create new ones + _fileContentMap = []; + + var testEnvironment = new TestEnvironment(); + var mockHostNameProvider = new Mock(MockBehavior.Strict, testEnvironment); + var startupContextProvider = new StartupContextProvider(testEnvironment, NullLogger.Instance); + + var secretManager = new SecretManager(_repo, NullLogger.Instance, new TestMetricsLogger(), mockHostNameProvider.Object, startupContextProvider); + + var hostSecrets = await secretManager.GetHostSecretsAsync(); + + Assert.NotNull(hostSecrets); + Assert.Null(hostSecrets.MasterKey); + Assert.Empty(hostSecrets.FunctionKeys); + Assert.Empty(hostSecrets.SystemKeys); + } + + [Fact] + public async Task SecretManager_DoesNotCreate_FunctionSecrets() + { + // no keys; we don't want the SecretManager to try to create new ones + _fileContentMap = []; + + var testEnvironment = new TestEnvironment(); + var mockHostNameProvider = new Mock(MockBehavior.Strict, testEnvironment); + var startupContextProvider = new StartupContextProvider(testEnvironment, NullLogger.Instance); + + var secretManager = new SecretManager(_repo, NullLogger.Instance, new TestMetricsLogger(), mockHostNameProvider.Object, startupContextProvider); + + var functionSecrets = await secretManager.GetFunctionSecretsAsync("function1"); + + Assert.NotNull(functionSecrets); + Assert.Empty(functionSecrets); + } + + private static MemoryStream GetStream(string content) + { + return new MemoryStream(Encoding.UTF8.GetBytes(content)); + } + + public void Dispose() + { + FileUtility.Instance = null; + } +} \ No newline at end of file