Combining with Built-In IAM
Custom resolvers and role providers stack on top of — not instead of — the built-in RBAC system. Understand the interaction model, the BaseAllowed flag, and the layering semantics.
The Layering Model
Custom extensions do not replace the built-in IAM — they extend it. The built-in system runs first, producing a tentative ALLOW or DENY. Custom resolvers then have the opportunity to override or affirm that decision.
// Full evaluation pipeline with custom extensions
Phase 1 — Built-in:
IIdentityProvider.ResolveIdentityAsync() → IdentityClaims
IMembershipProvider.GetUserGroupsAsync() → built-in roles
ICustomRoleProvider[0].GetRolesAsync() → extra roles (merged)
ICustomRoleProvider[1].GetRolesAsync() → extra roles (merged)
IPermissionProvider.GetUserPermissionsAsync() → permission strings
PermissionEngine.Match() → BaseAllowed = true/false
Phase 2 — Custom Resolvers (pipeline, short-circuits on Allow/Deny):
ICustomPermissionResolver[0].ResolveAsync() → Allow/Deny/Defer
ICustomPermissionResolver[1].ResolveAsync() → Allow/Deny/Defer (if previous Defer)
ICustomPermissionResolver[2].ResolveAsync() → Allow/Deny/Defer (if previous Defer)
Phase 3 — Final Gate:
IAccessDecisionProvider.CanUserAccessAsync() → true/false/null
Using context.BaseAllowed
The PermissionContext.BaseAllowed property tells you whether the built-in RBAC check produced an ALLOW. Use this to decide whether your resolver needs to act:
// Pattern 1: Only add ABAC constraints when RBAC already allows
// "RBAC is necessary but not sufficient"
public Task<PermissionResolution> ResolveAsync(PermissionContext ctx, CancellationToken ct)
{
// If RBAC already denied, don't override — it's already denied
if (!ctx.BaseAllowed) return Task.FromResult(PermissionResolution.Defer);
// RBAC allowed, but add additional constraints
if (!MeetsAbacCondition(ctx))
return Task.FromResult(PermissionResolution.Deny);
return Task.FromResult(PermissionResolution.Defer); // RBAC allow stands
}
// Pattern 2: Override RBAC entirely (use sparingly)
// "Custom resolver has full authority regardless of RBAC"
public Task<PermissionResolution> ResolveAsync(PermissionContext ctx, CancellationToken ct)
{
if (IsSystemAdmin(ctx.User))
return Task.FromResult(PermissionResolution.Allow); // bypass RBAC
if (IsContractor(ctx.User) && IsSensitiveResource(ctx))
return Task.FromResult(PermissionResolution.Deny); // override RBAC allow
return Task.FromResult(PermissionResolution.Defer); // let RBAC stand
}
Interaction Matrix
| RBAC Result | Resolver Returns | Final Decision |
|---|---|---|
| Allow | Defer | Allow (RBAC result stands) |
| Allow | Allow | Allow (explicit confirmation) |
| Allow | Deny | Deny (resolver overrides RBAC) |
| Deny | Defer | Deny (RBAC result stands) |
| Deny | Allow | Allow (resolver overrides RBAC — use carefully) |
| Deny | Deny | Deny (explicit confirmation) |
A resolver that returns Allow when BaseAllowed is false effectively bypasses the role system. This is a powerful capability — use it only for scenarios like "break-glass" emergency access or resource ownership grants. Every such override should be logged with a clear reason. Audit this code path carefully.
Multiple Resolvers — Priority and Order
// Registration order determines evaluation order
services.AddCustomPermissionResolver<ContractorRestrictionResolver>(); // first
services.AddCustomPermissionResolver<BusinessHoursResolver>(); // second
services.AddCustomPermissionResolver<ClearanceLevelResolver>(); // third
// Pipeline stops when any resolver returns Allow or Deny
// If ContractorRestrictionResolver returns Deny → done, Deny
// If ContractorRestrictionResolver returns Defer → BusinessHoursResolver is called
// If all return Defer → RBAC decision stands