From f0ce3828d059be045b755f361e78122cce261e59 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Thu, 5 Feb 2026 15:42:34 -0500 Subject: [PATCH 01/12] Updated middleware to do nothing when there is already an authenticated identity. Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/ApiAccessKeyAuthenticationMiddleware.php | 3 +- src/LaminasAuthenticationMiddleware.php | 6 ++++ ...iAccessKeyAuthenticationMiddlewareTest.php | 31 +++++++++++++++++-- test/LaminasAuthenticationMiddlewareTest.php | 11 +++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/ApiAccessKeyAuthenticationMiddleware.php b/src/ApiAccessKeyAuthenticationMiddleware.php index d74fe46..5b4841c 100644 --- a/src/ApiAccessKeyAuthenticationMiddleware.php +++ b/src/ApiAccessKeyAuthenticationMiddleware.php @@ -29,7 +29,8 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // if there is already an identity, do nothing - if ($request->getAttribute(IdentityInterface::class) !== null) { + $identity = $request->getAttribute(IdentityInterface::class); + if ($identity !== null && ! $identity instanceof GuestIdentity) { return $handler->handle($request); } diff --git a/src/LaminasAuthenticationMiddleware.php b/src/LaminasAuthenticationMiddleware.php index 88629a8..56e2809 100644 --- a/src/LaminasAuthenticationMiddleware.php +++ b/src/LaminasAuthenticationMiddleware.php @@ -27,6 +27,12 @@ public function __construct( #[Override] public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + // if there is already an identity, do nothing + $identity = $request->getAttribute(IdentityInterface::class); + if ($identity !== null && ! $identity instanceof GuestIdentity) { + return $handler->handle($request); + } + if ($this->authenticationService->hasIdentity()) { /** @var mixed $identity */ $identity = $this->authenticationService->getIdentity(); diff --git a/test/ApiAccessKeyAuthenticationMiddlewareTest.php b/test/ApiAccessKeyAuthenticationMiddlewareTest.php index 5b5bb19..d155a50 100644 --- a/test/ApiAccessKeyAuthenticationMiddlewareTest.php +++ b/test/ApiAccessKeyAuthenticationMiddlewareTest.php @@ -35,7 +35,7 @@ public function testWithIdentity(): void { $this->request->expects($this->once())->method('getAttribute') ->with(IdentityInterface::class) - ->willReturn(new GuestIdentity()); + ->willReturn(new AuthenticatedIdentity('foo')); $this->handler->expects($this->once())->method('handle')->with($this->request); $middleware = new ApiAccessKeyAuthenticationMiddleware( $this->createMock(ApiAccessKeyRepositoryInterface::class), @@ -109,7 +109,7 @@ public function testWithNotMatchingSecret(): void $middleware->process($this->request, $this->handler); } - public function testWithValidIdentity(): void + public function testWithValidCredentials(): void { $apiAccessKey = $this->createMock(ApiAccessKeyInterface::class); $apiAccessKey->expects($this->once())->method('getClientSecret') @@ -135,4 +135,31 @@ public function testWithValidIdentity(): void $middleware = new ApiAccessKeyAuthenticationMiddleware($apiRepository); $middleware->process($this->request, $this->handler); } + + public function testWithValidCredentialsGuestIdentity(): void + { + $apiAccessKey = $this->createMock(ApiAccessKeyInterface::class); + $apiAccessKey->expects($this->once())->method('getClientSecret') + ->willReturn('bar'); + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(new GuestIdentity()); + $this->request->expects($this->exactly(2))->method('getQueryParams') + ->willReturn([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + ]); + $apiRepository = $this->createMock(ApiAccessKeyRepositoryInterface::class); + $apiRepository->expects($this->once())->method('getByClientId') + ->with('foo')->willReturn($apiAccessKey); + $this->request->expects($this->once())->method('withAttribute') + ->with( + IdentityInterface::class, + $this->isInstanceOf(AuthenticatedIdentity::class), + ) + ->willReturnSelf(); + $this->handler->expects($this->once())->method('handle'); + $middleware = new ApiAccessKeyAuthenticationMiddleware($apiRepository); + $middleware->process($this->request, $this->handler); + } } diff --git a/test/LaminasAuthenticationMiddlewareTest.php b/test/LaminasAuthenticationMiddlewareTest.php index 428cc1a..2863abe 100644 --- a/test/LaminasAuthenticationMiddlewareTest.php +++ b/test/LaminasAuthenticationMiddlewareTest.php @@ -31,6 +31,17 @@ protected function setUp(): void parent::setUp(); } + #[AllowMockObjectsWithoutExpectations] + public function testWhenAlreadyIdentityPresentInAttributes(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(new AuthenticatedIdentity('foo')); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new LaminasAuthenticationMiddleware($this->authenticationService); + $middleware->process($this->request, $this->handler); + } + #[AllowMockObjectsWithoutExpectations] public function testWithoutIdentity(): void { From f126fef109f0ac3d0f4f10e99dd003bb2f4d16bd Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 10:31:58 -0500 Subject: [PATCH 02/12] Adding authorization handling Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- composer.json | 4 +- composer.lock | 437 +++++++++++++----- src/Authorization/AclAuthorization.php | 26 ++ src/Authorization/AclAuthorizationFactory.php | 170 +++++++ src/Authorization/AuthorizationInterface.php | 10 + src/AuthorizationRpcMiddleware.php | 54 +++ src/AuthorizationRpcMiddlewareFactory.php | 20 + src/ConfigProvider.php | 61 ++- src/Identity/IdentityInterface.php | 4 +- 9 files changed, 665 insertions(+), 121 deletions(-) create mode 100644 src/Authorization/AclAuthorization.php create mode 100644 src/Authorization/AclAuthorizationFactory.php create mode 100644 src/Authorization/AuthorizationInterface.php create mode 100644 src/AuthorizationRpcMiddleware.php create mode 100644 src/AuthorizationRpcMiddlewareFactory.php diff --git a/composer.json b/composer.json index 6946796..dddd372 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,9 @@ "laminas/laminas-authentication": "^2.13", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", - "psr/container": "^1.1 || ^2.0" + "psr/container": "^1.1 || ^2.0", + "laminas/laminas-permissions-acl": "^2.18", + "mezzio/mezzio-router": "^4.2" }, "require-dev": { "laminas/laminas-coding-standard": "^3.1", diff --git a/composer.lock b/composer.lock index 5364541..c92a055 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,64 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f350a828c68de7d7d2603d2e2b5ce95a", + "content-hash": "89e02969d47049a1d9d3639d282c4590", "packages": [ + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, { "name": "laminas/laminas-authentication", "version": "2.19.0", @@ -80,6 +136,70 @@ ], "time": "2025-10-17T15:08:49+00:00" }, + { + "name": "laminas/laminas-permissions-acl", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-permissions-acl.git", + "reference": "5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-permissions-acl/zipball/5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3", + "reference": "5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "laminas/laminas-servicemanager": "<3.0", + "zendframework/zend-permissions-acl": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-servicemanager": "^3.21", + "phpbench/phpbench": "^1.2.10", + "phpunit/phpunit": "^10.5.58", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "laminas/laminas-servicemanager": "To support Laminas\\Permissions\\Acl\\Assertion\\AssertionManager plugin manager usage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Permissions\\Acl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Provides a lightweight and flexible access control list (ACL) implementation for privileges management", + "homepage": "https://laminas.dev", + "keywords": [ + "acl", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-permissions-acl/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-permissions-acl/issues", + "rss": "https://github.com/laminas/laminas-permissions-acl/releases.atom", + "source": "https://github.com/laminas/laminas-permissions-acl" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-11-03T09:15:20+00:00" + }, { "name": "laminas/laminas-stdlib", "version": "3.21.0", @@ -139,6 +259,87 @@ ], "time": "2025-10-11T18:13:12+00:00" }, + { + "name": "mezzio/mezzio-router", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-router.git", + "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/e7a6505d7e460ce30c4135e146241d7d24d81c27", + "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.1.2 || ^2.0", + "psr/http-factory": "^1.0.2", + "psr/http-message": "^1.0.1 || ^2.0.0", + "psr/http-server-middleware": "^1.0.2", + "webmozart/assert": "^1.11.0 || ^2.1.1" + }, + "conflict": { + "mezzio/mezzio": "<3.5", + "zendframework/zend-expressive-router": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "laminas/laminas-servicemanager": "^4.4.0", + "laminas/laminas-stratigility": "^4.2.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "mezzio/mezzio-fastroute": "^3.0 to use the FastRoute routing adapter", + "mezzio/mezzio-laminasrouter": "^3.0 to use the laminas-router routing adapter" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\Router\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\Router\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Router subcomponent for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "http", + "laminas", + "mezzio", + "middleware", + "psr", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/router/intro/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-router/issues", + "rss": "https://github.com/mezzio/mezzio-router/releases.atom", + "source": "https://github.com/mezzio/mezzio-router" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2026-01-09T11:44:24+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -192,6 +393,61 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, { "name": "psr/http-message", "version": "2.0", @@ -357,6 +613,68 @@ "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" }, "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.2" + }, + "time": "2026-01-13T14:02:24+00:00" } ], "packages-dev": [ @@ -3084,61 +3402,6 @@ }, "time": "2025-03-31T18:49:55+00:00" }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, { "name": "psr/log", "version": "3.0.2", @@ -5472,68 +5735,6 @@ } ], "time": "2024-10-16T06:55:17+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" - }, - "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/src/Authorization/AclAuthorization.php b/src/Authorization/AclAuthorization.php new file mode 100644 index 0000000..e925e58 --- /dev/null +++ b/src/Authorization/AclAuthorization.php @@ -0,0 +1,26 @@ +hasResource($resource))) { + $this->addResource($resource); + } + + if (! $this->hasRole($identity)) { + $this->addRole($identity); + } + + return $this->isAllowed($identity, $resource, $privilege); + } +} diff --git a/src/Authorization/AclAuthorizationFactory.php b/src/Authorization/AclAuthorizationFactory.php new file mode 100644 index 0000000..904cc2a --- /dev/null +++ b/src/Authorization/AclAuthorizationFactory.php @@ -0,0 +1,170 @@ + true, + 'GET' => true, + 'PATCH' => true, + 'POST' => true, + 'PUT' => true, + ]; + + public function __invoke(ContainerInterface $container): AclAuthorization + { + /** @var array $config */ + $config = $container->get('config'); + $config = $config['lmc_api']['authorization']['authorization']; + return $this->createAclFromConfig($config); + } + + private function createAclFromConfig(mixed $config): AclAuthorization + { + $aclConfig = []; + $denyByDefault = false; + + if (array_key_exists('deny_by_default', $config)) { + $denyByDefault = $aclConfig['deny_by_default'] = (bool) $config['deny_by_default']; + unset($config['deny_by_default']); + } + + foreach ($config as $routeName => $privileges) { + $this->createAclConfigFromPrivileges($routeName, $privileges, $aclConfig, $denyByDefault); + } + + return $this->createAclInstance($aclConfig); + } + + private function createAclConfigFromPrivileges( + string $routeName, + array $privileges, + array &$aclConfig, + bool $denyByDefault + ): void { + if (isset($privileges['actions'])) { + foreach ($privileges['actions'] as $action => $methods) { + $action = lcfirst($action); + $aclConfig[] = [ + 'resource' => sprintf('%s::%s', $routeName, $action), + 'privileges' => $this->createPrivilegesFromMethods($methods, $denyByDefault), + ]; + } + } + + if (isset($privileges['collection'])) { + $aclConfig[] = [ + 'resource' => sprintf('%s::collection', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['collection'], $denyByDefault), + ]; + } + + if (isset($privileges['entity'])) { + $aclConfig[] = [ + 'resource' => sprintf('%s::entity', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['entity'], $denyByDefault), + ]; + } + } + + private function createPrivilegesFromMethods(array $methods, bool $denyByDefault): array|null + { + $privileges = []; + + if (isset($methods['default']) && $methods['default']) { + $privileges = $this->httpMethods; + unset($methods['default']); + } + + foreach ($methods as $method => $flag) { + // If the flag evaluates true, and we're denying by default, OR + // if the flag evaluates false, and we're allowing by default, + // THEN no rule needs to be added + if ( + ( $denyByDefault && $flag) + || (! $denyByDefault && ! $flag) + ) { + if (isset($privileges[$method])) { + unset($privileges[$method]); + } + continue; + } + + // Otherwise, we need to add a rule + $privileges[$method] = true; + } + + if (empty($privileges)) { + return null; + } + + return array_keys($privileges); + } + + private function createAclInstance(array $config): AclAuthorization + { + // Determine whether we are whitelisting or blacklisting + $denyByDefault = false; + if (array_key_exists('deny_by_default', $config)) { + $denyByDefault = (bool) $config['deny_by_default']; + unset($config['deny_by_default']); + } + + // By default, create an open ACL + $acl = new AclAuthorization(); + $acl->addRole('guest'); + $acl->allow(); + + $grant = 'deny'; + if ($denyByDefault) { + $acl->deny('guest', null, null); + $grant = 'allow'; + } + + if (! empty($config)) { + return $this->injectGrants($acl, $grant, $config); + } + + return $acl; + } + + private function injectGrants(AclAuthorization $acl, string $grantType, array $rules): AclAuthorization + { + foreach ($rules as $set) { + if (! is_array($set) || ! isset($set['resource'])) { + continue; + } + + $this->injectGrant($acl, $grantType, $set); + } + + return $acl; + } + + private function injectGrant(AclAuthorization $acl, string $grantType, array $ruleSet): void + { + // Add new resource to ACL + $resource = $ruleSet['resource']; + $acl->addResource($ruleSet['resource']); + + // Deny guest specified privileges to resource + $privileges = $ruleSet['privileges'] ?? null; + + // null privileges means no permissions were setup; nothing to do + if (null === $privileges) { + return; + } + $acl->$grantType('guest', $resource, $privileges); + } +} diff --git a/src/Authorization/AuthorizationInterface.php b/src/Authorization/AuthorizationInterface.php new file mode 100644 index 0000000..a6a9540 --- /dev/null +++ b/src/Authorization/AuthorizationInterface.php @@ -0,0 +1,10 @@ +getAttribute(IdentityInterface::class); + if (! $identity instanceof IdentityInterface) { + return $handler->handle($request); + } + + /** @var ?RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (null === $routeResult) { + return $handler->handle($request); + } + $routeMatchName = $routeResult->getMatchedRouteName(); + + if (false === $routeMatchName) { + return $handler->handle($request); + } + + $resource = sprintf('%s::%s', $routeMatchName, $request->getMethod()); + + if ($this->authorization->isAuthorized($identity, $resource, $request->getMethod())) { + return $handler->handle($request); + } + + return $this->responseFactory->createResponse(403, 'Forbidden'); + } +} diff --git a/src/AuthorizationRpcMiddlewareFactory.php b/src/AuthorizationRpcMiddlewareFactory.php new file mode 100644 index 0000000..21cbfea --- /dev/null +++ b/src/AuthorizationRpcMiddlewareFactory.php @@ -0,0 +1,20 @@ +get(AuthorizationInterface::class), + $container->get(ResponseFactoryInterface::class), + ); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index d60005c..e08b31e 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -4,6 +4,10 @@ namespace Lmc\Api\Auth; +use Lmc\Api\Auth\Authorization\AclAuthorization; +use Lmc\Api\Auth\Authorization\AclAuthorizationFactory; +use Lmc\Api\Auth\Authorization\AuthorizationInterface; + final class ConfigProvider { public function __invoke(): array @@ -17,15 +21,70 @@ public function __invoke(): array private function getDependencies(): array { return [ + 'aliases' => [ + AuthorizationInterface::class => AclAuthorization::class, + ], 'factories' => [ + AclAuthorization::class => AclAuthorizationFactory::class, ApiAccessKeyAuthenticationMiddleware::class => ApiAccessKeyAuthenticationMiddlewareFactory::class, LaminasAuthenticationMiddleware::class => LaminasAuthenticationMiddlewareFactory::class, + AuthorizationRpcMiddleware::class => AuthorizationRpcMiddlewareFactory::class, ], ]; } private function getConfig(): array { - return []; + return [ + 'authentication' => [ + 'authorization' => [ + // Toggle the following to true to change the ACL creation to + // require an authenticated user by default, and thus selectively + // allow unauthenticated users based on the rules. + 'deny_by_default' => false, + + /* + * Rules indicating what routes are behind authentication. + * + * Keys are route names. + * + * Values are arrays with either the key "actions" and/or one or + * more of the keys "collection" and "entity". + * + * The "actions" key will be a set of action name/method pairs. + * The "collection" and "entity" keys will have method values. + * + * Method values are arrays of HTTP method/boolean pairs. By + * default, if an HTTP method is not present in the list, it is + * assumed to be open (i.e., not require authentication). The + * special key "default" can be used to set the default flag for + * all HTTP methods. + * + 'route_name' => [ + 'actions' => [ + 'action' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + ], + 'collection' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + 'entity' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + ], + */ + ], + ], + ]; } } diff --git a/src/Identity/IdentityInterface.php b/src/Identity/IdentityInterface.php index c17d098..dfe4844 100644 --- a/src/Identity/IdentityInterface.php +++ b/src/Identity/IdentityInterface.php @@ -4,7 +4,9 @@ namespace Lmc\Api\Auth\Identity; -interface IdentityInterface +use Laminas\Permissions\Acl\Role\RoleInterface; + +interface IdentityInterface extends RoleInterface { public function getAuthenticationIdentity(): mixed; } From 7052219e3b10d7bda8a221b888f9466e0285fe3d Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 10:36:34 -0500 Subject: [PATCH 03/12] Updated mezzio router to v3 Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- composer.json | 2 +- composer.lock | 88 ++++++++++++++++++++++++++++----------------------- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index dddd372..98a46a6 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "psr/http-server-middleware": "^1.0", "psr/container": "^1.1 || ^2.0", "laminas/laminas-permissions-acl": "^2.18", - "mezzio/mezzio-router": "^4.2" + "mezzio/mezzio-router": "^3.19" }, "require-dev": { "laminas/laminas-coding-standard": "^3.1", diff --git a/composer.lock b/composer.lock index c92a055..2dc0011 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "89e02969d47049a1d9d3639d282c4590", + "content-hash": "7d76e1f130dcd6b1577694733cd2f931", "packages": [ { "name": "fig/http-message-util", @@ -261,16 +261,16 @@ }, { "name": "mezzio/mezzio-router", - "version": "4.2.0", + "version": "3.19.0", "source": { "type": "git", "url": "https://github.com/mezzio/mezzio-router.git", - "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27" + "reference": "3df4363e70611ddf096db95c62df6aa98817872c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/e7a6505d7e460ce30c4135e146241d7d24d81c27", - "reference": "e7a6505d7e460ce30c4135e146241d7d24d81c27", + "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/3df4363e70611ddf096db95c62df6aa98817872c", + "reference": "3df4363e70611ddf096db95c62df6aa98817872c", "shasum": "" }, "require": { @@ -280,7 +280,7 @@ "psr/http-factory": "^1.0.2", "psr/http-message": "^1.0.1 || ^2.0.0", "psr/http-server-middleware": "^1.0.2", - "webmozart/assert": "^1.11.0 || ^2.1.1" + "webmozart/assert": "^1.11" }, "conflict": { "mezzio/mezzio": "<3.5", @@ -292,10 +292,11 @@ "laminas/laminas-servicemanager": "^4.4.0", "laminas/laminas-stratigility": "^4.2.0", "phpunit/phpunit": "^11.5.42", - "psalm/plugin-phpunit": "^0.19.5", + "psalm/plugin-phpunit": "^0.19.0", "vimeo/psalm": "^6.13.1" }, "suggest": { + "mezzio/mezzio-aurarouter": "^3.0 to use the Aura.Router routing adapter", "mezzio/mezzio-fastroute": "^3.0 to use the FastRoute routing adapter", "mezzio/mezzio-laminasrouter": "^3.0 to use the laminas-router routing adapter" }, @@ -338,7 +339,7 @@ "type": "community_bridge" } ], - "time": "2026-01-09T11:44:24+00:00" + "time": "2025-10-11T08:41:44+00:00" }, { "name": "psr/container", @@ -616,23 +617,23 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^8.2" + "php": "^7.2 || ^8.0" }, "suggest": { "ext-intl": "", @@ -642,7 +643,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-feature/2-0": "2.0-dev" + "dev-master": "1.10-dev" } }, "autoload": { @@ -658,10 +659,6 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -672,9 +669,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2025-10-29T15:56:20+00:00" } ], "packages-dev": [ @@ -2907,16 +2904,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -2972,7 +2969,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -2992,20 +2989,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -3045,15 +3042,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -3241,16 +3250,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "83d4c158526c879b4c5cf7149d27958b6d912373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d4c158526c879b4c5cf7149d27958b6d912373", + "reference": "83d4c158526c879b4c5cf7149d27958b6d912373", "shasum": "" }, "require": { @@ -3265,7 +3274,7 @@ "phar-io/version": "^3.2.1", "php": ">=8.3", "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -3276,6 +3285,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -3318,7 +3328,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.9" }, "funding": [ { @@ -3342,7 +3352,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-05T08:01:09+00:00" }, { "name": "psalm/plugin-phpunit", From f8c471d82f7197c5bbf1edb8eeca3e026b1bd77e Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 11:25:42 -0500 Subject: [PATCH 04/12] Fixed issues for RPC authorization Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/Authorization/AclAuthorizationFactory.php | 16 ++++++++++------ src/ConfigProvider.php | 10 ++++------ src/Identity/AuthenticatedIdentity.php | 12 ++++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Authorization/AclAuthorizationFactory.php b/src/Authorization/AclAuthorizationFactory.php index 904cc2a..0ba129b 100644 --- a/src/Authorization/AclAuthorizationFactory.php +++ b/src/Authorization/AclAuthorizationFactory.php @@ -26,11 +26,12 @@ public function __invoke(ContainerInterface $container): AclAuthorization { /** @var array $config */ $config = $container->get('config'); - $config = $config['lmc_api']['authorization']['authorization']; - return $this->createAclFromConfig($config); + /** @var array $aclConfig */ + $aclConfig = $config['lmc_api']['authentication']['authorization']; + return $this->createAclFromConfig($aclConfig); } - private function createAclFromConfig(mixed $config): AclAuthorization + private function createAclFromConfig(array $config): AclAuthorization { $aclConfig = []; $denyByDefault = false; @@ -40,6 +41,10 @@ private function createAclFromConfig(mixed $config): AclAuthorization unset($config['deny_by_default']); } + /** + * @var string $routeName + * @var array $privileges + */ foreach ($config as $routeName => $privileges) { $this->createAclConfigFromPrivileges($routeName, $privileges, $aclConfig, $denyByDefault); } @@ -54,10 +59,9 @@ private function createAclConfigFromPrivileges( bool $denyByDefault ): void { if (isset($privileges['actions'])) { - foreach ($privileges['actions'] as $action => $methods) { - $action = lcfirst($action); + foreach ($privileges['actions'] as $methods) { $aclConfig[] = [ - 'resource' => sprintf('%s::%s', $routeName, $action), + 'resource' => sprintf('%s', $routeName), 'privileges' => $this->createPrivilegesFromMethods($methods, $denyByDefault), ]; } diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index e08b31e..c1323db 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -62,12 +62,10 @@ private function getConfig(): array * 'route_name' => [ 'actions' => [ - 'action' => [ - 'default' => boolean, - 'GET' => boolean, - 'POST' => boolean, - // etc. - ], + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. ], 'collection' => [ 'default' => boolean, diff --git a/src/Identity/AuthenticatedIdentity.php b/src/Identity/AuthenticatedIdentity.php index 136f807..194ca9a 100644 --- a/src/Identity/AuthenticatedIdentity.php +++ b/src/Identity/AuthenticatedIdentity.php @@ -6,13 +6,19 @@ use Override; +use function method_exists; + final class AuthenticatedIdentity implements IdentityInterface { protected mixed $identity; + protected string $name = ''; public function __construct(mixed $identity) { $this->identity = $identity; + if (method_exists($identity, 'getId')) { + $this->name = (string) $this->identity->getId(); + } } #[Override] @@ -20,4 +26,10 @@ public function getAuthenticationIdentity(): mixed { return $this->identity; } + + #[Override] + public function getRoleId(): string + { + return $this->name; + } } From 9fe97f25e5d96f7efcbfdb48fbf54d122934dd4a Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 11:47:03 -0500 Subject: [PATCH 05/12] Fixed issues for RPC authorization Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/AuthorizationRpcMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AuthorizationRpcMiddleware.php b/src/AuthorizationRpcMiddleware.php index 8956b40..d8bc002 100644 --- a/src/AuthorizationRpcMiddleware.php +++ b/src/AuthorizationRpcMiddleware.php @@ -43,7 +43,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } - $resource = sprintf('%s::%s', $routeMatchName, $request->getMethod()); + $resource = sprintf('%s', $routeMatchName); if ($this->authorization->isAuthorized($identity, $resource, $request->getMethod())) { return $handler->handle($request); From cc9163bfdb6ac32bc44ad1283882097bd7459068 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 11:59:07 -0500 Subject: [PATCH 06/12] Fixed issues for RPC authorization Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/Authorization/AclAuthorizationFactory.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Authorization/AclAuthorizationFactory.php b/src/Authorization/AclAuthorizationFactory.php index 0ba129b..8f0e353 100644 --- a/src/Authorization/AclAuthorizationFactory.php +++ b/src/Authorization/AclAuthorizationFactory.php @@ -59,12 +59,10 @@ private function createAclConfigFromPrivileges( bool $denyByDefault ): void { if (isset($privileges['actions'])) { - foreach ($privileges['actions'] as $methods) { - $aclConfig[] = [ - 'resource' => sprintf('%s', $routeName), - 'privileges' => $this->createPrivilegesFromMethods($methods, $denyByDefault), - ]; - } + $aclConfig[] = [ + 'resource' => sprintf('%s', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['actions'], $denyByDefault), + ]; } if (isset($privileges['collection'])) { From b93b21b5a98434cb10f217ffada4f60e46a44740 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 12:33:45 -0500 Subject: [PATCH 07/12] Added REST authorization Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/AuthorizationRestMiddleware.php | 74 ++++++++++++++++++++++ src/AuthorizationRestMiddlewareFactory.php | 27 ++++++++ src/ConfigProvider.php | 3 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/AuthorizationRestMiddleware.php create mode 100644 src/AuthorizationRestMiddlewareFactory.php diff --git a/src/AuthorizationRestMiddleware.php b/src/AuthorizationRestMiddleware.php new file mode 100644 index 0000000..6078d47 --- /dev/null +++ b/src/AuthorizationRestMiddleware.php @@ -0,0 +1,74 @@ +getAttribute(IdentityInterface::class); + if (! $identity instanceof IdentityInterface) { + return $handler->handle($request); + } + + /** @var ?RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (null === $routeResult) { + return $handler->handle($request); + } + $routeMatchName = $routeResult->getMatchedRouteName(); + + if (false === $routeMatchName) { + return $handler->handle($request); + } + + $resource = $this->buildResource($routeMatchName, $request); + + if ($this->authorization->isAuthorized($identity, $resource, $request->getMethod())) { + return $handler->handle($request); + } + + return $this->responseFactory->createResponse(403, 'Forbidden'); + } + + private function buildResource(string $routeMatchName, ServerRequestInterface $request): string + { + /** @var array|null $restConfig */ + $restConfig = $this->restConfig[$routeMatchName] ?? null; + if (null === $restConfig) { + // Return a resource name as if it was a RPC call + return sprintf('%s', $routeMatchName); + } + $identifier = $restConfig['route_identifier_name'] ?? null; + if (null === $identifier) { + // assume collection resource + return sprintf('%s::controller', $routeMatchName); + } + if ($request->getAttribute($identifier) !== null) { + return sprintf('%s::entity', $routeMatchName); + } + return sprintf('%s::controller', $routeMatchName); + } +} diff --git a/src/AuthorizationRestMiddlewareFactory.php b/src/AuthorizationRestMiddlewareFactory.php new file mode 100644 index 0000000..5c1fe95 --- /dev/null +++ b/src/AuthorizationRestMiddlewareFactory.php @@ -0,0 +1,27 @@ +get('config'); + /** @var array $config */ + $config = $config['lmc_api'] ?? []; + /** @var array $config */ + $config = $config['rest'] ?? []; + return new AuthorizationRestMiddleware( + $container->get(AuthorizationInterface::class), + $container->get(ResponseFactoryInterface::class), + $config, + ); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index c1323db..cc8575c 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -21,7 +21,7 @@ public function __invoke(): array private function getDependencies(): array { return [ - 'aliases' => [ + 'aliases' => [ AuthorizationInterface::class => AclAuthorization::class, ], 'factories' => [ @@ -29,6 +29,7 @@ private function getDependencies(): array ApiAccessKeyAuthenticationMiddleware::class => ApiAccessKeyAuthenticationMiddlewareFactory::class, LaminasAuthenticationMiddleware::class => LaminasAuthenticationMiddlewareFactory::class, AuthorizationRpcMiddleware::class => AuthorizationRpcMiddlewareFactory::class, + AuthorizationRestMiddleware::class => AuthorizationRestMiddlewareFactory::class, ], ]; } From b59aa53b73ad0f7a4eff8004b317bfb04550f6b8 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 6 Feb 2026 13:37:28 -0500 Subject: [PATCH 08/12] Added REST authorization Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/AuthorizationRestMiddleware.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AuthorizationRestMiddleware.php b/src/AuthorizationRestMiddleware.php index 6078d47..762a0c9 100644 --- a/src/AuthorizationRestMiddleware.php +++ b/src/AuthorizationRestMiddleware.php @@ -64,11 +64,11 @@ private function buildResource(string $routeMatchName, ServerRequestInterface $r $identifier = $restConfig['route_identifier_name'] ?? null; if (null === $identifier) { // assume collection resource - return sprintf('%s::controller', $routeMatchName); + return sprintf('%s::collection', $routeMatchName); } if ($request->getAttribute($identifier) !== null) { return sprintf('%s::entity', $routeMatchName); } - return sprintf('%s::controller', $routeMatchName); + return sprintf('%s::collection', $routeMatchName); } } From 965b3c1e43df3dbfa9744797dd31189456913c12 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 20 Feb 2026 15:22:41 -0500 Subject: [PATCH 09/12] Adding repository interface to retrieve an identity by user id Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/Repository/UserRepositoryInterface.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/Repository/UserRepositoryInterface.php diff --git a/src/Repository/UserRepositoryInterface.php b/src/Repository/UserRepositoryInterface.php new file mode 100644 index 0000000..6becf23 --- /dev/null +++ b/src/Repository/UserRepositoryInterface.php @@ -0,0 +1,12 @@ + Date: Fri, 20 Feb 2026 15:31:07 -0500 Subject: [PATCH 10/12] Adding repository interface to retrieve an identity by user id Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/Repository/UserRepositoryInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repository/UserRepositoryInterface.php b/src/Repository/UserRepositoryInterface.php index 6becf23..6d6899e 100644 --- a/src/Repository/UserRepositoryInterface.php +++ b/src/Repository/UserRepositoryInterface.php @@ -8,5 +8,5 @@ interface UserRepositoryInterface { - public function getByUserId(int|string $userId): IdentityInterface; + public function getByUserId(int|string $userId): ?IdentityInterface; } From 4aed297d2d1eedbcf7cf73a9d91715e5b43b7b5e Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Fri, 20 Feb 2026 15:57:04 -0500 Subject: [PATCH 11/12] Adding repository interface to retrieve an identity by user id Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- src/Repository/UserRepositoryInterface.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Repository/UserRepositoryInterface.php b/src/Repository/UserRepositoryInterface.php index 6d6899e..af893c6 100644 --- a/src/Repository/UserRepositoryInterface.php +++ b/src/Repository/UserRepositoryInterface.php @@ -4,9 +4,7 @@ namespace Lmc\Api\Auth\Repository; -use Lmc\Api\Auth\Identity\IdentityInterface; - interface UserRepositoryInterface { - public function getByUserId(int|string $userId): ?IdentityInterface; + public function getByUserId(int|string $userId): mixed; } From 6b12f4aa8fbdf7be8930ea87e88d8be43d62bc32 Mon Sep 17 00:00:00 2001 From: "Eric Richer eric.richer@vistoconsulting.com" Date: Mon, 23 Feb 2026 12:28:05 -0500 Subject: [PATCH 12/12] New version Signed-off-by: Eric Richer eric.richer@vistoconsulting.com --- .markdownlint.json | 3 + composer.json | 7 +- composer.lock | 92 ++++--- psalm.baseline.xml | 39 ++- src/Authorization/AclAuthorization.php | 4 +- src/Authorization/AclAuthorizationFactory.php | 23 +- src/Authorization/AuthorizationInterface.php | 4 +- src/AuthorizationRestMiddleware.php | 4 +- src/AuthorizationRestMiddlewareFactory.php | 7 + src/AuthorizationRpcMiddleware.php | 2 +- src/AuthorizationRpcMiddlewareFactory.php | 7 + src/Identity/GuestIdentity.php | 3 +- test/Assets/IdentityGetId.php | 28 ++ test/Assets/IdentityNoGetId.php | 9 + .../AclAuthorizationFactoryTest.php | 251 ++++++++++++++++++ ...AuthorizationRestMiddlewareFactoryTest.php | 45 ++++ test/AuthorizationRestMiddlewareTest.php | 77 ++++++ .../AuthorizationRpcMiddlewareFactoryTest.php | 37 +++ test/AuthorizationRpcMiddlewareTest.php | 118 ++++++++ test/Identity/AuthenticatedIdentityTest.php | 16 +- 20 files changed, 715 insertions(+), 61 deletions(-) create mode 100644 .markdownlint.json create mode 100644 test/Assets/IdentityGetId.php create mode 100644 test/Assets/IdentityNoGetId.php create mode 100644 test/Authorization/AclAuthorizationFactoryTest.php create mode 100644 test/AuthorizationRestMiddlewareFactoryTest.php create mode 100644 test/AuthorizationRestMiddlewareTest.php create mode 100644 test/AuthorizationRpcMiddlewareFactoryTest.php create mode 100644 test/AuthorizationRpcMiddlewareTest.php diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..31f5f69 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD014": false +} diff --git a/composer.json b/composer.json index 98a46a6..39c4f29 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,9 @@ }, "require-dev": { "laminas/laminas-coding-standard": "^3.1", - "phpunit/phpunit": "^12.5", + "phpunit/phpunit": "^12.5.14", "psalm/plugin-phpunit": "^0.19.5", - "vimeo/psalm": "6.14.3", + "vimeo/psalm": "6.15.1", "amphp/parallel": "^2.3.3", "amphp/dns": "^2.4.0", "amphp/socket": "^2.3.1" @@ -59,6 +59,7 @@ "cs-check": "phpcs", "cs-fix": "phpcbf", "test-coverage-html": "phpunit --coverage-html ./build/html", - "static-analysis": "psalm --shepherd --stats" + "static-analysis": "psalm --shepherd --stats", + "static-analysis-update-baseline": "psalm --shepherd --stats --update-baseline" } } diff --git a/composer.lock b/composer.lock index 2dc0011..65fb829 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7d76e1f130dcd6b1577694733cd2f931", + "content-hash": "2b31e66d17a297c0f61ae01480e00199", "packages": [ { "name": "fig/http-message-util", @@ -261,16 +261,16 @@ }, { "name": "mezzio/mezzio-router", - "version": "3.19.0", + "version": "3.20.0", "source": { "type": "git", "url": "https://github.com/mezzio/mezzio-router.git", - "reference": "3df4363e70611ddf096db95c62df6aa98817872c" + "reference": "be4de58dc8822b4af653dc554f963b4cb1e23355" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/3df4363e70611ddf096db95c62df6aa98817872c", - "reference": "3df4363e70611ddf096db95c62df6aa98817872c", + "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/be4de58dc8822b4af653dc554f963b4cb1e23355", + "reference": "be4de58dc8822b4af653dc554f963b4cb1e23355", "shasum": "" }, "require": { @@ -280,7 +280,7 @@ "psr/http-factory": "^1.0.2", "psr/http-message": "^1.0.1 || ^2.0.0", "psr/http-server-middleware": "^1.0.2", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11 || ^2.0" }, "conflict": { "mezzio/mezzio": "<3.5", @@ -339,7 +339,7 @@ "type": "community_bridge" } ], - "time": "2025-10-11T08:41:44+00:00" + "time": "2026-02-19T15:49:48+00:00" }, { "name": "psr/container", @@ -617,23 +617,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.1.5", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -643,7 +643,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -659,6 +659,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -669,9 +673,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.1.5" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-02-18T14:09:36+00:00" } ], "packages-dev": [ @@ -1936,29 +1940,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1978,9 +1982,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "felixfbecker/language-server-protocol", @@ -2454,16 +2458,16 @@ }, { "name": "netresearch/jsonmapper", - "version": "v5.0.0", + "version": "v5.0.1", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c" + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", "shasum": "" }, "require": { @@ -2499,9 +2503,9 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0" + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" }, - "time": "2024-09-08T10:20:00+00:00" + "time": "2026-02-22T16:28:03+00:00" }, { "name": "nikic/php-parser", @@ -3250,16 +3254,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.9", + "version": "12.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "83d4c158526c879b4c5cf7149d27958b6d912373" + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d4c158526c879b4c5cf7149d27958b6d912373", - "reference": "83d4c158526c879b4c5cf7149d27958b6d912373", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", "shasum": "" }, "require": { @@ -3273,7 +3277,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-code-coverage": "^12.5.3", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -3328,7 +3332,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" }, "funding": [ { @@ -3352,7 +3356,7 @@ "type": "tidelift" } ], - "time": "2026-02-05T08:01:09+00:00" + "time": "2026-02-18T12:38:40+00:00" }, { "name": "psalm/plugin-phpunit", @@ -5575,16 +5579,16 @@ }, { "name": "vimeo/psalm", - "version": "6.14.3", + "version": "6.15.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a" + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0b040a91f280f071c1abcb1b77ce3822058725a", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/28dc127af1b5aecd52314f6f645bafc10d0e11f9", + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9", "shasum": "" }, "require": { @@ -5608,7 +5612,7 @@ "netresearch/jsonmapper": "^5.0", "nikic/php-parser": "^5.0.0", "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^6.0 || ^7.0 || ^8.0", "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", @@ -5689,7 +5693,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-12-23T15:36:48+00:00" + "time": "2026-02-07T19:27:16+00:00" }, { "name": "webimpress/coding-standard", diff --git a/psalm.baseline.xml b/psalm.baseline.xml index 5ace72f..cd6695e 100644 --- a/psalm.baseline.xml +++ b/psalm.baseline.xml @@ -1,5 +1,5 @@ - + @@ -12,6 +12,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -28,4 +52,17 @@ + + + + + + + + + + + + + diff --git a/src/Authorization/AclAuthorization.php b/src/Authorization/AclAuthorization.php index e925e58..47cac6b 100644 --- a/src/Authorization/AclAuthorization.php +++ b/src/Authorization/AclAuthorization.php @@ -11,9 +11,9 @@ final class AclAuthorization extends Acl implements AuthorizationInterface { #[Override] - public function isAuthorized(IdentityInterface $identity, mixed $resource, mixed $privilege): bool + public function isAuthorized(IdentityInterface $identity, string $resource, string $privilege): bool { - if (null !== $resource && (! $this->hasResource($resource))) { + if (! $this->hasResource($resource)) { $this->addResource($resource); } diff --git a/src/Authorization/AclAuthorizationFactory.php b/src/Authorization/AclAuthorizationFactory.php index 8f0e353..2dee22a 100644 --- a/src/Authorization/AclAuthorizationFactory.php +++ b/src/Authorization/AclAuthorizationFactory.php @@ -4,15 +4,16 @@ namespace Lmc\Api\Auth\Authorization; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use function array_key_exists; use function array_keys; use function is_array; -use function lcfirst; use function sprintf; -class AclAuthorizationFactory +final class AclAuthorizationFactory { protected array $httpMethods = [ 'DELETE' => true, @@ -22,6 +23,10 @@ class AclAuthorizationFactory 'PUT' => true, ]; + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ public function __invoke(ContainerInterface $container): AclAuthorization { /** @var array $config */ @@ -43,7 +48,7 @@ private function createAclFromConfig(array $config): AclAuthorization /** * @var string $routeName - * @var array $privileges + * @var array> $privileges */ foreach ($config as $routeName => $privileges) { $this->createAclConfigFromPrivileges($routeName, $privileges, $aclConfig, $denyByDefault); @@ -52,6 +57,9 @@ private function createAclFromConfig(array $config): AclAuthorization return $this->createAclInstance($aclConfig); } + /** + * @param array> $privileges + */ private function createAclConfigFromPrivileges( string $routeName, array $privileges, @@ -89,6 +97,10 @@ private function createPrivilegesFromMethods(array $methods, bool $denyByDefault unset($methods['default']); } + /** + * @var string $method + * @var boolean $flag + */ foreach ($methods as $method => $flag) { // If the flag evaluates true, and we're denying by default, OR // if the flag evaluates false, and we're allowing by default, @@ -144,6 +156,7 @@ private function createAclInstance(array $config): AclAuthorization private function injectGrants(AclAuthorization $acl, string $grantType, array $rules): AclAuthorization { foreach ($rules as $set) { + // todo check if there is a case where this is executed if (! is_array($set) || ! isset($set['resource'])) { continue; } @@ -157,10 +170,12 @@ private function injectGrants(AclAuthorization $acl, string $grantType, array $r private function injectGrant(AclAuthorization $acl, string $grantType, array $ruleSet): void { // Add new resource to ACL + /** @var string $resource */ $resource = $ruleSet['resource']; - $acl->addResource($ruleSet['resource']); + $acl->addResource($resource); // Deny guest specified privileges to resource + /** @var ?string $privileges */ $privileges = $ruleSet['privileges'] ?? null; // null privileges means no permissions were setup; nothing to do diff --git a/src/Authorization/AuthorizationInterface.php b/src/Authorization/AuthorizationInterface.php index a6a9540..08fe0ce 100644 --- a/src/Authorization/AuthorizationInterface.php +++ b/src/Authorization/AuthorizationInterface.php @@ -1,10 +1,12 @@ getAttribute(IdentityInterface::class); if (! $identity instanceof IdentityInterface) { return $handler->handle($request); @@ -61,6 +62,7 @@ private function buildResource(string $routeMatchName, ServerRequestInterface $r // Return a resource name as if it was a RPC call return sprintf('%s', $routeMatchName); } + /** @var ?string $identifier */ $identifier = $restConfig['route_identifier_name'] ?? null; if (null === $identifier) { // assume collection resource diff --git a/src/AuthorizationRestMiddlewareFactory.php b/src/AuthorizationRestMiddlewareFactory.php index 5c1fe95..2522791 100644 --- a/src/AuthorizationRestMiddlewareFactory.php +++ b/src/AuthorizationRestMiddlewareFactory.php @@ -5,11 +5,17 @@ namespace Lmc\Api\Auth; use Lmc\Api\Auth\Authorization\AuthorizationInterface; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseFactoryInterface; final class AuthorizationRestMiddlewareFactory { + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + */ public function __invoke(ContainerInterface $container): AuthorizationRestMiddleware { /** @var array $config */ @@ -18,6 +24,7 @@ public function __invoke(ContainerInterface $container): AuthorizationRestMiddle $config = $config['lmc_api'] ?? []; /** @var array $config */ $config = $config['rest'] ?? []; + /** @psalm-suppress MixedArgument */ return new AuthorizationRestMiddleware( $container->get(AuthorizationInterface::class), $container->get(ResponseFactoryInterface::class), diff --git a/src/AuthorizationRpcMiddleware.php b/src/AuthorizationRpcMiddleware.php index d8bc002..26114b0 100644 --- a/src/AuthorizationRpcMiddleware.php +++ b/src/AuthorizationRpcMiddleware.php @@ -19,7 +19,7 @@ final readonly class AuthorizationRpcMiddleware implements MiddlewareInterface { public function __construct( - private AuthorizationInterface $authorization, + private AuthorizationInterface $authorization, private ResponseFactoryInterface $responseFactory, ) { } diff --git a/src/AuthorizationRpcMiddlewareFactory.php b/src/AuthorizationRpcMiddlewareFactory.php index 21cbfea..6c3f8af 100644 --- a/src/AuthorizationRpcMiddlewareFactory.php +++ b/src/AuthorizationRpcMiddlewareFactory.php @@ -5,13 +5,20 @@ namespace Lmc\Api\Auth; use Lmc\Api\Auth\Authorization\AuthorizationInterface; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseFactoryInterface; final class AuthorizationRpcMiddlewareFactory { + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ public function __invoke(ContainerInterface $container): AuthorizationRpcMiddleware { + /** @psalm-suppress MixedArgument */ return new AuthorizationRpcMiddleware( $container->get(AuthorizationInterface::class), $container->get(ResponseFactoryInterface::class), diff --git a/src/Identity/GuestIdentity.php b/src/Identity/GuestIdentity.php index 9a27dab..ab46ee2 100644 --- a/src/Identity/GuestIdentity.php +++ b/src/Identity/GuestIdentity.php @@ -14,9 +14,10 @@ public function __construct() { } + #[Override] public function getRoleId(): string { - return static::$identity; + return self::$identity; } #[Override] diff --git a/test/Assets/IdentityGetId.php b/test/Assets/IdentityGetId.php new file mode 100644 index 0000000..ccbde90 --- /dev/null +++ b/test/Assets/IdentityGetId.php @@ -0,0 +1,28 @@ +getId(); + } +} diff --git a/test/Assets/IdentityNoGetId.php b/test/Assets/IdentityNoGetId.php new file mode 100644 index 0000000..4ac36d3 --- /dev/null +++ b/test/Assets/IdentityNoGetId.php @@ -0,0 +1,9 @@ +container = $this->createMock(ContainerInterface::class); + parent::setUp(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testEmptyConfig(): void + { + $config = [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [], + ], + ], + ]; + $this->container->expects($this->once())->method('get') + ->with('config') + ->willReturn($config); + $factory = new AclAuthorizationFactory(); + $aclAuthorization = $factory($this->container); + $this->assertFalse($aclAuthorization->hasResource('foo')); + $this->assertFalse($aclAuthorization->hasRole('foo')); + $this->assertTrue($aclAuthorization->isAuthorized(new IdentityGetId(), 'bar', 'foo')); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + #[DataProvider('configProvider')] + public function testConfig(array $config, string $resource, string $privilege, bool $isAuthorized): void + { + $this->container->expects($this->once())->method('get') + ->with('config') + ->willReturn($config); + $factory = new AclAuthorizationFactory(); + $aclAuthorization = $factory($this->container); + $this->assertEquals($isAuthorized, $aclAuthorization->isAuthorized(new GuestIdentity(), $resource, $privilege)); + } + + /** @psalm-return iterable> */ + public static function configProvider(): array + { + return [ + 'empty config' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [], + ], + ], + ], + 'resource' => 'bar', + 'privilege' => 'for', + 'isAuthorized' => true, + ], + 'deny_by_default true' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + ], + ], + ], + ], + 'resource' => 'bar', + 'privilege' => 'for', + 'isAuthorized' => false, + ], + 'actions denied' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [ + 'GET' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'GET', + 'isAuthorized' => false, + ], + 'actions allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [ + 'POST' => false, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + 'actions denied by default' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'actions' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'GET', + 'isAuthorized' => false, + ], + 'collection allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'collection' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::collection', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'entity' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity default denied' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'entity' => [ + 'default' => true, + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity default allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'entity' => [ + 'default' => true, + 'POST' => false, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + 'entity empty rules' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + ]; + } +} diff --git a/test/AuthorizationRestMiddlewareFactoryTest.php b/test/AuthorizationRestMiddlewareFactoryTest.php new file mode 100644 index 0000000..72d8d00 --- /dev/null +++ b/test/AuthorizationRestMiddlewareFactoryTest.php @@ -0,0 +1,45 @@ +createMock(ContainerInterface::class); + $container->expects($this->exactly(3))->method('get') + ->willReturnMap([ + [AuthorizationInterface::class, $this->createMock(AuthorizationInterface::class)], + [ResponseFactoryInterface::class, $this->createMock(ResponseFactoryInterface::class)], + [ + 'config', + [ + 'lmc_api' => [ + 'rest' => [], + ], + ], + ], + ]); + $factory = new AuthorizationRestMiddlewareFactory(); + $this->assertInstanceOf(AuthorizationRestMiddleware::class, $factory($container)); + } +} diff --git a/test/AuthorizationRestMiddlewareTest.php b/test/AuthorizationRestMiddlewareTest.php new file mode 100644 index 0000000..0dcfd4b --- /dev/null +++ b/test/AuthorizationRestMiddlewareTest.php @@ -0,0 +1,77 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->responseFactory = $this->createMock(ResponseFactoryInterface::class); + $this->authorization = $this->createMock(AuthorizationInterface::class); + parent::setUp(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoIdentity(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(null); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRoute(): void + { + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, null], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRouteMatchName(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn(false); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, $routeResult], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } +} diff --git a/test/AuthorizationRpcMiddlewareFactoryTest.php b/test/AuthorizationRpcMiddlewareFactoryTest.php new file mode 100644 index 0000000..16095e9 --- /dev/null +++ b/test/AuthorizationRpcMiddlewareFactoryTest.php @@ -0,0 +1,37 @@ +createMock(ContainerInterface::class); + $container->expects($this->exactly(2))->method('get') + ->willReturnMap([ + [AuthorizationInterface::class, $this->createMock(AuthorizationInterface::class)], + [ResponseFactoryInterface::class, $this->createMock(ResponseFactoryInterface::class)], + ]); + $factory = new AuthorizationRpcMiddlewareFactory(); + $this->assertInstanceOf(AuthorizationRpcMiddleware::class, $factory($container)); + } +} diff --git a/test/AuthorizationRpcMiddlewareTest.php b/test/AuthorizationRpcMiddlewareTest.php new file mode 100644 index 0000000..73fff00 --- /dev/null +++ b/test/AuthorizationRpcMiddlewareTest.php @@ -0,0 +1,118 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->responseFactory = $this->createMock(ResponseFactoryInterface::class); + $this->authorization = $this->createMock(AuthorizationInterface::class); + parent::setUp(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoIdentity(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(null); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRoute(): void + { + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, null], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRouteMatchName(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn(false); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, $routeResult], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testAuthorized(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn('foo'); + $identity = new IdentityGetId(); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, $identity], + [RouteResult::class, $routeResult], + ]); + $this->request->expects($this->once())->method('getMethod')->willReturn('GET'); + $this->authorization->expects($this->once())->method('isAuthorized') + ->with($identity, 'foo', 'GET') + ->willReturn(true); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNotAuthorized(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn('foo'); + $identity = new IdentityGetId(); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, $identity], + [RouteResult::class, $routeResult], + ]); + $this->request->expects($this->once())->method('getMethod')->willReturn('GET'); + $this->authorization->expects($this->once())->method('isAuthorized') + ->with($identity, 'foo', 'GET') + ->willReturn(false); + $this->responseFactory->expects($this->once())->method('createResponse') + ->with(403, 'Forbidden'); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } +} diff --git a/test/Identity/AuthenticatedIdentityTest.php b/test/Identity/AuthenticatedIdentityTest.php index f0912e0..eeabc72 100644 --- a/test/Identity/AuthenticatedIdentityTest.php +++ b/test/Identity/AuthenticatedIdentityTest.php @@ -5,14 +5,24 @@ namespace LmcTest\Api\Auth\Identity; use Lmc\Api\Auth\Identity\AuthenticatedIdentity; +use LmcTest\Api\Auth\Assets\IdentityGetId; +use LmcTest\Api\Auth\Assets\IdentityNoGetId; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use stdClass; +#[CoversClass(AuthenticatedIdentity::class)] final class AuthenticatedIdentityTest extends TestCase { public function testConstruct(): void { - $a = new AuthenticatedIdentity(new stdClass()); - $this->assertInstanceOf(stdClass::class, $a->getAuthenticationIdentity()); + $a = new AuthenticatedIdentity(new IdentityNoGetId()); + $this->assertInstanceOf(IdentityNoGetId::class, $a->getAuthenticationIdentity()); + } + + public function testConstructGetId(): void + { + $a = new AuthenticatedIdentity(new IdentityGetId()); + $this->assertInstanceOf(IdentityGetId::class, $a->getAuthenticationIdentity()); + $this->assertEquals('foo', $a->getRoleId()); } }