Authorization (RBAC)
The bundle provides a four-level cascading permission system:
Resource-level → Team-level → Organization-level → Global system-level
Each level falls back to the next. A global super-admin passes every check without being explicitly named in each organization or resource.
Roles and permissions
Roles are identified by a unique code string. There is no scope
column — the context is defined by the relation that uses the role:
| Relation | Context | Example codes |
|---|---|---|
UserRole |
Global system | system.admin, system.auditor |
OrganizationMembership |
Organization | org.owner, org.admin, org.member |
ResourceAccess (app) |
Resource | contribuyente.admin, contribuyente.read |
Permissions are atomic capabilities attached to roles via the
RolePermission pivot:
Role "org.admin" ──► Permission "org.invite"
──► Permission "org.billing"
──► Permission "org.settings"
Using voters in controllers
The bundle registers three voters automatically:
Global (no subject)
#[IsGranted('system.manage')]
public function adminDashboard(): Response { ... }
Checks: user → UserRole → Role → RolePermission → Permission.code.
Organization-scoped (subject = Organization)
#[IsGranted('org.invite', subject: 'organization')]
public function inviteMember(Organization $organization): Response { ... }
Checks: user → OrganizationMembership(org) → Role → Permissions.
Falls back to global.
Resource-scoped (subject = ResourceAccessible entity)
#[IsGranted('write', subject: 'contribuyente')]
public function editContribuyente(Contribuyente $contribuyente): Response { ... }
Checks: user → ResourceAccess(resource) → Role → Permissions.
Falls back to org-level (if resource implements
OrganizationOwnedInterface), then to global.
Programmatic checks
use Derafu\IdentityBundle\Contract\Service\PermissionCheckerInterface;
class SomeService
{
public function __construct(
private readonly PermissionCheckerInterface $checker
) {}
public function doSomething(UserInterface $user, Organization $org): void
{
// Global
if ($this->checker->hasPermission($user, 'system.manage')) { ... }
// Organization-scoped
if ($this->checker->hasOrganizationPermission($user, $org, 'org.invite')) { ... }
// Resource-scoped
if ($this->checker->hasResourcePermission($user, $contribuyente, 'write')) { ... }
}
}
How the cascade works
hasResourcePermission($user, $resource, 'write'):
- If
$resourceimplementsResourceAccessibleInterface: iterate the resource’s direct access entries for this user → check each role’s permissions for'write'. - If not found and
$resourceimplementsTeamAccessibleInterface: iterate team access entries → for each team, check if the user is a member → if yes, check the team’s role permissions for'write'. - If not found and
$resourceimplementsOrganizationOwnedInterface: callhasOrganizationPermission($user, $resource->getOrganization(), 'write'). - If not found: call
hasPermission($user, 'write')(global).
Any level returning true short-circuits the rest.
API Platform integration
The bundle’s voters work with API Platform’s security attribute
out of the box — no extra configuration. API Platform calls Symfony’s
AuthorizationCheckerInterface, which triggers the voters:
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('read', object)"),
new Post(security: "is_granted('write', object)"),
],
)]
class Contribuyente { ... }
The Get and Post operations pass object as the subject, so the
ResourcePermissionVoter fires and resolves the full cascade
(direct → teams → org → global). The GetCollection operation uses a
global check (no subject), so the PermissionVoter fires.
For automatic listing filters (which rows the user sees), see Multi-tenancy & RLS.
Vote reasons (debugging)
All three voters add human-readable reasons to Symfony’s Vote object
when they deny. Check the Web Profiler’s Security panel to see why a
specific check failed:
User "[email protected]" does not hold permission "org.invite"
in organization #42.