The Problem
The source generator produces a full ASP.NET Core controller for every [DistributedTask]. That controller exposes three actions: Submit, Cancel, and View Status. Without authorization, anyone who can reach the endpoint can submit expensive tasks, cancel other users' work, or view data they should not see.
The DSL needs a security model that is:
- Pluggable — the generator does not know your auth scheme
- Declarative — simple cases should not require custom code
- Enforced — the generated controller calls the handler before every action, not after
IDistributedTaskAuthorizationHandler
The generator emits code that resolves this interface from DI and calls it before each controller action:
public interface IDistributedTaskAuthorizationHandler
{
Task<bool> CanSubmitAsync(string taskName, ClaimsPrincipal user);
Task<bool> CanCancelAsync(string taskId, ClaimsPrincipal user);
Task<bool> CanViewAsync(string taskId, ClaimsPrincipal user);
}public interface IDistributedTaskAuthorizationHandler
{
Task<bool> CanSubmitAsync(string taskName, ClaimsPrincipal user);
Task<bool> CanCancelAsync(string taskId, ClaimsPrincipal user);
Task<bool> CanViewAsync(string taskId, ClaimsPrincipal user);
}If no implementation is registered, the generated controller returns 403 Forbidden for all actions. Secure by default.
Generated Controller Integration
The generated controller calls the handler at the top of each action. Here is the Submit action (simplified):
// ── Generated: CreateZipFromFilesController.g.cs ──
[HttpPost("submit")]
public async Task<IActionResult> Submit(
[FromBody] CreateZipRequest request,
CancellationToken ct)
{
var authorized = await _authHandler.CanSubmitAsync(
"CreateZipFromFiles", User);
if (!authorized)
return Forbid();
var taskId = await _orchestrator.SubmitAsync(request, ct);
return Accepted(new { TaskId = taskId });
}// ── Generated: CreateZipFromFilesController.g.cs ──
[HttpPost("submit")]
public async Task<IActionResult> Submit(
[FromBody] CreateZipRequest request,
CancellationToken ct)
{
var authorized = await _authHandler.CanSubmitAsync(
"CreateZipFromFiles", User);
if (!authorized)
return Forbid();
var taskId = await _orchestrator.SubmitAsync(request, ct);
return Accepted(new { TaskId = taskId });
}The Cancel and View actions follow the same pattern. The taskId is passed to CanCancelAsync and CanViewAsync so the handler can enforce ownership — "user X can only cancel their own tasks."
Shortcut: [RequiresRole]
For the common case — role-based access — the DSL provides a shortcut attribute:
[DistributedTask("CreateZipFromFiles")]
[RequiresRole("FileProcessor")]
public partial class CreateZipFromFilesSaga
{
// Steps...
}[DistributedTask("CreateZipFromFiles")]
[RequiresRole("FileProcessor")]
public partial class CreateZipFromFilesSaga
{
// Steps...
}The generator reads [RequiresRole] and produces a built-in handler that checks User.IsInRole("FileProcessor") for Submit and View, and additionally checks task ownership for Cancel. No custom IDistributedTaskAuthorizationHandler needed.
If you register both [RequiresRole] and a custom handler, the custom handler wins — it replaces the generated one in the DI container.
Developer Implementation: Role + Tenant Isolation
For multi-tenant scenarios, you implement the interface yourself:
public class TenantAwareAuthHandler : IDistributedTaskAuthorizationHandler
{
private readonly ITaskRepository _tasks;
public TenantAwareAuthHandler(ITaskRepository tasks)
=> _tasks = tasks;
public Task<bool> CanSubmitAsync(string taskName, ClaimsPrincipal user)
{
var hasRole = user.IsInRole($"{taskName}.Submit");
return Task.FromResult(hasRole);
}
public async Task<bool> CanCancelAsync(string taskId, ClaimsPrincipal user)
{
var task = await _tasks.GetAsync(taskId);
if (task is null) return false;
var tenantId = user.FindFirstValue("tenant_id");
return task.TenantId == tenantId
&& user.IsInRole($"{task.TaskName}.Cancel");
}
public async Task<bool> CanViewAsync(string taskId, ClaimsPrincipal user)
{
var task = await _tasks.GetAsync(taskId);
if (task is null) return false;
var tenantId = user.FindFirstValue("tenant_id");
return task.TenantId == tenantId;
}
}public class TenantAwareAuthHandler : IDistributedTaskAuthorizationHandler
{
private readonly ITaskRepository _tasks;
public TenantAwareAuthHandler(ITaskRepository tasks)
=> _tasks = tasks;
public Task<bool> CanSubmitAsync(string taskName, ClaimsPrincipal user)
{
var hasRole = user.IsInRole($"{taskName}.Submit");
return Task.FromResult(hasRole);
}
public async Task<bool> CanCancelAsync(string taskId, ClaimsPrincipal user)
{
var task = await _tasks.GetAsync(taskId);
if (task is null) return false;
var tenantId = user.FindFirstValue("tenant_id");
return task.TenantId == tenantId
&& user.IsInRole($"{task.TaskName}.Cancel");
}
public async Task<bool> CanViewAsync(string taskId, ClaimsPrincipal user)
{
var task = await _tasks.GetAsync(taskId);
if (task is null) return false;
var tenantId = user.FindFirstValue("tenant_id");
return task.TenantId == tenantId;
}
}Register it once:
services.AddSingleton<IDistributedTaskAuthorizationHandler,
TenantAwareAuthHandler>();services.AddSingleton<IDistributedTaskAuthorizationHandler,
TenantAwareAuthHandler>();Every generated controller picks it up.
Webhook Security: HMAC Signatures
When a client chooses ListeningStrategy.Webhook, the system calls back to their URL with task progress. That callback must be verifiable — the recipient needs to confirm the payload came from the system and was not tampered with.
The generator produces HMAC-SHA256 signing on every webhook payload:
// ── Generated: WebhookNotifier.g.cs (simplified) ──
var payload = JsonSerializer.SerializeToUtf8Bytes(notification);
var signature = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(_options.WebhookSecret), payload);
var signatureHex = Convert.ToHexString(signature);
request.Headers.Add("X-DistributedTask-Signature", $"sha256={signatureHex}");
request.Headers.Add("X-DistributedTask-Timestamp", timestamp);// ── Generated: WebhookNotifier.g.cs (simplified) ──
var payload = JsonSerializer.SerializeToUtf8Bytes(notification);
var signature = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(_options.WebhookSecret), payload);
var signatureHex = Convert.ToHexString(signature);
request.Headers.Add("X-DistributedTask-Signature", $"sha256={signatureHex}");
request.Headers.Add("X-DistributedTask-Timestamp", timestamp);The recipient verifies:
var expected = HMACSHA256.HashData(secret, body);
var valid = CryptographicOperations.FixedTimeEquals(expected, received);var expected = HMACSHA256.HashData(secret, body);
var valid = CryptographicOperations.FixedTimeEquals(expected, received);The timestamp header prevents replay attacks — the recipient rejects payloads older than a configurable window (default: 5 minutes).
Queue Security
Messages on RabbitMQ carry task data — file paths, user identifiers, step results. Two layers protect them:
- In transit: RabbitMQ TLS (
amqps://). The generator emits connection configuration that defaults to TLS when a certificate is provided. - At rest: Message payload encryption. When
[EncryptMessages]is applied to the task, the generator wraps every published message in an AES-256-GCM envelope. The decryption key is resolved fromIDataProtectionProvider— the standard ASP.NET Core data protection system.
[DistributedTask("ProcessSensitiveData")]
[EncryptMessages]
public partial class ProcessSensitiveDataSaga
{
// Steps handle decrypted payloads transparently
}[DistributedTask("ProcessSensitiveData")]
[EncryptMessages]
public partial class ProcessSensitiveDataSaga
{
// Steps handle decrypted payloads transparently
}The developer never sees encryption code. The generator inserts it into the publish and consume paths.
Closing
This is Part XVI and the final article in the DistributedTask.Dsl series. Over sixteen parts, we walked from the problem statement — "distributed task processing is boilerplate-heavy and error-prone" — through the DSL design, the five-stage source generation pipeline, the saga pattern with compensation, resilience, observability, listening strategies, and now security.
The core idea has stayed the same throughout: declare intent with attributes, generate infrastructure with Roslyn, override via the Generation Gap pattern. The developer writes ~55 lines. The generator writes ~700+. The analyzers catch 14 categories of mistakes at compile time. The result is a system that is safe, observable, and extensible — without requiring a cloud platform, a JVM, or a visual designer.
The full series: DistributedTask.Dsl index.