diff --git a/docs/assets/azure-ad-token.png b/docs/assets/azure-ad-token.png new file mode 100644 index 0000000..6420059 Binary files /dev/null and b/docs/assets/azure-ad-token.png differ diff --git a/docs/assets/catalog-api-managed-identity.png b/docs/assets/catalog-api-managed-identity.png new file mode 100644 index 0000000..4ac6146 Binary files /dev/null and b/docs/assets/catalog-api-managed-identity.png differ diff --git a/docs/assets/redis-add-new-user.png b/docs/assets/redis-add-new-user.png new file mode 100644 index 0000000..748575f Binary files /dev/null and b/docs/assets/redis-add-new-user.png differ diff --git a/docs/assets/redis-assign-role-to-user.png b/docs/assets/redis-assign-role-to-user.png new file mode 100644 index 0000000..4590e7e Binary files /dev/null and b/docs/assets/redis-assign-role-to-user.png differ diff --git a/docs/assets/redis-enable-aad.png b/docs/assets/redis-enable-aad.png new file mode 100644 index 0000000..4c809f3 Binary files /dev/null and b/docs/assets/redis-enable-aad.png differ diff --git a/docs/assets/redis-validate-user-creation.png b/docs/assets/redis-validate-user-creation.png new file mode 100644 index 0000000..a6b50ea Binary files /dev/null and b/docs/assets/redis-validate-user-creation.png differ diff --git a/docs/workshop.md b/docs/workshop.md index 7ea921e..fa217ed 100644 --- a/docs/workshop.md +++ b/docs/workshop.md @@ -1339,6 +1339,158 @@ As a side note, we really encourage you to take the time to dig in the toolbox o --- +# Lab 5 : AAD + RBAC + +In this Lab you will focus on securing connections to Azure Cache for Redis by replacing secrets (e.g. connection strings) with [AAD-integration and RBAC][redis-aad-auth]. + +![Using AAD to connect to Azure Cache for Redis](./assets/azure-ad-token.png) + +Here are the steps for securing your application with AAD and RBAC: +- You enable the AAD-integration in your Azure Cache for Redis resource. +- You assign a role to your application's identity (e.g. System assigned identity) to allow it to access data in Redis. +- Your application requests a token from Azure AD. This can be done automatically using a Redis client library (if it supports AAD-integration) otherwise you need to use an authentication library like [Microsoft Authentication Library][msal] to get the token. +- The application then uses that token as a password to establish a connection to Azure Cache for Redis. +- The application uses the connection to communicate with Redis. +- Before the expiry of the AAD token, your application needs to refresh the token (e.g. via [MSAL][msal]) to avoid losing access to the Azure Cache for Redis instance. + +The goal of this lab is to update `catalog-api` to use AAD and RBAC instead of a Connection String. +`catalog-api` is already using [Microsoft.Azure.StackExchangeRedis][microsoft-azure-stackexchangeredis] which extends the Redis client [StackExchange.Redis][stackexchange-redis] to support AAD-integration by handling AAD token fetching and refreshing. + +## Enabling AAD-integration + +
+ +> Enable `AAD access authorization` on your Azure Cache for Redis resource from the `Advanced settings` menu + +
+ +
+ +> This operation may take few minutes to take effect in a real-life scenario but in this lab it should be fairly quick + +
+ +
+ +Toggle solution + +Head to the `Advanced settings` menu and then check the checkbox of `AAD access authorization`: + +![Enabling AAD access authorization](./assets/redis-enable-aad.png) + +
+ +## Assigning a role + +
+ +> Assign the `Data Contributor` role to the system-assigned identity of your `catalog-api` + +
+ +
+ +> [Configure RBAC with Data Access Policy][configure-rbac-with-data-access-policy] + +
+ +
+ +Toggle solution + +Head to the `Data Access Configuration` menu, then click on the `Add` button, and choose `New Redis User`: + +![Adding a new Redis user](./assets/redis-add-new-user.png) + +Select the `Data Contributor` policy, click on the `Redis Users` tab and select `Managed Identity`, and then click on the `Select member` button. + +![Assigning a role to the user](./assets/redis-assign-role-to-user.png) + +Select `App Service` in the type of Managed Identity, then select your App Service Web App in which `catalog-api` is deployed, and validate your selection by clicking the `Select` button. + +Finally click on `Review + assign` to crate the new Redis user: + +![Validating user creation](./assets/redis-validate-user-creation.png) + +
+ + +## Updating catalog-api to use the AAD-integration + +So far, you were using a Connection String to connect to the Azure Cache for Redis resource. + +Connections are currently established using the following code: + +```csharp +var connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(_connectionString!, AzureCacheForRedis.ConfigureForAzure); +``` + +To use system-assigned identities, the code above needs to be replaced with: + +```csharp +var configurationOptions = await ConfigurationOptions.Parse($"{_hostname}:{_port}").ConfigureForAzureWithSystemAssignedManagedIdentityAsync(_managedIdentityPrincipalId!); +var connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configurationOptions); +``` + +
+ +> - Update the code of the `GetDatabaseAsync` method in `src/catalog-api/RedisService.cs` to use system-assigned identity of the App Service Web App +> - Set the value of the `AZURE_MANAGED_IDENTITY_PRINCIPAL_ID` app setting to the system-assigned identity of the App Service Web App (`Object (principal) ID`) + +
+ +
+ +> The variables `_hostname`, `_port`, and `_managedIdentityPrincipalId` were already defined in `src/catalog-api/RedisService.cs` + +
+ +
+ +Toggle solution + +Open the file `src/catalog-api/RedisService.cs` and locate the `GetDatabaseAsync` method. + +Replace the `connectionMultiplexer` variable initialization logic in the first code block above (which uses a connection string) with the contents of the second code block (which uses a managed identity principal ID). + +Next, you need to re-deploy your code to App Service like what you did in Lab 1 using the Visual Studio Code Azure extension and the `Deploy to Web App...` option. + +Afterwards, go to the `Identity` menu on your App Service resource, and copy the value of `Object (principal) ID`. + +![Managed identity of catalog-api](./assets/catalog-api-managed-identity.png) + +Finally, go to the `Configuration` menu of your App Service resource and set the app setting `AZURE_MANAGED_IDENTITY_PRINCIPAL_ID` to the value of `Object (principal) ID`. + +Validate the change by clicking `Ok`, then `Save` and you should be all set now. + +
+ +## Testing the new setup + +
+ +> Use the Web App to view some products and ensure `catalog-api` can still use your Azure Cache for Redis instance + +
+ +
+ +Toggle solution + +Open the Static Web App and make sure you can still see a list of products: + +![Viewing products in the Web App](./assets/webapp-view-products.png) + +
+ +[redis-aad-auth]: https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-azure-active-directory-for-authentication +[microsoft-azure-stackexchangeredis]: https://github.com/Azure/Microsoft.Azure.StackExchangeRedis +[stackexchange-redis]: https://github.com/StackExchange/StackExchange.Redis +[configure-rbac-with-data-access-policy]: https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-configure-role-based-access-control +[msal]: https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-overview + +--- + # Closing the workshop The **Product Hands on Lab : Azure Cache for Redis in Azure World** comes to an end : We hope you liked practicing with Azure solutions and that this lab will help you kick start your journey to caching in Azure. diff --git a/solutions/catalog-api/RedisService.cs b/solutions/catalog-api/RedisService.cs new file mode 100644 index 0000000..34f9cc9 --- /dev/null +++ b/solutions/catalog-api/RedisService.cs @@ -0,0 +1,106 @@ +using Microsoft.Azure.StackExchangeRedis; +using StackExchange.Redis; + +public interface IRedisService { + Task Get(string key); + Task Set(string key, string value); + Task AddToStream(string streamName, Dictionary data); +} + +public class RedisService : IRedisService +{ + private IDatabase? _database = null; + private readonly string? _connectionString; + private readonly string? _managedIdentityPrincipalId; + private readonly string? _hostname; + private readonly string? _port; + + private readonly int _defaultTTLInSeconds = 60; + private readonly TimeSpan _ttl; // Time To Live + + public RedisService(IConfiguration configuration) + { + _ttl = TTL(configuration["AZURE_REDIS_TTL_IN_SECONDS"]); + _connectionString = configuration["AZURE_REDIS_CONNECTION_STRING"]; + _managedIdentityPrincipalId = configuration["AZURE_MANAGED_IDENTITY_PRINCIPAL_ID"]; + _port = configuration["AZURE_REDIS_PORT"]; + _hostname = configuration["AZURE_REDIS_HOSTNAME"]; + } + + private async Task GetDatabaseAsync() { + if (_database != null) { + return _database; + } + + Console.WriteLine("Initializing Redis database connection"); + + // var connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(_connectionString!, AzureCacheForRedis.ConfigureForAzure); + var configurationOptions = await ConfigurationOptions.Parse($"{_hostname}:{_port}").ConfigureForAzureWithSystemAssignedManagedIdentityAsync(_managedIdentityPrincipalId!); + var connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(configurationOptions); + + _database = connectionMultiplexer.GetDatabase(); + + Console.WriteLine("Redis database connection established"); + + return _database; + } + + private TimeSpan TTL(string? ttlInSecondsAsString) + { + int ttlInSeconds; + + try + { + ttlInSeconds = string.IsNullOrEmpty(ttlInSecondsAsString) ? _defaultTTLInSeconds : int.Parse(ttlInSecondsAsString); + } + catch + { + ttlInSeconds = _defaultTTLInSeconds; + } + + if (ttlInSeconds <= 0) { + ttlInSeconds = _defaultTTLInSeconds; + } + + return TimeSpan.FromSeconds(ttlInSeconds); + } + + public async Task Get(string key) + { + try + { + var database = await GetDatabaseAsync(); + var value = await database.StringGetAsync(key); + var stringValue = value.ToString(); + + if (stringValue == string.Empty) { + return null; + } + + return stringValue; + } + catch + { + return null; + } + } + + public async Task Set(string key, string value) + { + var database = await GetDatabaseAsync(); + await database.StringSetAsync(key, value, _ttl); + } + + public async Task AddToStream(string streamName, Dictionary data) + { + List entries = new(); + + foreach(KeyValuePair keyValuePair in data) + { + entries.Add(new(keyValuePair.Key, keyValuePair.Value)); + } + + var database = await GetDatabaseAsync(); + await database.StreamAddAsync(streamName, entries.ToArray()); + } +} \ No newline at end of file diff --git a/src/catalog-api/RedisService.cs b/src/catalog-api/RedisService.cs index 1e0f36c..5affafb 100644 --- a/src/catalog-api/RedisService.cs +++ b/src/catalog-api/RedisService.cs @@ -1,3 +1,4 @@ +using Microsoft.Azure.StackExchangeRedis; using StackExchange.Redis; public interface IRedisService { @@ -8,16 +9,38 @@ public interface IRedisService { public class RedisService : IRedisService { - // TODO: provide a sample with AAD authentication - private readonly IDatabase _database; + private IDatabase? _database = null; + private readonly string? _connectionString; + private readonly string? _managedIdentityPrincipalId; + private readonly string? _hostname; + private readonly string? _port; + private readonly int _defaultTTLInSeconds = 60; private readonly TimeSpan _ttl; // Time To Live public RedisService(IConfiguration configuration) { - var connectionMultiplexer = ConnectionMultiplexer.Connect(configuration["AZURE_REDIS_CONNECTION_STRING"], AzureCacheForRedis.ConfigureForAzure); - _database = connectionMultiplexer.GetDatabase(); _ttl = TTL(configuration["AZURE_REDIS_TTL_IN_SECONDS"]); + _connectionString = configuration["AZURE_REDIS_CONNECTION_STRING"]; + _managedIdentityPrincipalId = configuration["AZURE_MANAGED_IDENTITY_PRINCIPAL_ID"]; + _port = configuration["AZURE_REDIS_PORT"]; + _hostname = configuration["AZURE_REDIS_HOSTNAME"]; + } + + private async Task GetDatabaseAsync() { + if (_database != null) { + return _database; + } + + Console.WriteLine("Initializing Redis database connection"); + + var connectionMultiplexer = await ConnectionMultiplexer.ConnectAsync(_connectionString!, AzureCacheForRedis.ConfigureForAzure); + + _database = connectionMultiplexer.GetDatabase(); + + Console.WriteLine("Redis database connection established"); + + return _database; } private TimeSpan TTL(string? ttlInSecondsAsString) @@ -26,7 +49,7 @@ private TimeSpan TTL(string? ttlInSecondsAsString) try { - ttlInSeconds = String.IsNullOrEmpty(ttlInSecondsAsString) ? _defaultTTLInSeconds : Int32.Parse(ttlInSecondsAsString); + ttlInSeconds = string.IsNullOrEmpty(ttlInSecondsAsString) ? _defaultTTLInSeconds : int.Parse(ttlInSecondsAsString); } catch { @@ -44,7 +67,8 @@ private TimeSpan TTL(string? ttlInSecondsAsString) { try { - var value = await _database.StringGetAsync(key); + var database = await GetDatabaseAsync(); + var value = await database.StringGetAsync(key); var stringValue = value.ToString(); if (stringValue == string.Empty) { @@ -61,7 +85,8 @@ private TimeSpan TTL(string? ttlInSecondsAsString) public async Task Set(string key, string value) { - await _database.StringSetAsync(key, value, _ttl); + var database = await GetDatabaseAsync(); + await database.StringSetAsync(key, value, _ttl); } public async Task AddToStream(string streamName, Dictionary data) @@ -73,6 +98,7 @@ public async Task AddToStream(string streamName, Dictionary data entries.Add(new(keyValuePair.Key, keyValuePair.Value)); } - await _database.StreamAddAsync(streamName, entries.ToArray()); + var database = await GetDatabaseAsync(); + await database.StreamAddAsync(streamName, entries.ToArray()); } } \ No newline at end of file diff --git a/src/catalog-api/appsettings.json.template b/src/catalog-api/appsettings.json.template index 132cd3b..2dc4d1a 100644 --- a/src/catalog-api/appsettings.json.template +++ b/src/catalog-api/appsettings.json.template @@ -9,6 +9,9 @@ "AZURE_COSMOS_CONNECTION_STRING": "YOUR_COSMOS_CONNECTION_STRING", "AZURE_COSMOS_DATABASE": "catalogdb", "AZURE_REDIS_CONNECTION_STRING":"YOUR_REDIS_CONNECTION_STRING", + "AZURE_MANAGED_IDENTITY_PRINCIPAL_ID": "MANAGED_IDENTITY_PRINCIPAL_ID_FOR_AAD_INTEGRATION", + "AZURE_REDIS_PORT": "OPTIONAL_REDIS_PORT_FOR_ADD_INTEGRATION", + "AZURE_REDIS_HOSTNAME": "REDIS_HOSTNAME_FOR_ADD_INTEGRATION", "PRODUCT_LIST_CACHE_DISABLE":"0", "SIMULATED_DB_LATENCY_IN_SECONDS": "", "PRODUCT_VIEWS_STREAM_NAME": "productViews" diff --git a/terraform/app.tf b/terraform/app.tf index 57831a7..a4f92c9 100644 --- a/terraform/app.tf +++ b/terraform/app.tf @@ -15,6 +15,9 @@ resource "azurerm_linux_web_app" "this" { AZURE_COSMOS_CONNECTION_STRING = azurerm_cosmosdb_account.this.connection_strings[0] AZURE_COSMOS_DATABASE = "catalogdb" AZURE_REDIS_CONNECTION_STRING = azurerm_redis_cache.this.primary_connection_string + AZURE_REDIS_HOSTNAME = azurerm_redis_cache.this.hostname + AZURE_REDIS_PORT = azurerm_redis_cache.this.ssl_port + AZURE_MANAGED_IDENTITY_PRINCIPAL_ID = "" PRODUCT_LIST_CACHE_DISABLE = "0" SIMULATED_DB_LATENCY_IN_SECONDS = "2" APPINSIGHTS_INSTRUMENTATIONKEY = azurerm_application_insights.this.instrumentation_key