Portal Community

ICustomRoleProvider

namespace BizFirst.Essentials.Passport.IAM;

public interface ICustomRoleProvider
{
    /// <summary>
    /// Returns additional role names for the user at the time of evaluation.
    /// These roles are merged with Passport's built-in role resolution.
    /// The returned role names must exist in Passport's role registry to have any effect
    /// (they expand into permissions based on the role's permission set).
    ///
    /// Return empty list if no additional roles apply.
    /// Never throw — log and return empty list on failure.
    /// </summary>
    Task<IReadOnlyList<string>> GetRolesAsync(
        string userId,
        string tenantId,
        CancellationToken ct = default);
}

Project-Based Role Provider

/// <summary>
/// Dynamically assigns roles based on the user's active project assignments in the ERP.
/// A user who is a project manager for any active project gets the "project-manager" role.
/// A user who is assigned to a project gets the "project-member" role.
/// </summary>
public sealed class ProjectRoleProvider : ICustomRoleProvider
{
    private readonly IProjectRepository _projects;
    private readonly IMemoryCache _cache;
    private readonly ILogger<ProjectRoleProvider> _logger;

    public async Task<IReadOnlyList<string>> GetRolesAsync(
        string userId, string tenantId, CancellationToken ct)
    {
        var cacheKey = $"project-roles:{userId}:{tenantId}";

        return await _cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2);

            try
            {
                var assignments = await _projects.GetUserAssignmentsAsync(userId, tenantId, ct);
                var roles = new List<string>();

                if (assignments.Any(a => a.Role == "ProjectManager" && a.IsActive))
                    roles.Add("project-manager");

                if (assignments.Any(a => a.IsActive))
                    roles.Add("project-member");

                return (IReadOnlyList<string>)roles;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to resolve project roles for user {UserId}", userId);
                return Array.Empty<string>();
            }
        }) ?? [];
    }
}

On-Call Provider

/// <summary>
/// Grants the "on-call-responder" role to users who are currently on-call
/// according to the PagerDuty/ops schedule.
/// </summary>
public sealed class OnCallRoleProvider : ICustomRoleProvider
{
    private readonly IOnCallScheduleService _schedule;

    public async Task<IReadOnlyList<string>> GetRolesAsync(
        string userId, string tenantId, CancellationToken ct)
    {
        bool isOnCall = await _schedule.IsUserOnCallAsync(userId, ct);
        return isOnCall
            ? (IReadOnlyList<string>)["on-call-responder"]
            : Array.Empty<string>();
    }
}

Important Constraints

Cache External Calls

If your role provider calls an external API (ERP, project tracker, on-call system), cache the result per user per tenant for at least 1 minute. Without caching, a single page load can trigger dozens of calls to the external system — one per permission check. Use IMemoryCache with a short TTL rather than no cache.