yahyaerturan / auth-authorization
Optional RBAC and resource-policy authorization module for yahyaerturan/auth: flat roles, exact permission matching, default deny and explicitly registered policies.
Package info
github.com/yahyaerturan/auth-authorization
pkg:composer/yahyaerturan/auth-authorization
Requires
- php: ^8.5
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
- yahyaerturan/auth: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.3
- yahyaerturan/auth-testing: ^1.0
Suggests
- yahyaerturan/auth-pdo: Relational persistence for the role, permission and grant ports, plus the opt-in authorization migration set. This package requires no persistence adapter.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 20:04:26 UTC
README
Optional role-based access control and resource policies for
yahyaerturan/auth.
Flat roles, exact permission matching, default deny, no wildcards, no magic administrator, and no cache — so a revoked grant stops working on the next check rather than on the next cache expiry.
composer require yahyaerturan/auth-authorization
Requires PHP 8.5 and yahyaerturan/auth. It requires no persistence
adapter: you supply implementations of three small repository ports, or install
yahyaerturan/auth-pdo, which ships
relational ones.
Why you would install it
Authentication answers who is this. This package answers may they do this — and it is a separate package because plenty of applications need the first without the second, and should not be paying for it.
The shape of the answer is the point:
if (!$authorization->allows($identity, PermissionName::from('article.publish'), $article)) { // your 403, or your 404 — see "What this package does not own" }
Business code asks about a permission, never about a role name. The day
somebody invents an editor-in-chief who should also publish, that line does not
change; the grant graph does. That is the entire argument against
$user->hasRole('admin'), and it is why User in the core package has no role
API at all — asserted, not merely intended.
Minimal usage
<?php declare(strict_types=1); use YahyaErturan\Authz\Access\RbacAuthorizationService; use YahyaErturan\Authz\Permission\Permission; use YahyaErturan\Authz\Permission\PermissionId; use YahyaErturan\Authz\Permission\PermissionName; use YahyaErturan\Authz\Role\Role; use YahyaErturan\Authz\Role\RoleId; use YahyaErturan\Authz\Role\RoleName; // Three ports, however you implement them. `yahyaerturan/auth-pdo` ships // relational adapters; `yahyaerturan/auth-testing` ships in-memory ones. $authorization = new RbacAuthorizationService($grants); // --- administration: an admin screen or a seeding script drives this $publish = Permission::create(PermissionId::generate($ids), PermissionName::from('article.publish')); $editor = Role::create(RoleId::generate($ids), RoleName::from('editor')); $permissions->save($publish); $roles->save($editor); $grants->grantPermissionToRole($editor->id(), $publish->id()); $grants->assignRoleToUser($identity->id(), $editor->id()); // --- enforcement: one line, everywhere $authorization->allows($identity, PermissionName::from('article.publish')); // true
$ids is any YahyaErturan\Auth\Contract\IdGenerator — the core's own, or the
deterministic one from yahyaerturan/auth-testing.
A complete, framework-free composition lives in
examples/PlainAuthorizationExample.php.
It is not illustrative pseudocode: AuthorizationExampleTest drives every method
in it, so it cannot rot.
The model
Flat. A role holds permissions. A user holds roles. There is no role hierarchy, no permission hierarchy, and no inheritance:
User ──assigned──> Role ──granted──> Permission
Exact. article.publish is a key compared byte for byte. There is no
article.*, no fnmatch(), no LIKE. A name containing a wildcard
metacharacter is refused by the grammar outright, so nothing downstream has a
pattern to expand (ADR-060, ADR-061).
Grant-only, and default deny. Nothing is permitted that was not granted. In v1 there are no direct user→permission grants and no negative grants: every permission a user holds arrives through a role, which keeps "why can this person do that?" answerable by reading one graph.
No magic administrator. A role named admin grants exactly what was granted
to it. The literal 'admin' does not appear in this package's production source,
and a test asserts that it never will.
Resource policies
RBAC answers "may an editor publish articles?". It cannot answer "may this editor publish this article?", because the article is an application object this library knows nothing about.
A policy closes that gap — and may only ever restrict:
use YahyaErturan\Authz\Policy\AuthorizationPolicy; use YahyaErturan\Authz\Policy\PolicyDecision; use YahyaErturan\Authz\Policy\PolicyRegistry; final readonly class OwnArticlesOnly implements AuthorizationPolicy { public function supports(PermissionName $permission, mixed $resource): bool { return $permission->toString() === 'article.publish' && $resource instanceof Article; } public function decide( User $identity, PermissionName $permission, mixed $resource, ?AuthorizationContext $context = null, ): PolicyDecision { return $resource->authorId === $identity->id()->toString() ? PolicyDecision::Abstain // nothing to add; the grant stands : PolicyDecision::Deny; } } $authorization = new RbacAuthorizationService( $grants, new PolicyRegistry([new OwnArticlesOnly()]), );
The decision order is fixed and worth knowing:
- The RBAC grant is necessary. No grant, no access — and the registry is not consulted at all, so a policy that is never asked cannot elevate.
- No applicable policy means the grant stands.
- Every applicable policy is evaluated — there is no short-circuit — so neither the answer nor a propagating exception depends on registration order.
- Any
Denywins.Allowcannot overturn aDeny, and neither can overturn a missing grant.
Abstain is not Allow: it means this policy has no opinion, which is what
lets several policies coexist without each having to know about the others.
Policies are registered explicitly, as a constructor argument. Nothing is
discovered by scanning a directory, reading an attribute or probing with
class_exists() — the set of things that can affect an access decision is
exactly the set somebody wrote down, and a test enforces it.
See docs/POLICIES.md for AuthorizationContext, ordering,
exception behaviour and the traps.
Security-relevant defaults
| Default deny | an unknown permission, an unknown role, an identity with no roles: all false |
| No cache | grants are read from the store on every call, so the revocation window is zero (ADR-063) |
| No wildcard semantics | a pattern metacharacter is refused by the name grammar |
| No hard-coded privileged role | there is no bypass to find |
| Names reject rather than repair | whitespace, control characters and invalid UTF-8 are refused, never trimmed — two spellings of a permission are a grant that does not apply and a check that does not fire |
| A resource is never persisted | the live object reaches policies and nothing else; it is not stored, serialized or logged |
The absent cache is a deliberate trade. Every allows() is a repository read. If
you need to amortise that, do it inside a request — resolve once and pass the
answer down — rather than by adding a TTL, which is how a revoked administrator
keeps their access for five more minutes.
What this package does not own
- Authentication. Passwords, sessions, tokens and the account lifecycle are
yahyaerturan/auth. This package receives aUserthat has already been authenticated. - Persistence. Three repository ports, no implementations. Relational
adapters ship separately in
yahyaerturan/auth-pdo; in-memory ones inyahyaerturan/auth-testing. This package depends on neither. - HTTP. It never decides between 403 and 404 — only you know whether the
resource's existence is itself a secret.
yahyaerturan/auth-psr15does not make that decision either, deliberately. - A permission vocabulary.
article.publishmeans nothing to an invoicing system. This library defines the shape of a name and never the set.
Relationship to the rest of the ecosystem
| Package | Repository | Relationship |
|---|---|---|
yahyaerturan/auth |
https://github.com/yahyaerturan/auth | required |
yahyaerturan/auth-authorization |
https://github.com/yahyaerturan/auth-authorization | this package |
yahyaerturan/auth-pdo |
https://github.com/yahyaerturan/auth-pdo | optional — relational adapters for the three ports |
yahyaerturan/auth-psr15 |
https://github.com/yahyaerturan/auth-psr15 | independent |
yahyaerturan/auth-testing |
https://github.com/yahyaerturan/auth-testing | dev — in-memory adapters and contract suites |
This package depends on yahyaerturan/auth and on no sibling. In particular it
does not depend on auth-pdo: the dependency points the other way, and
auth-pdo declares this package as a suggest rather than a require.
Development
git clone https://github.com/yahyaerturan/auth-authorization cd auth-authorization composer install composer qa # platform, coding standard, PHPStan, PHPUnit
No database is needed. The authorization model is storage-agnostic, and its own
suite runs against in-memory adapters; the relational integration is tested in
yahyaerturan/auth-pdo, where the database is.
Working across several packages at once
Clone the repositories you need as siblings, then point Composer at the checkouts without committing anything:
cp composer.json composer.dev.json
composer config --file composer.dev.json repositories.siblings \
'{"type":"path","url":"../auth*","options":{"symlink":true}}'
composer config --file composer.dev.json minimum-stability dev
COMPOSER=composer.dev.json composer update
composer.dev.json and composer.dev.lock are git-ignored. The committed
manifest resolves everything from Packagist, which is what a consumer gets.
Documentation
docs/AUTHORIZATION.md— the model, the grammars, the decision pathdocs/POLICIES.md— writing, registering and testing policiesdocs/RELEASING.md— the release gate and sequence
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md
for the setup, the quality gate, and the conventions this project expects.
Participation is governed by the Code of Conduct.
Found a security vulnerability? Do not open an issue or a pull request.
Security
See SECURITY.md.
License
MIT — see LICENSE.