Multi client blob storage access using ASP.NET Core with Entra ID authentication and RBAC

This article shows how to onboard different clients or organizations in an ASP.NET Core application to use separated Azure blob containers with controlled access using security groups and RBAC applied roles. Each user in a client group can only access a single blob storage and has no access to blob containers belonging to different clients. Microsoft Entra ID is used to implement the blob storage access.

Code: https://github.com/damienbod/AspNetCoreEntraIdBlobStorage

Blogs in this series

Security context diagram

The web application uses different Enterprise applications to access the different Azure APIs. One Enterprise application is used to implement the blob application contributor access which can only be used from the application. This is used when a user in the application needs to write a file to the blob through the application. A second Enterprise application is used to create the RBAC access for the blob container reader access to the files. This is used when creating a new client or new organization. A third Enterprise application is used to implement the web application OpenID Connect web client. This is created using an Azure app registration and only allows delegated permissions. The user App roles are defined in this application. The Microsoft Graph APIs can be implemented using delegated permissions or application permissions. If using Graph application permissions to create or remove the groups, a separate Enterprise application is used to create the groups. You can also used the app service managed identity and use the service principal instead of the 3 enterprise applications to assign the required permissions.

Multi client blob storage setup

The Blob account uses Microsoft Entra ID to access the blob containers. The application can write to all containers and a security group is given RBAC blob container reader access, one per security group. Users are added to the security groups per client or per organization. The setup for the client blob container is implemented in three steps:

  • Create a Microsoft Entra ID security group
  • Create an Azure storage blob container
  • Create an RBAC to give the security group Blob storage reader permissions

Create a Microsoft Entra ID security group

The CreateSecurityGroupAsync method creates a new security group in Microsoft Entra ID. This is created using Microsoft Graph and returns the group with the group ID. The service uses application permissions and is implemented in a separate Enterprise application.

using System.Text;
using Microsoft.Graph.Models;

namespace MultiClientBlobStorage.Providers.GroupUserServices;

public class ApplicationMsGraphService
{
    private readonly GraphApplicationClientService _graphApplicationClientService;

    public ApplicationMsGraphService(
       GraphApplicationClientService graphApplicationClientService)
    {
        _graphApplicationClientService = graphApplicationClientService;
    }

    public async Task<Group?> CreateSecurityGroupAsync(string group)
    {
        var graphServiceClient = _graphApplicationClientService
            .GetGraphClientWithClientSecretCredential();

        var formatted = RemoveSpecialCharacters(group);
        var groupName = $"blob-{formatted.Trim()}-{Guid.NewGuid()}".ToLower();

        var requestBody = new Group
        {
            DisplayName = groupName,
            Description = $"Security group for all users from {groupName}",
            MailEnabled = false,
            MailNickname = formatted,
            SecurityEnabled = true
        };

        var result = await graphServiceClient.Groups.PostAsync(requestBody);
        return result;
    }

    private string RemoveSpecialCharacters(string str)
    {
        var sb = new StringBuilder();
        foreach (var c in str)
        {
            if (c is >= '0' and <= '9' || c is >= 'A' and <= 'Z' 
                  || c is >= 'a' and <= 'z' || c == '.' || c == '_')
            {
                sb.Append(c);
            }
        }

        return sb.ToString();
    }
}

Create an Azure storage blob container

The CreateContainer method creates a new Azure blob container using the BlobServiceClient from the Azure.Storage.Blobs nuget package.

