Search by

langsys / laravel-access-guard

hcuadra

Entity-scoped RBAC for Laravel: users hold roles within entities (orgs, projects, teams), API keys are first-class authorizable subjects, with super-admin bypass and collection filtering.

Package info

github.com/langsys/laravel-access-guard

pkg:composer/langsys/laravel-access-guard

Statistics

Installs: 3

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v0.2.0 2026-09-03 19:26 UTC

This package is auto-updated.

Last update: 2026-09-09 19:17:14 UTC


README

Entity-scoped role-based authorization for Laravel. Where spatie/laravel-permission gives users global roles, Access Guard scopes a role to an entity — a user is an admin of this organization and a viewer of that project — and treats API keys as first-class authorizable subjects alongside users.

It authorizes against interfaces you implement on your own models, so it works for any hierarchy (orgs, projects, teams, workspaces, tenants). No hard dependency on any other package; pairs naturally with langsys/laravel-api-keys.

Installation

composer require langsys/laravel-access-guard
php artisan vendor:publish --tag=access-guard-migrations
php artisan vendor:publish --tag=access-guard-config   # optional
php artisan migrate

Migrations are guarded with Schema::hasTable(), so publishing into an app that already has roles / permissions tables is a safe no-op.

The model

Authorization answers one question: may this subject perform this permission on this entity? A subject is either a user (with a role in the entity) or an API key (linked to the entity). Permissions are plain strings.

use Langsys\AccessGuard\Facades\AccessGuard;

AccessGuard::authorize('edit_projects', $project); // throws UnauthorizedException (403) if denied

if (AccessGuard::allows('view_projects', $project)) { /* ... */ }

// Keep only the entities the current subject may see:
$visible = AccessGuard::filterByPermission('view_projects', $projects);

Wiring your models

Implement the contracts on your own models — Access Guard never assumes your schema.

Don't have a role-assignment scheme yet? Use the HasRolesInEntities trait (see Assigning roles below) and skip writing userRoleInEntity() by hand.

User (AuthorizableByUser, plus optional Authorizable for super admins):

use Langsys\AccessGuard\Contracts\Authorizable;
use Langsys\AccessGuard\Contracts\AuthorizableByUser;
use Langsys\AccessGuard\Contracts\GrantsPermissions;

class User extends Authenticatable implements Authorizable, AuthorizableByUser
{
    public function isSuperAdmin(): bool
    {
        return $this->type === UserType::SuperAdmin;
    }

    public function userRoleInEntity(mixed $entity): ?GrantsPermissions
    {
        return $this->roleInEntity($entity); // your pivot lookup → a Role
    }

    public function userHasDisabledEntity(mixed $entity): bool
    {
        return $this->disabledEntities()->whereKey($entity->getKey())->exists();
    }
}

The role you return implements GrantsPermissions — a single method, hasPermission(string $permission): bool — so the role answers for itself whether it grants a permission. The bundled Role model implements it already.

Entity — mark anything you authorize against with GuardableResource:

use Langsys\AccessGuard\Contracts\GuardableResource;

class Project extends Model implements GuardableResource {}

API key (optional, AuthorizableByKey) — see the api-keys integration below.

Roles & permissions

The bundled Role and Permission models (UUID keys, value + label) cover the vocabulary; role_has_permissions links them, and Role implements GrantsPermissions.

use Langsys\AccessGuard\Models\Role;

$admin = Role::create(['value' => 'project_admin', 'label' => 'Project Admin']);
$admin->grantPermissions(['view_projects', 'edit_projects']); // creates missing permissions
$admin->hasPermission('view_projects'); // true

Seed these from a seeder; override the models via config('access-guard.models').

How the subject is resolved

