Azure KeyVault + docker

En este articulo quiero explicaros como podemos desplegar nuestro entorno de desarrollo de una forma fácil y segura. Para ello vamos a usar docker para poder crear un contenedor con nuestra aplicación "API NET Core 3.1" que conecta contra nuestra base de datos Azure SQL de forma segura sin tener que exponer nuestras credenciales.  Docker es una plataforma para desarrolladores y sysadmins (utilizando la filosofía DevOps) que nos permite desarrollar, desplegar y ejecutar aplicaciones en contenedores de una forma fácil y sencilla.

Para ello vamos a empezar creando nuestra API en .NET Core 3.1, pare ello usaremos el siguiente comando:

1dotnet new API -n Azuretraining
2

Esto nos creara la estructura de nuestro proyecto con el nombre que le hemos asignado «Azuretraining» tal y como podemos observar en la siguiente imagen:

Imagen 1.- Estructura del proyecto tras el lanzamiento del comando anteriormente mencionado

Ahora vamos a agregar el nuget para poder trabajar con SQL:

1dotnet add package Microsoft.EntityFrameworkCore.SqlServer
2
1dotnet add package Microsoft.EntityFrameworkCore.InMemory
2

Incorporaremos una nueva clase al modelo, para este ejemplo definiremos un modelo de ejemplo llamado «Courses» dentro de nuestra carpeta Models:

1namespace Azuretraining.Models
2{
3    public class Course
4    {
5        public long Id { get; set; }
6        public string Name { get; set; }
7        public string Description { get; set; }
8        public DateTime StartDate { get; set; }
9        public DateTime EndDate { get; set; }
10        public int Capacity {get; set;}
11        public double Qualification {get; set;}
12        public string Modality {get; set;}
13        public string Category {get; set;}
14        public bool IsComplete { get; set; }
15    }
16}
17

Una vez tenemos nuestro modelo incorporaremos el contexto de base de datos, será la clase principal que coordina la funcionalidad de Entity Framework para un modelo de datos. Para ello agregaremos a nuestra carpeta Models un nuevo fichero llamado «CourseContext.cs» con el siguiente formato:

1using Microsoft.EntityFrameworkCore;
2namespace Azuretraining.Models
3{
4    public class CourseContext : DbContext
5    {
6        public CourseContext(DbContextOptions<CourseContext> options)
7            : base(options)
8        {
9        }
10        public DbSet<Course> Courses { get; set; }
11    }
12}
13

Ahora deberemos de modificar nuestro fichero «Startup.cs» agregando las referencias necesarias para usar los servicios:

1using Microsoft.EntityFrameworkCore;
2using Azuretraining.Models;
3

Y modificaremos nuestra función «ConfigureServices» para que tenga el siguiente aspecto:

1public void ConfigureServices(IServiceCollection services)
2{
3    services.AddDbContext<CourseContext>(opt =>
4        opt.UseInMemoryDatabase("CourseList"));
5    services.AddControllers();
6}
7

Esto nos proporcionará poder usar una BD en memoria para realizar unas primeras pruebas antes de atacar a nuestra BD en la nube. Una vez lo tenemos todo preparado vamos a agregar los Nugets necesarios para poder hacer el scaffolding y generar de forma automática nuestro Controller con las siguientes instrucciones:

1dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
2
1dotnet add package Microsoft.EntityFrameworkCore.Design
2
1dotnet tool install --global dotnet-aspnet-codegenerator
2
1dotnet aspnet-codegenerator controller -name CoursesController -async -api -m Course -dc CourseContext -outDir Controllers
2

Veremos que en la carpeta Controller nos ha generado el archivo «CoursesController.cs» donde tendremos definida todas las acciones de nuestra API. Ahora ejecutaremos nuestra aplicación y en un explorador introducimos la siguiente URL: https://localhost:5001/api/courses.

Imagen 2.- Resultado de la invocación de la API al controlador Courses.

Ahora vamos a modificar nuestra aplicación para que conecte directamente con nuestra BD de Azure SQL. Para ello previamente deberemos haber creado nuestra instancia, podemos usar Azure CLI para poder hacerlo como se muestra en el siguiente ejemplo:

