How to implement Saga business membership constraints?

This topic concerns how to implement business constraints that look like authorization constraints, i.e. user must be a specific business member X of a specific business domain instance Y to complete action Z scenario.

Suppose we have a simple OrderAcceptance saga that processes either an AcceptOrder command or DeclineOrder command, both of which only supply an OrderId property. Each command handler in the saga publishes an appropriate event and completes the saga. The saga is created by an OrderPlaced event that supplies some context state for the saga to store as SagaData, such as OrderId and SellerId. Just for clarification, a saga is appropriate because a timeout may need to be implemented.

Let’s say we have the following requirement:
Only SellerOperators can accept or decline their seller’s orders for fulfillment.

Firstly, let’s discuss who owns the constraint relationship information in that statement.

The statement appears at first to be an authorization constraint and therefore an ITOPS responsibility, i.e. do some role check at the edge of the system before sending the command to NSB. However, the term “their seller’s orders” implies a business role constraint that is specific to a seller instance.

We know we need to model and maintain a SellerOperators list of UserIds related to SellerId. Is this model owned by ITOPS or a business service?

If the SellerOperator is a concept in the specific business service so not an ITOPS concept then it should be modelled explicitly in the business domain.

If the responsibility lays with ITOPS we could imagine a generic scoped authorization implementation that just supports mapping a string-ified RoleName to a UserId and a ScopeId, where RoleName is any string a business service requires and ScopeId is the instance Id of a business service’s domain concept.

I’d be interested to hear if others favour an ITOPS implementation or a specific business one instead.

Secondly, let’s discuss how the constraint is enforced in the system. I’ll propose some possible implemenations:

  1. Should each of the saga’s command handlers do a request/response to some RequestSellerOperatorVerification handler that checks if the user (assuming we’ve propogated UserId via message headers) is a SellerOperator for the given SagaData SellerId? I’m assuming stateless handler here so it can inject code to access DB or API to obtain data it needs (the SellerOperators list).

    This feels more aligned to the business process however complicates the process of handling responses for each individual command.

    For example, the RequestSellerOperatorVerification message can be sent from both AcceptOrder and DeclineOrder command handlers in the saga. The RequestSellerOperatorVerificationResponse handler in the saga needs to know which of the original command handlers sent its request so that it can reply with either AcceptOrderResponse or DeclineOrderResponse as appropriate, assuming we wanted to send responses back via say SignalR.

    We could pass in some string context into the RequestSellerOperatorVerification request, a header perhaps, that the response handler can use to determine its next course of action. Just feels a bit wrong though.

  2. Instead of no. 2 approach, should we assume that the saga is for business orchestration only and these type of constraints should be done at the edge always by some infrastructure code?

    For example, assuming we have some server-side viewmodel composition AC in the same business service. Web request for /orders/{id}/seller/accept is handled in AC. It loads relevant order and obtains the SellerId from a read model (can’t query saga). Calls some ITOPS service interface to authorize current user against given SellerId and SellerOperator scope.

    This approach feels like it’s pushing these business “membership” constraints out of the business services where I think they belong. It also feels wrong to be querying for data prior to sending the command. This would be similar to when command data validation does more than just checking data constraints, like the check-for-unique-value in the domain scenarios.

  3. A slight variation to no. 3 approach that can avoid the lookup from a read model is to modify the url to include the SellerId, e.g. /sellers/{sellerId}/orders/{orderId}/accept. The viewmodel AC handler would not lookup an order but only enforce the constraint check now that it has a SellerId.

    The commands need to change to now include a SellerId. The command handlers in the saga check both the message’s OrderId and SellerId values against the saga data and if they don’t both match the handler returns without modifying saga state.

    In essence this is policy-based authorization at the edge, maybe implemented using AspNetCore AuthorizationPolicy.

I’d be interested to hear people’s thoughts on this subject.

Another option would be to keep the logic within the Business Service but pull it out of the saga into a Pipeline Behavior, that would be guaranteed to run before the saga does:

What do you think?

That’s indeed an approach worth considering too.

As an experiment, I’ve opted to try the enforcement using a policy-based approach using AspNetCore’s authorization pipeline and custom AuthorizationPolicy requirements, where the requirement handlers use custom user identity claims mapped to URL route parameters. This is ITOPS infrastructure code to support authorization by group membership at the identity level.

The (view-model composition) AC that handles HTTP requests is in the same business service as the AC providing the saga so my assumption is that it’s okay to enforce the requirement upstream at the business service’s edge.

View-model ACs supply their own uniquely named (to the AC namespace) AuthorizationPolicy and the policy is applied to the ACs endpoint routes using the familiar Authorize attribute. These policies include an ITOPS implementation of IAuthorizationRequirement called UserMustHaveContextGroupClaimRequirement that merely informs the authorization pipeline of two things; GroupName and ContextIdName. So in my original example this would be “SellerOperator” and “SellerId” respectively. The policy is configured as part of the ACs DI setup phase, e.g. drop the AC into the host project and it’s good to go.

ITOPS supplies an AuthorizationHandler<TAuthorizationRequirement> called UserMustHaveContextGroupClaimRequirementByHttpRouteHandler that is responsible for the following:

  1. Firstly obtain an AspNetCore Endpoint Routing route Id from the current route where the route Id’s name is equal to ContextIdName value from the requirement.
  2. Check if we have a valid “Group” claim with a specific formatted value. Whilst a claim’s value can contain serialized data I opted for a simple “group-name:group-context-id” format.

In code this is:

        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserMustHaveContextGroupClaimRequirement requirement)
        {
            var request = _httpContextAccessor.HttpContext?.Request;
            var groupContextId = request?.GetRouteValue<Guid>(requirement.ContextIdName);

            if (groupContextId != Guid.Empty &&
                context.User?.HasClaim(c =>
                    c.Type == GroupClaimType &&
                    c.Value.Equals($"{requirement.GroupName}:{groupContextId}", StringComparison.OrdinalIgnoreCase) &&
                    c.Issuer == ClaimIssuer) == true)
            {
                context.Succeed(requirement);
                return Task.CompletedTask;
            }

            context.Fail();
            return Task.CompletedTask;
        }

So basically if the user doesn’t have the required claim they are sent back a HTTP 403 status via the normal authorization pipeline. This happens as fast as possible with no loading of remote storage, and happens early so doesn’t unnecessarily put a message on a queue that will be discarded.

Of course, this then poses the question of how to get these group name and context Id pairs of data into a user’s claims identity.

I’ve used IdentityServer4 for may years now so in this situation I’d provide a custom claims provider from ITOPS to include in IS4 for its use when building a list of required claims for an authenticated user. A separate ITOPS interface would support each business service adding their own group claims to the provider. Each business service manages their group membership data in what ever way they see fit.

If I come across any unforeseen issues with this approach I’ll add my observations here.