Skip to content

Instantly share code, notes, and snippets.

@OFark
Created August 26, 2022 14:17
Show Gist options
  • Save OFark/2a49e0a74a094ea02089d9aeaa414d1d to your computer and use it in GitHub Desktop.
Save OFark/2a49e0a74a094ea02089d9aeaa414d1d to your computer and use it in GitHub Desktop.
Adding a matrix style authorization to FastEndpoints
[Flags]
public enum AccessLevel : uint
{
None = 0,
Allowed = 1 << 0,
List = 1 << 1,
Update = 1 << 2,
Create = 1 << 3,
Delete = 1 << 4,
Grant = 1 << 5,
CRUD = ~(-1 << 5),
All = ~(-1 << 6)
}
public static class AccessLevelParser
{
public static AccessLevel Parse(string accessTo)
{
return Enum.Parse<AccessLevel>(accessTo);
}
}
// A Factory-Style class to manage AccessTo and AccessLevel to make the string generation consistent.
public record AccessRight : IAccessWriteTo
{
private readonly HashSet<AccessLevel> ignoreFlags = new() { AccessLevel.None, AccessLevel.CRUD, AccessLevel.All }; // The access levels are not required in the JWT
public AccessRight(AccessTo accessTo, AccessLevel accessLevel = AccessLevel.Allowed, bool deny = false)
{
AccessTo = accessTo;
AccessLevel = accessLevel;
Deny = deny;
}
public AccessTo AccessTo { get; init; }
public AccessLevel AccessLevel { get; init; }
public bool Deny { get; init; }
public override int GetHashCode() => ToString()?.GetHashCode() ?? 0;
public override string ToString() => $"{AccessTo} {(int)AccessLevel}";
public IEnumerable<string> ToPermissions()
{
return Enum.GetValues<AccessLevel>()
.Where(a => !ignoreFlags.Contains(a) && AccessLevel.HasFlag(a))
.Select(a => $"{AccessTo} {(int)a}");
}
public bool Has(AccessTo accessTo, AccessLevel accessLevel) => accessTo == AccessTo && (AccessLevel & accessLevel) == accessLevel;
public static AccessRight Parse(string accessRightString, bool deny = false)
{
var accessSplit = accessRightString.Split(" ");
if(accessSplit.Length != 2)
{
throw new ArgumentException($"{accessRightString} is not a valid permission claim");
}
var accessTo = AccessToParser.Parse(accessSplit[0]);
var accessLevel = (AccessLevel)int.Parse(accessSplit[1]);
return new AccessRight(accessTo, accessLevel, deny);
}
public static AccessRight Allowed(AccessTo accessTo) => new(accessTo, AccessLevel.Allowed);
public static IAccessWriteTo With(AccessTo accessTo) => new AccessRight(accessTo);
public AccessRight With(AccessLevel accessLevel) => this with { AccessLevel = accessLevel };
}
public interface IAccessWriteTo
{
AccessRight With(AccessLevel accessLevel);
}
// Define what you want access to here and the maximum possible access level required.
public enum AccessTo
{
[EnumData(maxAccessLevel: AccessLevel.Grant)]
Logins,
[EnumData(maxAccessLevel: AccessLevel.Grant)]
Orders,
[EnumData(maxAccessLevel: AccessLevel.Grant)]
Accounts,
[EnumData(maxAccessLevel: AccessLevel.Grant)]
Roles,
[EnumData(maxAccessLevel: AccessLevel.Allowed)]
ChargeToAccount,
[EnumData(maxAccessLevel: AccessLevel.Allowed)]
Impersonate
}
public static class AccessToParser
{
public static AccessTo Parse(string accessTo)
{
return Enum.Parse<AccessTo>(accessTo);
}
public static AccessTo Parse(Guid accessToId)
{
return Enum.GetValues<AccessTo>().FirstOrDefault(x => x.GetEnumGuid() == accessToId);
}
}
// This file isn't necessary it just provides some handy Methods in a Endpoint Subclass
public abstract partial class APIEndpoint<TRequest, TResponse, TMapper> : APIEndpoint<TRequest, TResponse>, IHasMapper<TMapper>, IEndpoint where TRequest : notnull, new() where TResponse : notnull where TMapper : notnull, IMapper, new()
{
/// <summary>
/// the entity mapper for the endpoint
/// <para>HINT: entity mappers are singletons for performance reasons. do not maintain state in the mappers.</para>
/// </summary>
public static TMapper Map { get; } = new();
}
public abstract partial class APIEndpoint<TRequest, TResponse> : Endpoint<TRequest, TResponse>, IEndpoint where TRequest : notnull, new() where TResponse : notnull
{
protected void Has(params AccessRight[] permissions) => Policies(permissions.Select(p => p.ToString()).ToArray());
protected void Is(params InRole[] roles) => Roles(roles.Select(r => r.ToString()).ToArray());
}
public abstract class APIEndpoint<TRequest> : APIEndpoint<TRequest, object>, IEndpoint where TRequest : notnull, new() { };
public abstract class APIEndpointWithoutRequest : APIEndpointWithoutRequest<object> { }
public abstract class APIEndpointWithoutRequest<TResponse> : EndpointWithoutRequest<TResponse> where TResponse : notnull
{
protected void Has(params AccessRight[] permissions) => Policies(permissions.Select(p => p.ToString()).ToArray());
protected void Is(params InRole[] roles) => Roles(roles.Select(r => r.ToString()).ToArray());
}
public abstract class APIEndpointWithoutRequest<TResponse, TMapper> : APIEndpointWithoutRequest<TResponse>, IHasMapper<TMapper> where TResponse : notnull where TMapper : notnull, IResponseMapper, new()
{
/// <summary>
/// the entity mapper for the endpoint
/// <para>HINT: entity mappers are singletons for performance reasons. do not maintain state in the mappers.</para>
/// </summary>
public static TMapper Map { get; } = new();
}
// A class to contain and serve policies.
static internal class AuthorizationPolicies
{
// An example duel permission policy
readonly static internal Action<AuthorizationPolicyBuilder> ListLogins = policy => policy.RequireAssertion(context =>
context.User.IsCustomer() && context.User.Has(AccessTo.CustomerLogins, AccessLevel.List) ||
context.User.IsStaff() && context.User.Has(AccessTo.StaffLogins, AccessLevel.List));
/// <summary>
/// All policies need to be defined here to be usable.
/// </summary>
static internal Dictionary<string, Action<AuthorizationPolicyBuilder>> AllPolicies {
get {
var policies = GenerateAuthorizationPolicies();
policies.Add(nameof(ListLogins), ListLogins);
return policies;
}
}
/// <summary>
/// Cycles through all AccessTos and AccessLevels and creates policies.
/// </summary>
/// <returns>A Dictionary of Policies, Name and Action to perform</returns>
{
var result = new Dictionary<string, Action<AuthorizationPolicyBuilder>>();
foreach (var accessTo in Enum.GetValues<AccessTo>())
{
var maxAccessLevel = accessTo.GetMaxAccessLevel();
foreach (var accessLevel in Enum.GetValues<AccessLevel>().Where(a => a != AccessLevel.None && a <= maxAccessLevel))
{
var accessRight = new AccessRight(accessTo, accessLevel);
result.Add(accessRight.ToString(),
policy => policy.RequireAssertion(context =>
context.User.Has(accessTo, accessLevel)));
}
}
return result;
}
}
public static class ClaimsPrincipalExtensions
{
private const string permissionsClaimType = "permissions";
/// <summary>
/// Tests the ClaimsPricipal for access to a permission as a certain access level
/// </summary>
/// <param name="user">The ClaimsPricipal</param>
/// <param name="accessTo">The Permission to test access for</param>
/// <param name="accessLevel">The level of access to be tested</param>
/// <param name="errorResponse">If Access is denied this is a ConditionalResponse returnable ErrorResponse</param>
/// <returns>True if the ClaimsPrincipal has the access level to the permission</returns>
public static bool Has(this ClaimsPrincipal user, AccessTo accessTo, AccessLevel accessLevel, [NotNullWhen(false)] out ErrorResponse? errorResponse)
{
var accessRight = new AccessRight(accessTo, accessLevel);
var allowed = user.FindAll(permissionsClaimType).Any(c => AccessRight.Parse(c.Value).Has(accessTo, accessLevel));
errorResponse = allowed ? null : new ErrorResponse($"{accessLevel} access to {accessTo} is denied", HttpStatusCode.Forbidden);
return allowed;
}
/// <summary>
/// Tests the ClaimsPricipal for access to a permission as a certain access level
/// </summary>
/// <param name="user">The ClaimsPricipal</param>
/// <param name="accessTo">The Permission to test access for</param>
/// <param name="accessLevel">Optional. The level of access to be tested (Default: Allowed)</param>
/// <returns>True if the ClaimsPrincipal has the access level to the permission</returns>
public static bool Has(this ClaimsPrincipal user, AccessTo accessTo, AccessLevel accessLevel = AccessLevel.Allowed) => Has(user, accessTo, accessLevel, out _);
/// <summary>
/// Tests the ClaimsPricipal for access to a permission at allowed level (minimum)
/// </summary>
/// <param name="user">The ClaimsPricipal</param>
/// <param name="accessTo">The Permission to test access for</param>
/// <param name="errorResponse"></param>
/// <returns>True if the ClaimsPrincipal has allowed level access to the permission</returns>
public static bool Has(this ClaimsPrincipal user, AccessTo accessTo, [NotNullWhen(false)] out ErrorResponse? errorResponse) => Has(user, accessTo, AccessLevel.Allowed, out errorResponse);
public static bool Is(this ClaimsPrincipal user, InRole role) => user.IsInRole(role.ToString());
public static bool IsCustomer(this ClaimsPrincipal user) => user.IsInRole(InRole.Customer.ToString());
public static bool IsStaff(this ClaimsPrincipal user) => user.IsInRole(InRole.MemberOfStaff.ToString());
}
// Socmething to hold data about the Enum Value
[AttributeUsage(AttributeTargets.Field)]
internal class EnumData : Attribute
{
public AccessLevel MaxAccessLevel;
public EnumData(AccessLevel maxAccessLevel = AccessLevel.Allowed)
{
MaxAccessLevel = maxAccessLevel;
}
}
//Adds some extensions to the AccessTo Enum for get the MaxAccessLevel
public static class EnumExtensions
{
public static AccessLevel GetMaxAccessLevel(this AccessTo e)
{
var memInfo = e.GetType().GetMember(e.ToString());
if (memInfo != null && memInfo.Length > 0)
{
var attrs = memInfo[0].GetCustomAttributes(typeof(EnumData), false);
if (attrs != null && attrs.Length > 0)
return ((EnumData)attrs[0]).MaxAccessLevel;
}
throw new ArgumentException("Enum " + e.ToString() + " has no EnumData defined!");
}
}
public enum InRole
{
SystemsAdministrator,
MemberOfStaff,
Customer
}
// add this in the right place, this registers the policies
builder.Services.AddAuthorization(options =>
{
foreach(var policy in AuthorizationPolicies.AllPolicies)
{
options.AddPolicy(policy.Key, policy.Value);
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment