malinichevvv/yii2-access

Powerful, flexible RBAC extension for Yii2 with role inheritance, dynamic rules, PHP 8 attributes, caching and audit log

Maintainers

Package info

github.com/malinichevvv/yii2-access

Type:yii2-extension

pkg:composer/malinichevvv/yii2-access

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

1.0.0 2026-05-18 11:39 UTC

This package is auto-updated.

Last update: 2026-05-18 11:41:17 UTC


README

A powerful, flexible RBAC extension for Yii2 with:

  • Hierarchical role inheritance — recursive CTE for MySQL 8+ / PostgreSQL, automatic fallback for older MySQL
  • Permission groups — organise permissions by module for clean UI trees
  • Dynamic rules — attach a callable to any permission for contextual access decisions
  • Two-layer caching — per-request in-memory pool + configurable persistent cache (Redis, etc.) with tag-based invalidation
  • PHP 8 attributes#[RequirePermission] / #[RequireRole] for declarative, zero-boilerplate controller guards
  • BehaviorsAccessControlBehavior (controller) and UserAccessBehavior (User model)
  • Config-based filterPermissionFilter for teams who prefer rules in behaviors()
  • Static facadeAm::checkAccess() for concise one-liner checks anywhere
  • Optional audit log — append-only table for every access-changing operation
  • Multi-tenant — optional company_id column scopes roles per tenant
  • Full AR models — for all tables, ready for use in admin UIs
  • Two migrations — core tables + audit log (can be applied independently)

Requirements

Requirement Version
PHP ≥ 8.1
Yii2 ~2.0

Installation

composer require malinichevvv/yii2-access

Run the migrations:

php yii migrate --migrationPath=@vendor/malinichevvv/yii2-access/src/migrations

Or register the path in your console config:

// console/config/main.php
'controllerMap' => [
    'migrate' => [
        'class' => 'yii\console\controllers\MigrateController',
        'migrationPath' => [
            '@app/migrations',
            '@vendor/malinichevvv/yii2-access/src/migrations',
        ],
    ],
],

Configuration

The extension auto-registers the access component via bootstrap. You can override it:

// common/config/main.php
'components' => [
    'access' => [
        'class'          => \malinichevvv\access\AccessManager::class,
        'db'             => 'db',      // DB component ID
        'cache'          => 'cache',   // Cache component ID (false = disable)
        'enableCache'    => true,
        'enableAuditLog' => true,
        'multiTenant'    => false,     // Set true for company-scoped roles
    ],
],

// Register bootstrap so the component is available everywhere:
'bootstrap' => ['log', 'access'],

Basic Usage

Via static facade

use malinichevvv\access\Am;

// Check a single permission
if (Am::checkAccess($userId, 'order.create')) {
    // ...
}

// Check multiple at once
$map = Am::checkMultipleAccess($userId, ['order.create', 'order.delete', 'report.view']);
// ['order.create' => true, 'order.delete' => false, 'report.view' => true]

// Check by role code
if (Am::hasRole($userId, 'admin')) { ... }
if (Am::hasRole($userId, ['admin', 'super_admin'])) { ... } // OR logic

Via component

/** @var \malinichevvv\access\AccessManager $access */
$access = Yii::$app->access;
$access->checkAccess($userId, 'order.create');

PHP 8 Attributes (Recommended)

Attach AccessControlBehavior to your controller, then annotate actions:

use malinichevvv\access\attributes\RequirePermission;
use malinichevvv\access\attributes\RequireRole;
use malinichevvv\access\behaviors\AccessControlBehavior;

class OrderController extends \yii\web\Controller
{
    public function behaviors(): array
    {
        return [
            'access' => AccessControlBehavior::class,
        ];
    }

    #[RequirePermission('order.view')]
    public function actionIndex(): string { ... }

    #[RequirePermission('order.create')]
    public function actionCreate(): string { ... }

    // Both permissions must be held
    #[RequirePermission('order.view')]
    #[RequirePermission('report.generate')]
    public function actionReport(): string { ... }

    // Role check — at least one role in the array must match
    #[RequireRole(['admin', 'super_admin'])]
    public function actionDelete(): string { ... }

    // Role AND permission
    #[RequireRole('manager')]
    #[RequirePermission('order.approve')]
    public function actionApprove(): string { ... }
}

Class-level attributes apply to all actions in the controller:

#[RequireRole('admin')]
class AdminController extends \yii\web\Controller
{
    public function behaviors(): array
    {
        return ['access' => AccessControlBehavior::class];
    }

    // All actions automatically require 'admin' role
    public function actionIndex(): string { ... }
    public function actionUsers(): string { ... }
}

Config-based Filter

For teams that prefer Yii2's declarative style:

use malinichevvv\access\filters\PermissionFilter;

public function behaviors(): array
{
    return [
        'permission' => [
            'class' => PermissionFilter::class,
            'rules' => [
                ['allow' => true,  'actions' => ['index', 'view']],
                ['allow' => true,  'actions' => ['create'], 'permissions' => ['order.create']],
                ['allow' => true,  'actions' => ['delete'], 'roles'       => ['admin']],
                ['allow' => true,  'actions' => ['export'], 'roles' => ['manager'], 'permissions' => ['report.export']],
                ['allow' => false], // deny all others
            ],
        ],
    ];
}

User Model Behavior

Add UserAccessBehavior to your User ActiveRecord to call access checks directly on the model:

// In your User model:
public function behaviors(): array
{
    return [
        'access' => \malinichevvv\access\behaviors\UserAccessBehavior::class,
    ];
}

// Usage:
$user = User::findOne($id);

$user->can('order.create');                     // bool
$user->hasRole('admin');                        // bool
$user->hasRole(['admin', 'super_admin']);        // bool — OR
$user->canAll(['order.create', 'order.view']);  // ['order.create' => true, ...]

$user->getPermissions();                        // string[]
$user->getPermissionsDetailed();                // grouped with inheritance info
$user->getPermissionsForUI();                   // UI-ready tree
$user->getPermissionsWithSources();             // direct/inherited split

$user->getRoles();                              // role records (with inheritance)
$user->getRoles(false);                         // direct roles only
$user->getEffectiveRoleIds();                   // int[]
$user->getDirectRoleIds();                      // int[]

$user->assignRole($roleId);                     // void
$user->revokeRole($roleId);                     // void

Managing Roles & Permissions

use malinichevvv\access\Am;

// Create a permission group
$groupId = Am::createPermissionGroup('Orders', 'crm', 'All order-related permissions');

// Create permissions
$createId = Am::createPermission('order.create', 'Create a new order', $groupId);
$deleteId = Am::createPermission('order.delete', 'Delete an order',   $groupId);

// Create a role
$roleId = Am::createRole('Manager', 'manager', 'Manages orders and clients');

// Assign permissions to the role
Am::addPermissionToRole($roleId, $createId);
Am::addPermissionToRole($roleId, $deleteId);

// Assign role to a user
Am::assignRole($userId, $roleId);

// Revoke role
Am::revokeRole($userId, $roleId);

// Update / delete
Am::updateRole($roleId, ['name' => 'Senior Manager']);
Am::deleteRole($roleId); // fails for system roles

Role Inheritance

Permissions flow upward: a child role inherits all permissions of its parent roles.

