From 089d18ab9087a1610aed1fff2799ec975734d392 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 13:37:51 +0400 Subject: [PATCH 1/5] OwnableInterface --- .../Common/Model/Interfaces/OwnableInterface.php | 13 +++++++++++++ src/Domain/Identity/Model/Administrator.php | 10 ++++++++++ src/Domain/Messaging/Model/Message.php | 3 ++- src/Domain/Subscription/Model/SubscribePage.php | 3 ++- src/Domain/Subscription/Model/SubscriberList.php | 3 ++- 5 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 src/Domain/Common/Model/Interfaces/OwnableInterface.php diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php new file mode 100644 index 00000000..f1b72728 --- /dev/null +++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php @@ -0,0 +1,13 @@ +modifiedBy; } + + public function owns(OwnableInterface $resource): bool + { + if ($this->getId() === null) { + return false; + } + + return $resource->getOwner()->getId() === $this->getId(); + } } diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index fbbfec8a..5064c4f1 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -23,7 +24,7 @@ #[ORM\Table(name: 'phplist_message')] #[ORM\Index(name: 'uuididx', columns: ['uuid'])] #[ORM\HasLifecycleCallbacks] -class Message implements DomainModel, Identity, ModificationDate +class Message implements DomainModel, Identity, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index e4696380..979b3c4c 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -7,12 +7,13 @@ use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] #[ORM\Table(name: 'phplist_subscribepage')] -class SubscribePage implements DomainModel, Identity +class SubscribePage implements DomainModel, Identity, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 947cbe26..32f85f5d 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\ListMessage; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; @@ -28,7 +29,7 @@ #[ORM\Index(name: 'nameidx', columns: ['name'])] #[ORM\Index(name: 'listorderidx', columns: ['listorder'])] #[ORM\HasLifecycleCallbacks] -class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate +class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] From 536ae7fcf157184d70de42ea17b12ef3f5d27888 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 14:20:07 +0400 Subject: [PATCH 2/5] PermissionChecker --- src/Domain/Common/Model/Ability.php | 12 +++ .../Identity/Service/PermissionChecker.php | 74 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/Domain/Common/Model/Ability.php create mode 100644 src/Domain/Identity/Service/PermissionChecker.php diff --git a/src/Domain/Common/Model/Ability.php b/src/Domain/Common/Model/Ability.php new file mode 100644 index 00000000..f31fd968 --- /dev/null +++ b/src/Domain/Common/Model/Ability.php @@ -0,0 +1,12 @@ +isSuperAdmin($actor)) { + return true; + } + + return match ($ability) { + Ability::VIEW => $resource && $this->isOwner($actor, $resource), + Ability::EDIT => $resource && $this->isOwner($actor, $resource), + Ability::CREATE => $this->canCreate($actor), + }; + } + + public function canView(Administrator $actor, OwnableInterface $resource): bool + { + if ($this->isSuperAdmin($actor)) { + return true; + } + + return $this->isOwner($actor, $resource); + } + + public function canEdit(Administrator $actor, OwnableInterface $resource): bool + { + if ($this->isSuperAdmin($actor)) { + return true; + } + + return $this->isOwner($actor, $resource); + } + + public function canCreate(Administrator $actor): bool + { + if ($this->isSuperAdmin($actor)) { + return true; + } + + return $actor->getId() !== null; + } + + private function isSuperAdmin(Administrator $actor): bool + { + if ($actor->isSuperUser()) { + return true; + } + + return false; + } + + private function isOwner(Administrator $actor, OwnableInterface $resource): bool + { + $owner = $resource->getOwner(); + $myId = $actor->getId(); + + return $owner !== null + && $myId !== null + && $owner->getId() === $myId; + } +} From 8f05eb15553e7079c30088e00aebbfc3f166af9c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 5 Sep 2025 11:03:33 +0400 Subject: [PATCH 3/5] Check related --- src/Domain/Common/Model/Ability.php | 12 --- .../Identity/Service/PermissionChecker.php | 91 +++++++++++-------- 2 files changed, 53 insertions(+), 50 deletions(-) delete mode 100644 src/Domain/Common/Model/Ability.php diff --git a/src/Domain/Common/Model/Ability.php b/src/Domain/Common/Model/Ability.php deleted file mode 100644 index f31fd968..00000000 --- a/src/Domain/Common/Model/Ability.php +++ /dev/null @@ -1,12 +0,0 @@ -isSuperAdmin($actor)) { - return true; - } + private const REQUIRED_PRIVILEGE_MAP = [ + Subscriber::class => PrivilegeFlag::Subscribers, + SubscriberList::class => PrivilegeFlag::Subscribers, + Message::class => PrivilegeFlag::Campaigns, + ]; - return match ($ability) { - Ability::VIEW => $resource && $this->isOwner($actor, $resource), - Ability::EDIT => $resource && $this->isOwner($actor, $resource), - Ability::CREATE => $this->canCreate($actor), - }; - } + private const OWNERSHIP_MAP = [ + Subscriber::class => SubscriberList::class, + Message::class => SubscriberList::class + ]; - public function canView(Administrator $actor, OwnableInterface $resource): bool + public function canManage(Administrator $actor, DomainModel $resource): bool { - if ($this->isSuperAdmin($actor)) { + if ($actor->isSuperUser()) { return true; } - return $this->isOwner($actor, $resource); - } + $required = $this->resolveRequiredPrivilege($resource); + if ($required !== null && !$actor->getPrivileges()->has($required)) { + return false; + } - public function canEdit(Administrator $actor, OwnableInterface $resource): bool - { - if ($this->isSuperAdmin($actor)) { - return true; + if ($resource instanceof OwnableInterface) { + return $actor->owns($resource); + } + + $notRestricted = true; + foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) { + if ($resource instanceof $resourceClass) { + $related = $this->resolveRelatedEntity($resource, $relatedClass); + $notRestricted = $this->checkRelatedResources($related, $actor); + } } - return $this->isOwner($actor, $resource); + return $notRestricted; } - public function canCreate(Administrator $actor): bool + private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag { - if ($this->isSuperAdmin($actor)) { - return true; + foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) { + if ($resource instanceof $class) { + return $flag; + } } - return $actor->getId() !== null; + return null; } - private function isSuperAdmin(Administrator $actor): bool + /** @return OwnableInterface[] */ + private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array { - if ($actor->isSuperUser()) { - return true; + if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) { + return $resource->getSubscribedLists()->toArray(); } - return false; + if ($resource instanceof Message && $relatedClass === SubscriberList::class) { + return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray(); + } + + return []; } - private function isOwner(Administrator $actor, OwnableInterface $resource): bool + private function checkRelatedResources(array $related, Administrator $actor): bool { - $owner = $resource->getOwner(); - $myId = $actor->getId(); + foreach ($related as $relatedResource) { + if ($actor->owns($relatedResource)) { + return true; + } + } - return $owner !== null - && $myId !== null - && $owner->getId() === $myId; + return false; } } From 79ea7a0fbe5452722166049b112644aa438efda0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 5 Sep 2025 11:29:32 +0400 Subject: [PATCH 4/5] Register service + test --- config/services/providers.yml | 4 --- config/services/services.yml | 6 ++-- .../Service/PermissionCheckerTest.php | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index cb784988..226c4e81 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,7 +2,3 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider: - autowire: true - autoconfigure: true diff --git a/config/services/services.yml b/config/services/services.yml index 1f509787..f1b68e74 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -108,9 +108,7 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } - # I18n - PhpList\Core\Domain\Common\I18n\SimpleTranslator: + PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true - - PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' + public: true diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php new file mode 100644 index 00000000..50820026 --- /dev/null +++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php @@ -0,0 +1,35 @@ +checker = self::getContainer()->get(PermissionChecker::class); + } + + public function testServiceIsRegisteredInContainer(): void + { + self::assertInstanceOf(PermissionChecker::class, $this->checker); + self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class)); + } + + public function testSuperUserCanManageAnyResource(): void + { + $admin = new Administrator(); + $admin->setSuperUser(true); + $resource = $this->createMock(SubscriberList::class); + $this->assertTrue($this->checker->canManage($admin, $resource)); + } +} From f9d83bc3215c8b3bdfde14d6cb5d6a753e54ced8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 5 Sep 2025 12:12:38 +0400 Subject: [PATCH 5/5] Style fix --- src/Domain/Common/Model/Interfaces/OwnableInterface.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php index f1b72728..16e54e40 100644 --- a/src/Domain/Common/Model/Interfaces/OwnableInterface.php +++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Common\Model\Interfaces; - use PhpList\Core\Domain\Identity\Model\Administrator; interface OwnableInterface