Skip to content

Commit 8866c02

Browse files
committed
feat(laravel): policy, auth and gate
1 parent b8430ab commit 8866c02

39 files changed

+643
-114
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@
5656
use ApiPlatform\Laravel\Eloquent\State\LinksHandlerInterface;
5757
use ApiPlatform\Laravel\Eloquent\State\PersistProcessor;
5858
use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor;
59-
use ApiPlatform\Laravel\Exception\Handler;
59+
use ApiPlatform\Laravel\Exception\ErrorHandler;
6060
use ApiPlatform\Laravel\Routing\IriConverter;
6161
use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter;
6262
use ApiPlatform\Laravel\Routing\SkolemIriConverter;
63+
use ApiPlatform\Laravel\Security\ResourceAccessChecker;
64+
use ApiPlatform\Laravel\State\AccessCheckerProvider;
6365
use ApiPlatform\Laravel\State\SwaggerUiProcessor;
6466
use ApiPlatform\Laravel\State\ValidateProvider;
6567
use ApiPlatform\Metadata\Exception\NotExposedHttpException;
@@ -91,6 +93,7 @@
9193
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
9294
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
9395
use ApiPlatform\Metadata\Resource\Factory\UriTemplateResourceMetadataCollectionFactory;
96+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
9497
use ApiPlatform\Metadata\ResourceClassResolver;
9598
use ApiPlatform\Metadata\ResourceClassResolverInterface;
9699
use ApiPlatform\Metadata\UrlGeneratorInterface;
@@ -262,7 +265,7 @@ public function register(): void
262265
null,
263266
$app->make(LoggerInterface::class),
264267
[
265-
'routePrefix' => $config->get('api-platform.prefix') ?? '/',
268+
'routePrefix' => $config->get('api-platform.routes.prefix') ?? '/',
266269
],
267270
false
268271
)
@@ -324,8 +327,12 @@ public function register(): void
324327
return new DeserializeProvider($app->make(JsonApiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
325328
});
326329

330+
$this->app->singleton(AccessCheckerProvider::class, function (Application $app) {
331+
return new AccessCheckerProvider($app->make(DeserializeProvider::class), $app->make(ResourceAccessCheckerInterface::class));
332+
});
333+
327334
$this->app->singleton(ContentNegotiationProvider::class, function (Application $app) use ($config) {
328-
return new ContentNegotiationProvider($app->make(DeserializeProvider::class), new Negotiator(), $config->get('api-platform.formats'), $config->get('api-platform.error_formats'));
335+
return new ContentNegotiationProvider($app->make(AccessCheckerProvider::class), new Negotiator(), $config->get('api-platform.formats'), $config->get('api-platform.error_formats'));
329336
});
330337

331338
$this->app->bind(ProviderInterface::class, ContentNegotiationProvider::class);
@@ -443,6 +450,10 @@ public function register(): void
443450
return new HydraEntrypointNormalizer($app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(IriConverterInterface::class), $app->make(UrlGeneratorInterface::class));
444451
});
445452

453+
$this->app->singleton(ResourceAccessCheckerInterface::class, function () {
454+
return new ResourceAccessChecker();
455+
});
456+
446457
$this->app->singleton(ItemNormalizer::class, function (Application $app) use ($defaultContext) {
447458
return new ItemNormalizer(
448459
$app->make(PropertyNameCollectionFactoryInterface::class),
@@ -454,8 +465,7 @@ public function register(): void
454465
$app->make(ClassMetadataFactoryInterface::class),
455466
$app->make(LoggerInterface::class),
456467
$app->make(ResourceMetadataCollectionFactoryInterface::class),
457-
/* $resourceAccessChecker */
458-
null,
468+
$app->make(ResourceAccessCheckerInterface::class),
459469
$defaultContext
460470
);
461471
});
@@ -481,8 +491,7 @@ public function register(): void
481491
$app->make(ClassMetadataFactoryInterface::class),
482492
$app->make(LoggerInterface::class),
483493
$app->make(ResourceMetadataCollectionFactoryInterface::class),
484-
// $app->make(ResourceAccessCheckerInterface::class),
485-
null,
494+
$app->make(ResourceAccessCheckerInterface::class),
486495
$defaultContext
487496
);
488497
});
@@ -628,9 +637,8 @@ public function register(): void
628637
$app->make(ClassMetadataFactoryInterface::class),
629638
$defaultContext,
630639
$app->make(ResourceMetadataCollectionFactoryInterface::class),
631-
null,
640+
$app->make(ResourceAccessCheckerInterface::class),
632641
null
633-
// $app->make(ResourceAccessCheckerInterface::class),
634642
// $app->make(TagCollectorInterface::class),
635643
);
636644
});
@@ -690,15 +698,14 @@ public function register(): void
690698
$app->make(NameConverterInterface::class),
691699
$app->make(ClassMetadataFactoryInterface::class),
692700
$defaultContext,
693-
// $app->make(ResourceAccessCheckerInterface::class),
694-
null
701+
$app->make(ResourceAccessCheckerInterface::class)
695702
);
696703
});
697704

