mini\Authorizer namespace

Authorizer - Authorization System

Overview

Check if a user can perform actions on resources:

use mini\Authorizer\Ability;
use function mini\can;

if (can(Ability::Delete, $post)) {
    $post->delete();
}

Setup

use mini\Authorizer\{Authorization, Ability, AuthorizationQuery};
use function mini\can;

Checking Authorization

// Collection-level
can(Ability::List, User::class);
can(Ability::Create, Post::class);

// Instance-level
can(Ability::Read, $post);
can(Ability::Update, $post);
can(Ability::Delete, $post);

// Field-level
can(Ability::Update, $user, 'role');
can(Ability::Read, $employee, 'salary');

// Non-class resources
can(Ability::Read, 'reports.financial');
can(Ability::Update, 'virtualdatabase.countries');

Registering Handlers

$auth = Mini::$mini->get(Authorization::class);

$auth->for(User::class)->listen(function(AuthorizationQuery $q): ?bool {
    return match ($q->ability) {
        Ability::List => auth()->isAuthenticated(),
        Ability::Create => auth()->hasRole('admin'),
        Ability::Read => true,
        Ability::Update, Ability::Delete =>
            $q->instance()?->id === auth()->getUserId() || auth()->hasRole('admin'),
        default => null,
    };
});

Handlers return:

  • true - Allow (stops processing)
  • false - Deny (stops processing)
  • null - Pass to next handler

Guards (Cross-Cutting Security)

Guards run before handlers and can only deny or pass. Use them for security concerns that must be enforced regardless of entity-specific rules.

// Tenant isolation: runs before any Product/Model handlers
$auth->guard(TenantScoped::class)->listen(function(AuthorizationQuery $q): ?bool {
    $entity = $q->instance();
    if ($entity && $entity->tenant_id !== auth()->getClaim('tenant_id')) {
        return false;  // Deny - wrong tenant
    }
    return null;  // Pass - correct tenant, continue to handlers
});

Guards returning true throws LogicException. Guards can only deny (false) or pass (null).

Execution Order

For can(Ability::Delete, $product) where class Product extends Model implements TenantScoped:

Phase 1: Guards (deny-only)
  Product guards → TenantScoped guards → Model guards
  If any returns false → deny immediately

Phase 2: Handlers (allow/deny)
  Product → TenantScoped → Model → fallback → default (allow)

This ensures tenant isolation (or other guards) cannot be bypassed by a Product handler returning true.

Handler Resolution

Within each phase, Mini checks the most specific first: entity class → marker interfaces → parent class → fallback.

// Generic: default rules for all Models
$auth->for(Model::class)->listen(fn($q) => auth()->isAuthenticated());

// Specific: custom rules for Product (checked before Model)
$auth->for(Product::class)->listen(fn($q) => ...);

AuthorizationQuery

$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
    $q->ability;      // Ability::Update or 'publish'
    $q->entity;       // Post::class or $post instance
    $q->field;        // 'title' or null

    $q->className();  // 'App\Post' (works for both)
    $q->instance();   // $post or null (for class-level checks)

    return null;
});

Field-Level Authorization

$auth->for(User::class)->listen(function(AuthorizationQuery $q): ?bool {
    if ($q->field === 'role') {
        return auth()->hasRole('admin');
    }
    if ($q->field === 'salary') {
        return auth()->hasRole('hr');
    }
    return null;
});

// Check
if (can(Ability::Update, $user, 'role')) {
    echo '<select name="role">...</select>';
}

Custom Abilities

$auth->registerAbility('publish');
$auth->registerAbility('archive');

$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
    if ($q->ability === 'publish') {
        return auth()->hasRole('editor');
    }
    return null;
});

if (can('publish', $post)) {
    $post->publish();
}

Unregistered custom abilities throw InvalidArgumentException.

Non-Class Resources

$auth->for('reports.financial')->listen(function(AuthorizationQuery $q): ?bool {
    return auth()->hasRole('finance');
});

$auth->for('virtualdatabase.countries')->listen(function(AuthorizationQuery $q): ?bool {
    return match ($q->ability) {
        Ability::List, Ability::Read => true,
        default => auth()->hasRole('admin'),
    };
});

if (can(Ability::Read, 'reports.financial')) {
    echo render('reports/financial');
}

Default Behavior

If no handler responds, authorization is allowed. To deny by default:

$auth->fallback->listen(fn($q) => false);

Standard Abilities

Ability Description
Ability::List View list of resources
Ability::Create Create new resource
Ability::Read View specific resource
Ability::Update Modify resource
Ability::Delete Remove resource

Examples

Owner-Based Access

$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
    $isOwner = $q->instance()?->author_id === auth()->getUserId();

    return match ($q->ability) {
        Ability::Read => true,
        Ability::Update, Ability::Delete => $isOwner || auth()->hasRole('admin'),
        default => null,
    };
});

Route Protection

// _routes/admin/users.php
if (!can(Ability::List, User::class)) {
    throw new \mini\Exceptions\AccessDeniedException();
}

$users = User::all();

API Authorization

// _routes/api/posts/{id}.php
$post = Post::find($id);

if (!can(Ability::Update, $post)) {
    http_response_code(403);
    exit(json_encode(['error' => 'Forbidden']));
}

Combining with Validation

function updateUser(int $id, array $data): void
{
    $user = User::find($id);

    if (!can(Ability::Update, $user)) {
        throw new AccessDeniedException();
    }

    foreach (array_keys($data) as $field) {
        if (!can(Ability::Update, $user, $field)) {
            throw new AccessDeniedException("Cannot modify: $field");
        }
    }

    if ($error = validator(User::class)->isInvalid($data)) {
        throw new ValidationException($error);
    }

    $user->update($data);
}

Classes (3)

Ability

Standard authorization abilities for entity operations

final
Authorization

Authorization service

AuthorizationQuery

Query object passed to authorization handlers