Search by

yahyaerturan / auth-authorization

yahyaerturan

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

Statistics

Installs: 7

Dependents: 2

Suggesters: 3

Stars: 0

Open Issues: 0

v1.0.0 2026-09-08 19:33 UTC

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:

  1. 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.
  2. No applicable policy means the grant stands.
  3. Every applicable policy is evaluated — there is no short-circuit — so neither the answer nor a propagating exception depends on registration order.
  4. Any Deny wins. Allow cannot overturn a Deny, 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 a User that has already been authenticated.
  • Persistence. Three repository ports, no implementations. Relational adapters ship separately in yahyaerturan/auth-pdo; in-memory ones in yahyaerturan/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-psr15 does not make that decision either, deliberately.
  • A permission vocabulary. article.publish means 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

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.