Search by

fedale / access-control-voter-bundle

fedale

Symfony RBAC bundle inspired by Yii2 (auth_item / auth_item_child / auth_assignment / auth_rule): roles, permissions, hierarchy, per-user assignments and contextual rules, with a can() API and DB-driven role hierarchy

Package info

github.com/fedale/rbac-bundle

Type:symfony-bundle

pkg:composer/fedale/access-control-voter-bundle

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.1 2026-11-14 09:59 UTC

This package is auto-updated.

Last update: 2026-11-14 10:24:43 UTC


README

A Symfony RBAC bundle inspired by Yii2's auth manager. It mirrors a four-table model (auth_item, auth_item_child, auth_assignment, auth_rule) and maps those concepts onto native Symfony primitives. It adds a can() API for permissions alongside isGranted() for roles, a DB-driven role hierarchy, and contextual rules (service- or expression-based).

Not to be confused with fedale/access-control-bundle, which is the perimeter HTTP guard (path/host/IP firewall). This bundle is RBAC authorization (roles/permissions/hierarchy/rules) and is fully independent.

Concept mapping

Yii2 RBAC Here
auth_item (role/permission, type field) auth_item table + AuthItemType enum (role/permission)
auth_item_child (parent→child hierarchy) auth_item_child table (role→role, role→permission, permission→permission)
auth_assignment (user→item) auth_assignment table (role or permission, including direct-to-user)
auth_rule (execute()) auth_rule table + RuleInterface / ExpressionRule
Yii::$app->user->can($item, $params) AccessManagerInterface::can($item, $subject)
role→role hierarchy RbacRoleHierarchy decorating security.role_hierarchy

The key expressiveness: you can assign a permission directly to a single user (via auth_assignment), bypassing the hierarchy — something Symfony's roles-only model does not support natively.

Architecture

  • Assignment (user→item): auth_assignment, the single source of truth (see Token integration).
  • Role→role hierarchy: RbacRoleHierarchy feeds isGranted(ROLE_*).
  • Permissions / decision: AccessManager::can($item, $subject) walks up auth_item_child gating each node with its auth_rule. It is also exposed through DynamicVoter, so #[IsGranted('PERMISSION', subject: $obj)] and isGranted('PERMISSION', $obj) keep working.

can() accepts any item (role or permission). Recommended convention: isGranted(ROLE) for a plain role check; can(item, $subject) when you need a rule's contextual gating.

RBAC model & NIST positioning

In NIST RBAC (ANSI INCITS 359) terms the bundle covers:

  • Core RBAC (L1) — users, roles, permissions; permissions acquired through roles, plus direct permission-to-user assignment (auth_assignment accepts a permission, not only a role).
  • Hierarchical RBAC (L2), general — auth_item_child is an arbitrary DAG with multiple inheritance (role→role, role→permission, permission→permission), not limited to a tree.
  • Beyond static RBAC — auth_rule adds contextual, object-level conditions (service- or expression-based on the $subject), an ABAC-style capability that plain RBAC lacks.

Not implemented: Separation of Duty (Constrained RBAC, L3) — mutually-exclusive roles (SSD/DSD) and role cardinality are out of scope.

Installation

composer require fedale/rbac-bundle

Register the bundle (usually automatic with Flex):

// config/bundles.php
return [
    // ...
    Fedale\RbacBundle\FedaleRbacBundle::class => ['all' => true],
];

With the Doctrine provider (default) the entities' ORM mapping is registered automatically: you don't need to add doctrine.orm.mappings entries.

Configuration

# config/packages/fedale_rbac.yaml
fedale_rbac:
    enabled: true
    super_admin_role: ROLE_SUPER_ADMIN   # '' to disable the short-circuit
    override_role_hierarchy: true        # decorate security.role_hierarchy (see below)
    inject_assigned_roles: false         # fallback; primary = User::getRoles() (see below)
    cache:
        enabled: true
        pool: cache.app
        ttl: null                        # seconds, or null (manual invalidation)
    provider: doctrine                   # 'doctrine' or a custom storage service id
    tenancy:
        context: null                    # service id of your TenantContextInterface (see Multi-tenancy)
        scoped_assignments: false        # true = assignments scoped per tenant in shared tables

Database schema (Doctrine provider)

The four tables (FK constraints as shown):