1az sql server create --subscription "NOMBRE DE LA SUSCRIPCIÓN" --name trainginappDB --resource-group TrainingApp --location "West Europe" --admin-user "NOMBRE DE USUARIO" --admin-password "PASSWORD"
2
1az sql server firewall-rule create --subscription "NOMBRE DE LA SUSCRIPCIÓN"  --resource-group TrainingApp --server trainginappdb --name AllowAllIps --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
2
1az sql db create --subscription "NOMBRE DE LA SUSCRIPCIÓN" --resource-group TrainingApp --server trainginappdb --name TrainingApp --service-objective S0
2

Una vez tenemos creada nuestra instancia de BD en Azure SQL, vamos a preparar nuestra solución para «dockerizar», para ello generaremos un fichero. Dockerfile con el siguiente contenido:

1# https://hub.docker.com/_/microsoft-dotnet-core
2FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
3  WORKDIR /app
4# copy csproj and restore as distinct layers
5  COPY *.csproj ./
6  RUN dotnet restore
7# copy everything else and build app
8  COPY . ./
9  RUN dotnet publish -c release -o out --no-restore
10# final stage/image
11  FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
12  WORKDIR /app
13  COPY --from=build /app/out .
14  ENTRYPOINT ["dotnet", "azuretraining.dll"]
15

Y un fichero «.dockerignore» en nuestra solución con el siguiente contenido:

1# directories
2**/bin/
3**/obj/
4**/out/
5# files
6Dockerfile*
7**/*.md
8

Para nuestra cadena de conexión usaremos Azure KeyVault para poder proteger nuestro «secretos», para ello iremos al portal de Azure y crearemos un nuevo Azure KeyVault. Una vez creado vamos a Secrets -> Generate/Import como se puede apreciar en la siguiente captura:​

Imagen 3.- Azure KeyVault en el portal de Azure.

En la siguiente pantalla deberemos de indicar que es una entrada manual, le damos un nombre a nuestro secreto, en este caso «ConnectionStrings–TrainingConnection» esto se debe a que en nuestro fichero «appsettings.json» tenemos la definición de nuestro ConnectionStrings de la siguiente forma y para que el KeyVault pueda insertar el valor en tiempo de ejecución debemos de separarlos con «–» el nombre concatenando la relación padre-hijo:

Imagen 4.- Contenido del fichero appsettings.json

Ahora añadimos la cadena de conexión hacia nuestro Azure SQL que nos facilita cuando creamos el servicio, como se puede apreciar en la siguiente captura:

Imagen 5.- Alta de un secreto en Azure KeyVault desde el portal de Azure.

Una vez que ya tenemos nuestro KeyVault para poder proteger nuestros «secretos» vamos a modificar nuestro proyecto para poder usarlo, para ello necesitaremos añadir los siguiente Nugets:

1dotnet add package Microsoft.Azure.KeyVault
2
1dotnet add package Microsoft.Azure.Services.AppAuthentication
2
1dotnet add package Microsoft.Extensions.Configuration.AzureKeyVault
2

Modificaremos nuestro archivo «Program.cs» añadiremos los imports necesarios:

1using Microsoft.Azure.KeyVault;
2using Microsoft.Azure.Services.AppAuthentication;
3using Microsoft.Extensions.Configuration;
4using Microsoft.Extensions.Configuration.AzureKeyVault;
5

Sustituiremos el método IHostBuilder para poder obtener la información de nuestro KeyVault y asignarlo el siguiente formato:

1public static IHostBuilder CreateHostBuilder(string[] args) =>
2    Host.CreateDefaultBuilder(args)
3        .ConfigureAppConfiguration((ctx, builder) =>
4        {
5            var keyVaultEndpoint = GetKeyVaultEndpoint();
6            if (!string.IsNullOrEmpty(keyVaultEndpoint))
7            {
8                var azureServiceTokenProvider = new AzureServiceTokenProvider();
9                var keyVaultClient = new KeyVaultClient(
10                    new KeyVaultClient.AuthenticationCallback(
11                        azureServiceTokenProvider.KeyVaultTokenCallback));
12                builder.AddAzureKeyVault(
13                    keyVaultEndpoint, keyVaultClient, new DefaultKeyVaultSecretManager());
14            }
15        })
16        .ConfigureWebHostDefaults(webBuilder =>
17        {
18            webBuilder.UseStartup<Startup>();
19        });
20static string GetKeyVaultEndpoint() => Environment.GetEnvironmentVariable("KEYVAULT_ENDPOINT");
21

Ahora agregaremos en el environment (todo esto lo hacemos para que la acción se realice en tiempo de ejecución), para ello nos fijaremos que en la última linea de nuestro «Program.cs» indicábamos obtener de la variable «KEYVAULT_ENDPOINT» en ella declararemos la URL de nuestro Azure KeyVault, esta información la deberemos de añadirla a nuestro fichero «launch.json» con el siguiente formato:

1{
2   // Use IntelliSense to find out which attributes exist for C# debugging
3   // Use hover for the description of the existing attributes
4   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
5   "version": "0.2.0",
6   "configurations": [
7        {
8            "name": ".NET Core Launch (web)",
9            "type": "coreclr",
10            "request": "launch",
11            "preLaunchTask": "build",
12            // If you have changed target frameworks, make sure to update the program path.
13            "program": "${workspaceFolder}/bin/Debug/netcoreapp3.1/trainingapp.courses.dll",
14            "args": [],
15            "cwd": "${workspaceFolder}",
16            "stopAtEntry": false,
17            // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
18            "serverReadyAction": {
19                "action": "openExternally",
20                "pattern": "^\\s*Now listening on:\\s+(https?://\\S+)"               
21            },
22            "env": {
23                "ASPNETCORE_ENVIRONMENT": "Development",
24                "KEYVAULT_ENDPOINT": "https://NOMBREDENUESTROKEYVAULT.vault.azure.net/"
25            },
26            "sourceFileMap": {
27                "/Views": "${workspaceFolder}/Views"
28            }
29        },
30        {
31            "name": ".NET Core Attach",
32            "type": "coreclr",
33            "request": "attach",
34            "processId": "${command:pickProcess}"
35        }
36    ]
37}
38

Por último, sustituiremos en nuestro fichero «Startup.cs» la conexión de la BD en memoria por la conexión hacia nuestro Azure SQL con la configuración que hemos preparado en los pasos anteriores, y quedará de la siguiente forma:​

 Imagen 6.- Contenido del fichero Startup.cs.

Este primer servicio es la conexión que realizamos para conectar con nuestra «Cadena de conexión» securizada:

1services.AddDbContext(options =>
2                 options.UseSqlServer(Configuration.GetConnectionString("TrainingConnection")));
3

Este segundo servicio nos permitirá crear las tabla y estructura iniciales en caso de que no lo tengamos:

1services.BuildServiceProvider().GetService().Database.Migrate();
2

Ahora lanzamos nuestra aplicación y vemos que nos ha funcionado correctamente:​

image7

ATENCIÓN: Como hemos podido ver hasta aquí lo único que hemos hecho es indicar la url de nuestro Azure KeyVault para poder recuperar la información de la cadena de conexión, pero el «truco» es que si no estamos logados en nuestro azure CLI en local no podremos usarlo y nos devolverá el siguiente error:

1Startup.cs(34,13): warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. [/Users/msanchez/Projects/Azuretraining/Azuretraining.csproj]  Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'connectionString')     at Microsoft.EntityFrameworkCore.Utilities.Check.NotEmpty(String value, String parameterName)     at Microsoft.EntityFrameworkCore.SqlServerDbContextOptionsExtensions.UseSqlServer(DbContextOptionsBuilder optionsBuilder, String connectionString, Action1 sqlServerOptionsAction)    at Azuretraining.Startup.<ConfigureServices>b__4_0(DbContextOptionsBuilder options) in /Users/msanchez/Projects/Azuretraining/Startup.cs:line 32    at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.<>c__DisplayClass1_02.b__0(IServiceProvider p, DbContextOptionsBuilder b)     at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.CreateDbContextOptions[TContext](IServiceProvider applicationServiceProvider, Action2 optionsAction)    at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.<>c__DisplayClass10_01.b__0(IServiceProvider p)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite singletonCallSite, RuntimeResolverContext context)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSite(ServiceCallSite callSite, TArgument argument)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite singletonCallSite, RuntimeResolverContext context)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSite(ServiceCallSite callSite, TArgument argument)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass1_0.b__0(ServiceProviderEngineScope scope)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)     at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType)     at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(Type serviceType)     at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T](IServiceProvider provider)     at Azuretraining.Startup.ConfigureServices(IServiceCollection services) in /Users/msanchez/Projects/Azuretraining/Startup.cs:line 34     at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)     at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)     at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.InvokeCore(Object instance, IServiceCollection services)     at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.<>c__DisplayClass9_0.g__Startup|0(IServiceCollection serviceCollection)     at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.Invoke(Object instance, IServiceCollection services)     at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.<>c__DisplayClass8_0.b__0(IServiceCollection services)     at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.UseStartup(Type startupType, HostBuilderContext context, IServiceCollection services)     at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.<>c__DisplayClass12_0.b__0(HostBuilderContext context, IServiceCollection services)     at Microsoft.Extensions.Hosting.HostBuilder.CreateServiceProvider()     at Microsoft.Extensions.Hosting.HostBuilder.Build()     at Azuretraining.Program.Main(String[] args) in /Users/msanchez/Projects/Azuretraining/Program.cs:line 19
2

Este error nos dará si intentamos ejecutar nuestro contenedor de docker, para solventarlo deberemos de aplicar un «work around» que nos permita poder trabajar sin problemas y a la vez que subimos el código a cualquier repositorio de código no tengamos que mostrar nuestras cadenas de conexión o información sensible. Para ello lo que vamos a hacer es añadir un nuevo fichero llamado docker-compose.yml con la siguiente composición:

1version: "3.7"
2networks:
3    azuretraining.services.network:
4        driver: bridge
5services:
6    azuretraining.services.courses:
7        container_name: Azuretraining.Services
8        build:
9          context: ../
10          dockerfile: ./Azuretraining.Dockerfile  
11        ports:
12            - "8001:80"
13        networks:
14            - azuretraining.services.network
15        volumes:
16            - ~/.azure:/root/.azure  
17        environment:
18            - KEYVAULT_ENDPOINT=https://NOMBREDENUESTROKEYVAULT.vault.azure.net/
19

En nuestro docker-compose hemos definido la estructura de ejecución de nuestro servicio, en este caso solo tenemos un contenedor, donde le indicamos el network, puerto, nombre del contenedor, etc. En este caso lo más importante son las propiedades volumes y environment. En el environment agregaremos nuestra url del Azure KeyVault, y en volumes lo que vamos a hacer es crear un volumen compartido donde copiaremos nuestra carpeta local de Azure para que podamos hacer sin ningún problema login con Azure CLI. Lo más importante es que, aunque esta carpeta se suba no compromete nuestra seguridad pues no tiene nada vinculante.

Ahora modificaremos nuestro fichero .dockerfile para incluirle el Azure CLI y que podamos consumir la conexión hacia nuestro Azure KeyVault desde nuestro contenedor:

1# https://hub.docker.com/_/microsoft-dotnet-core
2FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
3  WORKDIR /app
4# copy csproj and restore as distinct layers
5  COPY *.csproj ./
6  RUN dotnet restore
7# copy everything else and build app
8  COPY . ./
9  RUN dotnet publish -c release -o out --no-restore
10# final stage/image
11  FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
12# install azure cli
13ENV DEBIAN_FRONTEND noninteractive
14RUN apt-get update \
15    && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \
16    #
17    # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed
18    && apt-get -y install git openssh-client iproute2 procps apt-transport-https gnupg2 curl lsb-release \
19    && echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \
20    && curl -sL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 2>/dev/null \
21    && apt-get update \
22    && apt-get install -y azure-cli;
23  WORKDIR /app
24  COPY --from=build /app/out .
25  ENTRYPOINT ["dotnet", "azuretraining.dll"]
26

Por último, solo nos queda lanzar el siguiente comando para ejecutar nuestra aplicación en local «dockerizada» y «securizada»:

1docker-compose up
2

De esta forma tendremos nuestro proyecto completamente securizado pudiendo trabajar de forma fácil y sencilla, sin preocuparnos de que subamos información sensible a nuestro repositorio de código.

Conclusiones

Pienso que Azure KeyVault nos permite centralizar el almacenamiento de los secretos de aplicación. Esto nos permite controlar su distribución. Una de las grandes ventajas es que reduce en gran medida las posibilidades de que se puedan filtrar por accidente los secretos. Al no tener que almacenar información de seguridad en las aplicaciones elimina la necesidad de que esta información sea parte del código.

Manuel Sánchez Rodríguez
Manuss20@gmail.com
@manuss20
https://manuss20.com

Siguemos en LinkedInSiguemos en Twitter
Powered by  ENCAMINA