Skip to content

Authorization ​

GrydAuth provides a flexible authorization system combining Role-Based Access Control (RBAC) and Permission-Based Access Control (PBAC) with support for custom policies.

Authorization Models ​

Role-Based Access Control (RBAC) ​

Roles group users with similar access requirements:

csharp
[Authorize(Roles = "Admin")]
public IActionResult AdminOnly() { ... }

[Authorize(Roles = "Admin,Manager")]
public IActionResult AdminOrManager() { ... }

Permission-Based Access Control (PBAC) ​

Fine-grained permissions for specific actions:

csharp
[RequirePermission("create:products")]
public IActionResult CreateProduct() { ... }

[RequirePermission("read:products", "update:products")]
public IActionResult ViewOrUpdateProducts() { ... } // OR (default)

[RequirePermission(PermissionMatchMode.All, "read:products", "update:products")]
public IActionResult ManageProducts() { ... } // AND

Permission Format ​

GrydAuth uses the convention {action}:{entity}:

PermissionDescription
create:productsCan create products
read:productsCan view products
update:productsCan update products
delete:productsCan delete products
manage:usersCan manage users
admin:systemSystem administration

Dedicated global permissions (least privilege — F9/D7) ​

Some administrative surfaces need a global reach that is narrower than the admin:system wildcard. These permissions grant one global surface each, are IsSystemPermission=true, and are never assigned to a tenant Admin role by the bootstrap — the platform operator grants them manually.

They are the three the catalog declares PermissionReach.Platform, together with admin:system, and that declaration is what the least-privilege ceiling reads: since ADR 0007 the permission alone opens cross-tenant reach, so handing one of them to a role you do not hold answers 403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED — on the dedicated endpoint and on PUT /roles/{id} alike. Permissions a tenant invents for its own business are untouched by the ceiling: a tenant admin composes product:create into its own roles freely.

PermissionGrantsDoes NOT grant
manage:all-usersThe whole /api/v1/admin/users surface (cross-tenant) and the /api/v1/admin/tenant-link-requests queue (global scope)./admin/tenants, MFA policy, IdP/SSO, or anything else.
manage:all-tenantsThe global tenant read track: GET /api/v1/admin/tenants and GET /api/v1/admin/tenants/{id} (full TenantDto, all tenants)./admin/users, tenant roles listing, MFA policy, IdP/domains, group operations, or any tenant write.

manage:all-tenants radius (F9/US 9.2). The permission is deliberately narrow — the global read of tenant identity/hierarchy. Everything operational stays where it was:

SurfaceGateIn the radius?
GET /admin/tenants, GET /admin/tenants/{id}Any(admin:system, manage:all-tenants)✅ yes
GET /admin/tenants/{id}/rolesadmin:system❌ no — role listing is not tenant identity
GET/PUT /admin/tenants/{id}/mfa-policyAny(admin:system, update:tenants)❌ no — tenant settings write
IdP connections / tenant domains (/admin/...)Any(admin:system, update:tenants)❌ no — tenant settings write
Tenant write (create/update/delete, group ops) — TenantsControllercreate:/update:/delete:tenants, manage:child-tenants❌ no — never global-read

admin:system still satisfies every gate (it is the wildcard). The gates use the "any" form, e.g. [RequirePermission(PermissionMatchMode.Any, "admin:system", "manage:all-users")].

Cross-tenant reach. The admin user queries are cross-tenant, gated by a pipeline behavior that otherwise requires admin:system. They opt a specific alternative permission in via ICrossTenantAuthorizedBy (AuthorizingPermissions => ["manage:all-users"]), so manage:all-users pierces the choke point only for user requests — never for tenant/role cross-tenant requests.

The permission is the whole answer (ADR 0007, A1). There is no second condition: no column, no cache entry, no middleware pre-check. Granting manage:all-users through the roles API is the entire procedure — no database step, and nothing for an operator to remember to switch on afterwards. Until ADR 0007 a boolean column was also required, which had no API of its own and which the capability contributors could not read, so the session announced a track the pipeline then refused with 403 CROSS_TENANT_FORBIDDEN.

That error code still exists and is still reachable, now with a single cause: an endpoint whose policy is wider than the reach its request demands — e.g. naming a tenant other than your own on a route that accepts update:tenants, which makes the command a genuine cross-tenant act that only admin:system opens. It stays distinct from TENANT_FORBIDDEN (you are not a member of that tenant) and from MISSING_PERMISSION (the endpoint policy refused you).

