Cookbook

Permissions and Authorization

Gate, voters, policies, attributes, and the declared permission catalog, with PermissionManager integration.

This guide covers the Glueful Framework's permission system: the Gate, voters, policies, attributes, the declared permission catalog, and how they connect to authentication.

Overview

The Glueful permissions system has several layers:

  1. Gate: central authorization point using a voter-based system
  2. Voters: pluggable authorization logic (super roles, policies, roles, scopes, ownership)
  3. Attributes: #[RequiresPermission] / #[RequiresRole] on controllers and methods
  4. Policies: class-based authorization for specific resources
  5. PermissionManager: the single enforcement entry point; it uses an installed permission provider (for example glueful/aegis) or falls back to the Gate
  6. Permission catalog: permissions and roles that providers declare, inspected and synced with permissions:list|diff|sync

Core ships no database-backed RBAC. Persistent roles and user assignments come from a permission provider such as glueful/aegis; without one, the Gate evaluates config-defined and declared roles.

Quick Start

1. Configure roles in config/permissions.php

return [
    'strategy' => 'affirmative',     // 'affirmative' | 'consensus' | 'unanimous'
    'provider_mode' => 'replace',    // 'replace' | 'combine'
    'allow_deny_override' => false,

    // Roles that are granted every permission (opt-in, empty by default)
    'super_roles' => ['super_admin'],

    // Config-first RBAC for RoleVoter (no database needed)
    'roles' => [
        'admin'  => ['*'],
        'editor' => ['posts.create', 'posts.edit', 'posts.publish'],
        'user'   => ['posts.create', 'posts.edit.own'],
    ],

    // Resource slug or class => Policy class
    'policies' => [
        'posts' => App\Policies\PostPolicy::class,
    ],
];

2. Put attributes on controllers

use Glueful\Auth\Attributes\RequiresPermission;
use Glueful\Auth\Attributes\RequiresRole;

#[RequiresRole('admin')]
class AdminController
{
    // Every method requires the admin role
}

class PostController
{
    #[RequiresPermission('posts.create')]
    public function store() { }

    #[RequiresPermission('posts.edit')]
    #[RequiresPermission('posts.publish')]
    public function publish(int $id)
    {
        // Requires BOTH permissions
    }
}

3. Protect the routes with auth

The gate_permissions middleware is attached automatically to any route whose handler (class or method) carries #[RequiresPermission] or #[RequiresRole]. You only need auth in front of it so the request has an identity:

$router->group(['middleware' => ['auth']], function ($router) {
    $router->post('/posts', [PostController::class, 'store']);
    $router->post('/posts/{id}/publish', [PostController::class, 'publish'])->where('id', '\d+');
});

// Listing it explicitly is also fine; it won't be added twice
$router->post('/admin/users', [AdminController::class, 'create'])
    ->middleware(['auth', 'gate_permissions']);

There is no $router->resource(); register each route explicitly.

Configuration

Strategy Options

The strategy setting determines how votes are combined:

  • affirmative (default): one GRANT is enough
  • consensus: more GRANTs than DENYs
  • unanimous: at least one GRANT and no DENY

If no voter grants, the result is DENY. With allow_deny_override off (the default), any DENY ends evaluation immediately whatever the strategy.

Provider Modes

provider_mode controls how an installed permission provider interacts with the Gate:

  • replace (default): the provider alone decides; Gate voters are bypassed
  • combine: the provider is consulted first (true counts as GRANT, false as ABSTAIN), then the Gate voters run

With no provider installed, the Gate always decides.

Allow Deny Override

When allow_deny_override is true, a DENY no longer stops evaluation; all votes are tallied and the strategy decides. Keep this false.

Enforcing middleware

If your app enforces permissions through route middleware parameters (for example ->middleware('content_permission:content.view')), list those middleware names under enforcing_middleware in your app's config/permissions.php so permissions:diff counts them as enforced.

Using Attributes

RequiresPermission

use Glueful\Auth\Attributes\RequiresPermission;

class DocumentController
{
    #[RequiresPermission('documents.view')]
    public function index() { }

