Path-based authorization library for .NET. Define access rules per resource type using path patterns, and evaluate them against caller claims and operations.
Install
dotnet add package Winche.Sentinel
Quick Start
services.AddWincheSentinel<Document>(c =>
{
c.SetCallerClaimsAccessor<HttpContextClaimsAccessor>();
c.AddResourceAccessRule(new DocumentRule("/docs/{id}", [AccessOperation.Read, AccessOperation.Write]));
});
Inject IAccessRuleEvaluator<Document> and call it before performing the operation:
// throws AccessDeniedException or NoRulesMatchedException on failure
await evaluator.EvaluateAsync(
AccessOperation.Read,
"/docs/42",
loader: ct => repository.GetByIdAsync(42, ct));
How It Works
For a given operation and path, the evaluator considers every registered rule that matches the request:
- A rule matches when its path pattern matches the path and its operations set contains the operation. (A
nullpath matches every path; anulloperations set matches every operation.) - Each matching rule's
EvaluateAsyncis called with anAccessContext<TResource>. Returningtruegrants access — evaluation stops and the request is allowed. - A matching rule returning
falsedoes not deny; it simply doesn't grant, and evaluation continues to the next matching rule. - Access is the default-deny outcome: if at least one rule matched but none granted,
AccessDeniedExceptionis thrown; if no rule matched the path and operation at all,NoRulesMatchedExceptionis thrown.
These are OR / grant-only semantics (Firestore-style): there is no explicit deny, and registration order does not affect the decision. Because a grant cannot be revoked by another rule, grant narrowly — don't write a broad ** grant and expect a more specific rule to restrict it; grant access only where it should be allowed and let default-deny cover the rest.
Path Patterns
| Pattern | Matches |
|---|---|
/docs/42 |
Exact match |
/docs/{id} |
Any single segment; captured as Params["id"] |
/docs/* |
Any single segment (unnamed) |
/docs/** |
Zero or more remaining segments; captured as Params["**"] |
** must be the last segment in the pattern.
Implementing a Rule
using Winche.Sentinel.Interfaces;
using Winche.Sentinel.Models;
public class DocumentRule(string path, IEnumerable<AccessOperation> ops) : IResourceAccessRule<Document>
{
public string Path => path;
public IReadOnlySet<AccessOperation> Operations => new HashSet<AccessOperation>(ops);
public async Task<bool> EvaluateAsync(AccessContext<Document> context, CancellationToken ct)
{
var doc = await context.GetResourceAsync(ct);
var userId = context.Claims["sub"] as string;
return doc?.OwnerId == userId;
}
}
Register via instance or by type:
// instance
c.AddResourceAccessRule(new DocumentRule("/docs/{id}", [AccessOperation.Read]));
// type (resolved from DI)
c.AddResourceAccessRule<DocumentRule>();
AccessContext
| Member | Description |
|---|---|
Operation |
Read, Write, Delete, or Aggregate |
Path |
The path being accessed |
Params |
Path parameters extracted from the pattern |
Claims |
Caller claims from ICallerClaimsAccessor |
GetResourceAsync |
Lazy loader — invokes the loader function passed to EvaluateAsync |
IncomingData |
Optional extra data passed to EvaluateAsync |
GetIncomingData<T>() |
Casts IncomingData to T; returns null if the cast fails |
Caller Claims
Implement ICallerClaimsAccessor<TResource> to supply caller claims (e.g. from HttpContext):
public class HttpContextClaimsAccessor : ICallerClaimsAccessor<Document>
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextClaimsAccessor(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
public IReadOnlyDictionary<string, object?> GetClaims()
=> _httpContextAccessor.HttpContext?.User.Claims
.ToDictionary(c => c.Type, c => (object?)c.Value)
?? new Dictionary<string, object?>();
}
Register it via SetCallerClaimsAccessor:
c.SetCallerClaimsAccessor<HttpContextClaimsAccessor>();
// or pass an instance directly:
c.SetCallerClaimsAccessor(new HttpContextClaimsAccessor(httpContextAccessor));
If no claims accessor is registered, EmptyCallerClaimsAccessor is used (returns an empty dictionary).
Passing Incoming Data
Pass a write payload through to the rule via the data parameter:
await evaluator.EvaluateAsync(AccessOperation.Write, "/docs/42", data: updateRequest);
In the rule, retrieve it with GetIncomingData<T>():
public async Task<bool> EvaluateAsync(AccessContext<Document> context, CancellationToken ct)
{
var update = context.GetIncomingData<UpdateDocumentRequest>();
// ...
}
Post-Registration Configuration
Use ConfigureWincheSentinel<T>() to add rules or swap the claims accessor after the initial AddWincheSentinel<T>() call:
services.AddWincheSentinel<Document>();
// elsewhere (e.g. a feature module)
services.ConfigureWincheSentinel<Document>(c =>
{
c.AddResourceAccessRule(new ArchiveRule("/docs/{id}/archive", [AccessOperation.Write]));
});
Multiple Resource Types
Call AddWincheSentinel<T>() once per resource type — each gets its own isolated rule set and evaluator.
services.AddWincheSentinel<Document>(c => { /* ... */ });
services.AddWincheSentinel<Project>(c => { /* ... */ });
License
Elastic License 2.0