admin
 └── manager        (manager inherits admin's permissions)
      └── operator  (operator inherits manager's + admin's permissions)
// admin → manager (manager inherits admin)
Am::addRoleInheritance($adminRoleId, $managerRoleId);

// manager → operator
Am::addRoleInheritance($managerRoleId, $operatorRoleId);

// Cycle detection — throws Exception
Am::canInherit($adminRoleId, $adminRoleId); // false (self)
Am::canInherit($operatorRoleId, $adminRoleId); // false (would create cycle)

// Query hierarchy
Am::getParentRoles($managerRoleId);            // direct parents
Am::getParentRoles($operatorRoleId, true);     // all ancestors recursively
Am::getChildRoles($adminRoleId, true);         // all descendants recursively
Am::getRolePermissionsWithInheritance($roleId); // permissions with direct/inherited flags

Dynamic Rules

Attach a callable to any permission for contextual access decisions evaluated after the static permission check:

namespace App\Access\Rules;

class OwnerRule
{
    public static function check(int $userId, ?array $params): bool
    {
        $orderId = $params['order_id'] ?? null;
        if (!$orderId) {
            return false;
        }
        // Only the order's creator may delete it
        return Order::find()->where(['id' => $orderId, 'created_by' => $userId])->exists();
    }
}
// Register the rule
Am::addDynamicRule($permissionId, 'App\Access\Rules\OwnerRule::check', 'Owner check for orders');

// Check with params — static check + dynamic rule both evaluated
Am::checkAccess($userId, 'order.delete', ['order_id' => $orderId]);

// Remove the rule
Am::removeDynamicRule($permissionId);

Multi-Tenant Mode

Enable multiTenant = true to scope roles per company:

'access' => [
    'class'       => \malinichevvv\access\AccessManager::class,
    'multiTenant' => true,
],
// Roles are scoped to company_id
$roleId = Am::createRole('Manager', 'manager', 'Company manager', false, false, $companyId);

// Query company roles
$roles = Am::getRolesByCompany($companyId);
$role  = Am::getRoleByCompanyWithCode($companyId, 'manager');

Analytics & Comparison

// Compare permissions between two users
$diff = Am::compareUsersAccess($userId1, $userId2);
// [
//   'only_user1'         => ['order.delete', ...],
//   'only_user2'         => ['report.export', ...],
//   'common'             => ['order.view', 'order.create'],
//   'similarity_percent' => 66.7,
// ]

Console Commands

Register the controller in your console config once:

// console/config/main.php
'controllerMap' => [
    'access' => \malinichevvv\access\console\AccessController::class,
],
Command Description
php yii access/flush-cache Invalidate all access:permissions and access:roles cache tags
php yii access/list-roles List all roles with system/default flags
php yii access/list-roles --company-id=5 Roles for a specific company (multi-tenant)
php yii access/list-permissions List all permissions grouped by module
php yii access/list-permissions --module=crm Permissions for a specific module
php yii access/check 42 order.create Check if user #42 has order.create
php yii access/user-roles 42 Show direct and inherited roles for user #42
php yii access/user-permissions 42 Show all permissions for user #42 with sources

Configurable Cache TTLs

Cache durations are instance properties, not hardcoded constants, so you can tune them per environment:

'components' => [
    'access' => [
        'class'                => \malinichevvv\access\AccessManager::class,
        // Defaults shown — override as needed:
        'cacheDurationShort'    => 1800,   // 30 min — user role assignments
        'cacheDurationMedium'   => 3600,   // 1 h   — user permission sets, role details
        'cacheDurationLong'     => 7200,   // 2 h   — role hierarchy, role→permission links
        'cacheDurationVeryLong' => 86400,  // 24 h  — dynamic rule metadata, module groups
    ],
],

The constants (AccessManager::CACHE_DURATION_*) remain available as documented defaults and can be referenced in your own code.

Events

The AccessManager component extends yii\base\Component and fires events at every key point. Attach listeners anywhere in your application bootstrap or module init().

Access check events

Constant Class When
EVENT_BEFORE_CHECK_ACCESS AccessCheckEvent Before any permission check; can short-circuit
EVENT_AFTER_CHECK_ACCESS AccessCheckEvent After every check; carries $result
EVENT_ACCESS_DENIED AccessCheckEvent Only when the check fails

Super-admin bypass (short-circuit pattern):

Yii::$app->access->on(
    AccessManager::EVENT_BEFORE_CHECK_ACCESS,
    function (AccessCheckEvent $event) {
        if (isSuperAdmin($event->userId)) {
            $event->isHandled = true;  // skip DB/cache entirely
            $event->result    = true;
        }
    }
);

Access denied logging:

Yii::$app->access->on(
    AccessManager::EVENT_ACCESS_DENIED,
    function (AccessCheckEvent $event) {
        Yii::warning(
            "Denied: user={$event->userId} perm={$event->permissionCode}",
            'access'
        );
    }
);

Role events

Constant Cancellable Properties on event
EVENT_BEFORE_ASSIGN_ROLE yes userId, roleId
EVENT_AFTER_ASSIGN_ROLE userId, roleId
EVENT_BEFORE_REVOKE_ROLE yes userId, roleId
EVENT_AFTER_REVOKE_ROLE userId, roleId
EVENT_BEFORE_CREATE_ROLE yes data
EVENT_AFTER_CREATE_ROLE roleId, createdRoleId, data
EVENT_BEFORE_UPDATE_ROLE yes roleId, data
EVENT_AFTER_UPDATE_ROLE roleId, data
EVENT_BEFORE_DELETE_ROLE yes roleId
EVENT_AFTER_DELETE_ROLE roleId
EVENT_BEFORE_ADD_ROLE_INHERITANCE yes parentRoleId, childRoleId
EVENT_AFTER_ADD_ROLE_INHERITANCE parentRoleId, childRoleId
EVENT_BEFORE_REMOVE_ROLE_INHERITANCE yes parentRoleId, childRoleId
EVENT_AFTER_REMOVE_ROLE_INHERITANCE parentRoleId, childRoleId

Cancellable example — guard system roles from deletion:

Yii::$app->access->on(
    AccessManager::EVENT_BEFORE_DELETE_ROLE,
    function (RoleEvent $event) {
        if ($event->roleId === MY_PROTECTED_ROLE_ID) {
            $event->isValid = false; // vetoes the delete
        }
    }
);

After-event example — notify a user when their role changes:

Yii::$app->access->on(
    AccessManager::EVENT_AFTER_ASSIGN_ROLE,
    function (RoleEvent $event) {
        Notification::send($event->userId, 'Your access permissions have been updated.');
    }
);

Permission events

Constant Cancellable Properties on event
EVENT_BEFORE_CREATE_PERMISSION yes permissionCode, data
EVENT_AFTER_CREATE_PERMISSION permissionId, createdPermissionId, permissionCode, data
EVENT_BEFORE_UPDATE_PERMISSION yes permissionId, data
EVENT_AFTER_UPDATE_PERMISSION permissionId, data
EVENT_BEFORE_DELETE_PERMISSION yes permissionId
EVENT_AFTER_DELETE_PERMISSION permissionId
EVENT_BEFORE_ADD_PERMISSION_TO_ROLE yes roleId, permissionId
EVENT_AFTER_ADD_PERMISSION_TO_ROLE roleId, permissionId
EVENT_BEFORE_REMOVE_PERMISSION_FROM_ROLE yes roleId, permissionId
EVENT_AFTER_REMOVE_PERMISSION_FROM_ROLE roleId, permissionId

Auto-grant to super-admin on permission creation:

Yii::$app->access->on(
    AccessManager::EVENT_AFTER_CREATE_PERMISSION,
    function (PermissionEvent $event) {
        Am::addPermissionToRole(SUPERADMIN_ROLE_ID, $event->createdPermissionId);
    }
);

Block sensitive permission assignment:

Yii::$app->access->on(
    AccessManager::EVENT_BEFORE_ADD_PERMISSION_TO_ROLE,
    function (PermissionEvent $event) {
        $sensitive = ['payment.refund', 'user.delete', 'role.manage'];
        if (in_array($event->permissionCode, $sensitive, true)) {
            $event->isValid = false;
            Yii::warning("Blocked assignment of sensitive permission to role #{$event->roleId}");
        }
    }
);

Detaching listeners

$handler = function (RoleEvent $event) { ... };
Yii::$app->access->on(AccessManager::EVENT_AFTER_ASSIGN_ROLE, $handler);

// Later:
Yii::$app->access->off(AccessManager::EVENT_AFTER_ASSIGN_ROLE, $handler);

Database Schema

Table Description
access_permission_groups Module-based groups for organising permissions
access_permissions Individual permission codes
access_roles Role definitions (optionally company-scoped)
access_role_permissions Role ↔ permission pivot
access_role_includes Role inheritance (parent → child)
access_user_roles User ↔ role pivot
access_dynamic_rules Callable-based contextual rules per permission
access_audit_log Append-only audit trail (separate migration)

Caching

The component uses a two-layer cache strategy:

  1. Per-request in-memory pool — eliminates repeated DB/cache round-trips within a single HTTP request
  2. Persistent cache (Redis, Memcache, etc.) — shared across requests with tag-based invalidation

All mutating operations automatically invalidate the relevant cache tags. You can also clear the request pool manually (e.g. in tests):

Am::clearRequestCache();
// or
Yii::$app->access->clearRequestCache();

To disable caching entirely:

'access' => ['class' => AccessManager::class, 'enableCache' => false],

Audit Log

When enableAuditLog = true (default), every access-changing operation writes a row to access_audit_log. The table has no foreign keys to users so the log survives user deletion.

To disable:

'access' => ['class' => AccessManager::class, 'enableAuditLog' => false],

i18n

Error messages are translatable. Add to your i18n config:

'i18n' => [
    'translations' => [
        'access' => [
            'class'          => 'yii\i18n\PhpMessageSource',
            'basePath'       => '@vendor/malinichevvv/yii2-access/src/messages',
            'sourceLanguage' => 'en',
        ],
    ],
],

Supported languages: en, ru, uk. Contributions for other languages are welcome.

License

MIT