From 68a1088ab3eec238f415b209370b9630e426d304 Mon Sep 17 00:00:00 2001 From: t2d Date: Thu, 4 Sep 2025 22:11:41 +0200 Subject: [PATCH] Allow (domain-) admins to delete aliases --- src/Voter/AliasVoter.php | 42 +++++++++++++++++++++++----------- templates/Alias/show.html.twig | 31 +++++++++++++++++-------- tests/Voter/AliasVoterTest.php | 36 ++++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/Voter/AliasVoter.php b/src/Voter/AliasVoter.php index f5690f6a6..d5b512986 100644 --- a/src/Voter/AliasVoter.php +++ b/src/Voter/AliasVoter.php @@ -4,6 +4,8 @@ use App\Entity\Alias; use App\Entity\User; +use App\Enum\Roles; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -11,6 +13,10 @@ class AliasVoter extends Voter { public const DELETE = 'delete'; + public function __construct(private readonly Security $security) + { + } + protected function supports(string $attribute, mixed $subject): bool { if ($attribute !== self::DELETE) { @@ -27,18 +33,28 @@ protected function supports(string $attribute, mixed $subject): bool protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); - $alias = $subject; - - $isUserValid = $user instanceof User; - $isAliasValid = $alias instanceof Alias; - $isNotDeleted = $isAliasValid && !$alias->isDeleted(); - $isRandom = $isAliasValid && $alias->isRandom(); - $isOwner = $isAliasValid && $alias->getUser() === $user; - - return $isUserValid - && $isAliasValid - && $isNotDeleted - && $isRandom - && $isOwner; + $alias = $subject; // already ensured to be Alias by supports() + + if (!$user instanceof User || !$alias instanceof Alias) { + return false; // sanity check + } + + if ($alias->isDeleted()) { + return false; // cannot delete already deleted + } + + $isOwner = $alias->getUser() === $user; + + // owner can delete own random alias + if ($alias->isRandom() && $isOwner) { + return true; + } + + // ADMIN or DOMAIN_ADMIN can delete their own custom aliases + if ($isOwner && ($this->security->isGranted(Roles::ADMIN) || $this->security->isGranted(Roles::DOMAIN_ADMIN))) { + return true; + } + + return false; } } diff --git a/templates/Alias/show.html.twig b/templates/Alias/show.html.twig index deb1624f1..3b980090a 100644 --- a/templates/Alias/show.html.twig +++ b/templates/Alias/show.html.twig @@ -116,16 +116,27 @@ {% for alias in aliases_custom %}
{{ alias.source }} - +
+ + {% if is_granted('delete', alias) %} + + {{ ux_icon('heroicons:x-mark', {class: 'w-4 h-4'}) }} + + {% endif %} +
{% endfor %} {% endif %} diff --git a/tests/Voter/AliasVoterTest.php b/tests/Voter/AliasVoterTest.php index c732dd9b1..fa84c9bb3 100644 --- a/tests/Voter/AliasVoterTest.php +++ b/tests/Voter/AliasVoterTest.php @@ -4,7 +4,9 @@ use App\Entity\Alias; use App\Entity\User; +use App\Enum\Roles; use App\Voter\AliasVoter; +use Symfony\Component\Security\Core\Security; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -12,32 +14,32 @@ class AliasVoterTest extends TestCase { public function testSupportsReturnsTrueForDeleteAndAlias(): void { - $voter = new AliasVoter(); $alias = new Alias(); + $voter = new AliasVoter($this->createSecurity(false)); $this->assertTrue($this->invokeSupports($voter, AliasVoter::DELETE, $alias)); } public function testSupportsReturnsFalseForOtherAttribute(): void { - $voter = new AliasVoter(); $alias = new Alias(); + $voter = new AliasVoter($this->createSecurity(false)); $this->assertFalse($this->invokeSupports($voter, 'OTHER_ATTRIBUTE', $alias)); } public function testSupportsReturnsFalseForNonAliasSubject(): void { - $voter = new AliasVoter(); $user = new User(); + $voter = new AliasVoter($this->createSecurity(false)); $this->assertFalse($this->invokeSupports($voter, AliasVoter::DELETE, $user)); } public function testVoteOnAttributeReturnsTrueIfUserOwnsAlias(): void { - $voter = new AliasVoter(); $user = new User(); $alias = new Alias(); $alias->setRandom(true); $alias->setUser($user); + $voter = new AliasVoter($this->createSecurity(false)); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); $this->assertTrue($this->invokeVoteOnAttribute($voter, AliasVoter::DELETE, $alias, $token)); @@ -45,12 +47,12 @@ public function testVoteOnAttributeReturnsTrueIfUserOwnsAlias(): void public function testVoteOnAttributeReturnsFalseIfUserDoesNotOwnAlias(): void { - $voter = new AliasVoter(); $user = new User(); $otherUser = new User(); $alias = new Alias(); $alias->setRandom(true); $alias->setUser($otherUser); + $voter = new AliasVoter($this->createSecurity(false)); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); $this->assertFalse($this->invokeVoteOnAttribute($voter, AliasVoter::DELETE, $alias, $token)); @@ -58,13 +60,23 @@ public function testVoteOnAttributeReturnsFalseIfUserDoesNotOwnAlias(): void public function testVoteOnAttributeReturnsFalseIfTokenUserIsNotUser(): void { - $voter = new AliasVoter(); $alias = new Alias(); + $voter = new AliasVoter($this->createSecurity(false)); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn(null); $this->assertFalse($this->invokeVoteOnAttribute($voter, AliasVoter::DELETE, $alias, $token)); } + public function testVoteOnAttributeAllowsAdminCustomAlias(): void + { + $user = new User(); + $alias = new Alias(); + $alias->setUser($user); // custom (not random) + $voter = new AliasVoter($this->createSecurity(true)); + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + $this->assertTrue($this->invokeVoteOnAttribute($voter, AliasVoter::DELETE, $alias, $token)); + } private function invokeSupports(AliasVoter $voter, string $attribute, $subject): bool { $ref = new \ReflectionClass($voter); @@ -73,6 +85,18 @@ private function invokeSupports(AliasVoter $voter, string $attribute, $subject): return $method->invoke($voter, $attribute, $subject); } + private function createSecurity(bool $isAdmin): Security + { + $security = $this->createMock(Security::class); + $security->method('isGranted')->willReturnCallback(function (string $role) use ($isAdmin) { + if (!$isAdmin) { + return false; + } + return in_array($role, [Roles::ADMIN, Roles::DOMAIN_ADMIN], true); + }); + return $security; + } + private function invokeVoteOnAttribute(AliasVoter $voter, string $attribute, $subject, TokenInterface $token): bool { $ref = new \ReflectionClass($voter);