Granulare Rechte statt einfacher Rollen
Eine rollen-basierte Zugriffskontrolle mit ROLE_ADMIN und ROLE_USER reicht in der Praxis selten aus. Wenn ein Benutzer nur seine eigenen Ressourcen bearbeiten darf, nur Mitglieder seines Teams Zugriff haben oder Berechtigungen von Zustand des Objekts abhängen, ist der Symfony Security Voter die richtige Antwort.
Inhaltsverzeichnis
- 1. Das Problem mit einfachen Rollen
- 2. Wie Symfony Security Voters funktionieren
- 3. Den ersten Voter implementieren
- 4. Voter-Attribute sauber definieren
- 5. Eigentümerprüfungen und objektbasierte Rechte
- 6. Team- und Organisations-basierte Zugriffskontrolle
- 7. Voter in Templates und API-Ressourcen nutzen
- 8. Voters mit PHPUnit testen
- 9. Roles vs. Voters im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit einfachen Rollen
Rollen in Symfony – ROLE_ADMIN, ROLE_USER, ROLE_EDITOR – sind globale Berechtigungen. Sie sagen: "Dieser Benutzer darf die Funktion X generell nutzen." Was sie nicht ausdrücken können: "Dieser Benutzer darf Ressource Y nur dann bearbeiten, wenn er ihr Eigentümer ist." Die naive Lösung ist Controller-Code, der $resource->getOwner() === $user prüft und eine 403-Response wirft. Das funktioniert für eine Stelle – aber wenn dieselbe Prüfung in zehn Controllern, zwei API-Endpunkten und einem Twig-Template vorkommt, hat man Code-Duplikation und eine schwer wartbare Berechtigungslogik.
Der Symfony Security Voter löst dieses Problem durch Kapselung. Die Berechtigungslogik lebt in einer dedizierten Klasse, die einmal implementiert und überall per is_granted() aufgerufen wird – im Controller, im Twig-Template, in API-Platform-Security-Ausdrücken und in Services. Wenn die Berechtigungsregel sich ändert, wird sie an einer einzigen Stelle geändert. Das macht Berechtigungslogik wartbar, testbar und sicher – drei Eigenschaften, die bei verteilter if-else-Logik in Controllers schwer zu erreichen sind.
2. Wie Symfony Security Voters funktionieren
Das Security-System von Symfony ruft bei jedem isGranted()-Aufruf alle registrierten Voter auf und fragt sie nach ihrer Entscheidung. Jeder Voter gibt einen von drei Werten zurück: ACCESS_GRANTED, ACCESS_DENIED oder ACCESS_ABSTAIN. Ein Voter der das übergebene Attribut oder die Subjekt-Klasse nicht kennt, gibt ACCESS_ABSTAIN zurück – er enthält sich und überlässt anderen Voters die Entscheidung. Das AccessDecisionManager aggregiert alle Voter-Entscheidungen nach einer konfigurierbaren Strategie: affirmative (Standard), consensus oder unanimous.
Im täglichen Symfony-Einsatz erstellt man Voter durch Erweiterung der abstrakten Basisklasse Voter. Diese Basisklasse implementiert bereits das VoterInterface und liefert eine typsichere Schnittstelle: supports(string $attribute, mixed $subject) prüft, ob dieser Voter für das Attribut und Subjekt zuständig ist, und voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token) enthält die eigentliche Prüflogik. Durch die Trennung von "bin ich zuständig" und "ist Zugriff erlaubt" ist die Logik klar strukturiert und testbar.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for Article resource permissions.
* Handles EDIT, DELETE, and PUBLISH attributes for Article objects.
*/
final class ArticleVoter extends Voter
{
// Define allowed attributes as class constants for type safety
public const string EDIT = 'ARTICLE_EDIT';
public const string DELETE = 'ARTICLE_DELETE';
public const string PUBLISH = 'ARTICLE_PUBLISH';
private const array ATTRIBUTES = [self::EDIT, self::DELETE, self::PUBLISH];
/**
* Check if this voter handles the given attribute and subject combination.
*/
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::ATTRIBUTES, strict: true)
&& $subject instanceof Article;
}
/**
* Determine access based on attribute, subject state, and current user.
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// Unauthenticated users never get access
if (!$user instanceof User) {
return false;
}
/** @var Article $article */
$article = $subject;
return match ($attribute) {
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
self::PUBLISH => $this->canPublish($article, $user),
default => false,
};
}
private function canEdit(Article $article, User $user): bool
{
// Admins can always edit; otherwise only the author
return $user->isAdmin() || $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// Only admins and the author of unpublished articles can delete
return $user->isAdmin()
|| ($article->getAuthor() === $user && !$article->isPublished());
}
private function canPublish(Article $article, User $user): bool
{
// Publishing requires editor role and the article must be in draft state
return $user->hasRole('ROLE_EDITOR') && $article->isDraft();
}
}
4. Voter-Attribute sauber definieren
Voter-Attribute sind Strings, aber es empfiehlt sich, sie als Klassenkonstanten zu definieren. Der Grund ist Typsicherheit: Wenn man ArticleVoter::EDIT verwendet statt des rohen Strings 'ARTICLE_EDIT', prüft PHP, ob die Konstante existiert. Tippfehler führen zu einem Compile-Error statt einem stillen Sicherheitsfehler. Im Controller schreibt man $this->denyAccessUnlessGranted(ArticleVoter::EDIT, $article) – klar, typsicher und sofort verständlich.
Eine gute Konvention: Attribute werden mit dem Ressourcennamen als Präfix benannt, um Konflikte zwischen mehreren Voters zu vermeiden. ARTICLE_EDIT und POST_EDIT sind klar getrennt, während zwei Voters mit dem Attribut EDIT gleichzeitig reagieren würden – was je nach AccessDecisionManager-Strategie zu unerwartetem Verhalten führt. In großen Projekten mit vielen Entities lohnt sich die Erstellung eines zentralen Enum-Typs für alle Voter-Attribute, der Autovervollständigung in der IDE ermöglicht und alle definierten Berechtigungen auf einen Blick zeigt.
5. Eigentümerprüfungen und objektbasierte Rechte
Eigentümerprüfungen sind der häufigste Anwendungsfall für Symfony Security Voters. Das Muster ist immer gleich: Ein Benutzer darf eine Ressource dann modifizieren, wenn er ihr Eigentümer ist – oder wenn er eine privilegierte Rolle hat, die diese Einschränkung aufhebt. Im Voter wird diese Logik mit einer einfachen Equality-Prüfung auf das Owner-Objekt implementiert: $resource->getOwner() === $user oder per ID-Vergleich $resource->getOwnerId() === $user->getId() für den Fall, dass das Owner-Objekt nicht geladen ist.
Eine subtile aber wichtige Nuance: Das Vergleichen von Doctrine-Entities per === prüft die Objektidentität im PHP-Speicher. In den meisten Fällen ist das korrekt, weil derselbe eingeloggte User-Token immer dieselbe Objektinstanz aus dem Doctrine Identity Map zurückliefert. Aber wenn Entitäten aus separaten EntityManager-Kontexten kommen – etwa aus einem Cron-Job oder einem Test mit mehreren EntityManagern – schlägt === fehl obwohl es sich um dieselbe Datenbankzeile handelt. In solchen Szenarien verwendet man $resource->getOwnerId() === $user->getId() als sichere Alternative.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TeamMemberRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter for Project permissions — ownership, team membership and admin checks.
*/
final class ProjectVoter extends Voter
{
public const string VIEW = 'PROJECT_VIEW';
public const string EDIT = 'PROJECT_EDIT';
public const string DELETE = 'PROJECT_DELETE';
public const string INVITE = 'PROJECT_INVITE_MEMBER';
public function __construct(
private readonly TeamMemberRepository $teamMemberRepository,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::INVITE], strict: true)
&& $subject instanceof Project;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Project $project */
$project = $subject;
return match ($attribute) {
self::VIEW => $this->canView($project, $user),
self::EDIT => $this->canEdit($project, $user),
self::DELETE => $this->canDelete($project, $user),
self::INVITE => $this->canInvite($project, $user),
default => false,
};
}
private function isOwner(Project $project, User $user): bool
{
// ID-based comparison: safe across different EntityManager contexts
return $project->getOwnerId() === $user->getId();
}
private function isTeamMember(Project $project, User $user): bool
{
return $this->teamMemberRepository->isMember($project->getId(), $user->getId());
}
private function canView(Project $project, User $user): bool
{
return $project->isPublic()
|| $this->isOwner($project, $user)
|| $this->isTeamMember($project, $user)
|| $user->isAdmin();
}
private function canEdit(Project $project, User $user): bool
{
return $this->isOwner($project, $user) || $user->isAdmin();
}
private function canDelete(Project $project, User $user): bool
{
// Only owner can delete; admin cannot delete other users' projects
return $this->isOwner($project, $user);
}
private function canInvite(Project $project, User $user): bool
{
return $this->isOwner($project, $user) || $user->isAdmin();
}
}
6. Team- und Organisations-basierte Zugriffskontrolle
In SaaS-Anwendungen mit Mandantenstruktur müssen Symfony Security Voters prüfen, ob ein Benutzer überhaupt zur Organisation gehört, die eine Ressource besitzt. Diese Prüfung ist komplexer als einfache Eigentümerschaft, weil sie Datenbankabfragen erfordert – ob der aktuelle Benutzer ein aktives Mitglied der Organisation ist, die das angefragte Projekt oder Dokument besitzt. Der Voter erhält das Repository als Dependency und führt die Mitgliedschaftsprüfung durch. Das ist performant, solange die Abfragen durch geeignete Datenbankindizes unterstützt werden.
Für Hierarchien – Manager können mehr als Mitarbeiter, Eigentümer mehr als Manager – implementiert man eine explizite Hierarchie im Voter: jede Berechtigungsstufe umfasst alle niedrigeren Stufen. Das Muster canEdit() => canView() und canAdmin() => canEdit() stellt sicher, dass Benutzer mit höherer Berechtigung nicht versehentlich die niedrigere verlieren. In der voteOnAttribute()-Methode kann man diese Hierarchie elegant mit match und Kurzschluss-Evaluierung abbilden.
7. Voter in Templates und API-Ressourcen nutzen
In Twig-Templates steht is_granted() als globale Funktion bereit: {% if is_granted('ARTICLE_EDIT', article) %} ruft denselben Voter auf wie $this->isGranted() im Controller. Das bedeutet, dass Bearbeitungs-Buttons, Lösch-Links und kontextspezifische UI-Elemente dieselbe Berechtigungslogik verwenden wie der Controller. Es gibt keine Möglichkeit, dass das Template einen Button anzeigt, den der Controller dann ablehnt – die Quelle der Wahrheit ist immer der Voter.
In API-Platform-Ressourcen verwendet man Voter-Attribute im Security-Ausdruck: security: "is_granted('ARTICLE_EDIT', object)". Das object im Ausdruck ist das deserialisierte Datenobjekt, subject in der voteOnAttribute()-Methode. Die vollständige Integration ermöglicht es, dieselbe Berechtigungslogik in REST-APIs, GraphQL-Endpunkten, Twig-Templates und Symfony-Controllern zu teilen – ohne Code-Duplikation. Wenn die Berechtigungsregel sich ändert, reicht eine Änderung im Voter.
8. Voters mit PHPUnit testen
Symfony Security Voters sind normale PHP-Klassen und damit hervorragend testbar. Ein typischer Unit-Test erstellt eine Voter-Instanz mit gemockten Dependencies, erstellt ein Mock-Token mit einem bestimmten Benutzer und ruft vote() direkt auf dem Voter auf. Das Ergebnis – Voter::ACCESS_GRANTED, Voter::ACCESS_DENIED oder Voter::ACCESS_ABSTAIN – wird mit PHPUnit-Assertions geprüft. Die Testmatrix sollte alle Kombinationen von Attribut, Benutzerrolle und Objektzustand abdecken.
Ein Integrations-Ansatz testet den kompletten AccessDecisionManager mit allen Voters zusammen. Dabei injiziert man den Security-Service im KernelTestCase und prüft, ob $security->isGranted() für verschiedene Benutzer-Objekt-Kombinationen das erwartete Ergebnis liefert. Dieser Ansatz ist langsamer, stellt aber sicher, dass alle registrierten Voter korrekt zusammenarbeiten und keine Voter-Kombination zu unerwartetem Zugriff oder Verweigerung führt.
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use App\Security\Voter\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Unit tests for ArticleVoter — covers all attribute/role/state combinations.
*/
final class ArticleVoterTest extends TestCase
{
private ArticleVoter $voter;
protected function setUp(): void
{
$this->voter = new ArticleVoter();
}
public function testOwnerCanEditOwnArticle(): void
{
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: false);
$token = new UsernamePasswordToken($author, 'main', $author->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
public function testNonOwnerCannotEditArticle(): void
{
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$otherUser = $this->createUser(id: 2, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: false);
$token = new UsernamePasswordToken($otherUser, 'main', $otherUser->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
public function testAdminCanEditAnyArticle(): void
{
$admin = $this->createUser(id: 99, roles: ['ROLE_USER', 'ROLE_ADMIN']);
$author = $this->createUser(id: 1, roles: ['ROLE_USER']);
$article = $this->createArticle(author: $author, published: true);
$token = new UsernamePasswordToken($admin, 'main', $admin->getRoles());
$result = $this->voter->vote($token, $article, [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
public function testVoterAbstainsForUnknownSubject(): void
{
$user = $this->createUser(id: 1, roles: ['ROLE_USER']);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
// Unknown subject type — voter must abstain, not deny
$result = $this->voter->vote($token, new \stdClass(), [ArticleVoter::EDIT]);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
// Helper methods to create test objects without database
private function createUser(int $id, array $roles): User { /* ... */ }
private function createArticle(User $author, bool $published): Article { /* ... */ }
}
9. Roles vs. Voters im Vergleich
Rollen und Symfony Security Voters lösen verschiedene Probleme und ergänzen sich gegenseitig. Für die Entscheidung, welches Werkzeug das richtige ist, hilft der direkte Vergleich.
| Kriterium | ROLE_*-Prüfung | Symfony Security Voter | Wann verwenden |
|---|---|---|---|
| Objektbezug | Kein Objektbezug | Vollständiger Objektzugriff | Voter für objektbasierte Rechte |
| Testbarkeit | Nur indirekt über Security | Direkte Unit-Tests | Voter klar im Vorteil |
| Code-Duplikation | Häufig in Controllers | Zentralisiert im Voter | Voter eliminiert Duplikation |
| Komplexität | Sehr niedrig | Mittel (eigene Klasse) | Rollen für globale Zugriffe |
| Wiederverwendung | Nur per Copy-Paste | Überall via is_granted() | Voter in Templates, API, Services |
Die Empfehlung für die Praxis: Rollen für globale Zugriffsebenen wie "ist authentifiziert", "hat Admin-Rechte", "darf Admin-Bereich betreten". Symfony Security Voters für alle objektbezogenen Berechtigungen. In einem gut strukturierten Symfony-Projekt prüft ein Controller typischerweise per Rolle, ob der Bereich zugänglich ist, und per Voter, ob die konkrete Ressource manipuliert werden darf. Diese Kombination schafft klar strukturierte und vollständig testbare Zugriffskontrollen.
Mironsoft
Symfony Security, Voter-Architektur und Zugriffskontrollsysteme
Granulare Zugriffskontrolle mit Symfony Voters?
Wir implementieren durchdachte Voter-Architekturen für Symfony-Projekte – von einfachen Eigentümerprüfungen über Mandanten-basierte Isolation bis zur vollständigen API-Platform-Integration.
Security-Review
Analyse bestehender Symfony-Projekte auf verteilte Berechtigungslogik und Voter-Einsparpotenzial
Voter-Architektur
Voter-Hierarchien für Multi-Tenant-Systeme, Team-Rechte und objektbasierte Zugriffskontrollen
Test-Coverage
Vollständige PHPUnit-Test-Suites für alle Voter-Kombinationen aus Attribut, Rolle und Objektzustand
10. Zusammenfassung
Symfony Security Voters sind die richtige Antwort auf Berechtigungsanforderungen, die über einfache Rollen hinausgehen. Die Basisklasse Voter liefert eine klare Struktur: supports() für die Zuständigkeitsprüfung, voteOnAttribute() für die Berechtigungslogik. Attribute werden als Klassenkonstanten definiert, um Typsicherheit zu erzwingen. Eigentümerprüfungen, Team-Mitgliedschaft und zustandsbasierte Rechte sind sauber in der Voter-Klasse gekapselt.
Die Investition in eine klare Voter-Architektur zahlt sich schnell aus: Die Berechtigungslogik ist an einer Stelle, testbar mit PHPUnit und konsistent über Controller, Templates und API-Endpunkte hinweg. Wenn ein Security-Auditor die Berechtigungslogik überprüfen will, muss er nur die Voter-Klassen lesen – nicht hunderte Controller auf Code-Duplikation untersuchen. Das allein ist schon Argument genug für den konsequenten Einsatz von Symfony Security Voters in jedem Projekt, das mehr als triviale Zugriffsregeln hat.
Symfony Security Voters — Das Wichtigste auf einen Blick
Voter-Struktur
supports() prüft Zuständigkeit, voteOnAttribute() enthält die Logik. Attribute als Klassenkonstanten für Typsicherheit definieren.
Eigentümerprüfungen
ID-Vergleich statt Objektidentität für sichere Eigentümerprüfungen auch in verschiedenen EntityManager-Kontexten.
Wiederverwendung
Voter via is_granted() in Controllern, Twig-Templates und API-Platform-Security-Ausdrücken nutzen – eine Logik, überall konsistent.
Testbarkeit
Voter direkt mit PHPUnit testen – vote() aufrufen, ACCESS_GRANTED/DENIED/ABSTAIN prüfen. Kein HTTP-Request nötig.