    #[RequiresPermission('documents.edit', resource: 'documents')]
    public function edit(int $id) { }
}

The resource argument defaults to 'system' and is passed to PermissionManager::can().

RequiresRole

use Glueful\Auth\Attributes\RequiresRole;

#[RequiresRole('admin')]
class AdminDashboardController
{
}

Roles are checked as permissions: a plain role name becomes role.<name> (admin → role.admin); a dotted value passes through unchanged. Make sure something grants that token, for example:

return [
    'super_roles' => ['admin'],          // Option A: admin is a super role

    'roles' => [                         // Option B: grant role tokens explicitly
        'admin'  => ['*'],               // '*' already covers role.admin
        'editor' => ['role.editor', 'posts.*'],
    ],
];

Stacking Attributes

Attributes are collected from the method, its class and parent classes, then checked in two groups:

  • #[RequiresPermission]: all required. Every collected permission must pass.
  • #[RequiresRole]: any one of them. All collected roles form one set, and holding a single one satisfies it. A method-level role therefore widens a class-level one rather than narrowing it.

When both kinds are present, the permissions must all pass and the user must hold at least one of the roles.

#[RequiresRole('admin')]
class SettingsController
{
    #[RequiresRole('editor')]
    #[RequiresPermission('system.configure')]
    public function systemSettings()
    {
        // (admin OR editor) AND system.configure
    }
}

To require several roles at once, require their permissions with stacked #[RequiresPermission] (or use a single combined role).

Since 1.91: stacked #[RequiresRole] attributes mean "any one of these roles". Earlier releases enforced them as "all of them", contrary to the attribute's documentation.

Gate System

Direct Gate Usage

use Glueful\Permissions\Gate;
use Glueful\Permissions\Context;
use Glueful\Permissions\Vote;
use Glueful\Auth\UserIdentity;

$gate = app($context, Gate::class);

$user = new UserIdentity(
    uuid: 'user-123',
    roles: ['editor'],
    scopes: ['read', 'write'],
    attributes: ['department' => 'marketing']
);

$permCtx = new Context(
    tenantId: 'tenant-456',
    routeParams: ['id' => 123],
    jwtClaims: ['sub' => 'user-123'],
    extra: ['ip' => '192.168.1.1']
);

// decide() returns Vote::GRANT, Vote::DENY or Vote::ABSTAIN (strings)
$decision = $gate->decide($user, 'posts.edit', $post, $permCtx);

if ($decision === Vote::GRANT) {
    // Permission granted
}

In a controller, the authenticated identity is on the request: $request->attributes->get('auth.user') is a UserIdentity (the auth middleware always sets it), and $request->attributes->get('user') holds the raw user array. BaseController also exposes $this->currentUser.

Writing a Custom Voter

use Glueful\Permissions\VoterInterface;
use Glueful\Permissions\Vote;
use Glueful\Permissions\Context;
use Glueful\Auth\UserIdentity;

final class DepartmentVoter implements VoterInterface
{
    public function supports(string $permission, mixed $resource, Context $ctx): bool
    {
        return str_starts_with($permission, 'department.');
    }

    public function vote(UserIdentity $user, string $permission, mixed $resource, Context $ctx): Vote
    {
        if ($resource !== null && $user->attr('department') === $resource->department) {
            return new Vote(Vote::GRANT);
        }

        return new Vote(Vote::ABSTAIN);
    }

    public function priority(): int
    {
        return 40; // lower runs earlier
    }
}

Register it from a service provider (app or extension) by returning it from voters(). See Extension Development. You can also call $gate->registerVoter(...) on the shared Gate.

Voters

Built-in Voters

The shared Gate registers, in priority order:

  1. SuperRoleVoter (0): grants everything to super_roles (only registered when the list is non-empty)
  2. PolicyVoter (5): delegates to a registered policy
  3. RoleVoter (10): role → permission map from config/permissions.php roles
  4. RegistryRoleVoter (15): role → permission grants declared in the permission catalog
  5. ScopeVoter (20): JWT/OAuth scopes
  6. OwnershipVoter (30): .own permissions

RoleVoter

