Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/prod/Indirection/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/prod/Indirection/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
127 changes: 127 additions & 0 deletions src/test/Indirection.Tests/KeyPerFileConfigurationTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOptionsMonitor<ApiKeyAuthenticationOptions>>()
.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<IOptionsMonitor<ApiKeyAuthenticationOptions>>()
.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<OptionsValidationException>(() => services
.GetRequiredService<IOptionsMonitor<ApiKeyAuthenticationOptions>>().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();
}
}
Loading