Authorization & Ownership¶
Head.Net provides two ways to protect entity endpoints: ownership extraction for the common case where each entity belongs to one user, and custom authorization policies for more complex rules. Both integrate cleanly with the fluent builder without touching individual endpoint handlers.
Ownership extraction¶
The most common authorization pattern for user-owned entities: only the owner can read, update, or delete their data.
.RequireOwnership(invoice => invoice.UserId)
The lambda extracts the owner's user ID from the entity. On GET, PUT, DELETE, and custom actions, Head.Net:
- Loads the entity
- Extracts the owner ID using your lambda
- Compares it to the current user's ID
- Returns
403 Forbiddenif they do not match
Entity setup¶
Add a UserId field to your entity:
public sealed class Invoice : IHeadEntity<int>
{
public int Id { get; set; }
public int UserId { get; set; } // owner
public string CustomerName { get; set; } = string.Empty;
public decimal Total { get; set; }
public string Status { get; set; } = "draft";
public DateTimeOffset CreatedAt { get; set; }
}
Full example¶
app.MapEntity<Invoice>()
.WithCrud()
.BeforeCreate((invoice, ct) =>
{
invoice.UserId = /* resolved from current user */;
invoice.CreatedAt = DateTimeOffset.UtcNow;
return ValueTask.CompletedTask;
})
.RequireOwnership(invoice => invoice.UserId)
.Build();
Custom authorization policy¶
For rules that are more nuanced than simple ownership — roles, time-based access, entity state conditions — use RequireAuthorization:
.RequireAuthorization(async (invoice, userId, ct) =>
{
// Admins can access any invoice
if (await roleService.IsAdminAsync(userId, ct))
return true;
// Regular users can only access their own
return invoice.UserId == userId;
})
The policy delegate receives:
- TEntity entity — the loaded entity
- int userId — the current user's ID (extracted by the user ID provider)
- CancellationToken ct
Return true to allow, false to return 403 Forbidden.
Configuring user ID extraction¶
Head.Net extracts the current user ID from the HTTP context. The default implementation reads the "UserId" claim from the authentication principal:
// Default behavior (no configuration needed if you use this claim name)
ctx.User?.FindFirst("UserId")?.Value
If your auth scheme uses a different claim name or source, provide a custom extractor:
.WithUserIdProvider(ctx =>
{
var sub = ctx.User?.FindFirst("sub")?.Value;
return int.TryParse(sub, out var id) ? id : 0;
})
Or extract from a header for API key scenarios:
.WithUserIdProvider(ctx =>
{
var header = ctx.Request.Headers["X-User-Id"].FirstOrDefault();
return int.TryParse(header, out var id) ? id : 0;
})
A return value of 0 is treated as unauthenticated. Combined with RequireOwnership, any entity with a non-zero UserId will reject unauthenticated requests.
Which endpoints are protected?¶
Ownership and authorization checks apply to:
| Operation | Protected? |
|---|---|
GET /entities (list) |
No — filtered via WithQueryFilter instead |
GET /entities/{id} |
Yes |
POST /entities (create) |
No — use BeforeCreate to set owner |
PUT /entities/{id} |
Yes |
DELETE /entities/{id} |
Yes |
| Custom actions | Yes |
The list endpoint is intentionally not protected by ownership at the individual entity level — use WithQueryFilter to scope the list to the current user instead:
.WithQueryFilter(q => q.Where(inv => inv.UserId == currentUserId))
For this to work in a setup class, inject the user context through the constructor:
public sealed class InvoiceSetup : IHeadEntitySetup<Invoice>
{
private readonly ICurrentUser _currentUser;
public InvoiceSetup(ICurrentUser currentUser) => _currentUser = currentUser;
public void Configure(HeadEntityEndpointBuilder<Invoice> builder)
{
builder
.WithQueryFilter(q => q.Where(inv => inv.UserId == _currentUser.Id))
.RequireOwnership(inv => inv.UserId);
}
}
HeadAuthorizationResult¶
HeadAuthorizationResult is available if you need to express authorization outcomes explicitly in your own code:
var result = HeadAuthorizationResult.Allow();
var denied = HeadAuthorizationResult.Deny("Invoice is locked and cannot be modified");
result.Allowed; // true
denied.Allowed; // false
denied.Reason; // "Invoice is locked and cannot be modified"
Combining ownership and authorization¶
RequireOwnership and RequireAuthorization are mutually exclusive — registering both will use the authorization policy and ignore the ownership extractor. Use RequireAuthorization when you need a superset of ownership logic:
.RequireAuthorization(async (invoice, userId, ct) =>
{
// Ownership check
if (invoice.UserId == userId) return true;
// Admin override
return await admins.IsAdminAsync(userId, ct);
})
Without ASP.NET Core authentication¶
Head.Net does not require ASP.NET Core's authentication middleware. If you extract user IDs another way (API key lookup, session cookie, database token), provide a WithUserIdProvider that returns the right ID for the current request.
For unauthenticated public APIs, simply omit RequireOwnership and RequireAuthorization entirely. No checks run by default.