PouryaKit
Guides

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.

RemoveProductsCommand.cs
/// <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:

CallerPermissions.cs
/// <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:

SecurityTests.cs
[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

GuaranteeStated in
Where the check runs, and what fails the Host when it is missingDeclaring a permission
What an Actor holdsThe Actor's permissions
What a denied command commitsDenial
What a trusted continuation skips, and what still runsContinuations
How to test permissionsTesting permissions

Failure messages

Next steps

On this page