Skip to content

Commit 8a735ad

Browse files
committed
adding a read-only ContainerApps secret repository
1 parent 3a5d101 commit 8a735ad

File tree

4 files changed

+308
-3
lines changed

4 files changed

+308
-3
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Microsoft.Azure.WebJobs.Script.WebHost;
9+
10+
public class ContainerAppsSecretsRepository : ISecretsRepository
11+
{
12+
internal const string ContainerAppsSecretsDir = "/run/secrets/functions-keys";
13+
14+
// host.master = value
15+
private const string MasterKey = "host.master";
16+
// host.function.{keyName} = value
17+
private const string HostFunctionKeyPrefix = "host.function.";
18+
// host.systemKey.{keyName} = value
19+
private const string SystemKeyPrefix = "host.systemKey.";
20+
// functions.{functionName}.{keyName} = value
21+
private const string FunctionKeyPrefix = "functions.";
22+
23+
private readonly ILogger<ContainerAppsSecretsRepository> _logger;
24+
25+
public ContainerAppsSecretsRepository(ILogger<ContainerAppsSecretsRepository> logger)
26+
{
27+
_logger = logger;
28+
}
29+
30+
public event EventHandler<SecretsChangedEventArgs> SecretsChanged;
31+
32+
public bool IsEncryptionSupported => false;
33+
34+
public string Name => nameof(ContainerAppsSecretsRepository);
35+
36+
public async Task<ScriptSecrets> ReadAsync(ScriptSecretsType type, string functionName)
37+
{
38+
if (type == ScriptSecretsType.Function && string.IsNullOrEmpty(functionName))
39+
{
40+
throw new ArgumentNullException(nameof(functionName), $"{nameof(functionName)} cannot be null or empty with {nameof(type)} = {nameof(ScriptSecretsType.Function)}");
41+
}
42+
43+
return type == ScriptSecretsType.Host ? await ReadHostSecretsAsync() : await ReadFunctionSecretsAsync(functionName?.ToLowerInvariant());
44+
}
45+
46+
public Task WriteAsync(ScriptSecretsType type, string functionName, ScriptSecrets secrets)
47+
=> throw new NotImplementedException();
48+
49+
private async Task<ScriptSecrets> ReadHostSecretsAsync()
50+
{
51+
var secrets = await GetFromFilesAsync(ContainerAppsSecretsDir);
52+
53+
HostSecrets hostSecrets = new HostSecrets()
54+
{
55+
FunctionKeys = [],
56+
SystemKeys = []
57+
};
58+
59+
foreach (var pair in secrets)
60+
{
61+
if (pair.Key.StartsWith(MasterKey, StringComparison.OrdinalIgnoreCase))
62+
{
63+
hostSecrets.MasterKey = new Key("master", pair.Value);
64+
}
65+
else if (pair.Key.StartsWith(HostFunctionKeyPrefix, StringComparison.OrdinalIgnoreCase))
66+
{
67+
hostSecrets.FunctionKeys.Add(ParseKeyWithPrefix(HostFunctionKeyPrefix, pair.Key, pair.Value));
68+
}
69+
else if (pair.Key.StartsWith(SystemKeyPrefix))
70+
{
71+
hostSecrets.SystemKeys.Add(ParseKeyWithPrefix(SystemKeyPrefix, pair.Key, pair.Value));
72+
}
73+
}
74+
75+
// Always return a HostSecrets object, even if empty. This will prevent the SecretManager from thinking
76+
// it needs to create and persist new secrets, which is not supported in Container Apps.
77+
return hostSecrets;
78+
}
79+
80+
private async Task<ScriptSecrets> ReadFunctionSecretsAsync(string functionName)
81+
{
82+
var secrets = await GetFromFilesAsync(ContainerAppsSecretsDir);
83+
84+
var prefix = $"{FunctionKeyPrefix}{functionName}.";
85+
86+
var functionSecrets = new FunctionSecrets()
87+
{
88+
Keys = secrets
89+
.Where(p => p.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
90+
.Select(p => ParseKeyWithPrefix(prefix, p.Key, p.Value))
91+
.ToList()
92+
};
93+
94+
// Always return a FunctionSecrets object, even if empty. This will prevent the SecretManager from thinking
95+
// it needs to create and persist new secrets, which is not supported in Container Apps.
96+
return functionSecrets;
97+
}
98+
99+
private static async Task<IDictionary<string, string>> GetFromFilesAsync(string path)
100+
{
101+
string[] files = await FileUtility.GetFilesAsync(path, "*");
102+
var secrets = new Dictionary<string, string>(files.Length);
103+
104+
foreach (var file in files)
105+
{
106+
secrets.Add(Path.GetFileName(file), await FileUtility.ReadAsync(file));
107+
}
108+
109+
return secrets;
110+
}
111+
112+
public Task WriteSnapshotAsync(ScriptSecretsType type, string functionName, ScriptSecrets secrets)
113+
=> throw new NotImplementedException();
114+
115+
public Task PurgeOldSecretsAsync(IList<string> currentFunctions, ILogger logger)
116+
=> throw new NotImplementedException();
117+
118+
public Task<string[]> GetSecretSnapshots(ScriptSecretsType type, string functionName)
119+
=> throw new NotImplementedException();
120+
121+
private static Key ParseKeyWithPrefix(string prefix, string key, string value)
122+
=> new(key.Substring(prefix.Length), value);
123+
}

src/WebJobs.Script.WebHost/Security/KeyManagement/DefaultSecretManagerProvider.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ internal ISecretsRepository CreateSecretsRepository()
123123
_environment,
124124
_azureBlobStorageProvider);
125125
}
126+
else if (repositoryType == typeof(ContainerAppsSecretsRepository))
127+
{
128+
repository = new ContainerAppsSecretsRepository(_loggerFactory.CreateLogger<ContainerAppsSecretsRepository>());
129+
}
126130
}
127131

