Secure an Operation
Declare the permission a command or query requires, register its check, and say which permissions each Actor holds.
What you build
A permission declared on the command or query itself, checked in the pipeline's authorization step for every Operation, whatever entry point started it. The application says which permissions an Actor holds; the Kit reads no claim and no request itself. An Actor without the permission is denied with a forbidden Error, and the command commits nothing: see Denial.
A trusted continuation is not checked again
Work an authorized Operation causes, such as a delivery the Kit restores or a Continue() inside the running Operation, runs on its recorded authority: a declared permission is not checked again for it. So a handler can continue into protected work its Actor holds no permission for. Declare a permission on the Operation that decides to cause such work.
When to use it
When a command or query must be limited to Actors who hold a named permission. Every HTTP caller is already authenticated by default. A rule that is not "this Actor holds this permission", such as one that reads the command's data, is an IAuthorizer<TOperation> of the Module's own: see Authorization hook.
Steps
Declare the permission on the record
One permission per command or query, named as Declaring a permission says and kept in a constant on the record, so tests and grants name it without repeating the string.
/// <summary>Removes every product whose name, as stored, is one of these; returns how many it removed.</summary>
[RequiresPermission(RemoveProductsCommand.Permission)]
internal sealed record RemoveProductsCommand(IReadOnlyList<string> Names)
: Command<int>,
IInvalidatesCache
{
public const string Permission = "catalog.products.remove";
// It finds the products by name, so it cannot name their ids before it runs: it evicts every cached product read.
public IReadOnlyCollection<string> InvalidatedCacheTags => [GetProductQuery.ProductsTag];
}Register its check
Call AddPouryaKitPermissions() in the Composition Root of every Module whose Features declare a permission. A declared permission whose check no Composition Root registered fails the Host when it is built, so it never runs unchecked.
Say which permissions each Actor holds
Implement IActorPermissions in the Host and register it. It receives the Operation's context, so the answer can depend on the Actor and the tenant. The Proving Sample's Host reads them from the HTTP caller's permission claims, matched to the Operation's Actor:
/// <summary>
/// The Host's source of permissions: the <see cref="ClaimType"/> claims the authentication scheme issued to the HTTP
/// caller whose Operation this is. Any other Actor, a named system Actor included, holds none, so independent work never
/// runs a protected command by default; a trusted continuation of an authorized Operation is not asked again.
/// </summary>
public sealed class CallerPermissions(IHttpContextAccessor http, IOptions<PouryaKitWebOptions> web)
: IActorPermissions
{
public const string ClaimType = "permission";
public Task<bool> HasPermissionAsync(
OperationContext context,
string permission,
CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(context);
var caller = http.HttpContext?.User;
return Task.FromResult(
caller?.Identity?.IsAuthenticated == true
&& context.Actor.Kind == ActorKind.Person
&& caller.FindFirstValue(web.Value.ActorClaim) == context.Actor.Id
&& caller.HasClaim(ClaimType, permission)
);
}
}A named system Actor holds only what the application's source grants it. Nothing holds a permission by default.
Prove it both ways
Over HTTP, a caller holding the permission commits the change, and a caller without it gets a forbidden problem response and nothing changes. Read the rows back from the database. In the Kit's tests, an endpoint of a test Module sends RemoveProductsCommand through Catalog's Gateway:
[Fact]
public async Task A_caller_holding_the_declared_permission_commits_the_command()
{
var lamp = await CreateLampAsync();
using var client = Fixture.Client(
"ada",
AppFixture.TestTenant,
RemoveProductsCommand.Permission
);
var response = await client.PostAsJsonAsync(
"/stub/catalog-products/removal",
new { names = new[] { "Lamp" } },
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(
"1",
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)
);
Assert.Equal(
[
[true, "ada"],
],
await Fixture.RowsAsync(
$"select is_deleted, deleted_by from catalog.products where id = '{lamp}'"
)
);
}
[Fact]
public async Task A_caller_without_the_declared_permission_is_forbidden_with_a_403_problem_response_and_nothing_changes()
{
var lamp = await CreateLampAsync();
using var client = Fixture.Client("ada", AppFixture.TestTenant, "catalog.products.other");
var response = await client.PostAsJsonAsync(
"/stub/catalog-products/removal",
new { names = new[] { "Lamp" } },
TestContext.Current.CancellationToken
);
var problem = await ProblemAsync(response, HttpStatusCode.Forbidden);
Assert.Equal(
[PermissionErrors.Denied.Code],
problem
.GetProperty("errors")
.EnumerateArray()
.Select(error => error.GetProperty("code").GetString())
);
Assert.Equal(
[
[false, 1L],
],
await Fixture.RowsAsync(
$"select is_deleted, version from catalog.products where id = '{lamp}'"
)
);
}What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| Where the check runs, and what fails the Host when it is missing | Declaring a permission |
| What an Actor holds | The Actor's permissions |
| What a denied command commits | Denial |
| What a trusted continuation skips, and what still runs | Continuations |
| How to test permissions | Testing permissions |
Failure messages
Next steps
Schedule a delayed or recurring command
Schedule one of a Module's commands to run later from the Operation that causes it, or declare one to run on a cadence as a system Actor.
Make a Module tenant-scoped
Require a tenant for every command and query of a Module, and read and save its aggregates only within the tenant of the Operation.