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
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:
| Verdict | Recorded with | What 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:
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
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:
'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:
| Request | Plan |
|---|---|
/Products, /Products?$filter=… | EntitySetQueryPlan |
/Products(42) | EntityQueryPlan |
/Products(42)/name | PropertyValuePlan |
/CurrentUser | SingletonQueryPlan |
/GetFlightCount() | FunctionInvocationPlan |
/$metadata | MetadataQueryPlan |
/ | 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
- Configuration — the
read_authorizerkey - Error Handling — how a hard denial is serialized as
403 - Custom Entity Sets — where row-level scoping belongs
- Batch Requests — inner requests are gated individually