'roles' => [
    'admin'  => ['*'],                       // every permission
    'editor' => ['posts.*', 'comments.moderate'], // 'posts.*' matches posts.<anything>
    'user'   => ['posts.create', 'posts.edit.own'],
]

OwnershipVoter

Handles permissions that end in .own. The owner id comes from Context::$extra['ownerId'], or from the resource's authorId / ownerId property. A match grants; a mismatch denies; no owner id abstains.

$permCtx = new Context(extra: ['ownerId' => $post->author_id]);

$gate->decide($user, 'posts.edit.own', $post, $permCtx);

Policies

Creating a Policy

namespace App\Policies;

use Glueful\Permissions\PolicyInterface;
use Glueful\Permissions\Context;
use Glueful\Auth\UserIdentity;

final class PostPolicy implements PolicyInterface
{
    public function view(UserIdentity $user, mixed $post, Context $ctx): ?bool
    {
        return $post->isPublished() ? true : null;
    }

    public function create(UserIdentity $user, mixed $post, Context $ctx): ?bool
    {
        return in_array('editor', $user->roles(), true) ? true : null;
    }

    public function update(UserIdentity $user, mixed $post, Context $ctx): ?bool
    {
        if ((string) $post->author_id === $user->id()) {
            return true;
        }

        return in_array('editor', $user->roles(), true) ? true : null;
    }

    public function delete(UserIdentity $user, mixed $post, Context $ctx): ?bool
    {
        return in_array('admin', $user->roles(), true);
    }
}

Return true (GRANT), false (DENY) or null (ABSTAIN). The ability called is the last dot segment of the permission (posts.update → update()); a policy may add more methods (e.g. publish()), and a missing method abstains. Policies are instantiated with new, so they need a zero-argument constructor.

Registering Policies

In config/permissions.php:

'policies' => [
    'posts' => App\Policies\PostPolicy::class,
    App\Models\Post::class => App\Policies\PostPolicy::class,  // or by class name
]

Or at runtime with app($context, PolicyRegistry::class)->register('posts', PostPolicy::class), or from a provider's policies() hook.

Resource Lookup

The PolicyVoter only runs when a resource is passed to the Gate. It looks up the policy by Context::$extra['resource_slug'], else by the resource's class name (objects) or the string itself:

$permCtx = new Context(extra: ['resource_slug' => 'posts']);
$gate->decide($user, 'posts.update', $post, $permCtx);

#[RequiresPermission] checks don't pass a resource object to the Gate, so policies apply to explicit Gate::decide() calls (or PermissionManager::can() with resource_obj in the context).

UserIdentity

use Glueful\Auth\UserIdentity;

$user = new UserIdentity(
    uuid: 'user-123',
    roles: ['admin', 'editor'],
    scopes: ['read', 'write', 'delete'],
    attributes: [
        'department' => 'engineering',
        'permissions' => ['custom.permission'],
    ],
    email: '[email protected]',
);

$user->id();                         // 'user-123' (same as uuid())
$user->roles();                      // ['admin', 'editor']
$user->scopes();                     // ['read', 'write', 'delete']
$user->email();                      // '[email protected]'
$user->attr('department');           // 'engineering'
$user->attr('unknown', 'default');   // 'default'
$user->claims();                     // full claims bag

Integration with PermissionManager

PermissionManager is what the attribute middleware calls. Resolve it by class name; 'permission.manager' is an alias to the same shared instance:

use Glueful\Permissions\PermissionManager;

$manager = app($context, PermissionManager::class);

$canEdit = $manager->can(
    'user-123',            // user UUID
    'posts.edit',          // permission
    'posts',               // resource
    [
        'roles' => ['editor'],
        'scopes' => [],
        'tenant_id' => null,
        'resource_obj' => $post,    // passed to the Gate (enables policies/ownership)
        'extra' => ['ownerId' => $post->author_id],
    ]
);

In replace mode with a provider installed, the call goes straight to the provider. Otherwise the manager builds a UserIdentity and a Context from these keys (roles, scopes, attributes, tenant_id, route_params, jwt_claims, extra, resource_obj) and asks the Gate.

