Dynamic database driven authorization policies in .NET Core 3.0 (Blazor)

Dynamic database driven authorization policies in .NET Core 3.0 (Blazor)

Assume we had a requirement to dynamically load our authorization policies from a database. To make things easy, let's make the policies specific to a given resource, or page. Different pages can share policies.

Suppose we have a matrix of policies that drive access to pages that we'll store in a POLICY table. The only required columns in this table are Security Group and Resource Type.

Security group is self-explanatory.

In our case, the Resource Type could be one of a few values:

  • View
  • Edit
  • Approve
  • Override

In order to know a bit more about the page, we will parse the URL and get the ID in use for the page. This is done through convention (the ID is always the value after the page name - ex: http://server_name/page_name/1234).

I wanted to design the system to use as much of what is built into .NET as possible to avoid having the "not invented here" problem. This would allow future maintainers to easily find assistance on the web.

My first step was to get the information about the page to the policy provider. I'm using a class that implements a custom IPolicyService interface to provide this functionality. This information is specific to this use-case, but you could pass any type of data along. The core concept in this post is the use of dynamic policies with names built at runtime.

Here is the interface definition:

public interface IPolicyService
{
	string GetPolicy(string policyName, string pageHref = null, long? id = null);
}

The policy name will be described below. The pageHref is used when you want to "look ahead" and get the policies for a page that is different than the one you are currently on. The id is a custom id used in much the same way. The implementation for this Interface looks like this:

public class PolicyService : IPolicyService
{
	public PolicyService(ILocationService locationService, IPageInfoService pageInfoService)
	{
		LocationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
		PageInfoService = pageInfoService ?? throw new ArgumentNullException(nameof(pageInfoService));
	}

	public ILocationService LocationService { get; }
	public IPageInfoService PageInfoService { get; }

	public string GetPolicy(string policyName, string pageHref = null, long? id = null)
	{
		NavPage currentPage = null;
		if( pageHref == null)
		{
			currentPage = PageInfoService.CurrentPage;
		}

		var policyState = new PolicyState
		{
			
			PageHref = pageHref ?? currentPage?.Href,
			PrimaryId = id ?? currentPage?.CurrentId,
			CurrentLocation = LocationService.CurrentLocation
		};

		return $"{policyName}|{JsonConvert.SerializeObject(policyState)}";
	}
}

LocationService contains information about a user's location, which is one of the possible policy requirements on a page. PageInfoService contains information about the current page, including the Href, CurrentId, Title, etc. The important code to point out is the return value. This simply returns the policy name (as determined by the Policies constants - code below) with the policy state serialized and appended after a | delimiter. The policyState variable contains the 3 pieces of information that we'll need to lookup the policies from the database and make the claims determination later on in the process. This is where you would store the pieces of information that are important to your use-case.

The GetPolicy function is called to return a string that will be passed to the ApplicationPolicyProvider, which will lookup the policies for the page from the database.

The quickest way to explain how this works is to show how this class and function are used in .NET Core.

Below is code that exists in a Blazor .razor page component. It uses the AuthorizeView to determine the privileges for the user and displays information depending on the outcome.

<AuthorizeView Policy="@PolicyService.GetPolicy(Policies.HasEdit)">
	<Authorized>
		<div class="auth has-permission">This user has Edit permissions.</div>
	</Authorized>
	<NotAuthorized>
		<div class="auth no-permission">This user does not have Edit permissions.</div>
	</NotAuthorized>
</AuthorizeView>
<AuthorizeView Policy="@PolicyService.GetPolicy(Policies.HasApprove)">
	<Authorized>
		<div class="auth has-permission">This user has Approve permissions.</div>
	</Authorized>
	<NotAuthorized>
		<div class="auth no-permission">This user does not have Approve permissions.</div>
	</NotAuthorized>
</AuthorizeView>

The above PolicyService class is injected into the page so it can be used by the AuthorizeView. It has to be an instance, since it uses injected services to determine the output value from runtime data values.

Policies is a static class that contains constant values for the possible policies that will be retrieved.

public static class Policies
{
    public const string IsUser = "IsUser";
    
    [PolicyLinkType(1000)]
    public const string HasView = "HasView";
    [PolicyLinkType(1001)]
    public const string HasEdit = "HasEdit";
    [PolicyLinkType(1002)]
    public const string HasApprove = "HasApprove";
    [PolicyLinkType(1000)]
    public const string HasSpecialEdit = "HasSpecialEdit";
}

The PolicyLinkType attribute is used to map the policy to the "LinkType" (resource type) in the database. It is used during the claims comparison to determine whether or not the user is authorized to access the resource. In this case, the LinkType table contains 4 values:

  • 1000 - View
  • 1001 - Edit
  • 1002 - Approve
  • 5000 - Override (not listed in the code)

HasSpecialEdit is a special policy that I'll discuss below to demonstrate special conditions.

Override is a special condition. Think of it as "God mode". This link type is compared to the user's claims (the authority level claim specifically - which is not technically a claim since it changes while the user is using the application). If the user's authority level is lower than the required authority level as defined by the policy records, then the user is denied access (again, this is specific to our use-case and is not a requirement to determine access policies from a database). Let's take a look at how that is implemented. Here is the code for the ApplicationPolicyProvider and its GetPolicyAsync function (called by the framework during authorization calls). The class derives from DefaultAuthorizationPolicyProvider.

Note: It is not important for this example, but this code uses an ADO.NET wrapper as a database context, so when you see references to Database, replace that with your data access layer (ADO.NET, Entity Framework, etc.)

public override async Task<AuthorizationPolicy> GetPolicyAsync(string requestedPolicy)
{
	if (requestedPolicy is null)
	{
		throw new ArgumentNullException(nameof(requestedPolicy));
	}
	// Check static policies first
	var policy = await base.GetPolicyAsync(requestedPolicy).ConfigureAwait(false);
	if (policy == null)
	{
		// see if we have a | separator
		if (requestedPolicy.Contains("|", StringComparison.InvariantCultureIgnoreCase))
		{
			// split policy name and deserialize PolicyState JSON
			var policyParts = requestedPolicy.Split("|");
			var policyName = policyParts[0];
			var policyJson = policyParts[1];
			var policyState = JsonConvert.DeserializeObject<PolicyState>(policyJson);

			switch (policyName)
			{
				case Policies.HasSpecialEdit:
					var specialIdToEdit = policyState.PrimaryId;
					var requirement = new SpecialEditAuthorizationRequirement { SpecialIdToEdit = specialIdToEdit };
					policy = new AuthorizationPolicyBuilder()
						.AddRequirements(requirement)
						.Build();
					break;
				default:
					policy = ProcessDefaultPolicies(policyName, policyState);
					break;
			}

		}
	}

	return policy;
}

Note above that the code first checks to see if the requested policy is a "static" policy (one that was defined in the startup). If so, it returns it. If it is not an existing policy, then the system will then look to see if the requested policy contains a | delimiter. If so, it splits it by the delimiter and sets some working variables. It then deserializes the policyState that includes the important bits that we'll need later to lookup the policies from the database. This is where the special condition for the HasSpecialEdit policy is handled. I included it here to demonstrate that you can provide different policy requirements depending on the policy that was requested. In this case, it returns a special policy with the SpecialEditAuthorizationRequirement containing the special id to edit.

By default, the function will call ProcessDefaultPolicies. Here is the important code from that function:


private AuthorizationPolicy ProcessDefaultPolicies(string policyName, PolicyState policyState)
{
	AuthorizationPolicy policy;
	

	var linkType = typeof(Policies).GetMember(policyName)
        .FirstOrDefault()
        ?.GetCustomAttribute<PolicyLinkTypeAttribute>(false)
        ?.LinkType;

	if (linkType == null)
	{
		throw new InvalidOperationException($"{policyName} is an invalid policy. Please use the YourNamespace.Auth.Policies constants.");
	}
	using var conn = Database.CreateConnection();
	using var cmd = Database.CreateCommand(conn, "schema_name.get_policies");
	cmd.Parameters.AddRange(new[]
	{
        Database.CreateParameter(cmd, "p_tx_pg_href", policyState.PageHref, DbType.String, ParameterDirection.Input),
        Database.CreateParameter(cmd, "p_id_fwk_typ_lnk", linkType, DbType.Decimal, ParameterDirection.Input)
	});
	var reader = Database.GetReader(cmd);

	var requirementList = new List<IAuthorizationRequirement>();

	while (reader.Read())
	{
		var requirement = new ApplicationAuthorizationRequirement
		{
			PrimaryId = policyState.PrimaryId,
			AuthorityLevel = reader.IsDBNull(reader.GetOrdinal("id_fwk_athrty_lvl")) ?
            _defaultAuthorityLevelView : 
            reader.GetInt32(reader.GetOrdinal("id_fwk_athrty_lvl")),
			LinkType = linkType,
			Group = reader.GetInt64(reader.GetOrdinal("id_fwk_grp")),
			LocationId = reader.IsDBNull(reader.GetOrdinal("id_lctn")) ?
                           default(long?) :
                           reader.GetInt64(reader.GetOrdinal("id_lctn")),
			UserType = reader.IsDBNull(reader.GetOrdinal("id_type_usr")) ?
                             default(int?) : 
                             reader.GetInt32(reader.GetOrdinal("id_type_usr")),
			AssignmentType = reader.IsDBNull(reader.GetOrdinal("id_ref_type_asgnmt")) ? 
                default(long?) : 
                reader.GetInt64(reader.GetOrdinal("id_ref_type_asgnmt")),
			CurrentLocation = policyState.CurrentLocation
		};

		requirementList.Add(requirement);
	}

	if (!requirementList.Any())
	{
		requirementList.Add(new ApplicationAuthorizationRequirement());
	}

	policy = new AuthorizationPolicyBuilder()
			.AddRequirements(requirementList.ToArray())
			.Build();
	return policy;
}

The first line of code to look at is the line that gets the linkType from the static Policies class. It uses the policyName variable that was passed in to grab the PolicyLinkType attribute off of the associated property (the property has the same name because it was used to call the GetPolicy function). Once it has the attribute, it gets the LinkType property from it. This will be a value from 1000-1002 (defined in the Policies class as listed above).  This will allow us to retrieve all of the policies for the given page using the provided link type. So, we can get all of the edit policies for the current page.

You may wonder why I am passing Page Href into the PolicyState rather than injecting NavigationManager. This is because the NavigationManager is scoped and cannot be accessed from Dependency Injection in this code.

The rest of the code handles the creation of the ApplicationAuthorizationRequirement and policy that the ApplicationAuthorizationHandler will use to compare with the user's claims and determine whether or not the user has permissions to access the page (resource). This is the actual database lookup for policies based on the information passed into the policy from the policyState parameter. You may also notice that I set the value of the CurrentLocation property to the current location that was passed into the policyState. I'm not happy with this implementation detail, but I need to use this value later on in the authorization process and this was the only way I could see to pass it along.

For completion sake, here is the code for the ApplicationAuthorizationRequirement:

public class ApplicationAuthorizationRequirement : IAuthorizationRequirement
{
	public long? PrimaryId { get; set; }
	public int? AuthorityLevel { get; set; }
	public int? LinkType { get; set; }
	public long? Group { get; set; }
	public long? LocationId { get; set; }
	public int? UserType { get; set; }
	public long? AssignmentType { get; set; }
	public OfficeInfo CurrentOffice { get; set; }
}

Notice that it implements the IAuthorizationRequirement marker interface. This interface is defined by .NET.

Here is the HandleRequirementAsync function definition on the ApplicationAuthorizationHandler class. It derives from AuthorizationHandler<ApplicationAuthorizationRequirement>.

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ApplicationAuthorizationRequirement requirement)
{
	if (context is null)
	{
		throw new ArgumentNullException(nameof(context));
	}

	if (requirement is null)
	{
		throw new ArgumentNullException(nameof(requirement));
	}

	if (context.PendingRequirements.Any())
	{
		var succeeded = false;
		if (requirement.CurrentLocation != null)
		{
			var pendingCopy = context.PendingRequirements.ToArray();
			foreach (ApplicationAuthorizationRequirement req in pendingCopy)
			{
				// Go ahead and mark it as succeeded. If it fails, we'll fail the whole process at the end.
				context.Succeed(req);

				if (requirement.CurrentLocation.AuthorityLevel == 10000 /* User has override, they can do everything */)
				{
					succeeded = true;
				}
				else
				{
					var isInGroup = context.User.IsInRole(req.Group.ToString());

					if (isInGroup)
					{
						if (req.LocationId != null && req.CurrentLocation.Id != req.LocationId)
						{
							// Check to see if this user is in any of the parent locations of the required location.
							using var conn = Database.CreateConnection();
							using var cmd = Database.CreateFunction<string>(conn, "schema_name.f_is_location_in_chain");
							cmd.Parameters.AddRange(new[]
							{
								Database.CreateParameter(cmd, "p_id_lctn", req.CurrentOffice.Id, DbType.Decimal, ParameterDirection.Input),
								Database.CreateParameter(cmd, "p_id_cmpr_lctn", req.OfficeId, DbType.Decimal, ParameterDirection.Input)
							});

							cmd.ExecuteNonQuery();
							var result = Database.GetReturnValue<string>(cmd);
							if (result.ToUpper() != "Y")
							{
								continue;
							}
						}

						if(req.AssignmentType != null)
						{
							using var conn = Database.CreateConnection();
							using var cmd = Database.CreateFunction<long>(conn, "schema_name.get_assignment");
							cmd.Parameters.AddRange(new[] 
							{ 
								Database.CreateParameter(cmd, "p_id_usr", context.User.FindFirst(ClaimTypes.NameIdentifier).Value, DbType.Decimal, ParameterDirection.Input),
								Database.CreateParameter(cmd, "p_id_cntrct", req.PrimaryId, DbType.Decimal, ParameterDirection.Input)
							});

							cmd.ExecuteNonQuery();
							var returnValue = Database.GetReturnValue<long>(cmd);
							if(returnValue != req.AssignmentType)
							{
								continue;
							}
						}

						if (req.AuthorityLevel != null && req.CurrentOffice.AuthorityLevel < req.AuthorityLevel)
						{
							// They're in the required location, but they don't have the required authority level.
							succeeded = false;
							break;
						}

						succeeded = true;
					}
				}
			}
		}

		// Force a failure if we haven't succeeded to this point
		if (!succeeded)
		{
			context.Fail();
		}
	}

	return Task.CompletedTask;
}

This code looks a little strange. Essentially, all it's doing is making a copy of all of the requirements that were passed in, then looping through them all at once, rather than processing them one at a time. This is so that the requirements can be handled as an "OR" rather than an "AND". This means that I have complete control over whether or not the user meets the claims criteria for the policy requirements that were passed in. If one of the important claims is insufficient, I can short-circuit the loop (break) and then set the whole thing to fail by calling context.Fail() at the end. Or, I can process the less important requirements and simply continue the loop, thereby processing any additional requirements that may be remaining. This basically means that for situations where there are multiple policies for a given group and link type combination, the least restrictive policy will be enforced. If any of the policies succeeds, then the succeeded variable is set to true and the context.Fail() is not called. However, if the Authority Level (a property that is location-specific) is not equal to the required authority level, none of the other policies will pass. It is essential that this authority level be honored, so if it's on the policy row, it will be observed above all other policies.