698705
$this->app->singleton(
699706
ExceptionHandlerInterface::class,
700707
function (Application $app) {
701-
return new Handler(
708+
return new ErrorHandler(
702709
$app,
703710
$app->make(ResourceMetadataCollectionFactoryInterface::class),
704711
$app->make(ApiPlatformController::class),
@@ -736,25 +743,28 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
736743
return;
737744
}
738745

746+
$config = $this->app['config'];
739747
$routeCollection = new RouteCollection();
740748
foreach ($resourceNameCollectionFactory->create() as $resourceClass) {
741749
foreach ($resourceMetadataFactory->create($resourceClass) as $resourceMetadata) {
742750
foreach ($resourceMetadata->getOperations() as $operation) {
743751
$uriTemplate = $operation->getUriTemplate();
744752
// _format is read by the middleware
745753
$uriTemplate = $operation->getRoutePrefix().str_replace('{._format}', '{_format?}', $uriTemplate);
746-
$route = new Route([$operation->getMethod()], $uriTemplate, [ApiPlatformController::class, '__invoke']);
747-
$route->name($operation->getName());
748-
$route->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]);
749-
// Another option then to use a middleware, not sure what's best (you then retrieve $request->getRoute() somehow ?)
750-
// $route->??? = ['operation' => $operation];
751-
$routeCollection->add($route)
752-
->middleware(ApiPlatformMiddleware::class.':'.$operation->getName());
754+
$route = (new Route([$operation->getMethod()], $uriTemplate, [ApiPlatformController::class, '__invoke']))
755+
->name($operation->getName())
756+
->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]);
757+
758+
$route->middleware(ApiPlatformMiddleware::class.':'.$operation->getName());
759+
$route->middleware($config->get('api-platform.routes.middleware'));
760+
$route->middleware($operation->getMiddleware());
761+
762+
$routeCollection->add($route);
753763
}
754764
}
755765
}
756766

757-
$prefix = $this->app['config']->get('api-platform.prefix') ?? '';
767+
$prefix = $config->get('api-platform.routes.prefix') ?? '';
758768
$route = new Route(['GET'], $prefix.'/contexts/{shortName?}{_format?}', [ContextAction::class, '__invoke']);
759769
$route->name('api_jsonld_context')->middleware(ApiPlatformMiddleware::class);
760770
$routeCollection->add($route);
@@ -784,10 +794,6 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
784794

785795
private function shouldRegisterRoutes(): bool
786796
{
787-
if (!$this->app['config']->get('api-platform.register_routes')) {
788-
return false;
789-
}
790-
791797
if ($this->app instanceof CachesRoutes && $this->app->routesAreCached()) {
792798
return false;
793799
}

src/Laravel/Exception/Handler.php renamed to src/Laravel/Exception/ErrorHandler.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@
2323
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2424
use ApiPlatform\Metadata\Util\ContentNegotiationTrait;
2525
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
26+
use Illuminate\Auth\Access\AuthorizationException;
27+
use Illuminate\Auth\AuthenticationException;
2628
use Illuminate\Contracts\Container\Container;
2729
use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler;
2830
use Illuminate\Http\Request;
2931
use Negotiation\Negotiator;
3032
use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface;
3133
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface;
3234

33-
class Handler extends ExceptionsHandler
35+
class ErrorHandler extends ExceptionsHandler
3436
{
3537
use ContentNegotiationTrait;
3638
use OperationRequestInitiatorTrait;
@@ -154,6 +156,18 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err
154156
}
155157
}
156158