128132
if (repository == null)
@@ -166,6 +170,11 @@ internal bool TryGetSecretsRepositoryType(out Type repositoryType)
166170
repositoryType = typeof(KubernetesSecretsRepository);
167171
return true;
168172
}
173+
else if (secretStorageType != null && secretStorageType.Equals("containerapps", StringComparison.OrdinalIgnoreCase))
174+
{
175+
repositoryType = typeof(ContainerAppsSecretsRepository);
176+
return true;
177+
}
169178
else if (secretStorageSas != null)
170179
{
171180
repositoryType = typeof(BlobStorageSasSecretsRepository);

src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
using Microsoft.Azure.WebJobs.Script.WebHost.Properties;
1818
using Microsoft.Azure.WebJobs.Script.WebHost.Security;
1919
using Microsoft.Extensions.Logging;
20-
using static Microsoft.Azure.WebJobs.Script.WebHost.Models.FunctionAppSecrets;
2120
using DataProtectionConstants = Microsoft.Azure.Web.DataProtection.Constants;
2221

2322
namespace Microsoft.Azure.WebJobs.Script.WebHost
@@ -140,7 +139,7 @@ public async virtual Task<HostSecretsInfo> GetHostSecretsAsync()
140139
}
141140

142141
// before caching any secrets, validate them
143-
string masterKeyValue = hostSecrets.MasterKey.Value;
142+
string masterKeyValue = hostSecrets.MasterKey?.Value;
144143
var functionKeys = hostSecrets.FunctionKeys.ToDictionary(p => p.Name, p => p.Value);
145144
var systemKeys = hostSecrets.SystemKeys.ToDictionary(p => p.Name, p => p.Value);
146145
ValidateHostSecrets(masterKeyValue, functionKeys, systemKeys);
@@ -740,7 +739,7 @@ private HostSecrets ReadHostSecrets(HostSecrets hostSecrets)
740739
{
741740
return new HostSecrets
742741
{
743-
MasterKey = _keyValueConverterFactory.ReadKey(hostSecrets.MasterKey),
742+
MasterKey = hostSecrets.MasterKey is null ? null : _keyValueConverterFactory.ReadKey(hostSecrets.MasterKey),
744743
FunctionKeys = hostSecrets.FunctionKeys.Select(k => _keyValueConverterFactory.ReadKey(k)).ToList(),
745744
SystemKeys = hostSecrets.SystemKeys?.Select(k => _keyValueConverterFactory.ReadKey(k)).ToList() ?? new List<Key>()
746745
};
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.IO.Abstractions;
8+
using System.Linq;
9+
using System.Text;
10+
using System.Threading.Tasks;
11+
using Microsoft.Azure.WebJobs.Script.WebHost;
12+
using Microsoft.Extensions.Logging.Abstractions;
13+
using Moq;
14+
using Xunit;
15+
16+
namespace Microsoft.Azure.WebJobs.Script.Tests;
17+
18+
public class ContainerAppsSecretsRepositoryTests : IDisposable
19+
{
20+
private Dictionary<string, Func<MemoryStream>> _fileContentMap;
21+
private ContainerAppsSecretsRepository _repo;
22+
23+
public ContainerAppsSecretsRepositoryTests()
24+
{
25+
// Mock the file system to return predefined secrets
26+
var mockFileSystem = new Mock<IFileSystem>(MockBehavior.Strict);
27+
var mockFile = new Mock<FileBase>(MockBehavior.Strict);
28+
var mockDirectory = new Mock<DirectoryBase>(MockBehavior.Strict);
29+
30+
// Setup directory and file existence
31+
mockDirectory.Setup(d => d.Exists(It.IsAny<string>())).Returns(true);
32+
mockFileSystem.SetupGet(fs => fs.Directory).Returns(mockDirectory.Object);
33+
mockFileSystem.SetupGet(fs => fs.File).Returns(mockFile.Object);
34+
35+
// Return all files when asked
36+
mockDirectory
37+
.Setup(d => d.GetFiles(ContainerAppsSecretsRepository.ContainerAppsSecretsDir, "*"))
38+
.Returns(() => _fileContentMap.Keys.ToArray());
39+
40+
// Setup file existence checks
41+
mockFile
42+
.Setup(f => f.Exists(It.IsAny<string>()))
43+
.Returns((string f) => _fileContentMap.ContainsKey(f));
44+
45+
// Return content when asked
46+
mockFile
47+
.Setup(f => f.Open(It.IsAny<string>(), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
48+
.Returns((string f, FileMode _, FileAccess _, FileShare _) => _fileContentMap[f]());
49+
50+
FileUtility.Instance = mockFileSystem.Object;
51+
52+
_repo = new ContainerAppsSecretsRepository(NullLogger<ContainerAppsSecretsRepository>.Instance);
53+
}
54+
55+
[Fact]
56+
public async Task Read_Host_Secrets()
57+
{
58+
_fileContentMap = new()
59+
{
60+
{ "/run/secrets/functions-keys/host.master", () => GetStream("mk") },
61+
{ "/run/secrets/functions-keys/host.function.default", () => GetStream("hfd") },
62+
{ "/run/secrets/functions-keys/host.function.key1", () => GetStream("hfk1") },
63+
{ "/run/secrets/functions-keys/host.systemKey.key1", () => GetStream("hsk1") },
64+
};
65+
66+
var result = await _repo.ReadAsync(ScriptSecretsType.Host, null);
67+
68+
var hostSecrets = result as HostSecrets;
69+
Assert.NotNull(hostSecrets);
70+
Assert.Equal("mk", hostSecrets.MasterKey.Value);
71+
Assert.Equal("hfd", hostSecrets.GetFunctionKey("default", HostKeyScopes.FunctionKeys).Value);
72+
Assert.Equal("hfk1", hostSecrets.GetFunctionKey("Key1", HostKeyScopes.FunctionKeys).Value);
73+
Assert.Equal("hsk1", hostSecrets.GetFunctionKey("Key1", HostKeyScopes.SystemKeys).Value);
74+
}
75+
76+
[Fact]
77+
public async Task Read_Function_Secrets()
78+
{
79+
_fileContentMap = new()
80+
{
81+
{ "/run/secrets/functions-keys/functions.function1.key1", () => GetStream("f1k1") },
82+
{ "/run/secrets/functions-keys/functions.function1.key2", () => GetStream("f1k2") },
83+
{ "/run/secrets/functions-keys/functions.function2.key1", () => GetStream("f2k1") },
84+
{ "/run/secrets/functions-keys/functions.function2.key2", () => GetStream("f2k2") }
85+
};
86+
87+
var result = await _repo.ReadAsync(ScriptSecretsType.Function, "function1");
88+
89+
var functionSecrets = result as FunctionSecrets;
90+
Assert.NotNull(functionSecrets);
91+
Assert.Equal("f1k1", functionSecrets.GetFunctionKey("Key1", "function1").Value);
92+
Assert.Equal("f1k2", functionSecrets.GetFunctionKey("key2", "Function1").Value);
93+
94+
result = await _repo.ReadAsync(ScriptSecretsType.Function, "function2");
95+
functionSecrets = result as FunctionSecrets;
96+
Assert.NotNull(functionSecrets);
97+
Assert.Equal("f2k1", functionSecrets.GetFunctionKey("Key1", "funcTion2").Value);
98+
Assert.Equal("f2k2", functionSecrets.GetFunctionKey("key2", "function2").Value);
99+
}
100+
101+
[Fact]
102+
public async Task No_HostKeys_Returns_Empty_HostSecrets()
103+
{
104+
_fileContentMap = [];
105+
106+
var result = await _repo.ReadAsync(ScriptSecretsType.Host, null);
107+
108+
var hostSecrets = result as HostSecrets;
109+
Assert.NotNull(hostSecrets);
110+
Assert.Null(hostSecrets.MasterKey);
111+
Assert.Empty(hostSecrets.FunctionKeys);
112+
Assert.Empty(hostSecrets.SystemKeys);
113+
}
114+
115+
[Fact]
116+
public async Task No_FunctionKeys_Returns_Empty_FunctionSecrets()
117+
{
118+
_fileContentMap = [];
119+
120+
var result = await _repo.ReadAsync(ScriptSecretsType.Function, "function1");
121+
122+
var hostSecrets = result as FunctionSecrets;
123+
Assert.NotNull(hostSecrets);
124+
Assert.Empty(hostSecrets.Keys);
125+
}
126+
127+
[Fact]
128+
public async Task SecretManager_DoesNotCreate_HostSecrets()
129+
{
130+
// no keys; we don't want the SecretManager to try to create new ones
131+
_fileContentMap = [];
132+
133+
var testEnvironment = new TestEnvironment();
134+
var mockHostNameProvider = new Mock<HostNameProvider>(MockBehavior.Strict, testEnvironment);
135+
var startupContextProvider = new StartupContextProvider(testEnvironment, NullLogger<StartupContextProvider>.Instance);
136+
137+
var secretManager = new SecretManager(_repo, NullLogger.Instance, new TestMetricsLogger(), mockHostNameProvider.Object, startupContextProvider);
138+
139+
var hostSecrets = await secretManager.GetHostSecretsAsync();
140+
141+
Assert.NotNull(hostSecrets);
142+
Assert.Null(hostSecrets.MasterKey);
143+
Assert.Empty(hostSecrets.FunctionKeys);
144+
Assert.Empty(hostSecrets.SystemKeys);
145+
}
146+
147+
[Fact]
148+
public async Task SecretManager_DoesNotCreate_FunctionSecrets()
149+
{
150+
// no keys; we don't want the SecretManager to try to create new ones
151+
_fileContentMap = [];
152+
153+
var testEnvironment = new TestEnvironment();
154+
var mockHostNameProvider = new Mock<HostNameProvider>(MockBehavior.Strict, testEnvironment);
155+
var startupContextProvider = new StartupContextProvider(testEnvironment, NullLogger<StartupContextProvider>.Instance);
156+
157+
var secretManager = new SecretManager(_repo, NullLogger.Instance, new TestMetricsLogger(), mockHostNameProvider.Object, startupContextProvider);
158+
159+
var functionSecrets = await secretManager.GetFunctionSecretsAsync("function1");
160+
161+
Assert.NotNull(functionSecrets);
162+
Assert.Empty(functionSecrets);
163+
}
164+
165+
private static MemoryStream GetStream(string content)
166+
{
167+
return new MemoryStream(Encoding.UTF8.GetBytes(content));
168+
}
169+
170+
public void Dispose()
171+
{
172+
FileUtility.Instance = null;
173+
}
174+
}

0 commit comments

Comments
 (0)