Controller helpers

Controllers extending BaseController get $this->can($permission, $resource = 'system', $context = []) and $this->requirePermission(...). requirePermission() refuses unauthenticated callers first, then checks the permission: with a permission provider installed the provider decides; without one it falls back to PermissionManager::can(), so the Gate (config roles and voters) decides. A denial throws UnauthorizedException.

Framework endpoints such as metrics, health, config and webhook management (system.config.view for reads, system.config.edit for changes) use requirePermission(), so in a Gate-only app they admit users whose roles or voters grant the permission. Before 1.91 they refused everyone with "Permission system unavailable" when no provider was installed.

public function update(int $id): Response
{
    $this->requirePermission('posts.edit', 'posts');
    // ...
}

Permission Catalog

Providers (your app's providers in config/serviceproviders.php, and extensions) can declare the permissions and roles they introduce. Declarations are collected into the PermissionRegistry at boot; declared role grants are enforced by RegistryRoleVoter, and a role that grants an undeclared permission fails validation.

use Glueful\Extensions\ServiceProvider;
use Glueful\Permissions\Catalog\Permission;
use Glueful\Permissions\Catalog\Role;

final class AppServiceProvider extends ServiceProvider
{
    public function permissions(): array
    {
        return [
            Permission::define('posts.publish')
                ->label('Publish posts')
                ->category('Content')
                ->resource('posts'),
        ];
    }

    public function roles(): array
    {
        return [
            Role::define('editor')
                ->label('Editor')
                ->grants(['posts.publish']),
        ];
    }
}

Extensions also call ->managedBy('vendor/package') on their declarations. Inspect and persist the catalog with the CLI:

php glueful permissions:list          # declared catalog, grouped by category
php glueful permissions:diff          # drift between declared, enforced (attributes) and persisted
php glueful permissions:sync          # persist into the active provider (--prune removes stale managed entries)

permissions:sync needs a provider that implements PermissionCatalogSyncInterface (Aegis does); otherwise declarations stay in the registry only.

Examples

Example 1: Blog System

// config/permissions.php
return [
    'roles' => [
        'admin'      => ['*'],
        'editor'     => ['posts.*', 'comments.*'],
        'author'     => ['posts.create', 'posts.edit.own', 'comments.reply'],
        'subscriber' => ['posts.view', 'comments.view'],
    ],
    'policies' => [
        'posts' => App\Policies\PostPolicy::class,
    ],
];
use Glueful\Controllers\BaseController;
use Glueful\Auth\Attributes\RequiresPermission;
use Glueful\Permissions\{Gate, Context, Vote};
use App\Models\Post;

class BlogController extends BaseController
{
    #[RequiresPermission('posts.view')]
    public function index() { }

    #[RequiresPermission('posts.create')]
    public function store() { }

    public function update(int $id)
    {
        $post = Post::find($this->context, $id);
        if ($post === null) {
            return $this->notFound();
        }

        $gate = app($this->context, Gate::class);
        $permCtx = new Context(extra: ['ownerId' => $post->author_id]);

        if ($gate->decide($this->currentUser, 'posts.edit.own', $post, $permCtx) !== Vote::GRANT) {
            return $this->forbidden();
        }

        // Update post...
    }
}

Example 2: Multi-Tenant Voter

final class TenantVoter implements VoterInterface
{
    public function supports(string $permission, mixed $resource, Context $ctx): bool
    {
        return $ctx->tenantId !== null;
    }

    public function vote(UserIdentity $user, string $permission, mixed $resource, Context $ctx): Vote
    {
        $userTenant = $user->attr('tenant_id');

        if ($userTenant !== null && $userTenant !== $ctx->tenantId) {
            return new Vote(Vote::DENY);
        }

        return new Vote(Vote::ABSTAIN);
    }

    public function priority(): int
    {
        return 1; // before the built-in voters (except SuperRoleVoter at 0)
    }
}

For full row-level multi-tenancy, see glueful/tenancy on the Extensions page.

Example 3: API with Scopes

use Glueful\Controllers\BaseController;
use Glueful\Permissions\{Gate, Context, Vote};

class ApiController extends BaseController
{
    public function getData()
    {
        $user = $this->request->attributes->get('auth.user'); // UserIdentity with token scopes

        if (app($this->context, Gate::class)->decide($user, 'read:data', null, new Context()) !== Vote::GRANT) {
            return $this->forbidden('Insufficient scope');
        }

        return $this->success(['data' => $this->fetchData()]);
    }
}

For API keys, prefer #[RequireScope('read:data')] on the route. See Authentication.

Extension Development

Provider hooks

Glueful\Extensions\ServiceProvider has four authorization hooks. They're collected for every loaded provider at boot:

HookReturnsEffect
permissions()list<Permission>declared permissions (catalog)
roles()list<Role>declared roles and their grants
voters()list<VoterInterface>registered onto the shared Gate
policies()array<string, class-string<PolicyInterface>>registered into the PolicyRegistry
use Glueful\Extensions\ServiceProvider;
use Glueful\Permissions\Catalog\Permission;

final class BillingServiceProvider extends ServiceProvider
{
    public function permissions(): array
    {
        return [
            Permission::define('invoices.view')
                ->label('View invoices')
                ->category('Billing')
                ->resource('invoices')
                ->managedBy('vendor/billing'),
        ];
    }

    public function voters(): array
    {
        return [new FeatureFlagVoter()];
    }

    public function policies(): array
    {
        return ['invoices' => InvoicePolicy::class];
    }
}

Permission Providers

A database-backed RBAC system implements Glueful\Interfaces\Permission\PermissionProviderInterface. Its methods are: initialize(), can(), getUserPermissions(), assignPermission(), revokePermission(), getAvailablePermissions(), getAvailableResources(), batchAssignPermissions(), batchRevokePermissions(), assignRole(), revokeRole(), invalidateUserCache(), invalidateAllCache(), getProviderInfo(), and healthCheck(). To support permissions:sync, also implement PermissionCatalogSyncInterface (and RoleCatalogSyncInterface / CatalogPruneInterface as needed).

use Glueful\Bootstrap\ApplicationContext;
use Glueful\Permissions\PermissionManager;

public function boot(ApplicationContext $context): void
{
    $manager = $this->app->get(PermissionManager::class);
    $manager->setProvider($this->app->get(MyRbacProvider::class));
}

glueful/aegis is the reference implementation.

Security Guidelines for Extensions

  1. Abstain when unsure: return ABSTAIN (or null from a policy) rather than GRANT
  2. Validate tenant context: always check tenant isolation
  3. Use priorities deliberately: security voters should run early (low numbers)
  4. Test thoroughly: authorization bugs are security vulnerabilities
  5. Declare your permissions: use permissions() so permissions:diff and admin UIs can see them

Best Practices

  1. Use attributes for declarative permissions: cleaner than inline checks
  2. Implement policies for resource-specific logic: better than voter sprawl
  3. Check permissions, not roles, in code
  4. Declare permissions and roles in providers: run permissions:diff in CI
  5. Log authorization failures for security auditing
  6. Unit test voters and policies

Troubleshooting

Permission Always Denied

  1. Check the route has auth in front of the attribute check; without an identity the gate returns 403
  2. Check $request->attributes->get('auth.user') is a UserIdentity with the roles you expect
  3. With a provider in replace mode, config roles are not consulted; the provider decides
  4. Enable PermissionManager::setDebugMode(true) and inspect getDebugInfo() to trace decisions

Role Attribute Never Passes

#[RequiresRole('editor')] checks the permission role.editor. Grant it via super_roles, a '*' role, or an explicit role.editor entry.

Policy Not Applied

  1. Make sure a resource is passed to Gate::decide() (or resource_obj to can())
  2. Verify the resource slug or class matches the registration key
  3. Use Context::$extra['resource_slug'] for custom mapping

Summary

The Glueful permissions system combines a voter-based Gate, policies, attributes, and a declared permission catalog. Start with config roles and attributes, add policies for resource rules, and install a permission provider such as Aegis when you need persistent RBAC.