Capabilities. The session's users.default becomes global for a manage:all-users holder (computed by the same resolver as enforcement, so the UI routes to the admin track with no client change). Since US 4.2 each domain answers through its own ICapabilityContributor, and since US 4.3 the block announces the SET of scopes plus the default — with unreachable domains left out entirely. See session-capabilities-contract.

Group-scoped reads (ADR 0006) ​

Two permissions gate reading a group — the tenant of the token plus its group siblings — from the ordinary (non-admin) routes, via ?scope=group:

PermissionGrantsDoes NOT grant
read:group-analyticsAggregates over the group: counts, sums, per-tenant breakdowns.Reading the rows behind the numbers.
read:group-dataRecords owned by other tenants of the group, inside the normal filters/sort/paging.Anything outside the group; any write.

Both are in the GroupAdmin role template (AuthorizationCatalogDefinitions.GroupAdminRolePermissions), so every tenant's GroupAdmin is provisioned with them. Neither is in the Admin template: a tenant Admin administers its own tenant and is not, by that fact, a reader of its siblings.

Evaluated in the group tenant (G4). TenantScopeResolver resolves the caller's effective permissions with IEffectivePermissionResolver.ResolveAsync(userId, groupTenantId) — the tenant of the token is used to find the group and for nothing else. A permission granted inside a child company therefore cannot open its sibling's data; the segregation is structural, not a consequence of who granted what, where.

