Configuración y secretos en ASP.NET Core con Azure Key Vault y Azure App Configuration

Configuración y secretos, un tandem a resolver para cualquier aplicación.

Probablemente, nuestra primera intención cuando se habla de usar configuración en una aplicación de ASP.NET Core, sea usar el famoso y manido fichero appsettings.json y sus amigos appsettings.<Environment>.json. Puede que incluso en nuestra pipeline de CD, hagamos un reemplazo de ciertos valores en el fichero appsettings.json leyéndolos previamente desde un Key Vault, bien usando Azure Devops o bien GitHub Actions. En esta pipeline, y si estamos usando un App Service, otra práctica habitual sería configurar las app settings, pudiendo ser alguna de ellas una referencia a un secreto de Key Vault. Por último, en tiempo de desarrollo lo más seguro es que usemos los secretos de usuario para evitar la tentación de subir secretos al repositorio.

Todo lo expuesto en el anterior párrafo (que ha quedado compacto, lo sé), parece la forma más habitual de gestionar la configuración y los secretos en una aplicación “tipo” de ASP.NET Core.

Antes de continuar, creo resultaría interesante debatir sobre la diferencia entre configuración y secretos. Aunque muchas veces se usan indistintamente, son cosas muy diferentes. Con la configuración estamos decidiendo como se comportará nuestra aplicación. Por el contrario, con los secretos estamos guardando “credenciales” para acceder a servicios que requieren autenticación. Esto supone que si se compromete la configuración, no debería suponer un problema mayúsculo (no está bien, es obvio, pero el daño debería ser mínimo). Sin embargo, si se comprometen los secretos (que son privados por naturaleza), ahí sí va a morir un gatito… la habremos liado parda. Los secretos hay que mantenerlos siempre fuera de miradas indiscretas e incluso es probable que, en un equipo de desarrollo, no se tenga acceso a los secretos de producción. En cualquier caso, esta distinción daría para un post entero… para el que yo no estoy plenamente cualificado.

Aunque el uso de app settings en un App Service parece una buena idea, presenta los siguientes problemas:

  • Cualquier cambio en la configuración de la aplicación supondrá un reinicio de la misma.
  • Si además, estamos usando una referencia a un Key Vault y el valor del secreto cambia, la actualización en el App Service no va a ser inmediata. El compromiso es que el valor se actualizará en las siguientes 24 horas… dependiendo del caso, un mundo. Si por el contrario estás usando Container Apps, el valor es menor 30 min
  • En cualquier caso, si el App Service se reinicia o se despliega una nueva revisión en el caso de Container Apps, el valor será leído de inmediato.

En esta situación (y teniendo que soportar el cambio de valor de un secreto), nos va a tocar leerlo en caliente.

dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Identity
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

namespace WebApplication1;

public class KeyVaultManager
{
    private static SecretClient CreateSecretClient(string vaultUri)
    {
        return new SecretClient(new Uri(vaultUri), new DefaultAzureCredential());
    }

    public async Task<string?> GetSecretAsync(string vaultUri, string name, string? version = null)
    {
        var secretClient = CreateSecretClient(vaultUri);
        var secret = await secretClient.GetSecretAsync(name, version);
        if (secret is not null)
        {
            return secret.Value.Value;
        }
        return null;
    }
}

Respecto a la autenticación en el Key Vault, hay un montón de formas de hacerlo. DefaultAzureCredential funcionará tanto en local como cuando la aplicación esté desplegada en Azure y usemos una Managed Identity.

Logicamente, el snippet anterior está pidiendo a gritos algún tipo de caché como IMemoryCache o IDistributedCache. Key Vault tiene sus propios límites de servicio y si no tenemos cuidado, sufriremos un throttling con un bonito 429 (Too many requests).

Una alternativa a leer en caliente Key Vault, es usar la integración que tiene con el servicio común de configuración de ASP.NET Core.

dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
// KeyVault is retreived from appsettings.json
builder.Configuration.AddAzureKeyVault(
    new Uri(builder.Configuration["KeyVault"]),
    new DefaultAzureCredential());

Ahora bastaría con inyectarnos IConfiguration y tendríamos todos los secretos a mano. Parece un buen trato.

Si quieres más control sobre que secretos se cargarán en configuración y cuando se renovarán, también puedes hacerlo. Por ejemplo, en el siguiente código filtramos los secretos a sólo aquellos que tengan un prefijo determinado y establecemos de forma explícita cuando volveremos a leerlos.

  "KeyVault": {
    "Uri": "YOUR_KEY_VAULT_URI",
    "Prefix": "YOUR_PREFIX",
    "ReloadIntervalInMinutes": 60
  }
