Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Laravel/ApiResource/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class Error extends \Exception implements ProblemExceptionInterface, HttpExcepti
*/
public function __construct(
private readonly string $title,
private readonly string $detail,
private readonly ?string $detail,
#[ApiProperty(identifier: true)] private int $status,
array $originalTrace = [],
private readonly ?string $instance = null,
Expand All @@ -127,7 +127,7 @@ public function getOriginalTrace(): array
}

#[SerializedName('description')]
public function getDescription(): string
public function getDescription(): ?string
{
return $this->detail;
}
Expand Down
56 changes: 56 additions & 0 deletions src/Laravel/Tests/Unit/ApiResource/ErrorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Laravel\Tests\Unit\ApiResource;

use ApiPlatform\Laravel\ApiResource\Error;
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
use PHPUnit\Framework\TestCase;

final class ErrorTest extends TestCase
{
public function testProblemWithoutDetailDoesNotExposeTheExceptionMessage(): void
{
$exception = new class('Internal message') extends \Exception implements ProblemExceptionInterface {
public function getType(): string
{
return '/errors/400';
}

public function getTitle(): string
{
return 'Invalid request';
}

public function getStatus(): int
{
return 400;
}

public function getDetail(): ?string
{
return null;
}

public function getInstance(): ?string
{
return null;
}
};

$error = Error::createFromException($exception, 400);

$this->assertNull($error->getDetail());
$this->assertNull($error->getDescription());
}
}
19 changes: 17 additions & 2 deletions src/Mcp/Server/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Mcp\Server;

use ApiPlatform\Mcp\State\ToolProvider;
use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\Exception\HttpExceptionInterface;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
Expand Down Expand Up @@ -139,7 +140,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er
try {
$body = $this->provider->provide($operation, $uriVariables, $context);
} catch (HttpExceptionInterface $e) {
return Error::forInternalError($e->getMessage(), $request->getId());
return Error::forInternalError($this->getPublicErrorMessage($e), $request->getId());
}

if (!$isResource && null !== ($httpRequest = $context['request'] ?? null)) {
Expand All @@ -160,7 +161,21 @@ public function handle(Request $request, SessionInterface $session): Response|Er
try {
return $this->processor->process($body, $operation, $uriVariables, $context);
} catch (HttpExceptionInterface $e) {
return Error::forInternalError($e->getMessage(), $request->getId());
return Error::forInternalError($this->getPublicErrorMessage($e), $request->getId());
}
}

private function getPublicErrorMessage(\Throwable $exception): string
{
$current = $exception;
while (null !== $current) {
if ($current instanceof AccessDeniedException) {
return $current->getDetail() ?? 'Access Denied.';
}

$current = $current->getPrevious();
}

return $exception->getMessage();
}
}
32 changes: 31 additions & 1 deletion src/Metadata/Exception/AccessDeniedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,38 @@

use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

final class AccessDeniedException extends AccessDeniedHttpException implements HttpExceptionInterface
final class AccessDeniedException extends AccessDeniedHttpException implements HttpExceptionInterface, ProblemExceptionInterface
{
public function __construct(string $message = '', ?\Throwable $previous = null, int $code = 0, array $headers = [], private readonly ?string $detail = null)
{
parent::__construct($message, $previous, $code, $headers);
}

public function getType(): string
{
return '/errors/403';
}

public function getTitle(): string
{
return 'An error occurred';
}

public function getStatus(): int
{
return 403;
}

public function getDetail(): ?string
{
return $this->detail;
}

public function getInstance(): ?string
{
return null;
}

public function getStatusCode(): int
{
return 403;
Expand Down
42 changes: 42 additions & 0 deletions src/Metadata/Tests/Exception/AccessDeniedExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Metadata\Tests\Exception;

use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
use PHPUnit\Framework\TestCase;

final class AccessDeniedExceptionTest extends TestCase
{
public function testKeepsTheDefaultMessageForBackwardCompatibility(): void
{
$this->assertSame('', (new AccessDeniedException())->getMessage());
}

public function testKeepsTheInternalMessageSeparateFromThePublicDetail(): void
{
$exception = new AccessDeniedException('Access Denied. Voter reason.', detail: 'Access Denied.');

$this->assertInstanceOf(ProblemExceptionInterface::class, $exception);
$this->assertSame('Access Denied. Voter reason.', $exception->getMessage());
$this->assertSame('Access Denied.', $exception->getDetail());
}

public function testKeepsAMissingPublicDetailNull(): void
{
$exception = new AccessDeniedException('Access Denied. Voter reason.');

$this->assertNull($exception->getDetail());
}
}
2 changes: 1 addition & 1 deletion src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a

if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
if ($throwOnPropertyAccessDenied) {
throw new AccessDeniedException($securityMessage ?? 'Access denied');
throw new AccessDeniedException($securityMessage ?? 'Access denied', detail: $securityMessage);
}
if (null !== $previousObject) {
$this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
Expand Down
18 changes: 12 additions & 6 deletions src/Serializer/Tests/AbstractItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -573,14 +573,20 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro
$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, $resourceAccessChecker->reveal()) extends AbstractItemNormalizer {};
$normalizer->setSerializer($serializerProphecy->reveal());

$this->expectException(AccessDeniedException::class);
$this->expectExceptionMessage('Custom access denied message');

$operation = new Patch(securityMessage: 'Custom access denied message', extraProperties: ['throw_on_access_denied' => true]);

$normalizer->denormalize($data, SecuredDummy::class, 'json', [
'operation' => $operation,
]);
$exception = null;
try {
$normalizer->denormalize($data, SecuredDummy::class, 'json', [
'operation' => $operation,
]);
} catch (\Throwable $caughtException) {
$exception = $caughtException;
}

$this->assertInstanceOf(AccessDeniedException::class, $exception);
$this->assertSame('Custom access denied message', $exception->getMessage());
$this->assertSame('Custom access denied message', $exception->getDetail());
}

public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPropertyInOperationThrowsAccessDeniedException(): void
Expand Down
29 changes: 29 additions & 0 deletions src/State/ErrorProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\State;

use ApiPlatform\Metadata\ErrorResourceInterface;
use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
Expand Down Expand Up @@ -74,6 +75,20 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$status = $operation->getStatus() ?? 500;
$cl = is_a($operation->getClass(), ErrorResourceInterface::class, true) ? $operation->getClass() : Error::class;
$error = $cl::createFromException($exception, $status);
if (null !== ($accessDeniedException = $this->findAccessDeniedException($exception))) {
if ($status !== $accessDeniedException->getStatus()) {
if (method_exists($error, 'setStatus')) {
$error->setStatus($status);
}
if (method_exists($error, 'setType')) {
$error->setType("/errors/$status");
}
}

if (method_exists($error, 'setDetail')) {
$error->setDetail($accessDeniedException->getDetail() ?? ($this->debug ? $accessDeniedException->getMessage() : 'Access Denied.'));
}
}
if (!$this->debug && $status >= 500 && method_exists($error, 'setDetail')) {
$error->setDetail('Internal Server Error');
}
Expand All @@ -94,4 +109,18 @@ private function renderError(int $status, string $text): Response
</html>
HTML);
}

