The intersection rule
The rule
effective authority = what the USER may do
∩ what the AGENT may do
∩ what the GRANT covers (and the grant is still Active)
Never the union. Evaluated fresh by the PDP on every check — the token’s scopes are an upper
bound, not the decision.
Why intersection and not union
The union would mean an agent adds authority to a user (privilege escalation by delegation), or a
user lends authority the agent’s operators never reviewed. Both are the confused deputy
problem wearing different hats. The intersection means a compromised prompt can, at absolute worst,
do only what both the human consented to and the agent was structurally allowed — for the token’s
few remaining minutes.
The two layers are real PDP subjects
Agents are ordinary PDP subjects (agent:{ulid}) with their own roles/permissions. That is what
makes the intersection computable with the existing engine: checkDelegated runs the normal
check() twice — once for the user, once for the agent — then verifies the cited grant:
$decision = app(DelegatedAuthorizationEngine::class)->checkDelegated(
new SubjectRef('user', '42'),
new DelegationChain(ActorRef::fromAgentId('01J8XKQ0V2')),
['action' => 'shop:orders.read', 'delegation_grant_id' => 'dgr_01J9…'],
);
The decision cites both sub-decision ids, so an auditor can replay separately why the user side
allowed and why the agent side allowed.
Deny-overrides, composed
| Situation | Outcome |
|---|---|
| User allowed ∧ agent allowed ∧ grant Active | allow |
| User denied (anything) | deny — the agent’s permissions are irrelevant |
Agent unknown / pending / suspended / retired |
deny — only active delegates |
| Grant revoked / expired / for a different pair | deny, reason delegation_grant_not_active |
No DelegatedAuthorizationEngine bound (module absent) |
deny — a delegated request never falls back to a single-subject check |
That last row matters: fail-closed applies to the plumbing, not just the data. A resource server
receiving an act claim without delegated-check capability refuses — it does not “helpfully” check
just the user.
Scopes vs permissions
At exchange time the server intersects requested scopes ∩ grant scopes ∩ agent max_scopes
(empty ⇒ invalid_scope). The user layer is not projected into scopes: it is enforced
per-request by checkDelegated. Token = upper bound; PDP = truth. This is deliberate — a scope
projection would freeze the user’s permissions at exchange time, and a permission removed mid-token
would keep working until expiry.