On each authorize() call, Access Guard:

  1. checks for a super admin (the resolved user implements Authorizable and isSuperAdmin() is true) → allow;
  2. otherwise picks the API key if one is present on the request, else the authenticated user;
  3. runs the key path (keyHasPermission and keyBelongsToEntity) or the user path (userRoleInEntity → the role's hasPermission, and not userHasDisabledEntity).

By default the user comes from Auth::user() and the API key from the api_key request attribute (set by langsys/laravel-api-keys). Override either from a service provider:

AccessGuard::resolveUserUsing(fn () => /* ... */);
AccessGuard::resolveApiKeyUsing(fn () => /* ... */);

The resolvers run on every authorization check, and whatever they return is the identity every decision is made for. If your resolver memoises (a per-request cache, a function-static), it must invalidate when the identity changes, not only when it disappears — a cache that survives a subject swap serves the previous subject's authorization to the next one, which under Octane or queue workers means across requests.

Using with laravel-api-keys (zero-config)

Install both packages and it just works — no subclassing, no contract to implement. When a key from langsys/laravel-api-keys authenticates, its middleware puts it on the request and Access Guard adapts it automatically: the key's own permissions are checked, and it is authorized against an entity only if it has been linked to that entity.

Link keys to entities with the AuthorizesWithApiKeys trait:

use Langsys\AccessGuard\Concerns\AuthorizesWithApiKeys;
use Langsys\AccessGuard\Contracts\GuardableResource;

class Project extends Model implements GuardableResource
{
    use AuthorizesWithApiKeys;
}

$project->grantApiKey($apiKey);   // this key may now act on this project
$project->revokeApiKey($apiKey);

AccessGuard::authorize('edit_projects', $project) then passes for a key that both holds edit_projects and is linked to $project, and is denied otherwise. Linking is the one explicit step — it's a security boundary — and it lives in the entity_has_api_keys pivot. Without api-keys installed, only the user path runs.

Other key systems: any object implementing AuthorizableByKey is used as-is. Point config('access-guard.api_key.bridge') at a different key class to auto-adapt it, or set it to null to turn the bridge off.

Assigning roles (batteries included)

If you don't already have a membership scheme, add the HasRolesInEntities trait to your subject and implement AuthorizableInEntity. You get entity-scoped assignment backed by the model_has_roles pivot — no custom pivot or userRoleInEntity() to write:

use Langsys\AccessGuard\Concerns\HasRolesInEntities;
use Langsys\AccessGuard\Contracts\AuthorizableInEntity;

class User extends Authenticatable implements Authorizable, AuthorizableInEntity
{
    use HasRolesInEntities;
}
$user->assignRole('project_admin', $project);   // role value, backed enum, or Role model
$user->syncRoles(['viewer'], $project);
$user->removeRole('project_admin', $project);

$user->hasRole('project_admin', $project);                // bool
$user->hasPermissionInEntity('edit_projects', $project);  // unions every role + direct grant
$user->rolesInEntity($project);                           // Collection<Role>
$user->permissionsInEntity($project);                     // array<string> (roles ∪ direct)

A subject can hold multiple roles in one entity; permission checks union them. Override entityIsDisabled($entity) to exclude a subject from an entity even when a role would grant access (e.g. a user who left a project).

Already have your own pivot (like langsys's organization_has_users.role)? Skip the trait and implement AuthorizableByUser instead — both paths are supported.

Direct permissions

Sometimes a subject needs one permission in an entity without minting a role for it. Grant it directly — backed by the model_has_permissions pivot, still entity-scoped:

$user->givePermission('export_data', $project);
$user->revokePermission('export_data', $project);

$user->directPermissionsInEntity($project);   // array<string> — just the direct grants
$user->permissionsInEntity($project);         // array<string> — roles ∪ direct grants

hasPermissionInEntity() and every Gate check see the union of role-derived and direct permissions, so the two compose freely.

Wildcard permissions

Off by default (checks are exact-match). Enable wildcard.enabled and a held permission may use * as a segment wildcard:

// config/access-guard.php → 'wildcard' => ['enabled' => true, 'separator' => '.']

$role->grantPermissions(['projects.*']);
$user->hasPermissionInEntity('projects.edit', $project);   // true
$user->hasPermissionInEntity('projects.delete', $project); // true
$user->hasPermissionInEntity('users.edit', $project);      // false

$role->grantPermissions(['*']);                            // grants everything

Wildcards apply only to held permissions; the permission you check for is always literal. With wildcards off, projects.* is just a permission named projects.*.

Gate & policy integration

With register_gate on (default), Gate checks against a GuardableResource route through Access Guard, so the idiomatic Laravel APIs just work:

$user->can('edit_projects', $project);
$this->authorize('edit_projects', $project);   // in a controller
// @can('edit_projects', $project) ... @endcan

The hook is grant-only (the same contract as spatie/laravel-permission's): it answers true when the subject holds the permission and abstains otherwise — it never denies. Your gates and policies therefore keep full authority, even on GuardableResource models: a policy ability like isOrganizationAdmin is simply not Access Guard's to answer, and an unpermitted ability that nothing else defines falls to the Gate's default deny — so $user->can('edit_projects', $project) is still false for a user without the permission.

The one consequence to know: permissions grant. A held permission short-circuits allow before a policy that would have denied, so don't give a policy method the same name as a permission unless you want the permission to win. A subject whose isSuperAdmin() is true passes every Gate check (super_admin_via_gate). Set register_gate => false to leave the Gate completely untouched — the facade, middleware, and everything else work the same without it.

Permissions already work in Blade through @can (the Gate integration above). For the entity-scoped role check there's a dedicated directive:

@hasrole('project_admin', $project)
    {{-- visible only to admins of this project --}}
@endhasrole

Route middleware

Route::get('/projects/{project}', [ProjectController::class, 'show'])
    ->middleware('access-guard:view_projects,project');

The first argument is the permission; the optional second names the route parameter holding the entity (otherwise the first route-bound GuardableResource is used). Denial throws UnauthorizedException (403).

Exceptions

A denied authorize() throws Langsys\AccessGuard\Exceptions\UnauthorizedException, which extends Laravel's AuthorizationException (so it still renders as a 403 and existing handlers keep working). It carries the context that was denied:

use Langsys\AccessGuard\Exceptions\UnauthorizedException;

try {
    AccessGuard::authorize('edit_projects', $project);
} catch (UnauthorizedException $e) {
    $e->permission; // 'edit_projects'
    $e->entity;     // $project
}

The permission name is omitted from the exception message by default so you don't leak your permission vocabulary in production responses; set display_permission_in_exception => true to include it (handy in local dev). Looking up a role that doesn't exist throws RoleDoesNotExist.

If your app routes exceptions to handlers by exact class (a renderer map, a handler-per-basename factory) rather than by instanceof, register Langsys\AccessGuard\Exceptions\UnauthorizedException there — otherwise denials fall through to your generic handler and render as 500s even though authorization itself is working correctly.

Caching

The role → permission map is cached (config cache.store / expiration_time) and flushed automatically on grant/revoke and any role/permission save or delete. Flush manually with php artisan access-guard:cache-reset.

Artisan commands

php artisan access-guard:create-permission view_projects "View projects"
php artisan access-guard:create-role project_admin "Project Admin" --permissions=view_projects,edit_projects
php artisan access-guard:show          # roles and the permissions they grant
php artisan access-guard:cache-reset

Enums

Anywhere a permission or role name is accepted you can pass a string or a backed enum:

enum Ability: string { case ViewProjects = 'view_projects'; }

$role->grantPermissions([Ability::ViewProjects]);
AccessGuard::allows(Ability::ViewProjects, $project);

Events

Set events_enabled => true to fire RoleAssignedToModel, RoleRemovedFromModel, PermissionAssignedToRole, and PermissionRemovedFromRole — useful for audit logging.

Testing

composer install
composer test