builder.Configuration.AddAzureKeyVault(
    new Uri(builder.Configuration["KeyVault:Uri"]),
    new DefaultAzureCredential(),
    new AzureKeyVaultConfigurationOptions()
    {
        Manager = new SamplePrefixKeyVaultSecretManager(builder.Configuration["KeyVault:Prefix"]),
        ReloadInterval = TimeSpan.FromMinutes(Convert.ToInt32(builder.Configuration["KeyVault:ReloadIntervalInMinutes"]))
    });

Si no te gusta KeyVault:Uri, puede usar Options pattern y quedaría mas chulo.

public class KeyVaultOptions
{
    public string Uri { get; set; }
    public string Prefix { get; set; }
    public int ReloadIntervalInMinutes { get; set; }
}
var keyVaultOptions = new KeyVaultOptions();
builder.Configuration.GetSection("KeyVault").Bind(keyVaultOptions);

builder.Configuration.AddAzureKeyVault(
    new Uri(keyVaultOptions.Uri),
    new DefaultAzureCredential(),
    new AzureKeyVaultConfigurationOptions()
    {
        Manager = new SamplePrefixKeyVaultSecretManager(keyVaultOptions.Prefix),
        // By default, it never reloads
        // https://docs.microsoft.com/es-es/aspnet/core/security/key-vault-configuration?view=aspnetcore-6.0#configuration-options
        ReloadInterval = TimeSpan.FromSeconds(keyVaultOptions.ReloadIntervalInMinutes)
    });

El ejemplo de SamplePrefixKeyVaultSecretManager no es mío, es de la documentación oficial.

Usando Key Vault con el servicio común de configuración, IConfiguration.Reload() también forzará que se vuelvan a leer los secretos.

Hasta ahora, sólo hemos hablando de Key Vault, es decir, de secretos. Si lo queremos es tratar sobre configuración, Azure tiene otro servicio más preparado para ello que es Azure App Configuration.

Con App Configuration, no sólo tenemos una pareja clave-valor, sino que incorpora el concepto de label para tener distintos valores por clave. De este modo, tener distinta configuración por entorno es tan sencillo como etiquetarla con dev, stage o prod. Además, también podemos (igual que los app settings de un App Service) tener referencias a un Key Vault. Convirtiéndose así, en un único sitio donde centralizar la gestión de la configuración y secretos de nuestra aplicación.

Aunque podemos leer una clave a pelo, en App Configuration todo la documentación te sugiere usarlo como un proveedor de configuración.

dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore
// You can get the connection string from Settings > Access Keys blade in Azure portal
builder.Configuration.AddAzureAppConfiguration(builder.Configuration["AppConfiguration:ConnectionString"]);
// ...
builder.Services.AddAzureAppConfiguration();
// ...
app.UseAzureAppConfiguration();

Si quieres usar una Managed Identity, sería algo muy parecido al ejemplo de KeyVault.

```csharp` builder.Configuration.AddAzureAppConfiguration(options => options.Connect( new Uri(builder.Configuration[“AppConfiguration:Uri”]), new DefaultAzureCredential())); ``

En su ejemplo más básico, poco más que añadir. Ahora toca disfrutar de IConfiguration y la inyección de dependencias de .NET.

Si estamos usando referencias a un Key Vault, nos tocará también configurar la conexión al mismo desde App Configuration.

builder.Configuration.AddAzureAppConfiguration((AzureAppConfigurationOptions options) =>
{
    options.Connect(builder.Configuration["AppConfiguration:ConnectionString"]);
    options.ConfigureKeyVault((AzureAppConfigurationKeyVaultOptions options) =>
    {
        options.SetCredential(new DefaultAzureCredential());
    });
});

Por defecto, al igual que sucedía con AddAzureKeyVault, App Configuration no va a refrescar nuestras claves. Así que toca decidir por clave, cuando queremos que se refresque. En la documentación, se habla de poll model (por defecto), pero también hay disponible otra forma guiada por eventos llamada push model. De igual modo que sucedía con Key Vault, también hay límites en el servicio.

    options.ConfigureRefresh(refresh =>
    {
        refresh
            .Register("YOUR_KEY")
            .SetCacheExpiration(TimeSpan.FromMinutes(1));  // 30 seconds by default
    });

Lo que sí que se puede y parece bastante útil, es condicionar el refresco de todas las claves al cambio de una de ellas. De este modo puedes sentirte libre de hacer los cambios que creas oportunos en App Configuration para, finalmente, cambiar una clave (denominada sentinel o similar) que disparará la actualización del resto.

    // poll model
    // https://learn.microsoft.com/en-us/azure/azure-app-configuration/enable-dynamic-configuration-aspnet-core#add-a-sentinel-key
    options.ConfigureRefresh(refresh =>
    {
        refresh
            .Register("Sentinel", refreshAll: true)  // Refresh all keys when Sentinel changes
            .SetCacheExpiration(TimeSpan.FromMinutes(1));
    });

