From ebb468e8b6a2ebbbede08d285035ae7d7d118cbd Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 12 May 2026 16:36:08 +0200 Subject: [PATCH 1/5] Allow class FQCN strings as authorization resources in OrmResolver and MapResolver Enables the long-requested pattern from issue #135: $user->can('add', Article::class); which is the natural shape for menu/button visibility checks and any authorization gate that runs before an entity instance is on hand. OrmResolver: a string matching one of the standard entity/table namespace markers (\Model\Entity\ or \Model\Table\) is decomposed into namespace + name and routed through the existing findPolicy() conventions. MapResolver: a string that resolves to an existing class is treated as the map key. Non-class strings still raise InvalidArgumentException; valid class strings without a registered policy raise MissingPolicyException, in line with the object case. --- src/Policy/MapResolver.php | 21 ++++++++++---- src/Policy/OrmResolver.php | 35 ++++++++++++++++++++++- tests/TestCase/Policy/MapResolverTest.php | 21 +++++++++++++- tests/TestCase/Policy/OrmResolverTest.php | 31 ++++++++++++++++++++ 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/Policy/MapResolver.php b/src/Policy/MapResolver.php index 170ed71..24b7d40 100644 --- a/src/Policy/MapResolver.php +++ b/src/Policy/MapResolver.php @@ -89,20 +89,29 @@ public function map(string $resourceClass, callable|object|string $policy) /** * {@inheritDoc} * - * @throws \InvalidArgumentException When a resource is not an object. + * Accepts either an object instance or a class FQCN string registered in + * the map. Strings that are not valid class names continue to raise + * InvalidArgumentException. + * + * @throws \InvalidArgumentException When a resource is neither an object nor a class FQCN string. * @throws \Authorization\Policy\Exception\MissingPolicyException When a policy for a resource has not been defined. */ public function getPolicy($resource): mixed { - if (!is_object($resource)) { - $message = sprintf('Resource must be an object, `%s` given.', gettype($resource)); + if (is_object($resource)) { + $class = $resource::class; + } elseif (is_string($resource) && class_exists($resource)) { + $class = $resource; + } else { + $message = sprintf( + 'Resource must be an object or class FQCN string, `%s` given.', + is_string($resource) ? $resource : gettype($resource), + ); throw new InvalidArgumentException($message); } - $class = $resource::class; - if (!isset($this->map[$class])) { - throw new MissingPolicyException($resource); + throw new MissingPolicyException(is_object($resource) ? $resource : [$class]); } $policy = $this->map[$class]; diff --git a/src/Policy/OrmResolver.php b/src/Policy/OrmResolver.php index d52aedd..3e2f615 100644 --- a/src/Policy/OrmResolver.php +++ b/src/Policy/OrmResolver.php @@ -65,7 +65,11 @@ public function __construct( } /** - * Get a policy for an ORM Table, Entity or Query. + * Get a policy for an ORM Table, Entity, Query or class name string. + * + * Accepting an entity/table FQCN string as the resource allows checks + * like `$user->can('add', Article::class)` where no instance is on hand + * (e.g. menu rendering before a `newEmptyEntity()`). * * @param mixed $resource The resource. * @return mixed @@ -88,10 +92,39 @@ public function getPolicy(mixed $resource): mixed return $this->getRepositoryPolicy($repo); } + if (is_string($resource) && class_exists($resource)) { + return $this->getPolicyByClassName($resource); + } throw new MissingPolicyException([get_debug_type($resource)]); } + /** + * Locate a policy from a class name string by matching the standard + * entity/table namespace markers. + * + * @param string $class The fully qualified class name. + * @return mixed + * @throws \Authorization\Policy\Exception\MissingPolicyException When the + * string does not match an entity/table namespace pattern or no policy + * exists at the conventional location. + */ + protected function getPolicyByClassName(string $class): mixed + { + foreach (['\Model\Entity\\', '\Model\Table\\'] as $marker) { + $pos = strpos($class, $marker); + if ($pos === false) { + continue; + } + $namespace = str_replace('\\', '/', substr($class, 0, $pos)); + $name = str_replace('\\', '/', substr($class, $pos + strlen($marker))); + + return $this->findPolicy($class, $name, $namespace); + } + + throw new MissingPolicyException([$class]); + } + /** * Get a policy for an entity * diff --git a/tests/TestCase/Policy/MapResolverTest.php b/tests/TestCase/Policy/MapResolverTest.php index abbf2f5..39668b2 100644 --- a/tests/TestCase/Policy/MapResolverTest.php +++ b/tests/TestCase/Policy/MapResolverTest.php @@ -92,11 +92,30 @@ public function testGetPolicyPrimitive(): void $resolver = new MapResolver(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Resource must be an object, `string` given.'); + $this->expectExceptionMessage('Resource must be an object or class FQCN string, `Foo` given.'); $resolver->getPolicy('Foo'); } + public function testGetPolicyClassNameAsResource(): void + { + $resolver = new MapResolver(); + $resolver->map(Article::class, ArticlePolicy::class); + + $result = $resolver->getPolicy(Article::class); + $this->assertInstanceOf(ArticlePolicy::class, $result); + } + + public function testGetPolicyUnregisteredClassString(): void + { + $resolver = new MapResolver(); + + $this->expectException(MissingPolicyException::class); + $this->expectExceptionMessage('Policy for `TestApp\Model\Entity\Article` has not been defined.'); + + $resolver->getPolicy(Article::class); + } + public function testGetPolicyMissing(): void { $resolver = new MapResolver(); diff --git a/tests/TestCase/Policy/OrmResolverTest.php b/tests/TestCase/Policy/OrmResolverTest.php index b8d19c7..617ca64 100644 --- a/tests/TestCase/Policy/OrmResolverTest.php +++ b/tests/TestCase/Policy/OrmResolverTest.php @@ -28,6 +28,7 @@ use OverridePlugin\Policy\TagPolicy as OverrideTagPolicy; use stdClass; use TestApp\Model\Entity\Article; +use TestApp\Model\Table\ArticlesTable; use TestApp\Policy\ArticlePolicy; use TestApp\Policy\ArticlesTablePolicy; use TestApp\Policy\TestPlugin\BookmarkPolicy; @@ -125,6 +126,36 @@ public function testGetPolicyUnknownTable(): void $resolver->getPolicy($articles); } + public function testGetPolicyFromEntityClassString(): void + { + $resolver = new OrmResolver('TestApp'); + $policy = $resolver->getPolicy(Article::class); + $this->assertInstanceOf(ArticlePolicy::class, $policy); + } + + public function testGetPolicyFromTableClassString(): void + { + $resolver = new OrmResolver('TestApp'); + $policy = $resolver->getPolicy(ArticlesTable::class); + $this->assertInstanceOf(ArticlesTablePolicy::class, $policy); + } + + public function testGetPolicyFromUnrelatedClassString(): void + { + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy(TestService::class); + } + + public function testGetPolicyFromNonClassString(): void + { + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy('NotAClassName'); + } + public function testGetPolicyViaDIC(): void { $container = new Container(); From 1a784e461c662484588252f558f1d1f28f5ecbf5 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 10 Jun 2026 05:00:59 +0200 Subject: [PATCH 2/5] Update src/Policy/MapResolver.php Co-authored-by: Mark Story --- src/Policy/MapResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Policy/MapResolver.php b/src/Policy/MapResolver.php index 24b7d40..6df2c57 100644 --- a/src/Policy/MapResolver.php +++ b/src/Policy/MapResolver.php @@ -104,7 +104,7 @@ public function getPolicy($resource): mixed $class = $resource; } else { $message = sprintf( - 'Resource must be an object or class FQCN string, `%s` given.', + 'Resource must be an object or fully-qualified class name, `%s` given.', is_string($resource) ? $resource : gettype($resource), ); throw new InvalidArgumentException($message); From b046a16bc143032debf849015078e9a482fbba37 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 10 Jun 2026 05:01:06 +0200 Subject: [PATCH 3/5] Update src/Policy/OrmResolver.php Co-authored-by: Mark Story --- src/Policy/OrmResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Policy/OrmResolver.php b/src/Policy/OrmResolver.php index fd6c830..a852820 100644 --- a/src/Policy/OrmResolver.php +++ b/src/Policy/OrmResolver.php @@ -67,7 +67,7 @@ public function __construct( /** * Get a policy for an ORM Table, Entity, Query or class name string. * - * Accepting an entity/table FQCN string as the resource allows checks + * Accepting an entity/table fully-qualified class name as the resource allows checks * like `$user->can('add', Article::class)` where no instance is on hand * (e.g. menu rendering before a `newEmptyEntity()`). * From d33c4d39ebb2d1825d6788009164fb69b876a577 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 11 Jun 2026 13:42:00 +0200 Subject: [PATCH 4/5] Sync test assertion with spelled-out resource message --- src/Policy/MapResolver.php | 4 ++-- tests/TestCase/Policy/MapResolverTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Policy/MapResolver.php b/src/Policy/MapResolver.php index 6df2c57..2a9b209 100644 --- a/src/Policy/MapResolver.php +++ b/src/Policy/MapResolver.php @@ -89,11 +89,11 @@ public function map(string $resourceClass, callable|object|string $policy) /** * {@inheritDoc} * - * Accepts either an object instance or a class FQCN string registered in + * Accepts either an object instance or a fully-qualified class name registered in * the map. Strings that are not valid class names continue to raise * InvalidArgumentException. * - * @throws \InvalidArgumentException When a resource is neither an object nor a class FQCN string. + * @throws \InvalidArgumentException When a resource is neither an object nor a fully-qualified class name. * @throws \Authorization\Policy\Exception\MissingPolicyException When a policy for a resource has not been defined. */ public function getPolicy($resource): mixed diff --git a/tests/TestCase/Policy/MapResolverTest.php b/tests/TestCase/Policy/MapResolverTest.php index 39668b2..82325fb 100644 --- a/tests/TestCase/Policy/MapResolverTest.php +++ b/tests/TestCase/Policy/MapResolverTest.php @@ -92,7 +92,7 @@ public function testGetPolicyPrimitive(): void $resolver = new MapResolver(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Resource must be an object or class FQCN string, `Foo` given.'); + $this->expectExceptionMessage('Resource must be an object or fully-qualified class name, `Foo` given.'); $resolver->getPolicy('Foo'); } From 56c6cf3ef04b4706cab8db51794859d16bca2673 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 11 Jun 2026 14:31:23 +0200 Subject: [PATCH 5/5] Discriminate class-string resources by interface, not namespace substring Use is_subclass_of() against EntityInterface/RepositoryInterface to decide whether a class-name resource is an entity or a table, instead of substring matching the \Model\Entity\ / \Model\Table\ markers. The string path now folds into the existing getEntityPolicy()/getRepositoryPolicy() methods, so string and instance resolution share one body and getPolicyByClassName() is removed. This rejects classes that merely live under the entity/table namespace but do not implement the interface (covered by the new FakeEntity regression test), and keeps the namespace extraction that findPolicy() already relied on. Note for release: getEntityPolicy()/getRepositoryPolicy() change their protected signature from the object instance to a class-name string. Any subclass overriding these protected methods must update its signature. Minor BC break, release-notes worthy. --- src/Policy/OrmResolver.php | 57 ++++++------------- tests/TestCase/Policy/OrmResolverTest.php | 12 ++++ .../TestApp/Model/Entity/FakeEntity.php | 15 +++++ .../TestApp/Policy/FakeEntityPolicy.php | 14 +++++ 4 files changed, 59 insertions(+), 39 deletions(-) create mode 100644 tests/test_app/TestApp/Model/Entity/FakeEntity.php create mode 100644 tests/test_app/TestApp/Policy/FakeEntityPolicy.php diff --git a/src/Policy/OrmResolver.php b/src/Policy/OrmResolver.php index a852820..a078802 100644 --- a/src/Policy/OrmResolver.php +++ b/src/Policy/OrmResolver.php @@ -79,10 +79,10 @@ public function __construct( public function getPolicy(mixed $resource): mixed { if ($resource instanceof EntityInterface) { - return $this->getEntityPolicy($resource); + return $this->getEntityPolicy($resource::class); } if ($resource instanceof RepositoryInterface) { - return $this->getRepositoryPolicy($resource); + return $this->getRepositoryPolicy($resource::class); } if ($resource instanceof QueryInterface) { $repo = $resource->getRepository(); @@ -90,69 +90,48 @@ public function getPolicy(mixed $resource): mixed throw new RuntimeException('No repository set for the query.'); } - return $this->getRepositoryPolicy($repo); + return $this->getRepositoryPolicy($repo::class); } if (is_string($resource) && class_exists($resource)) { - return $this->getPolicyByClassName($resource); - } - - throw new MissingPolicyException([get_debug_type($resource)]); - } - - /** - * Locate a policy from a class name string by matching the standard - * entity/table namespace markers. - * - * @param string $class The fully qualified class name. - * @return mixed - * @throws \Authorization\Policy\Exception\MissingPolicyException When the - * string does not match an entity/table namespace pattern or no policy - * exists at the conventional location. - */ - protected function getPolicyByClassName(string $class): mixed - { - foreach (['\Model\Entity\\', '\Model\Table\\'] as $marker) { - $pos = strpos($class, $marker); - if ($pos === false) { - continue; + if (is_subclass_of($resource, EntityInterface::class)) { + return $this->getEntityPolicy($resource); + } + if (is_subclass_of($resource, RepositoryInterface::class)) { + return $this->getRepositoryPolicy($resource); } - $namespace = str_replace('\\', '/', substr($class, 0, $pos)); - $name = str_replace('\\', '/', substr($class, $pos + strlen($marker))); - return $this->findPolicy($class, $name, $namespace); + throw new MissingPolicyException([$resource]); } - throw new MissingPolicyException([$class]); + throw new MissingPolicyException([get_debug_type($resource)]); } /** - * Get a policy for an entity + * Get a policy for an entity class. * - * @param \Cake\Datasource\EntityInterface $entity The entity to get a policy for + * @param string $class The entity class name to get a policy for. * @return mixed */ - protected function getEntityPolicy(EntityInterface $entity): mixed + protected function getEntityPolicy(string $class): mixed { - $class = $entity::class; $entityNamespace = '\Model\Entity\\'; $namespace = str_replace('\\', '/', substr($class, 0, (int)strpos($class, $entityNamespace))); - $name = str_replace('\\', '/', substr($class, strpos($class, $entityNamespace) + strlen($entityNamespace))); + $name = str_replace('\\', '/', substr($class, (int)strpos($class, $entityNamespace) + strlen($entityNamespace))); return $this->findPolicy($class, $name, $namespace); } /** - * Get a policy for a table + * Get a policy for a table/repository class. * - * @param \Cake\Datasource\RepositoryInterface $table The table/repository to get a policy for. + * @param string $class The table/repository class name to get a policy for. * @return mixed */ - protected function getRepositoryPolicy(RepositoryInterface $table): mixed + protected function getRepositoryPolicy(string $class): mixed { - $class = $table::class; $tableNamespace = '\Model\Table\\'; $namespace = str_replace('\\', '/', substr($class, 0, (int)strpos($class, $tableNamespace))); - $name = str_replace('\\', '/', substr($class, strpos($class, $tableNamespace) + strlen($tableNamespace))); + $name = str_replace('\\', '/', substr($class, (int)strpos($class, $tableNamespace) + strlen($tableNamespace))); return $this->findPolicy($class, $name, $namespace); } diff --git a/tests/TestCase/Policy/OrmResolverTest.php b/tests/TestCase/Policy/OrmResolverTest.php index 4636435..6ef74a9 100644 --- a/tests/TestCase/Policy/OrmResolverTest.php +++ b/tests/TestCase/Policy/OrmResolverTest.php @@ -28,6 +28,7 @@ use OverridePlugin\Policy\TagPolicy as OverrideTagPolicy; use stdClass; use TestApp\Model\Entity\Article; +use TestApp\Model\Entity\FakeEntity; use TestApp\Model\Entity\SubDir\Widget; use TestApp\Model\Table\ArticlesTable; use TestApp\Model\Table\SubDir\WidgetsTable; @@ -170,6 +171,17 @@ public function testGetPolicyFromUnrelatedClassString(): void $resolver->getPolicy(TestService::class); } + public function testGetPolicyFromEntityNamespacedNonEntityClassString(): void + { + // FakeEntity lives under `\Model\Entity\` and has a matching FakeEntityPolicy, + // but is not an EntityInterface - interface discrimination must reject it + // rather than wrongly resolving the policy via namespace matching. + $resolver = new OrmResolver('TestApp'); + + $this->expectException(MissingPolicyException::class); + $resolver->getPolicy(FakeEntity::class); + } + public function testGetPolicyFromNonClassString(): void { $resolver = new OrmResolver('TestApp'); diff --git a/tests/test_app/TestApp/Model/Entity/FakeEntity.php b/tests/test_app/TestApp/Model/Entity/FakeEntity.php new file mode 100644 index 0000000..e7e1d3b --- /dev/null +++ b/tests/test_app/TestApp/Model/Entity/FakeEntity.php @@ -0,0 +1,15 @@ +