private function findAccessDeniedException(\Throwable $exception): ?AccessDeniedException
{
$current = $exception;
while (null !== $current) {
if ($current instanceof AccessDeniedException) {
return $current;
}

$current = $current->getPrevious();
}

return null;
}
}
7 changes: 6 additions & 1 deletion src/State/Provider/SecurityParameterProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ class_exists(AccessDeniedException::class, true) => AccessDeniedException::class
default => AccessDeniedHttpException::class,
};

throw new ($exception)($parameter->getSecurityMessage() ?? 'Access Denied.');
$securityMessage = $parameter->getSecurityMessage();
if (MetadataAccessDeniedException::class === $exception) {
throw new $exception($securityMessage ?? 'Access Denied.', detail: $securityMessage);
}

throw new $exception($securityMessage ?? 'Access Denied.');
}
}

Expand Down
58 changes: 58 additions & 0 deletions src/State/Tests/ErrorProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\State\Tests;

use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\Get;
use ApiPlatform\State\ApiResource\Error;
use ApiPlatform\State\ErrorProvider;
Expand Down Expand Up @@ -44,4 +45,61 @@ public function testErrorProviderProduction(): void
$error = $provider->provide(new Get(), [], ['request' => $request]);
$this->assertEquals('Internal Server Error', $error->getDetail());
}

public function testAccessDeniedReasonIsExposedInDebugMode(): void
{
$error = self::provideError(new AccessDeniedException('Access Denied. Voter reason.'), true);

$this->assertSame('Access Denied. Voter reason.', $error->getDetail());
}

public function testAccessDeniedReasonIsHiddenInProduction(): void
{
$error = self::provideError(new AccessDeniedException('Access Denied. Voter reason.'), false);

$this->assertSame('Access Denied.', $error->getDetail());
}

public function testConfiguredAccessDeniedDetailIsPreservedInProduction(): void
{
$error = self::provideError(new AccessDeniedException('Internal message', detail: 'Public message'), false);

$this->assertSame('Public message', $error->getDetail());
}

public function testConfiguredEmptyAccessDeniedDetailIsPreservedInProduction(): void
{
$error = self::provideError(new AccessDeniedException('Internal message', detail: ''), false);

$this->assertSame('', $error->getDetail());
}

public function testUsesTheResolvedErrorOperationStatusForAccessDeniedProblem(): void
{
$error = self::provideError(new AccessDeniedException('Internal message', detail: 'Public message'), false, 404);

$this->assertSame(404, $error->getStatus());
$this->assertSame('/errors/404', $error->getType());
$this->assertSame('Public message', $error->getDetail());
}

public function testFindsTheAccessDeniedProblemInTheExceptionChain(): void
{
$problem = new AccessDeniedException('Access Denied. Voter reason.');
$exception = new \RuntimeException('Wrapper exception', previous: $problem);
$error = self::provideError($exception, false);

$this->assertSame('Access Denied.', $error->getDetail());
}

private static function provideError(\Throwable $exception, bool $debug, int $status = 403): Error
{
$request = Request::create('/');
$request->attributes->set('exception', $exception);

$error = (new ErrorProvider(debug: $debug))->provide(new Get(status: $status), [], ['request' => $request]);
self::assertInstanceOf(Error::class, $error);

return $error;
}
}
13 changes: 9 additions & 4 deletions src/State/Tests/Provider/SecurityParameterProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\State\Tests\Provider;

use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Parameters;
Expand Down Expand Up @@ -63,9 +64,6 @@ public function testIsNotGrantedLink(): void

public function testSecurityMessageLink(): void
{
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('You are not admin.');

$obj = new \stdClass();
$barObj = new \stdClass();
$operation = new GetCollection(uriVariables: [
Expand All @@ -77,6 +75,13 @@ public function testSecurityMessageLink(): void
$resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class);
$resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false);
$accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker);
$accessChecker->provide($operation, ['barId' => 1], ['request' => $request]);

try {
$accessChecker->provide($operation, ['barId' => 1], ['request' => $request]);
self::fail('An AccessDeniedException should have been thrown.');
} catch (AccessDeniedException $exception) {
$this->assertSame('You are not admin.', $exception->getMessage());
$this->assertSame('You are not admin.', $exception->getDetail());
}
}
}
Loading
Loading