diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a604ba5c..4859aa5a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue is stale because it has been open for 120 days with no activity. Remove the `stale` label or comment or this will be closed in 15 days' diff --git a/composer.json b/composer.json index d5ca32bc..7acd7de9 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,27 @@ { "name": "cakephp/authorization", "description": "Authorization abstraction layer plugin for CakePHP", + "license": "MIT", + "type": "cakephp-plugin", "keywords": [ "auth", "authorization", "access", "cakephp" ], - "type": "cakephp-plugin", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/authorization/graphs/contributors" + } + ], + "support": { + "issues": "https://github.com/cakephp/authorization/issues", + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "source": "https://github.com/cakephp/authorization", + "docs": "https://cakephp.org/authorization/2/en/" + }, "require": { "php": ">=8.1", "cakephp/http": "^5.0", @@ -27,7 +41,6 @@ "cakephp/http": "To use \"RequestPolicyInterface\" (Not needed separately if using full CakePHP framework).", "cakephp/orm": "To use \"OrmResolver\" (Not needed separately if using full CakePHP framework)." }, - "license": "MIT", "autoload": { "psr-4": { "Authorization\\": "src/" @@ -41,18 +54,12 @@ "TestPlugin\\": "tests/test_app/Plugin/TestPlugin/src/" } }, - "authors": [ - { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/authorization/graphs/contributors" - } - ], - "support": { - "issues": "https://github.com/cakephp/authorization/issues", - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "source": "https://github.com/cakephp/authorization", - "docs": "https://cakephp.org/authorization/2/en/" + "config": { + "allow-plugins": { + "cakephp/plugin-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true }, "scripts": { "check": [ @@ -60,25 +67,18 @@ "@stan", "@test" ], + "cs-check": "phpcs --colors -p src/ tests/", + "cs-fix": "phpcbf --colors -p src/ tests/", "phpstan": "tools/phpstan analyse", "psalm": "tools/psalm --show-info=false", + "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "stan": [ "@phpstan", "@psalm" ], "stan-baseline": "tools/phpstan --generate-baseline", - "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "stan-setup": "phive install", - "cs-check": "phpcs --colors -p src/ tests/", - "cs-fix": "phpcbf --colors -p src/ tests/", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" - }, - "config": { - "sort-packages": true, - "allow-plugins": { - "cakephp/plugin-installer": true, - "dealerdirect/phpcodesniffer-composer-installer": true - } } } diff --git a/docs.Dockerfile b/docs.Dockerfile index dd058731..9bb0a51e 100644 --- a/docs.Dockerfile +++ b/docs.Dockerfile @@ -1,13 +1,14 @@ # Generate the HTML output. FROM ghcr.io/cakephp/docs-builder as builder -COPY docs /data/docs - ENV LANGS="en es fr ja" +WORKDIR /data/docs-builder + +COPY docs /data/docs + # Build docs with sphinx -RUN cd /data/docs-builder && \ - make website LANGS="$LANGS" SOURCE=/data/docs DEST=/data/website +RUN make website LANGS="$LANGS" SOURCE=/data/docs DEST=/data/website # Build a small nginx container with just the static site in it. FROM ghcr.io/cakephp/docs-builder:runtime as runtime @@ -18,11 +19,7 @@ ENV SEARCH_SOURCE="/usr/share/nginx/html" ENV SEARCH_URL_PREFIX="/authorization/3" COPY --from=builder /data/docs /data/docs -COPY --from=builder /data/website /data/website +COPY --from=builder /data/website/html/ /usr/share/nginx/html/ COPY --from=builder /data/docs-builder/nginx.conf /etc/nginx/conf.d/default.conf -# Move files into final location -RUN cp -R /data/website/html/* /usr/share/nginx/html \ - && rm -rf /data/website/ - RUN ln -s /usr/share/nginx/html /usr/share/nginx/html/2.x diff --git a/docs/en/policies.rst b/docs/en/policies.rst index d30ae2b4..e2f6bd67 100644 --- a/docs/en/policies.rst +++ b/docs/en/policies.rst @@ -132,6 +132,32 @@ Before hooks are expected to return one of three values: - ``null`` The before hook did not make a decision, and the authorization method will be invoked. +Scope Pre-conditions +==================== + +Like policies, scopes can also define pre-conditions. These are useful when you +want to apply common conditions to all scopes in a policy. To use pre-conditions +on scopes you need to implement the ``BeforeScopeInterface`` in your scope policy:: + + namespace App\Policy; + + use Authorization\Policy\BeforeScopeInterface; + + class ArticlesTablePolicy implements BeforeScopeInterface + { + public function beforeScope($user, $query, $action) + { + if ($user->getOriginalData()->is_trial_user) { + return $query->where(['Articles.is_paid_only' => false]); + } + // fall through + } + } + +Before scope hooks are expected to return the modified resource object, or if +``null`` is returned then the scope method will be invoked as normal. + + Applying Policies ----------------- diff --git a/docs/en/request-authorization-middleware.rst b/docs/en/request-authorization-middleware.rst index ce4977e0..756d3fe6 100644 --- a/docs/en/request-authorization-middleware.rst +++ b/docs/en/request-authorization-middleware.rst @@ -23,6 +23,7 @@ and add:: use Authorization\Policy\RequestPolicyInterface; use Cake\Http\ServerRequest; + use Authorization\Policy\ResultInterface class RequestPolicy implements RequestPolicyInterface { @@ -31,9 +32,9 @@ and add:: * * @param \Authorization\IdentityInterface|null $identity Identity * @param \Cake\Http\ServerRequest $request Server Request - * @return bool + * @return \Authorization\Policy\ResultInterface|bool */ - public function canAccess($identity, ServerRequest $request) + public function canAccess($identity, ServerRequest $request): bool|ResultInterface { if ($request->getParam('controller') === 'Articles' && $request->getParam('action') === 'index' diff --git a/src/AuthorizationService.php b/src/AuthorizationService.php index fdec3125..3171dfbf 100644 --- a/src/AuthorizationService.php +++ b/src/AuthorizationService.php @@ -18,6 +18,7 @@ use Authorization\Exception\Exception; use Authorization\Policy\BeforePolicyInterface; +use Authorization\Policy\BeforeScopeInterface; use Authorization\Policy\Exception\MissingMethodException; use Authorization\Policy\ResolverInterface; use Authorization\Policy\Result; @@ -115,6 +116,15 @@ public function applyScope(?IdentityInterface $user, string $action, mixed $reso { $this->authorizationChecked = true; $policy = $this->resolver->getPolicy($resource); + + if ($policy instanceof BeforeScopeInterface) { + $result = $policy->beforeScope($user, $resource, $action); + + if ($result !== null) { + return $result; + } + } + $handler = $this->getScopeHandler($policy, $action); return $handler($user, $resource, ...$optionalArgs); diff --git a/src/Identity.php b/src/Identity.php index 35f60e44..38c70ac4 100644 --- a/src/Identity.php +++ b/src/Identity.php @@ -49,7 +49,7 @@ public function __construct(AuthorizationServiceInterface $service, AuthenIdenti /** * Get the primary key/id field for the identity. * - * @return array|string|int|null + * @return array|string|int|null */ public function getIdentifier(): string|int|array|null { diff --git a/src/Policy/BeforeScopeInterface.php b/src/Policy/BeforeScopeInterface.php new file mode 100644 index 00000000..44831953 --- /dev/null +++ b/src/Policy/BeforeScopeInterface.php @@ -0,0 +1,39 @@ +assertFalse($result); } + public function testBeforeScopeNonNull() + { + $entity = new Article(); + + $policy = $this->getMockBuilder(BeforeScopeInterface::class) + ->onlyMethods(['beforeScope']) + ->addMethods(['scopeIndex']) + ->getMock(); + + $policy->expects($this->once()) + ->method('beforeScope') + ->with($this->isInstanceOf(IdentityDecorator::class), $entity, 'index') + ->willReturn('foo'); + + $policy->expects($this->never()) + ->method('scopeIndex'); + + $resolver = new MapResolver([ + Article::class => $policy, + ]); + + $service = new AuthorizationService($resolver); + + $user = new IdentityDecorator($service, [ + 'role' => 'admin', + ]); + + $result = $service->applyScope($user, 'index', $entity); + $this->assertEquals('foo', $result); + } + + public function testBeforeScopeNull() + { + $entity = new Article(); + + $policy = $this->getMockBuilder(BeforeScopeInterface::class) + ->onlyMethods(['beforeScope']) + ->addMethods(['scopeIndex']) + ->getMock(); + + $policy->expects($this->once()) + ->method('beforeScope') + ->with($this->isInstanceOf(IdentityDecorator::class), $entity, 'index') + ->willReturn(null); + + $policy->expects($this->once()) + ->method('scopeIndex') + ->with($this->isInstanceOf(IdentityDecorator::class), $entity) + ->willReturn('bar'); + + $resolver = new MapResolver([ + Article::class => $policy, + ]); + + $service = new AuthorizationService($resolver); + + $user = new IdentityDecorator($service, [ + 'role' => 'admin', + ]); + + $result = $service->applyScope($user, 'index', $entity); + $this->assertEquals('bar', $result); + } + public function testMissingMethod() { $entity = new Article();