Como dijimos antes, en App Configuration una clave puede tener distintas versiones según su label, luego parece razonable filtrar por label durante la inicialización del proveedor de configuración.

    options.Select(KeyFilter.Any);  // Select all keys
    options.Select(KeyFilter.Any, "prod");  // When available, give me keys with label prod

Tanto el filtro por claves como por labels, permiten wildcards.

App Configuration va un paso más allá de configuración y referencias a Key Vault, también nos permite hacer una gestión de las feature toggles de nuestra aplicación. Si quieres ubicar mejor en el mapa a las feature toggles, te recomiendo leer este post.

El concepto detrás de las feature toggles es tener una clave activada o desactivada, y hacer una cosa (o dejar de hacerlo) en función del valor de la misma.

Lo primero es decirle a App Configuration que queremos usar las feature toggles.

    options.UseFeatureFlags((FeatureFlagOptions options) =>
    {
        // By default, 30 seconds is used as the cache expiration time.
        options.CacheExpirationInterval = TimeSpan.FromMinutes(1);
    });

    builder.Services.AddFeatureManagement();

A continuación, y esto es la magia que nos brinda App Configuration, podremos condicionar la ejecución de una acción de un controlador a que esté o no activada una feature toggle. También nos permite preguntar de forma imperativa por el estado de la feature toggle.

Para usar IFeatureManagerSnapshot debemos agregar un nuevo paquete:

dotnet add package Microsoft.FeatureManagement.AspNetCore
using Microsoft.AspNetCore.Mvc;
using Microsoft.FeatureManagement;
using Microsoft.FeatureManagement.Mvc;

namespace WebApplication1.Controllers;

[ApiController]
[Route("[controller]")]
public class FeatureFlagController : ControllerBase
{
    private readonly IFeatureManagerSnapshot _featureManagerSnapshot;

    public FeatureFlagController(IFeatureManagerSnapshot featureManagerSnapshot)
    {
        _featureManagerSnapshot = featureManagerSnapshot;
    }

    [HttpGet("feature_gate")]
    [FeatureGate("feature1")]
    public string GetWithFeatureGate()
    {
        // If feature1 is disabled, 404 will be sended to the client.
        return "Feature1 is enabled using a feature gate";
    }

    [HttpGet("feature_manager_snapshot")]
    public async Task<string?> GetWithFeatureManagerSnapshot()
    {
        if (await _featureManagerSnapshot.IsEnabledAsync("feature1"))
        {
            return "Feature1 is enabled using feature manager snapshot";
        }
        return null;
    }
}

Si queremos, podemos hacer el combo de gestionar las features toggles en appsettings.json.

{
    "FeatureManagement": {
        "feature1": true
    }
}

Las feature toggles también permiten hacer testing A/B, estar activadas durante X tiempo, etc. Parece algo muy potente, pero todavía no lo he probado.

Y aunque después de todo esto no parece necesario, si todavía quieres guardar tu configuración en base de datos, es relativamente sencillo crear un proveedor de configuración personalizado que haga “exactamente” lo que tu quieras. Y la clave está en “exactamente”, porque como decía el tío Ben “un gran poder conlleva una gran responsabilidad” y ahora serás sólo tú el responsable de agregar complejidad accidental a tu aplicación (que no estoy diciendo que no se haga, ojo, sólo quiero advertir de que un mal uso de algo tan transversal como la configuración podría suponer un problema para el rendimiento de tu aplicación).

dotnet add package Microsoft.Data.SqlClient
public class DatabaseConfigurationSource : IConfigurationSource
{
    private readonly string _connectionString;

    public DatabaseConfigurationSource(string connectionString)
    {
        _connectionString = connectionString;
    }
    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new DatabaseConfigurationProvider(_connectionString);
    }
}

public class DatabaseConfigurationProvider: ConfigurationProvider
{
    private readonly string _connectionString;

    public DatabaseConfigurationProvider(string connectionString)
    {
        _connectionString = connectionString;
    }

    public override void Load()
    {
        using var connection = new SqlConnection(_connectionString);
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = "SELECT [Key], Value FROM Configuration";
        using var reader = command.ExecuteReader();
        // If we are reloading configuration, we should clear Data first
        Data = new Dictionary<string, string>();
        while (reader.Read())
        {
            var key = reader.GetString(0);
            var value = reader.GetString(1);
            Data.Add(key, value);
        }
    }
}

public static class DatabaseConfigurationProviderExtensions
{
    public static IConfigurationBuilder AddDatabaseConfigurationProvider(
        this IConfigurationBuilder builder, string connectionString)
    {        
        return builder.Add(new DatabaseConfigurationSource(connectionString));
    }
}

Dicho todo lo anterior, me lo he pasado bien escribiendo el post y espero tú también leyéndolo.

¡Un saludo!


Ver también