admin:system does not widen the set (G5). The wildcard satisfies the gate, but the tenant set returned is still the group. Platform-wide reads live on the /admin/* track.

OutcomeHTTPCode
Unknown ?scope= value400SCOPE_INVALID
Entitlement missing in the group tenant403SCOPE_NOT_AUTHORISED
The token's tenant belongs to no group422SCOPE_GROUP_UNAVAILABLE

The three constants live in one place, Gryd.Application.Abstractions.Tenancy.ScopeErrorCodes (US 3.2): the contract that rejects a bad ?scope= value is in Gryd.API and the module that refuses the read is in GrydAuth, and both have to spell the code identically. All three answer with type: https://gryd.io/errors/scope-error, and title/detail are localized (pt-BR, en, es-ES) — clients branch on code, never on the text.

Neither response names a member tenant, and the 403 does not name the missing permission either: a refusal must not double as a way to enumerate the group.

Cache of the group-scope decision (US 2.4) ​

Keygrydauth:smartfed:group-scope:{userId}:{tenantId}:{tokenVersion} (CacheKeys.Users.GroupScope)
Valuethe group's id and name, its active member ids, and the two verdicts (CanReadAggregates, CanReadRecords)
TTL5 minutes (CachePolicies.Users.GroupScope)
Invalidationonly the token_version bump that every authorization change performs (AuthorizationAffectingEventHandler). A new version is a new key, so the old entry is unreachable. There is no dedicated eviction path.
  • tenantId in the key is the token's tenant, not the group's: the group is the answer the entry holds, so it is unknown at lookup time. The mapping is deterministic, so a caller in a child and one in the group tenant simply hold two entries for the same group.
  • A refusal and a "no group" answer are cached too, so an unauthorized caller cannot make every request pay for two queries.
  • scope=tenant reads nothing: no cache lookup, no token-version lookup, no query.
  • The TTL is deliberately shorter than TenantPermissions (1 hour). The entitlement half of the entry is covered by the version bump, but the membership half is topology — a child deactivated, a tenant moved out of a group — and tenant lifecycle events do not bump anyone's token version. Five minutes bounds how long a just-deactivated sibling can stay inside a consolidated read. Making tenant lifecycle and group assignment bump the affected users' version would allow aligning this TTL with TenantPermissions; that is tracked as debt in F7.
  • Fail-secure. A cache that cannot be read behaves as a miss (the decision is resolved from the database), never as "authorized". A token version that cannot be determined skips the cache entirely rather than guessing a key.

Trace and log of the scope (US 5.1) ​

A group-scoped read publishes three facts, on the span and on every log line written while the scope is in force. ITenantScopeTelemetry (Core abstraction, implemented by Gryd.Observability.Tenancy.TenantScopeTelemetry) is the only component that writes them, and TenantScopeBehavior is its only caller.

FactSpan attributeSerilog property
Reading mode (always group)gryd.scope.modeScopeMode
Group tenantgryd.scope.group_idScopeGroupId
Size of the setgryd.scope.tenant_countScopeTenantCount

The span attribute follows the dotted OpenTelemetry convention the ADR names; the Serilog property follows the shape of every other property in LoggingConstants.Properties, which are addressed by name inside message templates. The pairs are declared side by side in TracingConstants and LoggingConstants.

  • scope=tenant publishes nothing — not mode=tenant, nothing. Every request served today is tenant-scoped, so a constant attribute on all of them would be volume rather than signal, and the question an operator asks ("which reads crossed company lines") is answered by the attribute's presence. The behavior short-circuits before the enricher is even reached.
  • The member ids are never published. The count is what detects an anomaly; the group id is already not a secret (it travels in the token as group_id). The list of siblings is the one part that would turn a log aggregator into a map of which companies belong to which group.
  • The enrichment wraps the applier, so a log line written during the query and the exception that escapes the handler both carry the scope — the failing read is the one an operator goes looking for.

Audit trail of group record reads (US 5.2) ​

Reading records of a group (read:group-data) writes one audit entry per request through IAuditStore, the shared abstraction — so a host that composes GrydAudit gets it persisted and one that does not gets NullAuditStore. IGroupScopeAuditor (Core abstraction, Gryd.Observability.Tenancy.GroupScopeAuditor) is the only writer.

FieldValue
EntityTypeGroupScopedRead
EntityIdthe group tenant id
ChangeTypeAccessed
CategorySecurity
Who / where fromuser id + email, the token's tenant, IP, user agent
Metadatascope.mode, scope.group_name, scope.tenant_count, scope.request, scope.trace_id

The compliance question — who read records of group X between two dates — is one filter: EntityType = GroupScopedRead, EntityId = <group>, FromDate/ToDate.

Aggregates are deliberately not recorded. read:group-analytics returns counts and sums; auditing every one of them would be high volume against low risk, and the noise would bury the record reads the trail exists for. This is a decision, not an omission: it is enforced inside GroupScopeAuditor — not in the behavior, which reports every group-scoped read — and pinned by a negative assertion in GroupScopeAuditorTests and again in GroupScopeReadTrailTests against the persisted trail.

The write is fail-closed, and it happens before the handler. If the trail cannot be written, the read is refused and the tenant set is never applied. That is a deliberate departure from FederationAuditor, which swallows and logs: a federation login that goes unrecorded costs a log line, whereas a read of another legal entity's records that goes unrecorded costs the only answer there will ever be to "who saw our data". Under G3 the trail is a precondition of the read.

The member ids are not recorded, for the same reason they are not logged. The group's composition at any point in time is recoverable from the tenant hierarchy; the trail does not need a copy per read.

Known gap. AuditLog.FromEntry keeps CorrelationId and drops the typed AuditEntry.TraceId and AuditEntry.Category — the entity has no column for either. CorrelationId here is ASP.NET's TraceIdentifier, not the W3C trace the span carries, so the auditor also puts the trace id in scope.trace_id metadata; without it the durable trail could not be joined to the trace at all. When AuditLog gains a TraceId column, drop that metadata entry.

Ceiling on the size of the set (US 5.3) ​

MultiTenancyOptions.MaxGroupScopeTenants bounds how many tenants one group-scoped read may touch. Default 200, configurable per environment under the MultiTenancy section; Validate() refuses zero or less at startup, because a ceiling of zero would turn every group read into an empty set — fail-closed and silent about why.

The ceiling is deliberately generous. The hierarchy is one level deep by construction, so a group is its direct children — dozens, not thousands. This is a backstop against a pathological group turning one query into an unbounded IN list, not a product limit anyone should hit.

When it does bite:

  • the set is cut and TenantScope.Truncated is true — applied inside TenantScope.Group, so there is no way to build a scope that was cut and does not say so;
  • TenantScopeResolver logs a warning naming the group, the limit and the real size;
  • the response's scope block carries truncated: true. A complete read omits the field, so the client reads its presence rather than comparing against a default;
  • scope.tenantCount becomes the limit, not the size of the group.

The group tenant stays in the set, first — its own rows are what a group read can least afford to lose — but it is never inserted when it was absent from the membership: reordering must not widen a read. Everything after it keeps the order the membership arrived in (GetActiveGroupMembersAsync orders by name), so the cut is deterministic and two consecutive pages agree about which tenants they came from.

The ceiling is applied to the resolved scope, not to the cached decision: the cache entry holds the group's real membership, so the warning fires on every request that is actually served short — not only on the one that happened to miss the cache.

POST /auth/switch-tenant needs an active UserTenant link in the target tenant, for everyone. Belonging to the target's group does not substitute for it, and neither does admin:system: reaching DATA across tenants (/admin/*) and BEING a tenant are separate questions with separate gates.

Two paths used to exist. switch:any-group-tenant, evaluated in the parent, let a group admin assume a sibling with no link — and SwitchTenantCommandHandler was the only component that knew that rule. GrydAuthTokenProcessor validates tenant access with UserTenantRepository.HasAccessAsync, a link lookup with no notion of group, so the switch succeeded, issued a token for the sibling, and every request made with that token answered 403. The permission was removed rather than taught to the middleware, for the reason recorded in ADR 0007: inheritance is only safe when the effective set is enumerable and each grant is an explicit object, and here it was neither — creating a child tenant silently widened the reach of every group admin, with no grant event and no trail.

What this buys: "which tenants does this person act in?" is a SELECT over UserTenants.

Reading consolidated across a group is a different act and is unchanged — ?scope=group still resolves entitlement in the group tenant (ADR 0006, G4), and read:group-analytics / read:group-data are untouched. A group admin sees the group's numbers and rows; it does not become a member of the branch.

The GroupAdmin role inside a Company tenant ​

Every tenant is provisioned with every role in the catalog, so a company has a GroupAdmin role too. Holding it there grants nothing, and that is intended: a group-scoped read resolves the entitlement in the group tenant (G4) — a question that assignment cannot answer.

It is documented rather than suppressed. Not provisioning it in companies would put a tenant-type conditional back inside the provisioning loop — which iterates the catalog knowing no role names on purpose (US 2.2, OCP) — and would require retroactively stripping the role from companies already provisioned, for zero difference in what anyone is authorized to do.

ITenantDataFilter.Disable() is not the group path ​

Disabling the tenant filter is fail-open: with it off, the handler is the only thing between the caller and the whole platform. It has exactly two legitimate callers — CrossTenantDataFilterBehavior (which first demands platform authority) and the bootstrap, which runs before any tenant exists.

A group read narrows to a set and is fail-closed: it goes through ITenantScopeApplier, and an empty set means no rows rather than all rows. The two are mutually exclusive by construction — TenantScopeBehavior throws when a single request carries both markers.

Role Management ​

Built-in Roles ​

GrydAuth comes with predefined roles:

RoleDescription
SuperAdminFull system access
AdminAdministrative access
UserStandard user access
GuestLimited read-only access

Creating Roles ​

http
POST /api/v1/roles
Authorization: Bearer {admin-token}
Content-Type: application/json

{
  "name": "Manager",
  "description": "Department manager with elevated permissions",
  "permissions": [
    "read:products",
    "create:products",
    "update:products",
    "read:users",
    "create:reports"
  ]
}

Assigning Roles to Users ​

http
POST /api/v1/users/{userId}/roles
Authorization: Bearer {admin-token}
Content-Type: application/json

{
  "roleNames": ["Manager", "User"]
}

Programmatic Role Management ​

csharp
public class RoleService
{
    private readonly IRoleRepository _roleRepository;
    private readonly IUserRepository _userRepository;
    
    public async Task AssignRoleAsync(Guid userId, string roleName)
    {
        var user = await _userRepository.GetByIdAsync(userId);
        var role = await _roleRepository.GetByNameAsync(roleName);
        
        user.AssignRole(role);
        
        await _userRepository.UpdateAsync(user);
    }
}

Permission Management ​

Creating Permissions ​

http
POST /api/v1/permissions
Authorization: Bearer {admin-token}
Content-Type: application/json

{
  "code": "export:reports",
  "description": "Can export reports to PDF/Excel",
  "category": "Reports"
}

Auto-Generated Permissions ​

Use [AutoPermission] attribute to automatically generate and enforce permissions for your controllers:

csharp
using Gryd.API.Attributes;

[ApiController]
[Route("api/[controller]")]
[AutoPermission("products")] // Generates AND enforces: create:products, read:products, etc.
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll() { ... } // read:products
    
    [HttpPost]
    public IActionResult Create() { ... } // create:products
    
    [HttpPut("{id}")]
    public IActionResult Update() { ... } // update:products
    
    [HttpDelete("{id}")]
    public IActionResult Delete() { ... } // delete:products
}

Automatic Enforcement with GrydCrud ​

When using GrydCrud.Auth with autoPermissions: true, controllers that inherit from CrudController or CqrsCrudController get permissions enforced automatically — no method overrides needed:

csharp
// Program.cs
builder.Services.AddGrydCrudAuth(autoPermissions: true);
csharp
// Controller — zero boilerplate
[AutoPermission("departments")]
[Route("api/[controller]")]
public class DepartmentsController : CrudController<Department, CreateDeptDto, UpdateDeptDto, DeptDto, DeptQueryParams>
{
    public DepartmentsController(ICrudService<...> service) : base(service) { }

    // ✅ All inherited CRUD endpoints are protected automatically:
    // GetAll   → Permission:read:departments
    // GetById  → Permission:read:departments
    // Create   → Permission:create:departments
    // Update   → Permission:update:departments
    // Delete   → Permission:delete:departments
}

The CrudPermissionConvention respects existing [Authorize] / [RequirePermission] attributes and will not override them. See the GrydCrud Permissions documentation for full details.

Permission Generation Service ​

Generate all permissions for seeding:

csharp
using GrydCrud.Auth.Abstractions;

public class PermissionSeeder
{
    private readonly ICrudPermissionGenerator _permissionGenerator;
    private readonly IPermissionRepository _permissionRepository;
    
    public async Task SeedPermissionsAsync()
    {
        // Scan all controllers for [AutoPermission] attributes
        // Includes inherited CRUD methods from base controllers
        var permissions = _permissionGenerator.GeneratePermissions(
            typeof(ProductsController).Assembly,
            typeof(UsersController).Assembly
        );
        
        foreach (var permission in permissions)
        {
            await _permissionRepository.CreateIfNotExistsAsync(permission);
        }
    }
}

Authorization Attributes ​

[Authorize] ​

Standard ASP.NET Core authorization:

csharp
[Authorize] // Any authenticated user
public class SecureController : ControllerBase { }

[Authorize(Roles = "Admin")]
public class AdminController : ControllerBase { }

[RequirePermission] ​

GrydAuth permission-based authorization:

csharp
using GrydAuth.Application.Authorization;

// Single permission
[RequirePermission("read:products")]
public IActionResult GetProducts() { ... }

// Multiple permissions (OR - default)
[RequirePermission("read:products", "export:products")]
public IActionResult ReadOrExportProducts() { ... }

// Multiple permissions (AND)
[RequirePermission(PermissionMatchMode.All, "read:products", "export:products")]
public IActionResult ExportProducts() { ... }

Behavior summary:

  • 1 permission: same behavior as previous versions.
  • 2+ permissions without PermissionMatchMode: OR (Any).
  • 2+ permissions with PermissionMatchMode.All: AND (All).
  • Internal policy format is versioned (Permission:v2:*) and backward-compatible with legacy Permission:<code>.

Stale claims and token revocation ​

Permission claims are authorization snapshots. GrydAuth bounds their lifetime with access tokens that expire after 10 minutes by default (configurable from 1 to 15 minutes), while refresh tokens preserve session continuity.

Every change to an existing user's effective permissions increments User.TokenVersion. The token processor compares the JWT token_version claim with the current version on every request and rejects obsolete tokens globally. This includes direct permissions, user roles, permissions attached to roles, tenant access changes and user deactivation. User deletion performs complete session invalidation. Role permission changes fan out the version increment to every user assigned to that role.

Authorization remains declarative through [RequirePermission]; endpoints do not perform live permission queries. This keeps database/cache I/O out of the authorization hot path and avoids endpoint-specific revocation behavior.

[AllowAnonymous] ​

Skip authorization:

csharp
[AllowAnonymous]
public IActionResult PublicEndpoint() { ... }

Policy-Based Authorization ​

Defining Policies ​

csharp
builder.Services.AddAuthorization(options =>
{
    // Simple permission policy
    options.AddPolicy("CanReadProducts", policy =>
        policy.RequirePermission("read:products"));
    
    // Multiple permissions (ALL required)
    options.AddPolicy("CanManageProducts", policy =>
        policy.RequirePermission("create:products", "update:products", "delete:products"));
    
    // Role-based policy
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin", "SuperAdmin"));
    
    // Combined policy
    options.AddPolicy("ProductManager", policy =>
        policy.RequireRole("Manager")
              .RequirePermission("read:products", "update:products"));
    
    // Custom requirement
    options.AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

Using Policies ​

csharp
[Authorize(Policy = "CanManageProducts")]
public IActionResult ManageProducts() { ... }

Custom Authorization Handlers ​

csharp
public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        var dateOfBirthClaim = context.User.FindFirst(c => c.Type == "date_of_birth");
        
        if (dateOfBirthClaim != null)
        {
            var dateOfBirth = DateTime.Parse(dateOfBirthClaim.Value);
            var age = DateTime.Today.Year - dateOfBirth.Year;
            
            if (age >= requirement.MinimumAge)
            {
                context.Succeed(requirement);
            }
        }
        
        return Task.CompletedTask;
    }
}

// Register handler
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();

Checking Permissions in Code ​

Using IAuthorizationService ​

csharp
public class ProductService
{
    private readonly IAuthorizationService _authorizationService;
    private readonly IHttpContextAccessor _httpContextAccessor;
    
    public async Task<Result> DeleteProductAsync(Guid productId)
    {
        var user = _httpContextAccessor.HttpContext!.User;
        
        var authResult = await _authorizationService.AuthorizeAsync(
            user, 
            null, 
            new PermissionRequirement("delete:products"));
        
        if (!authResult.Succeeded)
        {
            return Result.Failure("ACCESS_DENIED", "You don't have permission to delete products");
        }
        
        // Proceed with deletion...
    }
}

Using ICurrentUserService ​

csharp
public class ProductService
{
    private readonly ICurrentUserService _currentUser;
    
    public bool CanUserEditProduct(Product product)
    {
        // Check if user has update permission
        if (_currentUser.HasPermission("update:products"))
            return true;
        
        // Or is the owner
        if (product.CreatedBy == _currentUser.UserId)
            return true;
        
        return false;
    }
}

ICurrentUserService Methods ​

MethodDescription
HasPermission(string)Check single permission
HasAnyPermission(params string[])Check if user has any of the permissions
HasAllPermissions(params string[])Check if user has all permissions
IsInRole(string)Check if user is in role
IsInAnyRole(params string[])Check if user is in any of the roles

Resource-Based Authorization ​

For complex scenarios where authorization depends on the resource:

csharp
public class DocumentAuthorizationHandler : 
    AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OperationAuthorizationRequirement requirement,
        Document resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        
        if (requirement.Name == Operations.Read.Name)
        {
            // Anyone in the same department can read
            if (resource.DepartmentId == GetUserDepartment(context.User))
            {
                context.Succeed(requirement);
            }
        }
        else if (requirement.Name == Operations.Update.Name)
        {
            // Only owner or admin can update
            if (resource.OwnerId.ToString() == userId || 
                context.User.IsInRole("Admin"))
            {
                context.Succeed(requirement);
            }
        }
        
        return Task.CompletedTask;
    }
}

// Usage
var document = await _documentRepository.GetByIdAsync(id);
var authResult = await _authorizationService.AuthorizeAsync(
    User, document, Operations.Update);

if (!authResult.Succeeded)
    return Forbid();

Hierarchical Permissions ​

Implement permission hierarchies:

csharp
public class HierarchicalPermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    private readonly Dictionary<string, string[]> _hierarchies = new()
    {
        ["admin:*"] = new[] { "create:*", "read:*", "update:*", "delete:*" },
        ["manage:products"] = new[] { "create:products", "read:products", "update:products" },
    };
    
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        var userPermissions = context.User.Claims
            .Where(c => c.Type == "permissions")
            .Select(c => c.Value)
            .ToHashSet();
        
        // Check direct permission
        if (userPermissions.Contains(requirement.Permission))
        {
            context.Succeed(requirement);
            return Task.CompletedTask;
        }
        
        // Check hierarchical permissions
        foreach (var (parent, children) in _hierarchies)
        {
            if (userPermissions.Contains(parent) && 
                children.Contains(requirement.Permission))
            {
                context.Succeed(requirement);
                break;
            }
        }
        
        return Task.CompletedTask;
    }
}

Best Practices ​

  1. Principle of Least Privilege - Grant minimum permissions needed
  2. Use Permissions over Roles - Roles group permissions, but check permissions
  3. Avoid Hardcoded Roles - Use configuration or database
  4. Audit Permission Changes - Log all authorization changes
  5. Regular Permission Review - Periodically review user permissions
  6. Use Resource-Based Auth - For complex scenarios, check the resource itself

Released under the MIT License.