159+
if ($exception instanceof AuthenticationException) {
160+
return 401;
161+
}
162+
163+
if ($exception instanceof AuthorizationException) {
164+
return 403;
165+
}
166+
167+
if ($exception instanceof SymfonyHttpExceptionInterface) {
168+
return $exception->getStatusCode();
169+
}
170+
157171
if ($exception instanceof SymfonyHttpExceptionInterface) {
158172
return $exception->getStatusCode();
159173
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Security;
15+
16+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
17+
use Illuminate\Support\Facades\Gate;
18+
19+
class ResourceAccessChecker implements ResourceAccessCheckerInterface
20+
{
21+
public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool
22+
{
23+
return Gate::allows($expression, $extraVariables['object']);
24+
}
25+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
18+
use ApiPlatform\State\ProviderInterface;
19+
use Illuminate\Auth\Access\AuthorizationException;
20+
21+
/**
22+
* Allows access based on the ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface.
23+
* This implementation covers GraphQl and HTTP.
24+
*
25+
* @see ResourceAccessCheckerInterface
26+
*
27+
* @implements ProviderInterface<object>
28+
*/
29+
final class AccessCheckerProvider implements ProviderInterface
30+
{
31+
/**
32+
* @param ProviderInterface<object> $decorated
33+
*/
34+
public function __construct(private readonly ProviderInterface $decorated, private readonly ResourceAccessCheckerInterface $resourceAccessChecker)
35+
{
36+
}
37+
38+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
39+
{
40+
$policy = $operation->getPolicy();
41+
$message = $operation->getSecurityMessage();
42+
43+
$body = $this->decorated->provide($operation, $uriVariables, $context);
44+
if (null === $policy) {
45+
return $body;
46+
}
47+
48+
$request = $context['request'] ?? null;
49+
50+
$resourceAccessCheckerContext = [
51+
'object' => $body,
52+
'request' => $request,
53+
'operation' => $operation,
54+
];
55+
56+
if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $policy, $resourceAccessCheckerContext)) {
57+
throw new AuthorizationException($message ?? 'Access Denied.');
58+
}
59+
60+
return $body;
61+
}
62+
}

src/Laravel/Tests/AuthTest.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Illuminate\Foundation\Testing\RefreshDatabase;
18+
use Orchestra\Testbench\Concerns\WithWorkbench;
19+
use Orchestra\Testbench\TestCase;
20+
21+
class AuthTest extends TestCase
22+
{
23+
use ApiTestAssertionsTrait;
24+
use RefreshDatabase;
25+
use WithWorkbench;
26+
27+
public function testGetCollection(): void
28+
{
29+
$response = $this->get('/api/vaults', ['accept' => ['application/ld+json']]);
30+
$this->assertArraySubset(['detail' => 'Unauthenticated.'], $response->json());
31+
$response->assertHeader('content-type', 'application/problem+json; charset=utf-8');
32+
$response->assertStatus(401);
33+
}
34+
35+
public function testAuthenticated(): void
36+
{
37+
$response = $this->post('/tokens/create');
38+
$token = $response->json()['token'];
39+
$response = $this->get('/api/vaults', ['accept' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]);
40+
$response->assertStatus(200);
41+
}
42+
43+
public function testAuthenticatedPolicy(): void
44+
{
45+
$response = $this->post('/tokens/create');
46+
$token = $response->json()['token'];
47+
$response = $this->post('/api/vaults', [], ['content-type' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]);
48+
$response->assertStatus(403);
49+
}
50+
}

src/Laravel/composer.json

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"illuminate/routing": "^11.0",
3838
"illuminate/support": "^11.0",
3939
"illuminate/container": "^11.0",
40+
"laravel/sanctum": "^4.0",
4041
"symfony/web-link": "^6.4 || ^7.1",
4142
"willdurand/negotiation": "^3.1",
4243
"phpstan/phpdoc-parser": "^1.29"
@@ -82,6 +83,20 @@
8283
},
8384
"scripts": {
8485
"build": "@php vendor/bin/testbench workbench:build --ansi",
85-
"test": "@php vendor/bin/testbench package:test"
86+
"test": "@php vendor/bin/testbench package:test",
87+
"post-autoload-dump": [
88+
"@clear",
89+
"@prepare"
90+
],
91+
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
92+
"prepare": "@php vendor/bin/testbench package:discover --ansi",
93+
"serve": [
94+
"Composer\\Config::disableProcessTimeout",
95+
"@build",
96+
"@php vendor/bin/testbench serve --ansi"
97+
],
98+
"lint": [
99+
"@php vendor/bin/phpstan analyse --verbose --ansi"
100+
]
86101
}
87-
}
102+
}

src/Laravel/config/api-platform.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@
1616
'description' => 'My awesome API',
1717
'version' => '1.0.0',
1818

19-
/*
20-
* Automatic registration of routes will only happen if this setting is `true`
21-
*/
22-
'register_routes' => true,
23-
'prefix' => '/api',
19+
'routes' => [
20+
'prefix' => '/api',
21+
'middleware' => [],
22+
],
2423

2524
/*
2625
* Where are ApiResource defined

src/Laravel/testbench.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
providers:
22
- ApiPlatform\Laravel\ApiPlatformProvider
3+
- Laravel\Sanctum\SanctumServiceProvider
34
- Workbench\App\Providers\WorkbenchServiceProvider
45

56
migrations:
7+
- vendor/laravel/sanctum/database/migrations
68
- workbench/database/migrations
79

810
seeders:

0 commit comments

Comments
 (0)