Usar secretos con Dapr

El siguiente punto que creo que es importante investigar, en mi serie de artículos sobre Dapr, es el uso de secretos. Como sabes, estos pueden estar almacenados en un simple archivo de texto plano (en entornos de desarrollo) o incluso en servicios externos en proveedores cloud. Lo guay es que con Dapr tu aplicación se abstraerá de donde están tus secretos, y en este artículo quiero contarte cómo.

Aplicación de ejemplo

Como en la mayoría de los artículos de Dapr, voy a utilizar mi API Tour of heroes como ejemplo. Esta utiliza un SQL Server como base de datos y voy a modificar la configuración en Program.cs para que la cadena de conexión no la obtenga de appsettings.json sino que la recupere a través de Dapr. Hasta ahora la misma estaba de la siguiente forma:

using Dapr.Client;
using Microsoft.EntityFrameworkCore;
using tour_of_heroes_api.Models;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddDaprClient();
builder.Services.AddDbContext<HeroContext>(
    opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
);
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Como ves, en el apartado builder.Services.AddDbContext<HeroContext> se recupera la cadena de conexión de la configuración, en appsettings.json. Esto es lo que vamos a mejorar.

Configurar el componente secret Store con un archivo local

Lo primero que voy a mostrarte es como configurar un componente del tipo secret store con el ejemplo más básico, que es basando el mismo en un archivo de texto:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: heroessecretstore
spec:
  type: secretstores.local.file
  version: v1
  metadata:
  - name: secretsFile
    value: secrets.json  #path to secrets file
  - name: nestedSeparator
    value: ":"

Esta configuración queda almacenada en el directorio components, junto con el resto que he ido configurando en los artículos anteriores. El archivo de texto, secrets.json, tiene la siguiente pinta:

{
    "sql-connection-string": "Server=localhost,1433;Initial Catalog=heroes;Persist Security Info=False;User ID=sa;Password=Password1!"
}

Por último, para que poder usar este componente, modifica el archivo Program.cs de la siguiente manera:

using Dapr.Client;
using Microsoft.EntityFrameworkCore;
using tour_of_heroes_api.Models;
const string SECRET_STORE_NAME = "heroessecretstore";
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddDaprClient();
using var daprClient = new DaprClientBuilder().Build();
// builder.Services.AddDbContext<HeroContext>(
//     opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
// );
await daprClient.WaitForSidecarAsync();
var connectionString = await daprClient.GetSecretAsync(SECRET_STORE_NAME, "sql-connection-string");
builder.Services.AddDbContext<HeroContext>(opt => opt.UseSqlServer(connectionString.First().Value));
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Ahora, con daprClient.GetSecretAsync, utilizo la secret store que he llamado heroessecretstore para recuperar la cadena de conexión. Es importante, antes de recuperar el secreto, esperar a que el sidecar esté disponible, haciendo uso de daprClient.WaitForSidecarAsync ya que en entornos dockerizados puedes encontrarte con el problema de que la API intente recuperar esta información antes de que el sidecar esté listo y de error.

Ahora que ya tienes todo configurado lo último que queda es probarlo. Crea la base de datos en Docker y ejecuta el entorno pulsando F5 en Visual Studio Code. Lanzas las siguientes llamadas, con la ayuda de la extensión REST Client, para llenar la base de datos si no la tenías:

#### Fill database ###
POST http://localhost:5222/api/hero HTTP/1.1
content-type: application/json
{
    "name": "Batman",
    "description": "Un multimillonario magnate empresarial y filántropo dueño de Empresas Wayne en Gotham City. Después de presenciar el asesinato de sus padres, el Dr. Thomas Wayne y Martha Wayne en un violento y fallido asalto cuando era niño, juró venganza contra los criminales, un juramento moderado por el sentido de la justicia.",
    "alterEgo": "Bruce Wayne" 
   
}
###
POST  http://localhost:5222/api/hero HTTP/1.1
content-type: application/json
{
    "name": "Spiderman",
    "description": "un joven huérfano neoyorquino que adquiere superpoderes después de ser mordido por una araña radiactiva, y cuya ideología como héroe se ve reflejada primordialmente en la expresión «un gran poder conlleva una gran responsabilidad».20​21​ Suele ser asociado con una personalidad bromista, amable, inventiva y optimista, lo que le ha llevado a ser catalogado como el «vecino amigable» de cualquiera lo cual, aunado a sus vivencias caracterizadas por los problemas cotidianos.",
    "alterEgo": "Peter Parker"
}
###
POST  http://localhost:5222/api/hero HTTP/1.1
content-type: application/json
{
    "name": "Luke Cage",
    "description": "Es un exconvicto encarcelado por un crimen que no cometió, que gana los poderes de la fuerza sobrehumana y la piel irrompible después de someterse voluntariamente a un procedimiento experimental.",
    "alterEgo": "Carl Lucas"  
}
###
POST  http://localhost:5222/api/hero HTTP/1.1
content-type: application/json
{
    "name": "Thor",
    "description": "Es el dios del trueno y fuerza en la mitología nórdica y germánica. Su papel es complejo ya que tenía influencia en áreas muy diferentes, tales como el clima, las cosechas, la protección, la consagración, la justicia, las lidias, los viajes y las batallas.",
    "alterEgo": "Thor"  
}

###
GET  http://localhost:5222/api/hero HTTP/1.1

Si todo ha ido bien, podrás lanzar cada una de las peticiones sin problemas y acceder a la base de datos obteniendo la cadena de conexión a través de Dapr.

Configurar un secret Store con Azure Key Vault

Ahora que ya has visto cómo funciona Dapr con el componente de tipo secret store, vamos a modificar el mismo para que en lugar de recuperar la cadena de conexión del archivo local, secrets.json, lo haga de un Azure Key Vault. Si no tienes uno puedes crearlo con los siguientes comandos:

# Create an Azure Key Vault and authorize a Service Principal
### Variables ###
LOCATION="northeurope"
RESOURCE_GROUP="dapr-experiments"
KEYVAULT_NAME="azkeyvaultdaprexp"
AZ_AD_APP_NAME="tour-of-heroes-dapr"
# Create Azure AD app
APP_ID=$(az ad app create --display-name "${AZ_AD_APP_NAME}"  | jq -r .appId)
# Create a client secret valid for 2 years
az ad app credential reset --id "${APP_ID}" --years 2
# Create a service principal
SERVICE_PRINCIPAL_ID=$(az ad sp create --id "${APP_ID}"  | jq -r .id)
# Create a resource group
RESOURE_GROUP_ID=$(az group create --name "${RESOURCE_GROUP}" --location $LOCATION  | jq -r .id)
# Create an Azure Key Vault
az keyvault create \
  --name "${KEYVAULT_NAME}" \
  --enable-rbac-authorization true \
  --resource-group "${RESOURCE_GROUP}" \
  --location "${LOCATION}"
# Assign a role to the service principal
az role assignment create \
  --assignee "${SERVICE_PRINCIPAL_ID}" \
  --role "Key Vault Secrets User" \
  --scope "${RESOURE_GROUP_ID}/providers/Microsoft.KeyVault/vaults/${KEYVAULT_NAME}"
# Assing role to me
az role assignment create \
  --assignee $(az ad signed-in-user show --query id -o tsv) \
  --role "Key Vault Secrets Officer" \
  --scope "${RESOURCE_GROUP_ID}/providers/Microsoft.KeyVault/vaults/${KEYVAULT_NAME}"
# Add new secret to the az key vault
az keyvault secret set -n sql-connection-string \
--value 'Server=localhost,1433;Initial Catalog=heroes;Persist Security Info=False;User ID=sa;Password=Password1!' \
--vault-name $KEYVAULT_NAME

Con la salida de los comando anteriores, ahora modifica el secret store de la siguiente manera:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: heroessecretstore
spec:
  type: secretstores.azure.keyvault
  version: v1
  metadata:
  - name: vaultName
    value: "azkeyvaultdaprexp"
  - name: azureTenantId
    value: "<YOUR_TENANT_ID>"
  - name: azureClientId
    value: "<YOUR_CLIENT_ID>"
  - name: azureClientSecret
    value : "<YOUR_CLIENT_SECRET>"

Como el nombre del componente, y el del secreto, es el mismo, el código para la obtención de la cadena de conexión no varía, solo la configuración.

Si vuelves a ejecutar la API y accedes a http://localhost:5222/api/hero comprobarás que la cadena de conexión se recupera correctamente, ahora desde Azure Key Vault.

¡Saludos!