Authorization¶
Note
Not part of core. Install it separately:
composer require kinetis/authorization
Kinetis is deliberately unopinionated about how an application organizes
authorization checks. There’s no required Policy convention, no
ability-name registry, and nothing here inspects an object’s runtime
class to decide which code answers a check — that kind of implicit,
type-based dispatch is exactly what this package avoids. Gate is a
small, generic wrapper: hand it any callable and it normalizes the
result into an allow/deny decision.
use Kinetis\Authorization\Gate;
use Kinetis\Http\Attributes\Patch;
use Kinetis\Http\CurrentUserInterface;
final readonly class PostController
{
public function __construct(
private Gate $gate,
private PostPolicy $postPolicy,
private PostRepository $posts,
) {}
#[Patch('/posts/{id}')]
public function update(int $id, CurrentUserInterface $user, #[Body] UpdatePostRequest $body): array
{
$post = $this->posts->find($id);
$this->gate->authorize($user, $this->postPolicy->update(...), $post);
$updated = $this->posts->update($post->id, $body->title, $body->content);
return ['id' => $updated->id];
}
}
$this->postPolicy->update(...) is PHP’s own first-class callable
syntax — PostPolicy is a plain, constructor-injected class with plain
methods, resolved and called exactly like any other service. Gate
never resolves anything itself and never sees PostPolicy exist as a
concept; “Policy” is a naming convention a developer chooses, not
something this package enforces or discovers.
final readonly class PostPolicy
{
public function update(CurrentUserInterface $user, Post $post): bool|AuthorizationResponse
{
if ($post->locked) {
return AuthorizationResponse::deny('This post is locked and cannot be edited.');
}
return $post->authorId === $user->id();
}
}
CurrentUserInterface is core’s own minimal identity contract — Gate
works identically regardless of which package resolved it: kinetis/auth,
kinetis/auth-jwt, a session-based check via kinetis/session, or
anything an application writes itself. Authorization is orthogonal to
authentication mechanism; nothing here depends on either auth package.
The three methods¶
$this->gate->authorize($user, $check, ...$arguments): void; // throws on denial
$this->gate->allows($user, $check, ...$arguments): bool; // never throws
$this->gate->denies($user, $check, ...$arguments): bool; // the exact inverse of allows()
authorize() throws Kinetis\Authorization\Exception\AuthorizationException
on denial. Use it when a denial should hard-stop the request with a
generic 403 — the common case, and the one the package’s own
registered middleware exists for (see below).
allows() returns a plain bool and never throws. Use it when
execution should continue either way and the result itself is what you
need — shaping a response value, or branching positively:
return [
'id' => $post->id,
'canEdit' => $this->gate->allows($user, $this->postPolicy->update(...), $post),
];
denies() is the exact inverse of allows(), offered purely for
guard-clause readability — “if denied, bail” reads more directly than “if
not allowed, bail” — and matters most when a denial shouldn’t produce the
generic 403 body, say a redirect on a web-flavored route instead:
if ($this->gate->denies($user, $this->postPolicy->update(...), $post)) {
return new Response(302, ['Location' => '/posts/' . $post->id]);
}
AuthorizationResponse¶
A check’s callable may return a plain bool, or an AuthorizationResponse
when a denial should carry a specific reason instead of the generic
fallback message:
use Kinetis\Authorization\AuthorizationResponse;
AuthorizationResponse::allow();
AuthorizationResponse::deny('This post is locked and cannot be edited.');
AuthorizationResponse::deny(); // "This action is unauthorized."
Gate normalizes true/false into the generic allow/deny form
internally, so the common case stays a one-line boolean check and only a
check that needs a specific reason has to build one.
How a denial reaches the client¶
The controller never sees an AuthorizationResponse and never returns
one — the flow is exception propagation, not a return value:
authorize()calls the given callable, gets backbool|AuthorizationResponse.On denial, it throws
AuthorizationExceptionright there, insideGate— several stack frames below the controller.That throw unwinds everything above it:
Gate::authorize(), the controller method (its ownreturnis never reached),Dispatcher::dispatch(), any route middleware — all the way out to the global middleware pipeline, since nothing in between catches it.Installing this package registers
Kinetis\Authorization\AuthorizationExceptionMiddlewareglobally — it wraps the entire request in atry/catchand turns a caughtAuthorizationExceptionintoErrorResponse::create(403, $e->getMessage()).
This is the same mechanism Kinetis\Http\Middleware\ExceptionHandlerMiddleware
already uses for turning any uncaught exception into a 500 — just
narrower, catching one specific exception type and mapping it to 403
instead. No Router, Dispatcher, or Kernel change was needed to add
it.
Reading claims or roles without a query¶
CurrentUserInterface is deliberately minimal — id() only — so Gate
never assumes any auth mechanism carries more than that. But a Policy
method’s own parameter type isn’t limited to CurrentUserInterface
either: it can type-hint the concrete class your auth middleware actually
resolves, and Gate passes the real object straight through untouched.
kinetis/auth-jwt’s JwtUser is the clearest case — it already exposes
every claim the token carried (claim(string): mixed, claims(): stdClass)
with nothing to look up, since a verified JWT’s claims are decoded once,
in memory, at the moment the token is verified. roles here isn’t a
claim kinetis/auth-jwt defines or expects — it’s plain data your own
login endpoint chose to put there; see JWT Authentication’s “Issuing
tokens” section for setting it in the first place:
use Kinetis\AuthJwt\JwtUser;
final readonly class ArticlePolicy
{
public function publish(JwtUser $user): bool
{
return in_array('editor', (array) ($user->claims()->roles ?? []), true);
}
}
$this->gate->authorize($user, $this->articlePolicy->publish(...));
This works because Gate never inspects $check’s own parameter type —
it only forwards whatever CurrentUserInterface instance it was given.
allows()/denies()/authorize() are generic over the concrete user
type (@template TUser of CurrentUserInterface), so PHPStan accepts a
check typed narrower than the interface as long as the object actually
passed at that call site really is that type — verified directly, not
assumed: a check typed against a fixture richer than CurrentUserInterface
fails PHPStan level 8 without this generic and passes cleanly with it.
The same pattern works for kinetis/auth’s opaque Bearer tokens, just
with the richer type coming from your own application instead of a
package — UserProviderInterface::findByToken() resolves once per
request, so a CurrentUserInterface implementation you write yourself
(carrying roles as constructor properties, populated by whatever your own
lookup does) pays that cost once per request, not once per Gate check,
the same as JwtUser pays it once per token decode.
Important
Only type a Policy method against a concrete user class when that
Policy is reachable from exactly one auth mechanism. A route that could
be reached by more than one (JWT on some paths, Bearer on others) needs
either a Policy typed against CurrentUserInterface itself, or two
separate Policy methods — a mismatched concrete type is a TypeError at
the point Gate calls the check, not a caught, reported denial.
Gating a whole route by role¶
A role/claim check that needs nothing beyond CurrentUserInterface — no
specific resolved object, unlike Gate’s own case — is better expressed
declaratively than as the first line of every controller method. This
package doesn’t ship a class for it; core’s existing #[Middleware]
attribute already does the job, resolved before the controller runs and
captured into the AOT route cache automatically, since Route::toArray()
already carries the middleware list #[Middleware] produces.
The one thing #[Middleware(class-string)] can’t carry is an argument —
so a role check is a thin, per-role subclass, the same pattern
Kinetis\Http\Middleware\RateLimitMiddleware/JwtAuthMiddleware are left
non-final for:
class RequireRoleMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly RequestScope $scope,
private readonly string $role,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $this->scope->get(CurrentUserInterface::class);
if (!$user->hasRole($this->role)) {
return ErrorResponse::create(403, "Missing role: {$this->role}");
}
return $handler->handle($request);
}
}
final class RequireEditorMiddleware extends RequireRoleMiddleware
{
public function __construct(RequestScope $scope) { parent::__construct($scope, 'editor'); }
}
#[Patch('/posts/{id}')]
#[Middleware(RequireEditorMiddleware::class)]
public function update(int $id): array { ... }
$user->hasRole() above is a stand-in — the actual line depends on which
auth mechanism resolved CurrentUserInterface for this route (JwtUser’s
claims(), or whatever your own UserProviderInterface implementation
carries), which is also why this stays a documented pattern rather than a
class this package ships: there’s little left to share once that one line
is auth-mechanism-specific.
This composes with Gate rather than replacing it: a role gate answers
“can this user do this kind of thing at all,” resolved once per
request before the controller runs; Gate::authorize() still answers
“can this user do this to this specific object,” which needs the
object in hand and so stays an imperative call inside the controller.
Provides¶
Installing this package auto-registers, via extra.kinetis:
A global middleware —
AuthorizationExceptionMiddleware, described above.
Gate itself needs no explicit binding: it has no constructor
dependencies, so plain autowiring resolves it wherever a controller
constructor-injects it. There’s no attribute to discover and no registry
— nothing else is registered.
See also¶
Container — why
Gate(holding no per-request state) is safe as a worker-lifetime autowired instance, the same criterionKinetis\Http\Middleware\RateLimitMiddleware’s own docblock establishes.Authentication / JWT Authentication / Sessions & CSRF — where
CurrentUserInterfaceactually comes from; this package has no dependency on any of them.Middleware — the PSR-15 pipeline
AuthorizationExceptionMiddlewarejoins,ExceptionHandlerMiddleware’s own identical exception-to-response shape, and the#[Middleware]/thin-subclass pattern “Gating a whole route by role” above builds on.