private async Task<BlobContainerClient> CreateContainer(string name)
{
    try
    {
        var formatted = RemoveSpecialCharacters(name);
        string containerName = $"blob-{formatted.Trim()}-{Guid.NewGuid()}"
           .ToLower();

        var storage = _configuration.GetValue<string>("AzureStorage:Storage");

        var credential = _clientSecretCredentialProvider
           .GetClientSecretCredential();

        if (storage != null && credential != null)
        {
            var blobServiceClient = 
            new BlobServiceClient(new Uri(storage), credential);

            var metadata = new Dictionary<string, string?>
            {
                { "name", name },
            };

            // Create the root container
            var blobContainerClient = await blobServiceClient
            .CreateBlobContainerAsync(
                containerName,
                PublicAccessType.None,
                metadata);

            if (blobContainerClient.Value.Exists())
            {
                Console.WriteLine(
                   $"Created container: {name} {blobContainerClient.Value.Name}");
            }

            return blobContainerClient.Value;
        }

        throw new Exception($"Could not create container: {name}");
    }
    catch (RequestFailedException e)
    {
        Console.WriteLine("HTTP error code {0}: {1}", e.Status, e.ErrorCode);
        Console.WriteLine(e.Message);
        throw;
    }
}

Create an RBAC to give the security group Blob storage reader permissions

The ApplyReaderGroupToBlobContainer method creates an RBAC for the security group on the blob container itself. The group and the container were created in the previous steps and this takes an unknown length of time. Polly is used to repeat until the group and the container are ready and it creates the assignment.

public async Task ApplyReaderGroupToBlobContainer(
     BlobContainerClient blobContainer, string groupId)
{
    var maxRetryAttempts = 20;
    var pauseBetweenFailures = TimeSpan.FromSeconds(3);

    var retryPolicy = Policy
        .Handle<Exception>()
        .WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures);

    await retryPolicy.ExecuteAsync(async () =>
    {
        // RBAC security group Blob data read
        await _azureMgmtClientService
            .StorageBlobDataReaderRoleAssignment(groupId,
                blobContainer.AccountName,
                blobContainer.Name);

        // NOTE service principal blob write is configured on root 
    });
}

Azure management REST API is used to create the RBAC. This rest API is implemented using a HttpClient and uses an Enterprise application to define the required permissions. This requires an administration Azure role and with this, you have full control of the Azure tenant.

using System.Net.Http.Headers;
using System.Text.Json.Serialization;

namespace MultiClientBlobStorage.Providers.Rbac;

public class AzureMgmtClientService
{
    private readonly AzureMgmtClientCredentialService _azureMgmtClientCredentialService;
    private readonly IHttpClientFactory _clientFactory;
    private readonly IConfiguration _configuration;
    private readonly ILogger<AzureMgmtClientService> _logger;

    public AzureMgmtClientService(AzureMgmtClientCredentialService azureMgmtClientCredentialService,
        IHttpClientFactory clientFactory,
        IConfiguration configuration,
        ILogger<AzureMgmtClientService> logger)
    {
        _azureMgmtClientCredentialService = azureMgmtClientCredentialService;
        _clientFactory = clientFactory;
        _configuration = configuration;
        _logger = logger;
    }

    /// <summary>
    /// Storage Blob Data Reader: ID: 2a2b9908-6ea1-4ae2-8e65-a410df84e7d1
    /// Role assignment required for application in Azure on resource group
    /// https://learn.microsoft.com/en-us/rest/api/authorization/role-assignments/create-by-id?view=rest-authorization-2022-04-01&tabs=HTTP
    /// https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-rest
    /// </summary>
    public async Task StorageBlobDataReaderRoleAssignment(string groupId, string storageAccountName, string blobContainerName)
    {
        // The role ID: Storage Blob Data Reader
        var roleId = "2a2b9908-6ea1-4ae2-8e65-a410df84e7d1";
        var roleNameUnique = $"{Guid.NewGuid()}"; // Must be a guid
        var subscriptionId = _configuration["AzureMgmt:SubscriptionId"];
        // the service principal ID
        var servicePrincipalId = groupId;
        // the resource group name
        var resourceGroupName = _configuration["AzureMgmt:ResourceGroupName"];

        var objectId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}/blobServices/default/containers/{blobContainerName}";
        var url = $"https://management.azure.com{objectId}/providers/Microsoft.Authorization/roleAssignments/{roleNameUnique}?api-version=2022-04-01";

