Implementing a Custom Outcome Action Handler
When the Policy Decision Point (PDP) evaluates a policy, as well as returning an outcome, it can return obligations and advice. Obligations must be performed for the policy outcome to be valid; advice may be acted upon.
In Enforcer, the Policy Enforcement Point (PEP) does not return to the caller until it has tried to perform all obligations. This means, for the PDP outcome to be honored, you must register handlers for the obligations that get emitted from policy evaluation.
In Enforcer, we refer to obligations and advice, collectively, as outcome actions. You register outcome action handlers when you configure Enforcer.
What is an Outcome Action Handler?
An outcome action handler is any class that derives, directly or indirectly, from Rsk.Enforcer.PEP.OutcomeActionHandler
. OutcomeActionHandler
is defined as follows:
public abstract class OutcomeActionHandler
{
public abstract string Name { get; }
public virtual ValueTask<bool> CanExecute(IEnumerable<PolicyAttributeValue> parameters,
IEnforcerLogger evaluationLogger)
{
return new ValueTask<bool>(true);
}
public abstract Task Execute(IEnumerable<PolicyAttributeValue> parameters,
IEnforcerLogger evaluationLogger);
}
All outcome action handlers must have a Name
, which is used to match the handler to an emitted obligation or advice.
The matching handler is then asked whether it can run, with the available attribute values, using CanExecute
.
If the handler says it can execute, then it may be asked to execute (all obligation handlers must state they can execute before any are told to do so). The CanExecute
method should return false
if the available attributes are missing required information.
The handlers Execute
method is called, passing the available attributes, and it must attempt to execute as a result.
Implementing a Custom OutcomeActionHandler
Although you can derive directly from OutcomeActionHandler
, you are left to do the heavy lifting of pulling out the attribute values yourself. A simpler alternative is to create a class that models the parameters you need and derive from OutcomeActionHandler<T>
where T
is the parameter class.
As an example, imagine we have a policy that controls access to airlock doors. When we open the outer door, we want to ensure that the inner door in closed (and vice versa). To do this we can use an obligation that is issued when a door opening is permitted. If the obligation cannot be run successfully (maybe the other airlock door is blocked) then the result will be indeterminate and, with PEP Bias, the permit will be converted to a deny.
Let’s assume we have a door service that we can use that provides the following operations:
public interface IDoorService
{
Task<bool> IsClosed(string door);
Task<bool> CanClose(string door);
Task Close(string door);
}
We can find out if the door is already closed and if not whether it is possible to close it. Finally, we can close the door.
The critical piece of data that we will need is the name of the door we are interested in. This will come from the policy evaluation passing this information, in the form of an attribute value, to the obligation. We can model this attribute in a parameter class
public class DoorParameters
{
[PolicyAttribute(PolicyAttributeCategories.Resource, "doorName", MustBePresent = true)]
public string DoorName { get; set; }
}
Here we are stating that the door name comes from an attribute called doorName
in the resource
category and that it is a required parameter. If the value is not present, then the handler will not be able to perform any actions.
We can now create our outcome action handler by deriving from OutcomeActionHandler<T>
public class EnsureDoorClosedHandler : OutcomeActionHandler<DoorParameters>
{
private readonly IDoorService doorService;
public EnsureDoorClosedHandler(IDoorService doorService)
{
this.doorService = doorService;
}
public override string Name { get; } = "ensureDoorClosed";
protected override async ValueTask<bool> CanExecute(DoorParameters parameters,
IEnforcerLogger evaluationLogger)
{
if (await doorService.IsClosed(parameters.DoorName)) return true;
return await doorService.CanClose(parameters.DoorName);
}
protected override async Task Execute(DoorParameters parameters,
IEnforcerLogger evaluationLogger)
{
if (await doorService.IsClosed(parameters.DoorName)) return;
await doorService.Close(parameters.DoorName);
}
}
Looking at the code we can note the following features:
- The
Name
property must match the name of the obligation or advice in the ALFA policy document. CanExecute
returnstrue
if the door is already closed and otherwise returns whether the door can be closed.Execute
has nothing to do if the door is closed, otherwise it closes the door. We know the door can be closed because otherwiseCanExecute
would have returnedfalse
andExecute
would never have been called.
This handler can now be registered using the AddOutcomeActionHandler<T>
extension method on the EnforcerBuilder
created by calling AddEnforcer
on a ServiceCollection
.
public void ConfigureServices(IServiceCollection services)
{
services.AddEnforcer("AcmeCorpPolicies.global", o =>
{
// Options omitted
})
// Other Enforcer set-up omitted
.AddOutcomeActionHandler<EnsureDoorClosedHandler>();
}
AddOutcomeActionHandler<T>
registers the handler as a singleton. This means that multiple evaluation requests use the same instance of the handler. Currently, our handler is stateless (does not cache information between method calls) and so this is not a problem. However, there are two calls made to IsClosed
on the door service. If this call is expensive then we really only want to make it once, but then we would have to save that state from the first call in CanExecute
. This means that multiple concurrent evaluations could not use the same instance safely. To enable this scenario, instead of registering the handler directly, you can register a handler factory instead.
Using a Handler Factory
To use a stateful handler, you create a class derived from OutcomeActionHandlerFactory
. This is defined as follows:
public abstract class OutcomeActionHandlerFactory
{
public abstract string Name { get; }
public abstract OutcomeActionHandler Create();
}
The Name
property, again, must match the value defined for the obligation or advice in the ALFA policy document. The Create
method allows you to pass back a created instance of the handler. With a factory, the Create
method is called once for each matching outcome action in an evaluation. Therefore, a handler can safely retain state between a call to CanExecute
and Execute
. In the case of our EnsureDoorClosedHandler
, the factory is as follows:
public class EnsureDoorClosedHandlerFactory : OutcomeActionHandlerFactory
{
private readonly IDoorService doorService;
public EnsureDoorClosedHandlerFactory(IDoorService doorService)
{
this.doorService = doorService;
}
public override string Name { get; } = "ensureDoorClosed";
public override OutcomeActionHandler Create()
{
return new EnsureDoorClosedHandler(doorService);
}
}
With this implementation our handler can now cache the state of IsClosed
between the call to CanExecute
and Execute
meaning we only have to make this, potentially expensive, door service call once.
The factory is then registered by calling the AddOutcomeActionHandlerFactory<T>
extension method of EnforcerBuilder
.
public void ConfigureServices(IServiceCollection services)
{
services.AddEnforcer("AcmeCorpPolicies.global", o =>
{
// Options omitted
})
// Other Enforcer set-up omitted
.AddOutcomeActionHandlerFactory<EnsureDoorClosedHandlerFactory>();
}
There is an override to AddOutcomeActionHandlerFactory<T>
which allows the factory instance to be provided, rather than just the type. This can be useful for test automation where full control of the factory creation is required. In both cases, the factory will be registered as a singleton.
public void ConfigureServices(IServiceCollection services)
{
var factory = new EnsureDoorClosedHandlerFactory();
services.AddEnforcer("AcmeCorpPolicies.global", o =>
{
// Options omitted
})
// Other Enforcer set-up omitted
.AddOutcomeActionHandlerFactory(factory);
}