Passport
Custom Role Provider
Implement ICustomRoleProvider to inject additional roles at evaluation time — derived from external systems, project assignments, or real-time organizational state.
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
- Returned role names must be registered in Passport's role registry. Unknown role names are silently ignored — they produce no permissions.
- Custom roles are ephemeral — they are resolved fresh on every permission check. They are not persisted to the JWT or the session.
- Multiple
ICustomRoleProviders are registered and all results are merged (unlikeICustomPermissionResolverwhich short-circuits). - Custom role resolution runs before permission expansion — the custom roles' permission sets are included in the effective permission calculation.
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.