GraphQL - Incorporating role-based authorization at the field level

  • 3 min read

Intro

With great power comes great responsibilities. And if you're reading this, you already know that GraphQL gives you a lot of flexibility in terms of how you want to structure your query.

But once you're done finalizing your queries, say you want to limit access to some of the fields based on a role (e.g. admin, user, etc.). How do you do that in GraphQL you ask? ? Well, it's significantly less complicated than it's counterpart (RESTful services ?).


Create User Context

If you've already created a User Context object that implements IProvideClaimsPrincipal interface from GraphQL.Authorization package then you can skip this section and go the next one.

Let's set up a User Context object that will get associated to on every request and we will use this to determine the user and his/her role accordingly.

public class GraphQlUserContext : IProvideClaimsPrincipal
{
    /// <summary>
    /// Gets/sets a claims principal user associated to the request.
    /// </summary>
    public ClaimsPrincipal User { get; set; }

    /// <summary>
    /// Gets/sets the current Http Request.
    /// </summary>
    public HttpRequest Request { get; set; }
}

Now, let's use the above model in the execution options when you're executing the query:

var inputs = query.Variables.ToInputs();
var queryToExecute = query.Query;
var result = await _executer.ExecuteAsync(executionOptions =>
{
    executionOptions.Schema = _schema;
    executionOptions.Query = queryToExecute;
    executionOptions.OperationName = query.OperationName;
    executionOptions.Inputs = inputs;
    executionOptions.UserContext = new GraphQlUserContext
    {
        User = User as ClaimsPrincipal,
        Request = HttpContext.Current.Request
    };
    executionOptions.ValidationRules = _validationRules;
    executionOptions.ComplexityConfiguration = new ComplexityConfiguration 
    { 
        MaxDepth = 20 
    };
    executionOptions.FieldMiddleware.Use<InstrumentFieldsMiddleware>();
}).ConfigureAwait(false);

Create Validation Rule

This is going to be very similiar to what I posted earlier about creating validation rules in GraphQL.

We're simply going to create a validation rule that checks whether a given role has been assigned access to the field that is being queried.

public class RequiresRoleValidationRule : IValidationRule
{
    public INodeVisitor Validate(ValidationContext context)
    {
        //get the user context object
        var userContext = context.UserContext as GraphQlUserContext;
        //determine whether this is an authenticated request
        var authenticated = userContext?.User?.Identity.IsAuthenticated ?? false;
        //get user's role (if any)
        var userRole = userContext?.User?.GetRole();

        return new EnterLeaveListener(_ =>
        {
            _.Match<Field>(fieldAst =>
            {
                //get currently executed field information
                var fieldDef = context.TypeInfo.GetFieldDef();
                if (fieldDef != null && fieldDef.RequiresRole() &&
                    (!authenticated || !fieldDef.CanAccess(userRole)))
                {
                    //user doesn't have access, let's throw an error.
                    context.ReportError(new ValidationError(
                        context.OriginalQuery,
                        "auth-required",
                        $"You are not authorized to query field name: {fieldDef.Name}",
                        fieldAst));
                }
            });
        });
    }
}

Validation Extensions

Here are the extension methods that I am leveraging to verify whether a given field has a metadata associated to it that tells me all the roles that can access it.

public static class GraphQlExtensions
{
    private const string RolesKey = "Roles";

    public static void RequiresRole(this IProvideMetadata type, params string[] rolesToAdd)
    {
        var roles = type.GetMetadata<List<string>>(RolesKey);

        if (roles == null)
        {
            roles = new List<string>();
            type.Metadata[RolesKey] = roles;
        }

        roles.Add($"{string.Join(",", rolesToAdd)}");
    }

    public static bool RequiresRole(this IProvideMetadata type)
    {
        var permissions = type.GetMetadata<IEnumerable<string>>(RolesKey, new List<string>());
        return permissions.Any();
    }

    public static FieldBuilder<TSourceType, TReturnType> RequiresRole<TSourceType, TReturnType>(
        this FieldBuilder<TSourceType, TReturnType> builder, params string[] rolesToAdd)
    {
        builder.FieldType.RequiresRole(rolesToAdd);        
        return builder;
    }

    public static bool CanAccess(this IProvideMetadata type, string role)
    {
        var roles = type.GetMetadata<IEnumerable<string>>(RolesKey, new List<string>());
        var enumerable = roles.ToDelimitedString();
        if (enumerable.Any() && string.IsNullOrWhiteSpace(role))
        {
            //if there are any roles associated with this field yet no role was provided in the parameter then we need to deny access to this field.
            return false;
        }

        if (enumerable.Any() && !string.IsNullOrWhiteSpace(role))
        {
            //if the role provided is valid and we have a set of roles that were assigned to this field then we need to validate it.
            return enumerable.Contains(role);
        }

        //ok this is likely an unauthenticated request and at this point we can let it continue.
        return true;
    }
}

Final Step

Now, we're almost there. We just have to go to the individual field that we're exposing and associate role(s) that have access to it.

public class UserType : ObjectGraphType<User>
{
    public UserType()
    {
        Field(u => u.UserAgent, false, typeof(StringGraphType))
                    .Description("Represents the user agent.")
                    .Resolve(fieldContext => fieldContext.Source.UserAgent)
                    .RequiresRole("admin", "internal");
    }
}

That's it folks! If you have any comments/suggestions or you're simply stuck, please feel free to contact me or reach out in this post's discussion.