Search code examples
azureasp.net-coreazure-active-directoryasp.net-core-webapiasp.net-core-2.2

Get Azure AD Groups Before Building Authorization Policies


We're developing an application that uses a back-end built on .Net Core 2.2 Web API. Most of our controllers merely require the [Authorize] attribute with no policy specified. However, some endpoints are going to require the user to be in a particular Azure AD Security Group. For those cases, I implemented policies like this in the Startup.cs file:

var name = "PolicyNameIndicatingGroup";
var id = Guid.NewGuid; // Actually, this is set to the object ID of the group in AD.

services.AddAuthorization(
    options =>
    {
        options.AddPolicy(
            name,
            policyBuilder => policyBuilder.RequireClaim(
                "groups",
                id.ToString()));
    });

Then, on controllers requiring this type of authorization, I have:

[Authorize("PolicyNameIndicatingGroup")]
public async Task<ResponseBase<string>> GroupProtectedControllerMethod() {}

The problem is that our users are all in a large number of groups. This causes the Graph API to return no group claims at all, and instead a simple hasGroups boolean claim set to true. Therefore, no one has any groups, and thus cannot pass authorization. This no-groups issue can be read about here.

This string-based policy registration, lackluster as it may be, seems to be what the .Net Core people are recommending, yet it falls flat if the groups aren't populated on the User Claims. I'm not really seeing how to circumnavigate the issue. Is there some special way to set up the AppRegistration for my API so that it does get all of the groups populated on the User Claims?

Update:

In the solution, I do have a service that calls Graph to get the user's groups. However, I can't figure out how to call it before it's too late. In other words, when the user hits the AuthorizeAttribute on the controller to check for the policy, the user's groups have not yet been populated, so the protected method always blocks them with a 403.

My attempt consisted of making a custom base controller for all of my Web API Controllers. Within the base controller's constructor, I'm calling a method that checks the User.Identity (of type ClaimsIdentity) to see if it's been created and authenticated, and, if so, I'm using the ClaimsIdentity.AddClaim(Claim claim) method to populate the user's groups, as retrieved from my Graph call. However, when entering the base controller's constructor, the User.Identity hasn't been set up yet, so the groups don't get populated, as previously described. Somehow, I need the user's groups to be populated before I ever get to constructing the controller.


Solution

  • I found an answer to this solution thanks to some tips from someone on the ASP.NET Core team. This solution involves implementing an IClaimsTransformation (in the Microsoft.AspNetCore.Authentication namespace). To quote my source:

    [IClaimsTransformation] is a service you wire into the request pipeline which will run after every authentication and you can use it to augment the identity as you like. That would be where you’d do your Graph API call [...]."

    So I wrote the following implementation (see an important caveat below the code):

    public class AdGroupClaimsTransformer : IClaimsTransformation
    {
        private const string AdGroupsAddedClaimType = "adGroupsAlreadyAdded";
        private const string ObjectIdClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier";
    
        private readonly IGraphService _graphService; // My service for querying Graph
        private readonly ISecurityService _securityService; // My service for querying custom security information for the application
    
        public AdGroupClaimsTransformer(IGraphService graphService, ISecurityService securityService)
        {
            _graphService = graphService;
            _securityService = securityService;
        }
    
        public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
        {
            var claimsIdentity = principal.Identity as ClaimsIdentity;
            var userIdentifier = FindClaimByType(claimsIdentity, ObjectIdClaimType);
            var alreadyAdded = AdGroupsAlreadyAdded(claimsIdentity);
    
            if (claimsIdentity == null || userIdentifier == null || alreadyAdded)
            {
                return Task.FromResult(principal);
            }
    
            var userSecurityGroups = _graphService.GetSecurityGroupsByUserId(userIdentifier).Result;
            var allSecurityGroupModels = _securityService.GetSecurityGroups().Result.ToList();
    
            foreach (var group in userSecurityGroups)
            {
                var groupIdentifier = allSecurityGroupModels.Single(m => m.GroupName == group).GroupGuid.ToString();
    
                claimsIdentity.AddClaim(new Claim("groups", groupIdentifier));
            }
    
            claimsIdentity.AddClaim(new Claim(AdGroupsAddedClaimType, "true"));
    
            return Task.FromResult(principal);
        }
    
        private static string FindClaimByType(ClaimsIdentity claimsIdentity, string claimType)
        {
            return claimsIdentity?.Claims?.FirstOrDefault(c => c.Type.Equals(claimType, StringComparison.Ordinal))
                ?.Value;
        }
    
        private static bool AdGroupsAlreadyAdded(ClaimsIdentity claimsIdentity)
        {
            var alreadyAdded = FindClaimByType(claimsIdentity, AdGroupsAddedClaimType);
            var parsedSucceeded = bool.TryParse(alreadyAdded, out var valueWasTrue);
    
            return parsedSucceeded && valueWasTrue;
        }
    }
    

    Within my Startup.cs, in the ConfigureServices method, I register the implementation like this:

    services.AddTransient<IClaimsTransformation, AdGroupClaimsTransformer>();
    

    The Caveat

    You may have noticed that my implementation is written defensively to make sure the transformation will not be run a second time on a ClaimsPrincipal that has already undergone the procedure. The potential issue here is that calls to the IClaimsTransformation might occur multiple times, and that might be bad in some scenarios. You can read more about this here.