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 Azuretraining2
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:
Ahora vamos a agregar el nuget para poder trabajar con SQL:
1dotnet add package Microsoft.EntityFrameworkCore.SqlServer2
1dotnet add package Microsoft.EntityFrameworkCore.InMemory2
Incorporaremos una nueva clase al modelo, para este ejemplo definiremos un modelo de ejemplo llamado «Courses» dentro de nuestra carpeta Models:
1namespace Azuretraining.Models2{3 public class Course4 {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.Models3{4 public class CourseContext : DbContext5 {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.Design2
1dotnet add package Microsoft.EntityFrameworkCore.Design2
1dotnet tool install --global dotnet-aspnet-codegenerator2
1dotnet aspnet-codegenerator controller -name CoursesController -async -api -m Course -dc CourseContext -outDir Controllers2
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.
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.02
1az sql db create --subscription "NOMBRE DE LA SUSCRIPCIÓN" --resource-group TrainingApp --server trainginappdb --name TrainingApp --service-objective S02
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-core2FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build3 WORKDIR /app4# copy csproj and restore as distinct layers5 COPY *.csproj ./6 RUN dotnet restore7# copy everything else and build app8 COPY . ./9 RUN dotnet publish -c release -o out --no-restore10# final stage/image11 FROM mcr.microsoft.com/dotnet/core/aspnet:3.112 WORKDIR /app13 COPY --from=build /app/out .14 ENTRYPOINT ["dotnet", "azuretraining.dll"]15
Y un fichero «.dockerignore» en nuestra solución con el siguiente contenido:
1# directories2**/bin/3**/obj/4**/out/5# files6Dockerfile*7**/*.md8
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:
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:
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:
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.KeyVault2
1dotnet add package Microsoft.Azure.Services.AppAuthentication2
1dotnet add package Microsoft.Extensions.Configuration.AzureKeyVault2
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# debugging3 // Use hover for the description of the existing attributes4 // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md5 "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-WebBrowser18 "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:
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:
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 192
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: bridge5services:6 azuretraining.services.courses:7 container_name: Azuretraining.Services8 build:9 context: ../10 dockerfile: ./Azuretraining.Dockerfile11 ports:12 - "8001:80"13 networks:14 - azuretraining.services.network15 volumes:16 - ~/.azure:/root/.azure17 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-core2FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build3 WORKDIR /app4# copy csproj and restore as distinct layers5 COPY *.csproj ./6 RUN dotnet restore7# copy everything else and build app8 COPY . ./9 RUN dotnet publish -c release -o out --no-restore10# final stage/image11 FROM mcr.microsoft.com/dotnet/core/aspnet:3.112# install azure cli13ENV DEBIAN_FRONTEND noninteractive14RUN 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) installed18 && 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 /app24 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 up2
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