From d9dbf8eddde3423b995fe16f1f56f9dc34524146 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 28 Oct 2026 09:17:10 -0700 Subject: [PATCH] Add optional Key-Per-File configuration for container secrets --- README.md | 8 ++ src/prod/Indirection/Program.cs | 1 + src/prod/Indirection/Startup.cs | 3 + .../KeyPerFileConfigurationTests.cs | 127 ++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/test/Indirection.Tests/KeyPerFileConfigurationTests.cs diff --git a/README.md b/README.md index 8c761e1..e4d452a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ dotnet run --project src/prod/Indirection -c Release --no-build --no-launch-prof Keep the generated key in the shell for the examples below, or supply a secret through your deployment's secret store. No key is included in application configuration. Startup rejects a missing/blank key or an unsupported provider. Use at least 32 cryptographically random bytes for production keys. Serve deployed management endpoints over HTTPS, normally through your hosting platform/reverse proxy; the HTTP binding above is for local use. +### File-based secrets + +Indirection optionally loads configuration from `/run/secrets` using ASP.NET Core's Key-Per-File provider. Each file's contents supply its value; `__` in the filename maps to `:` for any configuration key. For example, mount a file containing your API key at `/run/secrets/Authentication__ApiKey` to set `Authentication:ApiKey`. No directory or configuration changes are required when running without mounted secrets. + +Files take precedence over all standard sources, including command-line arguments. Otherwise, standard precedence is preserved: command-line arguments, environment variables, `appsettings.{Environment}.json`, then `appsettings.json` (highest to lowest). Restart the application after rotating secret files. + +### Application behavior + SQLite is the only supported provider. `appsettings.json` defaults to `Data Source=urls.db`; relative SQLite paths resolve against the process working directory. An absolute `ConnectionStrings__Urls` avoids ambiguity between the EF CLI and host. Put the database and its sidecar files on persistent, writable storage and back them up. Apply migrations explicitly before starting/upgrading; the application does not migrate at startup. `dotnet ef migrations has-pending-model-changes --project src/prod/Indirection` checks model drift. The production application is one project under `src/prod/Indirection`. Controllers access EF Core and HybridCache directly. Caches are lazy, local and disposable, use HybridCache's default expiration settings, and never retain missing lookups. Successful writes save first and then invalidate the normalized code. Operate one application instance; out-of-band database writes do not invalidate its cache. `/health` is an anonymous liveness check returning `200` with `Healthy`; it does not probe database readiness. diff --git a/src/prod/Indirection/Program.cs b/src/prod/Indirection/Program.cs index f9689a5..d1b0d40 100644 --- a/src/prod/Indirection/Program.cs +++ b/src/prod/Indirection/Program.cs @@ -4,6 +4,7 @@ var migrate = args.Contains("--migrate", StringComparer.Ordinal); var builder = WebApplication.CreateBuilder(args.Where(argument => argument != "--migrate").ToArray()); +Startup.ConfigureConfiguration(builder.Configuration); Startup.ConfigureServices(builder.Services, builder.Configuration); await using var app = builder.Build(); diff --git a/src/prod/Indirection/Startup.cs b/src/prod/Indirection/Startup.cs index 9233fbc..4f803fd 100644 --- a/src/prod/Indirection/Startup.cs +++ b/src/prod/Indirection/Startup.cs @@ -6,6 +6,9 @@ namespace Indirection; public static class Startup { + public static void ConfigureConfiguration(IConfigurationBuilder configuration, string secretsDirectory = "/run/secrets") + => configuration.AddKeyPerFile(secretsDirectory, optional: true); + public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) { if (!string.Equals(configuration["Database:Provider"], "Sqlite", StringComparison.OrdinalIgnoreCase)) diff --git a/src/test/Indirection.Tests/KeyPerFileConfigurationTests.cs b/src/test/Indirection.Tests/KeyPerFileConfigurationTests.cs new file mode 100644 index 0000000..d69adaf --- /dev/null +++ b/src/test/Indirection.Tests/KeyPerFileConfigurationTests.cs @@ -0,0 +1,127 @@ +using Indirection.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Indirection.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class KeyPerFileConfigurationTests +{ + private DirectoryInfo directory = null!; + private string? originalApiKey; + + [TestInitialize] + public void Initialize() + { + directory = Directory.CreateTempSubdirectory("indirection-configuration-"); + originalApiKey = Environment.GetEnvironmentVariable("Authentication__ApiKey"); + Environment.SetEnvironmentVariable("Authentication__ApiKey", null); + File.WriteAllText(Path.Combine(directory.FullName, "appsettings.json"), """ + { + "Database": { "Provider": "Sqlite" }, + "ConnectionStrings": { "Urls": "Data Source=:memory:" }, + "Authentication": { "ApiKey": "json-test-key" }, + "ConfigurationTest": { "BaseOnly": "base-json", "Layered": "base-json" } + } + """); + File.WriteAllText(Path.Combine(directory.FullName, "appsettings.Testing.json"), """ + { "ConfigurationTest": { "Layered": "environment-json" } } + """); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable("Authentication__ApiKey", originalApiKey); + directory.Delete(recursive: true); + } + + [TestMethod] + public void FilesMapHierarchicalKeysAndBindAuthenticationOptions() + { + var secrets = Directory.CreateDirectory(Path.Combine(directory.FullName, "secrets")); + File.WriteAllText(Path.Combine(secrets.FullName, "Authentication__ApiKey"), "file-test-key"); + File.WriteAllText(Path.Combine(secrets.FullName, "ConfigurationTest__Nested__Value"), "arbitrary-value"); + using var configuration = CreateConfiguration(secrets.FullName); + + Assert.AreEqual("file-test-key", configuration["Authentication:ApiKey"]); + Assert.AreEqual("arbitrary-value", configuration["ConfigurationTest:Nested:Value"]); + using var services = CreateServices(configuration); + Assert.AreEqual("file-test-key", services.GetRequiredService>() + .Get(ApiKeyDefaults.AuthenticationScheme).ApiKey); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void FilesOverrideJsonEnvironmentAndCommandLine(bool commandLine) + { + Environment.SetEnvironmentVariable("Authentication__ApiKey", "environment-test-key"); + var secrets = Directory.CreateDirectory(Path.Combine(directory.FullName, "secrets")); + File.WriteAllText(Path.Combine(secrets.FullName, "Authentication__ApiKey"), "file-test-key"); + using var configuration = CreateConfiguration(secrets.FullName, + commandLine ? ["--Authentication:ApiKey=command-line-test-key"] : []); + + Assert.AreEqual("file-test-key", configuration["Authentication:ApiKey"]); + } + + [TestMethod] + [DataRow(false, false, "json-test-key")] + [DataRow(true, false, "environment-test-key")] + [DataRow(true, true, "command-line-test-key")] + public void MissingDirectoryPreservesStandardConfiguration(bool environment, bool commandLine, string expected) + { + if (environment) + { + Environment.SetEnvironmentVariable("Authentication__ApiKey", "environment-test-key"); + } + + var missing = Path.Combine(directory.FullName, "missing"); + using var configuration = CreateConfiguration(missing, + commandLine ? ["--Authentication:ApiKey=command-line-test-key"] : []); + + Assert.IsFalse(Directory.Exists(missing)); + Assert.AreEqual(expected, configuration["Authentication:ApiKey"]); + Assert.AreEqual("base-json", configuration["ConfigurationTest:BaseOnly"]); + Assert.AreEqual("environment-json", configuration["ConfigurationTest:Layered"]); + using var services = CreateServices(configuration); + Assert.AreEqual(expected, services.GetRequiredService>() + .Get(ApiKeyDefaults.AuthenticationScheme).ApiKey); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + public void BlankSecretStillFailsApiKeyValidation(string key) + { + var secrets = Directory.CreateDirectory(Path.Combine(directory.FullName, "secrets")); + File.WriteAllText(Path.Combine(secrets.FullName, "Authentication__ApiKey"), key); + using var configuration = CreateConfiguration(secrets.FullName); + using var services = CreateServices(configuration); + + Assert.ThrowsExactly(() => services + .GetRequiredService>().Get(ApiKeyDefaults.AuthenticationScheme)); + } + + private ConfigurationManager CreateConfiguration(string secretsDirectory, string[]? args = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = directory.FullName, + EnvironmentName = "Testing", + Args = args ?? [] + }); + Startup.ConfigureConfiguration(builder.Configuration, secretsDirectory); + return builder.Configuration; + } + + private static ServiceProvider CreateServices(IConfiguration configuration) + { + var services = new ServiceCollection(); + Startup.ConfigureServices(services, configuration); + return services.BuildServiceProvider(); + } +}