Portal Community

ICustomPermissionResolver

namespace BizFirst.Essentials.Passport.IAM;

public interface ICustomPermissionResolver
{
    /// <summary>
    /// Evaluate a permission check with ABAC logic.
    /// Called after built-in role/permission evaluation.
    /// Return:
    ///   Allow  — explicitly grant (short-circuits pipeline, stops further resolvers)
    ///   Deny   — explicitly deny (short-circuits pipeline, stops further resolvers)
    ///   Defer  — no opinion, continue to next resolver
    /// </summary>
    Task<PermissionResolution> ResolveAsync(
        PermissionContext context,
        CancellationToken ct = default);
}

public class PermissionContext
{
    public required IDInfo             User        { get; init; }
    public required string             Permission  { get; init; }  // e.g., "form.submit"
    public string?                     ResourceId  { get; init; }
    public string?                     ResourceType{ get; init; }  // e.g., "form"
    public ResourceAttributes?         Resource    { get; init; }  // resource metadata (if available)
    public EnvironmentContext          Environment { get; init; } = new();
    public bool                        BaseAllowed { get; init; }  // result of role-based check
}

public class ResourceAttributes
{
    public string?                     OwnerId     { get; init; }
    public string?                     Department  { get; init; }
    public string?                     Classification { get; init; }
    public IReadOnlyDictionary<string, object> Custom { get; init; } = new Dictionary<string, object>();
}

public class EnvironmentContext
{
    public DateTimeOffset              Now         { get; init; } = DateTimeOffset.UtcNow;
    public string?                     RequestIp   { get; init; }
    public string?                     Country     { get; init; }
    public bool                        IsMfaVerified { get; init; }
}

Simple ABAC Resolver — Department Check

/// <summary>
/// Restricts payroll-related permissions to Finance department employees only.
/// Contractors (EmployeeType == "contractor") are always denied payroll access.
/// </summary>
public sealed class DepartmentBasedPayrollResolver : ICustomPermissionResolver
{
    private static readonly string[] PayrollPermissions =
        ["workflow.initiate", "form.submit", "report.payroll.read"];

    private readonly IUserProfileService _profiles;

    public DepartmentBasedPayrollResolver(IUserProfileService profiles)
        => _profiles = profiles;

    public async Task<PermissionResolution> ResolveAsync(
        PermissionContext context,
        CancellationToken ct)
    {
        // Only apply to payroll resources
        if (context.ResourceType != "payroll" &&
            context.ResourceId?.StartsWith("payroll-") != true)
            return PermissionResolution.Defer;

        // Only apply to relevant permissions
        if (!PayrollPermissions.Contains(context.Permission))
            return PermissionResolution.Defer;

        // Load user profile attributes
        var profile = await _profiles.GetAsync(context.User.UserId, ct);
        if (profile is null) return PermissionResolution.Deny;

        // Contractors always denied payroll access
        if (profile.EmployeeType == "contractor")
            return PermissionResolution.Deny;

        // Finance department members allowed
        if (profile.Department == "Finance")
            return PermissionResolution.Allow;

        // No opinion for other departments — let role check stand
        return PermissionResolution.Defer;
    }
}

Business Hours Resolver

public sealed class BusinessHoursPaymentResolver : ICustomPermissionResolver
{
    public Task<PermissionResolution> ResolveAsync(
        PermissionContext context,
        CancellationToken ct)
    {
        // Only apply to live payment workflows
        if (context.ResourceType != "payment-workflow")
            return Task.FromResult(PermissionResolution.Defer);

        var now = context.Environment.Now;
        var dayOfWeek = now.DayOfWeek;
        var hour = now.Hour;

        // Allow Monday-Friday 9:00-17:00 UTC
        bool isBusinessHours =
            dayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday &&
            hour is >= 9 and < 17;

        return Task.FromResult(
            isBusinessHours
                ? PermissionResolution.Defer   // let normal role check stand
                : PermissionResolution.Deny);  // outside hours → deny always
    }
}

Resource Ownership Resolver

/// <summary>
/// Allows users to edit their own resources even if their base role only grants "view".
/// Example: a user with "viewer" role can still edit forms they created.
/// </summary>
public sealed class ResourceOwnershipResolver : ICustomPermissionResolver
{
    public Task<PermissionResolution> ResolveAsync(
        PermissionContext context,
        CancellationToken ct)
    {
        // Only apply to edit permissions
        if (!context.Permission.EndsWith(".edit") && !context.Permission.EndsWith(".delete"))
            return Task.FromResult(PermissionResolution.Defer);

        // Check if user owns the resource
        if (context.Resource?.OwnerId == context.User.UserId)
            return Task.FromResult(PermissionResolution.Allow);

        return Task.FromResult(PermissionResolution.Defer);
    }
}
Resolver Performance

Custom resolvers are called on every permission check in the hot path. Avoid blocking I/O without caching. If your resolver calls an external service, implement an in-memory cache with a short TTL (30 seconds to 2 minutes) to prevent latency spikes. A resolver that takes >50ms will noticeably degrade API response times.