        var client = _clientFactory.CreateClient();
        var accessToken = await _azureMgmtClientCredentialService.GetAccessToken();

        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        var roleDefinitionId = $"{objectId}/providers/Microsoft.Authorization/roleDefinitions/{roleId}";

        var PayloadRoleAssignment = new PayloadRoleAssignment
        {
            Properties = new Properties
            {
                RoleDefinitionId = roleDefinitionId,
                PrincipalId = servicePrincipalId,
                PrincipalType = "Group"
            }
        };

        // view containers
        //var getRe = $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}/blobServices/default/containers?api-version=2023-01-01";
        //var response = await client.GetAsync(getRe);
        //var test = await response.Content.ReadAsStringAsync();

        var response = await client.PutAsJsonAsync(url, PayloadRoleAssignment);
        if (response.IsSuccessStatusCode)
        {
            var responseContent = await response.Content.ReadAsStringAsync();
            _logger.LogInformation("Created RBAC for read group {blobContainerName} {responseContent}", blobContainerName, responseContent);
            return;
        }

        var responseError = await response.Content.ReadAsStringAsync();
        _logger.LogCritical("Created RBAC for read group {blobContainerName} {responseError}", blobContainerName, responseError);
        throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}, {responseError}");
    }

    private class PayloadRoleAssignment
    {
        [JsonPropertyName("properties")]
        public Properties Properties { get; set; } = new();
    }

    /// <summary>
    ///     "properties": {
    ///     "roleDefinitionId":
    ///     "subscriptions/SUBSCRIPTION_ID/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.Storage/storageAccounts/STORAGE_ACCOUNT_NAME/providers/Microsoft.Authorization/roleDefinitions/ROLE_ID",
    ///     "principalId": "SP_ID"
    ///     }
    /// </summary>
    private class Properties
    {
        [JsonPropertyName("roleDefinitionId")]
        public string RoleDefinitionId { get; set; } = string.Empty;
        [JsonPropertyName("principalId")]
        public string PrincipalId { get; set; } = string.Empty;
        [JsonPropertyName("principalType")]
        public string PrincipalType { get; set; } = "Group";
    }
}

Putting it together

A Razor page can be used to create the new clients. This method takes an unknown length of time to run and the RBAC also take an unknown length of time to get applied.

[Authorize(Policy = "blob-admin-policy")]
public class CreateClientModel : PageModel
{
    private readonly ClientBlobContainerProvider _clientBlobContainerProvider;
    private readonly ApplicationMsGraphService _applicationMsGraphService;

    [BindProperty]
    public string ClientName { get; set; } = string.Empty;

    public CreateClientModel(
        ClientBlobContainerProvider clientBlobContainerProvider,
        ApplicationMsGraphService applicationMsGraphService)
    {
        _clientBlobContainerProvider = clientBlobContainerProvider;
        _applicationMsGraphService = applicationMsGraphService;
    }

    public void OnGet()
    {
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (ModelState.IsValid)
        {
            var group = await _applicationMsGraphService
              .CreateSecurityGroupAsync(ClientName);

            var blobContainer = await _clientBlobContainerProvider
                .CreateBlobContainerClient(ClientName);

            if(blobContainer != null && group != null && group.Id != null)
            {
                await _clientBlobContainerProvider
                    .ApplyReaderGroupToBlobContainer(blobContainer, group.Id);
            }
        }

        return Page();
    }
}

Notes

This works well but requires that the application has high privileged access permissions. Most IT departments will not allow this and the creation of blob containers would have to use the IT preferred tools. This type of automation requires 2 different Azure APIs and is not well documented.

Links

Using Blob storage from ASP.NET Core with Entra ID authentication

https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory

https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction

https://github.com/AzureAD/microsoft-identity-web

2 comments

  1. […] Multi client blob storage access using ASP.NET Core with Entra ID authentication and RBAC […]

  2. […] Multi client blob storage access using ASP.NET Core with Entra ID authentication and RBAC […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.