CREATE TABLE auth_rule (
    name        VARCHAR(64) NOT NULL PRIMARY KEY,
    service_id  VARCHAR(255) NULL,           -- RuleInterface service id
    expression  TEXT NULL,                   -- or an ExpressionLanguage string
    data        JSON NOT NULL DEFAULT ('[]'),
    created_at  DATETIME NOT NULL,
    updated_at  DATETIME NOT NULL
);

CREATE TABLE auth_item (
    name        VARCHAR(64) NOT NULL PRIMARY KEY,
    type        VARCHAR(32) NOT NULL,        -- 'role' | 'permission'
    description TEXT NULL,
    rule_name   VARCHAR(64) NULL,
    data        JSON NOT NULL DEFAULT ('[]'),
    created_at  DATETIME NOT NULL,
    updated_at  DATETIME NOT NULL,
    CONSTRAINT fk_item_rule FOREIGN KEY (rule_name) REFERENCES auth_rule (name)
        ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX idx_auth_item_type ON auth_item (type);

CREATE TABLE auth_item_child (
    parent VARCHAR(64) NOT NULL,
    child  VARCHAR(64) NOT NULL,
    PRIMARY KEY (parent, child),
    CONSTRAINT fk_child_parent FOREIGN KEY (parent) REFERENCES auth_item (name)
        ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT fk_child_child  FOREIGN KEY (child)  REFERENCES auth_item (name)
        ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE auth_assignment (
    item_name  VARCHAR(64)  NOT NULL,
    user_id    VARCHAR(255) NOT NULL,        -- the Symfony user identifier (e.g. email)
    tenant_id  VARCHAR(64)  NOT NULL DEFAULT 'global', -- see Multi-tenancy
    created_at DATETIME NOT NULL,
    PRIMARY KEY (item_name, user_id, tenant_id),
    CONSTRAINT fk_assignment_item FOREIGN KEY (item_name) REFERENCES auth_item (name)
        ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX idx_auth_assignment_user ON auth_assignment (user_id, tenant_id);

Generate the migration with php bin/console doctrine:migrations:diff after installing the bundle.

Notes: a rule references a service_id or an expression (no serialized PHP object); timestamps are DATETIME; user_id is VARCHAR(255).

Token integration

auth_assignment is the single source of truth for user→item. For isGranted(ROLE_*) to reflect assigned roles, choose one mechanism:

Primary (recommended): User::getRoles() reads auth_assignment

The User entity implements AssignedRolesAwareInterface; the AssignedRolesUserProvider decorator injects the roles on every load/refresh (token always fresh, no session lag).

use Fedale\RbacBundle\Security\AssignedRolesAwareInterface;

class User implements UserInterface, AssignedRolesAwareInterface
{
    /** @var string[] */
    private array $assignedRoles = [];

    public function setAssignedRoles(array $roles): void
    {
        $this->assignedRoles = $roles;
    }

    public function getRoles(): array
    {
        return array_values(array_unique($this->assignedRoles));
    }
}
# config/services.yaml — decorate your user provider
Fedale\RbacBundle\Security\AssignedRolesUserProvider:
    decorates: 'security.user.provider.concrete.app_user_provider'
    arguments:
        $inner: '@.inner'

Fallback: inject_assigned_roles: true

For apps that cannot modify the User class. A listener on AuthenticationTokenCreatedEvent enriches the token (handles the standard login's PostAuthenticationToken). With stateful firewalls the injected roles stay in the session token until re-login; can() always reads fresh anyway.

Migrating from an existing user_role_assigned table

If your app already stores role assignments in its own table, migrate them into auth_assignment and drop it:

-- 1) create the role items if missing
INSERT INTO auth_item (name, type, data, created_at, updated_at)
SELECT DISTINCT ura.role, 'role', '[]', NOW(), NOW()
FROM user_role_assigned ura
LEFT JOIN auth_item ai ON ai.name = ura.role
WHERE ai.name IS NULL;

-- 2) copy the assignments
INSERT INTO auth_assignment (item_name, user_id, created_at)
SELECT ura.role, ura.user_id, NOW() FROM user_role_assigned ura;

-- 3) DROP TABLE user_role_assigned;  (after pointing User::getRoles at the new source)

DB-driven role hierarchy

With override_role_hierarchy: true the bundle decorates security.role_hierarchy with RbacRoleHierarchy, which expands the role→role edges from auth_item_child (transitive closure). It replaces the security.yaml role_hierarchy: any static edges defined there must be mirrored in auth_item_child. Permissions do not enter the role hierarchy (they are resolved by can()).

isGranted(ROLE_*) is answered by the bundle's RbacRoleVoter, which replaces Symfony's RoleVoter/RoleHierarchyVoter: Symfony registers only the plain RoleVoter when security.yaml has no role_hierarchy, and that one never reads the hierarchy.

Rules (auth_rule)

A rule attached to an item via rule_name runs during can() (node gate). Two forms, both stored in the DB:

Service rule (service_id)

use Fedale\RbacBundle\Contract\RuleInterface;
use Fedale\RbacBundle\Dto\AuthItem;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

// Auto-tagged 'fedale_rbac.rule' via autoconfigure.
final class AuthorRule implements RuleInterface
{
    public function execute(TokenInterface $token, AuthItem $item, mixed $params = null): bool
    {
        return $params instanceof Post && $params->getAuthor() === $token->getUser();
    }
}

auth_rule.service_id = 'App\Security\AuthorRule'. A service rule can also delegate to one of your own voters: $this->security->isGranted('MY_ATTR', $params).

Expression rule (expression)

An ExpressionLanguage string in auth_rule.expression, evaluated by ExpressionRule. Variables: user, token, subject (the $subject of can(), which may be a map), item, roles, auth_checker (for is_granted(...)), tenant (the current tenant id, global without tenancy). Examples:

subject.getAuthor() == user
user === subject["author"] and subject["post"].isPublished()

With #[IsGranted], the attribute Expression is the condition (→ auth_rule.expression); the subject: part that uses args[...] stays in the controller attribute and produces the $subject that reaches the rule.

Usage

// Controller
use Fedale\RbacBundle\Security\CanTrait;

class InvoiceController extends AbstractController
{
    use CanTrait;

    public function edit(Invoice $invoice): Response
    {
        if (!$this->can('EDIT_INVOICE', $invoice)) {
            throw $this->createAccessDeniedException();
        }
        // ...
    }
}
// Or via the native flow (DynamicVoter answers on permission attributes)
#[IsGranted('EDIT_INVOICE', subject: 'invoice')]
public function edit(Invoice $invoice): Response { /* ... */ }

In Twig (when twig/twig is installed) the can() function mirrors the PHP API; native is_granted('ROLE_X') still covers plain role checks:

{% if can('EDIT_INVOICE', invoice) %}
    <a href="{{ path('invoice_edit', {id: invoice.id}) }}">Edit</a>
{% endif %}

Your own voters coexist with DynamicVoter (which abstains on non-permission attributes); use disjoint attributes to avoid double votes.

Management (write API)

With the Doctrine provider the bundle exposes a write API to manage the graph, RbacManagerInterface. Every mutation flushes and invalidates the affected cache.

public function addItem(string $name, AuthItemType $type, ?string $description = null, ?string $ruleName = null): void;
public function removeItem(string $name): void;
public function renameItem(string $name, string $newName): void; // keeps its edges and assignments
public function addChild(string $parent, string $child): void;
public function removeChild(string $parent, string $child): void;
public function assign(string $userId, string $item, string $tenantId = 'global'): void;
public function revoke(string $userId, string $item, string $tenantId = 'global'): void;
public function addRule(string $name, ?string $serviceId = null, ?string $expression = null): void;
public function removeRule(string $name): void;

CLI equivalents (Doctrine provider):

php bin/console rbac:item:add EDIT_INVOICE --type=permission --description="Edit invoices"
php bin/console rbac:item:add ROLE_EDITOR --type=role
php bin/console rbac:child:add ROLE_EDITOR EDIT_INVOICE
php bin/console rbac:assign gianna EDIT_INVOICE     # direct permission to a user
php bin/console rbac:revoke gianna EDIT_INVOICE
php bin/console rbac:item:remove EDIT_INVOICE
php bin/console rbac:child:remove ROLE_EDITOR EDIT_INVOICE

assign/revoke use the value returned by getUserIdentifier() as user_id.

The write API invalidates the cache automatically. When you seed the tables out-of-band (SQL/fixtures), clear the bundle's cache keys explicitly (only the RBAC keys, not the whole pool; available when the cache is enabled):

php bin/console rbac:cache:clear

Writes inside a database transaction are safe to roll back. While the connection has a transaction open, the item graph and the rules are read from the database on every call and never cached (TransactionCacheWriteGuard): a read there sees the transaction's own writes, and a rollback would otherwise leave items in the cache that were never committed. So an app can create items inside its own transaction (the permissions of a new record type, created with it) and roll the whole thing back. The graph is rebuilt on each read meanwhile, so keep such transactions short.

A custom cache setup can pass its own CacheWriteGuardInterface as the last argument of CachedItemStorage and CachedRuleStorage.

Multi-tenancy

Two setups are supported. Both start from a TenantContextInterface that tells the bundle the tenant of the current decision; it is read on every can()/isGranted(), so the tenant can come from the request (host, route, header) and change from one request to the next.

use Fedale\RbacBundle\Contract\TenantContextInterface;

final class HostTenantContext implements TenantContextInterface
{
    public function __construct(private readonly RequestStack $requests) {}

    public function getTenantId(): string
    {
        $host = $this->requests->getMainRequest()?->getHost();

        return null !== $host ? explode('.', $host)[0] : self::GLOBAL;
    }
}

The id global (TenantContextInterface::GLOBAL) is reserved. Without a tenancy.context the bundle uses GlobalTenantContext, which always answers global: a single-tenant app sees no difference.

Tables per tenant (schema or database per tenant)

Each tenant has its own auth_* tables, and the app points the connection at the current tenant's (e.g. PostgreSQL search_path). Roles and permissions belong to the tenant. Only the context is needed:

fedale_rbac:
    tenancy:
        context: App\Tenant\HostTenantContext

The bundle then keeps the cache per tenant: fedale_rbac.items.<tenant>, and the rule keys likewise (the global tenant keeps the unsuffixed keys). The in-memory graph, the role-hierarchy memo and the per-request memo of the assignments are per tenant too, so a worker or a command switching tenant does not see the previous tenant's graph or assignments. The write API and rbac:cache:clear invalidate the current tenant's keys; the write API also resets everything kept in memory (graphs, rules, role hierarchy, assignments), as kernel.reset does between two requests of a worker.

Shared tables, assignments per tenant

One set of roles and permissions for every tenant; what changes is who has what: a user may be ROLE_ADMIN in acme and ROLE_VIEWER in globex.

fedale_rbac:
    tenancy:
        context: App\Tenant\HostTenantContext
        scoped_assignments: true
  • Every assignment has a tenant_id. An assignment in global applies in every tenant (e.g. a platform admin); the others only in their tenant.
  • can() seeds from the global assignments plus the current tenant's.
  • The token holds the global roles only (AssignmentRolesProvider, AssignedRolesUserProvider): they do not depend on the request, so the token does not change when the tenant does. Symfony logs a user out when the refreshed user's roles differ from the token's.
  • RbacRoleVoter adds the current tenant's roles to isGranted(ROLE_*) at decision time, through the role hierarchy. Code reading $token->getRoleNames() (or the roles variable of an expression rule) sees the global roles only: check with isGranted().
  • assign() and revoke() take the tenant; revoke() removes the assignment of exactly that tenant.
  • rbac:assign, rbac:revoke, rbac:list-assignments and rbac:check get an --assignment-tenant option, defaulting to the current tenant. It is not called --tenant so as not to clash with an app's own global --tenant option.

Expression rules get a tenant variable in both setups, e.g. subject.getTenantId() == tenant.

Isolating the data itself (one tenant's records never reaching another) is the app's job, e.g. with a Doctrine filter; RBAC only answers "may this user, in this tenant, do this?".

Upgrading an existing installation

  • Doctrine provider: auth_assignment gains tenant_id (default 'global', so existing rows stay global) and its primary key becomes (item_name, user_id, tenant_id). Generate the migration with doctrine:migrations:diff.
  • Custom providers: AssignmentStorageInterface::getAssignments() takes a second argument, string $tenantId = TenantContextInterface::GLOBAL.

Custom provider (no Doctrine)

Set provider: to anything other than doctrine and register services for the three interfaces ItemStorageInterface, AssignmentStorageInterface, RuleStorageInterface. The rest of the bundle (AccessManager, role hierarchy, voter, rule resolver) is source-agnostic. The write API and management commands are Doctrine-only; a custom provider can implement RbacManagerInterface itself.

Read-only commands

php bin/console rbac:list-items
php bin/console rbac:list-assignments <user>
php bin/console rbac:check <user> <item>   # static reachability (rules NOT evaluated)

With tenancy.scoped_assignments, both take --assignment-tenant=<id>.

Tests

composer install
vendor/bin/phpunit