Skip to content

Read Authorization ​

OData is security-agnostic by design. The engine parses a URL, plans a query, and serves whatever the schema declares — it has no notion of who is asking. That is deliberate: an entity set is a contract about shape, and the question of who may read it belongs to the application that knows about actors, tenants, and permissions.

read_authorizer is the seam where that application answers it. It sees the parsed query plan rather than a URL string, so it can gate the set being read and every set the request reaches through $expand — which route middleware, working one level higher, cannot.

The shipped default, AllowAllReadAuthorizer, records no verdict: every read proceeds, exactly as it would without the seam. Binding your own is opt-in, and middleware on the route still applies — this is a second, finer gate, not a replacement.

The contract ​

php
namespace LaravelUi5\OData\Service\Contracts;

interface ReadAuthorizerInterface
{
    public function authorize(QueryPlanInterface $plan, Request $request, ReadContext $read): void;
}

An authorizer never throws and never returns a decision. It records verdicts into the ReadContext, and the engine enforces them:

VerdictRecorded withWhat the engine does
Allow$read->allow($target) (or nothing at all)The read proceeds
Drop$read->denyDrop($target, $message)The $expand for that set is pruned from the plan at every depth; the response is a 200 carrying a sap-messages warning
Hard denial$read->denyHard($target, $message)ForbiddenException → 403, with the message in the error envelope

The drop verdict is the honest-partial model: a client that expanded something it may not see gets the rest of its request, plus a message saying what was withheld — rather than a blanket 403 or, worse, data it should not have.

The $plan is typed as the marker QueryPlanInterface to keep the contract free of protocol imports. Downcast it to EntitySetQueryPlan or EntityQueryPlan to read $plan->target (the set being read) and $plan->expand (the sets it reaches).

Dropping is keyed by entity-set name, so one denyDrop() removes every $expand pointing at that set at any depth — nesting is not a bypass.

Messages ​

ReadMessage is the standard OData/SAP message shape:

php
new ReadMessage(
    code: 'READ_DENIED',
    message: 'You are not authorized to read Salaries.',
    numericSeverity: 4,     // 1 success, 2 info, 3 warning, 4 error
    target: '',             // MUST be empty when the message is not bound to a property
);

A hard denial is an error (4); a dropped $expand is a warning (3). Leave target empty for unbound messages — the UI5 v4 model silently discards a message whose target it cannot resolve.

Example ​

php
final class GateReads implements ReadAuthorizerInterface
{
    public function __construct(private Gate $gate) {}

    public function authorize(QueryPlanInterface $plan, Request $request, ReadContext $read): void
    {
        if (!$plan instanceof EntitySetQueryPlan && !$plan instanceof EntityQueryPlan) {
            return;
        }

        $set = $plan->target->getName();

        if (!$this->gate->allows('read-odata', $set)) {
            $read->denyHard($set, new ReadMessage(
                code: 'READ_DENIED',
                message: "Not authorized to read {$set}.",
            ));
        }

        foreach ($plan->expand->items as $item) {
            $expanded = $item->targetSet->getName();

            if (!$this->gate->allows('read-odata', $expanded)) {
                $read->denyDrop($expanded, new ReadMessage(
                    code: 'READ_PRUNED',
                    message: "{$expanded} was omitted from the response.",
                    numericSeverity: 3,
                ));
            }
        }
    }
}

Bind it in config/odata.php:

php
'read_authorizer' => App\OData\GateReads::class,

Where it runs ​

The gate wraps every read: direct entity and entity-set requests, and each inner request of a $batch — a batch cannot be used to reach a set that a direct request would refuse. The outer $batch request is what the authorizer sees, with the inner plan, so decisions are made from the plan and the actor, never from the URL path.

Every request is planned before it is gated, so the authorizer is also called for $metadata, the service document, singletons, function invocations, and property-value reads. Each arrives as its own plan type:

RequestPlan
/Products, /Products?$filter=…EntitySetQueryPlan
/Products(42)EntityQueryPlan
/Products(42)/namePropertyValuePlan
/CurrentUserSingletonQueryPlan
/GetFlightCount()FunctionInvocationPlan
/$metadataMetadataQueryPlan
/ServiceDocumentQueryPlan

An authorizer that only handles the two collection plans — like the example above — leaves the rest open. That is usually what you want, with one consequence worth stating out loud: a gated set still appears in $metadata and in the service document. The actor is refused the rows, not the knowledge that the set exists. If the schema itself is sensitive, handle MetadataQueryPlan and ServiceDocumentQueryPlan too — or serve those actors a different service with a narrower schema.

What it does not do ​

Read authorization gates targets, not rows. It decides whether this actor may read Salaries at all; it cannot express "only their own row". Row-level scoping belongs in the entity set itself — a custom entity set whose query() applies the restriction, so the rows never enter the result in the first place.

It is also not a substitute for scoping the query. $filter is what the client asks for, never what the server enforces — and a filter construct the translator cannot handle is currently dropped rather than rejected, which widens the result set silently. See Coverage by resolver.

See also ​