Permissions and Authorization
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:
- Gate: central authorization point using a voter-based system
- Voters: pluggable authorization logic (super roles, policies, roles, scopes, ownership)
- Attributes:
#[RequiresPermission]/#[RequiresRole]on controllers and methods - Policies: class-based authorization for specific resources
- PermissionManager: the single enforcement entry point; it uses an installed permission provider (for example
glueful/aegis) or falls back to the Gate - 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 enoughconsensus: more GRANTs than DENYsunanimous: 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 bypassedcombine: the provider is consulted first (truecounts as GRANT,falseas 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:
- SuperRoleVoter (0): grants everything to
super_roles(only registered when the list is non-empty) - PolicyVoter (5): delegates to a registered policy
- RoleVoter (10): role → permission map from
config/permissions.phproles - RegistryRoleVoter (15): role → permission grants declared in the permission catalog
- ScopeVoter (20): JWT/OAuth scopes
- OwnershipVoter (30):
.ownpermissions
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:
| Hook | Returns | Effect |
|---|---|---|
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
- Abstain when unsure: return
ABSTAIN(ornullfrom a policy) rather than GRANT - Validate tenant context: always check tenant isolation
- Use priorities deliberately: security voters should run early (low numbers)
- Test thoroughly: authorization bugs are security vulnerabilities
- Declare your permissions: use
permissions()sopermissions:diffand admin UIs can see them
Best Practices
- Use attributes for declarative permissions: cleaner than inline checks
- Implement policies for resource-specific logic: better than voter sprawl
- Check permissions, not roles, in code
- Declare permissions and roles in providers: run
permissions:diffin CI - Log authorization failures for security auditing
- Unit test voters and policies
Troubleshooting
Permission Always Denied
- Check the route has
authin front of the attribute check; without an identity the gate returns 403 - Check
$request->attributes->get('auth.user')is aUserIdentitywith the roles you expect - With a provider in
replacemode, configrolesare not consulted; the provider decides - Enable
PermissionManager::setDebugMode(true)and inspectgetDebugInfo()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
- Make sure a resource is passed to
Gate::decide()(orresource_objtocan()) - Verify the resource slug or class matches the registration key
- Use
Context::$extra['resource_slug']for custom mapping
Related Documentation
- Authentication - User authentication and sessions
- Middleware - Creating custom middleware
- Dependency Injection - Service container and providers
- Extensions - Provider hooks and packaging
- CLI Reference -
permissions:*commands
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.