Apricot Framework

Rules

The ordered pipeline, what each stage is for, and the one thing an assigned access cannot say.

Every decision is made by walking an ordered pipeline. The first rule with an opinion about an access settles it; when every rule abstains, the answer is no.

#StageRegistered with
1Bypass rules, in registration orderAddAccessBypassRule<T>()
2The assigned-access lookupbuilt in, always present
3Application rules, in registration orderAddAccessRule<T>()
Denywhen everything abstains

Stage membership does not depend on when you call AddAccessAuthorization relative to the rules, so the two lines mean the same thing in either order.

Writing one

Derive from AccessRule and answer for a single access. Rules never see whether the caller wanted any or all of a set — that arithmetic happens after each access has been decided on its own, which is what lets one code path serve both a gate and a listing.

public sealed class OwnerCanEditRule : AccessRule
{
    public override Task<AccessDecision> EvaluateAsync(AccessContext context, string access, CancellationToken ct)
    {
        if (access != ContentAccesses.OrdersEdit || context.Resource is null)
        {
            return Task.FromResult(AccessDecision.Abstain);
        }

        var owner = context.Resource.Attributes.GetValueOrDefault("ownerId") as string;

        return Task.FromResult(owner == context.Subject.Id ? AccessDecision.Allow : AccessDecision.Abstain);
    }
}
DecisionMeaning
AbstainNo opinion. The next rule decides. Prefer this to Deny
AllowGranted, and no later rule is consulted for that access
DenyRefused, and no later rule can grant it

Return Abstain rather than Deny unless you mean to veto: a Deny from an early rule cannot be overridden, which is what makes it useful as a guard and dangerous as a default.

Attribute types

AccessContext carries the three attribute-based dimensions. Subject attributes are string-valued because they come from claims and feed the cache key; resource and environment attributes are object? because they come from your domain, so a rule matches rather than parses:

if (context.Resource.Attributes["isPublic"] is true) { … }
if (context.Resource.Attributes["memberCount"] is int count && count > 5) { … }
if (context.Environment.Attributes.GetValueOrDefault("ipAddress") is string address) { … }

A value that is present but null is distinguishable from an absent one.

Bypass rules

A rule registered as a bypass runs before the assigned lookup, so a rule that grants an operator everything also prevents the store from being consulted at all. That is the position for a short-circuit, and the reason for two registration methods rather than one.

The batched form

EvaluateManyAsync answers for many accesses at once. The base class implements it as a loop, so overriding it is purely an optimisation — but it must agree with EvaluateAsync for every access, or a listing and a gate will disagree.

The built-in assigned-access rule overrides it to load the effective set once and intersect, which is why listing sixty declared accesses costs one store round trip rather than sixty.

What an assigned access cannot say

Warning

The assigned-access rule ignores the resource. A granted sales:orders:edit means the subject may edit orders, not that they may edit order 42.

This is deliberate, and it is the one place a reader could assume otherwise and be wrong in the permissive direction. Narrowing a grant to an instance, or widening it based on the instance, is an application rule — which receives the whole context, resource attributes included.

The division is also what keeps caching sound: the assigned set is a function of the subject alone, so it can be cached, while a rule that reads resource attributes never is. See architecture.

When a rule cannot answer

A rule that cannot reach the data it needs should let the exception propagate rather than abstain. An outage is not the same answer as "not applicable", and reporting it as one turns a fault into a silent denial nobody investigates. The pipeline does not catch it, so the host answers 500 rather than 403.

On this page