Skip to content

Commit

Permalink
fix: graphql policy
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Oct 4, 2024
1 parent 40b3f17 commit 3dde0f6
Show file tree
Hide file tree
Showing 15 changed files with 192 additions and 49 deletions.
21 changes: 10 additions & 11 deletions src/GraphQl/Type/FieldsBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\InflectorInterface;
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
Expand Down Expand Up @@ -303,34 +302,34 @@ public function resolveResourceArgs(array $args, Operation $operation): array
$parsedKey = explode('[:property]', $key);
$flattenFields = [];

if ($filter instanceof OpenApiParameterFilterInterface) {
foreach ($filter->getOpenApiParameters($parameter) as $value) {
if ($filter instanceof FilterInterface) {
foreach ($filter->getDescription($operation->getClass()) as $name => $value) {
$values = [];
parse_str($value->getName(), $values);
parse_str($name, $values);
if (isset($values[$parsedKey[0]])) {
$values = $values[$parsedKey[0]];
}

$name = key($values);
$flattenFields[] = ['name' => $name, 'required' => $value->getRequired() ?? null, 'description' => $value->getDescription() ?? null, 'leafs' => $values[$name], 'type' => $value->getSchema()['type'] ?? 'string'];
$flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string'];
}

$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0] . $operation->getShortName() . $operation->getName());
$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]);
}

if ($filter instanceof FilterInterface) {
foreach ($filter->getDescription($operation->getClass()) as $key => $value) {
if ($filter instanceof OpenApiParameterFilterInterface) {
foreach ($filter->getOpenApiParameters($parameter) as $value) {
$values = [];
parse_str($value['name'], $values);
parse_str($value->getName(), $values);
if (isset($values[$parsedKey[0]])) {
$values = $values[$parsedKey[0]];
}

$name = key($values);
$flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string'];
$flattenFields[] = ['name' => $name, 'required' => $value->getRequired(), 'description' => $value->getDescription(), 'leafs' => $values[$name], 'type' => $value->getSchema()['type'] ?? 'string'];
}

$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]);
$args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0].$operation->getShortName().$operation->getName());
}

continue;
Expand Down
54 changes: 27 additions & 27 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -1129,30 +1129,24 @@ private function registerGraphQl(Application $app): void
return new GraphQlSerializerContextBuilder($app->make(NameConverterInterface::class));
});

$app->singleton('api_platform.graphql.state_provider.parameter', function (Application $app) {
$tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class));
$tagged['api_platform.serializer.filter_parameter_provider'] = $app->make(SerializerFilterParameterProvider::class);
$app->singleton(GraphQlReadProvider::class, function (Application $app) {
/** @var ConfigRepository */
$config = $app['config'];

return new ParameterProvider(
new ParameterValidatorProvider(
new SecurityParameterProvider(
$app->make(GraphQlDenormalizeProvider::class),
$app->make(ResourceAccessCheckerInterface::class)
),
),
new ServiceLocator($tagged)
return new GraphQlReadProvider(
$this->app->make(CallableProvider::class),
$app->make(IriConverterInterface::class),
$app->make(GraphQlSerializerContextBuilder::class),
$config->get('api-platform.graphql.nesting_separator') ?? '__'
);
});

$app->singleton('api_platform.graphql.state_provider.access_checker', function (Application $app) {
return new AccessCheckerProvider($app->make('api_platform.graphql.state_provider.parameter'), $app->make(ResourceAccessCheckerInterface::class));
});
$app->alias(GraphQlReadProvider::class, 'api_platform.graphql.state_provider.read');

$app->singleton(ResolverProvider::class, function (Application $app) {
$resolvers = iterator_to_array($app->tagged('api_platform.graphql.resolver'));

return new ResolverProvider(
$app->make(CallableProvider::class),
$app->make(GraphQlReadProvider::class),
new ServiceLocator($resolvers),
);
});
Expand All @@ -1167,20 +1161,26 @@ private function registerGraphQl(Application $app): void
);
});

$app->alias(ResolverProvider::class, 'api_platform.graphql.state_provider.denormalize');
$app->alias(GraphQlDenormalizeProvider::class, 'api_platform.graphql.state_provider.denormalize');

$app->singleton(GraphQlReadProvider::class, function (Application $app) {
/** @var ConfigRepository */
$config = $app['config'];
$app->singleton('api_platform.graphql.state_provider.parameter', function (Application $app) {
$tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class));
$tagged['api_platform.serializer.filter_parameter_provider'] = $app->make(SerializerFilterParameterProvider::class);

return new GraphQlReadProvider(
$this->app->make('api_platform.graphql.state_provider.parameter'),
$app->make(IriConverterInterface::class),
$app->make(GraphQlSerializerContextBuilder::class),
$config->get('api-platform.graphql.nesting_separator') ?? '__'
return new ParameterProvider(
new ParameterValidatorProvider(
new SecurityParameterProvider(
$app->make(GraphQlDenormalizeProvider::class),
$app->make(ResourceAccessCheckerInterface::class)
),
),
new ServiceLocator($tagged)
);
});
$app->alias(GraphQlReadProvider::class, 'api_platform.graphql.state_provider');

$app->singleton('api_platform.graphql.state_provider.access_checker', function (Application $app) {
return new AccessCheckerProvider($app->make('api_platform.graphql.state_provider.parameter'), $app->make(ResourceAccessCheckerInterface::class));
});

$app->singleton(NormalizeProcessor::class, function (Application $app) {
return new NormalizeProcessor(
Expand All @@ -1200,7 +1200,7 @@ private function registerGraphQl(Application $app): void

$app->singleton(ResolverFactoryInterface::class, function (Application $app) {
return new ResolverFactory(
$app->make('api_platform.graphql.state_provider'),
$app->make('api_platform.graphql.state_provider.access_checker'),
$app->make('api_platform.graphql.state_processor')
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\DeleteMutation;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
Expand All @@ -39,6 +44,12 @@ final class EloquentResourceCollectionMetadataFactory implements ResourceMetadat
GetCollection::class => 'viewAny',
Delete::class => 'delete',
Patch::class => 'update',

Query::class => 'view',
QueryCollection::class => 'viewAny',
Mutation::class => 'update',
DeleteMutation::class => 'delete',
Subscription::class => 'viewAny',
];

public function __construct(
Expand Down Expand Up @@ -94,6 +105,12 @@ public function create(string $resourceClass): ResourceMetadataCollection
$graphQlOperations = $resourceMetadata->getGraphQlOperations();

foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) {
if (!$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) {
if (($policyMethod = self::POLICY_METHODS[$graphQlOperation::class] ?? null) && method_exists($policy, $policyMethod)) {
$graphQlOperation = $graphQlOperation->withPolicy($policyMethod);
}
}

if (!$graphQlOperation->getProvider()) {
$graphQlOperation = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class);
}
Expand Down
4 changes: 3 additions & 1 deletion src/Laravel/State/AccessCheckerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

namespace ApiPlatform\Laravel\State;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use ApiPlatform\State\ProviderInterface;
use Illuminate\Auth\Access\AuthorizationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
* Allows access based on the ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface.
Expand Down Expand Up @@ -54,7 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
];

if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $policy, $resourceAccessCheckerContext)) {
throw new AuthorizationException($message ?? 'Access Denied.');
throw $operation instanceof HttpOperation ? new AuthorizationException($message ?? 'Access Denied.') : new AccessDeniedHttpException($message ?? 'Access Denied.');
}

return $body;
Expand Down
1 change: 0 additions & 1 deletion src/Laravel/Tests/AuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
namespace ApiPlatform\Laravel\Tests;

use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
Expand Down
65 changes: 60 additions & 5 deletions src/Laravel/Tests/GraphQlAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\Attributes\DefineEnvironment;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;

Expand All @@ -36,13 +37,67 @@ protected function defineEnvironment($app): void
});
}

public function testUnauthenticated(): void
{
$response = $this->postJson('/api/graphql', [], []);
$response->assertStatus(401);
}

public function testAuthenticated(): void
{
// $response = $this->post('/tokens/create');
// $token = $response->json()['token'];
$response = $this->post('/api/graphql', ['accept' => ['application/json']]);
//, 'authorization' => 'Bearer '.$token]
dd($response->json());
$response = $this->post('/tokens/create');
$token = $response->json()['token'];
$response = $this->get('/api/graphql', ['accept' => ['text/html'], 'authorization' => 'Bearer '.$token]);
$response->assertStatus(200);
$response = $this->postJson('/api/graphql', ['query' => '{books { edges { node {id, name, publicationDate, author {id, name }}}}}'], ['accept' => ['application/json'], 'authorization' => 'Bearer '.$token]);
$response->assertStatus(200);
$data = $response->json();
$this->assertArrayHasKey('data', $data);
$this->assertArrayNotHasKey('errors', $data);
}

public function testPolicy(): void
{
$response = $this->post('/tokens/create');
$token = $response->json()['token'];
$response = $this->postJson('/api/graphql', ['query' => 'mutation {
updateVault(input: {secret: "secret", id: "/api/vaults/1"}) {
vault {id}
}
}
'], ['accept' => ['application/json'], 'authorization' => 'Bearer '.$token]);
$response->assertStatus(200);
$data = $response->json();
$this->assertArrayHasKey('errors', $data);
$this->assertEquals('Access Denied.', $data['errors'][0]['message']);
}

/**
* @param Application $app
*/
protected function useProductionMode($app): void
{
tap($app['config'], function (Repository $config): void {
$config->set('api-platform.routes.middleware', ['auth:sanctum']);
$config->set('api-platform.graphql.enabled', true);
$config->set('app.debug', false);
});
}

#[DefineEnvironment('useProductionMode')]
public function testProductionError(): void
{
$response = $this->post('/tokens/create');
$token = $response->json()['token'];
$response = $this->postJson('/api/graphql', ['query' => 'mutation {
updateVault(input: {secret: "secret", id: "/api/vaults/1"}) {
vault {id}
}
}
'], ['accept' => ['application/json'], 'authorization' => 'Bearer '.$token]);
$response->assertStatus(200);
$data = $response->json();
$this->assertArrayHasKey('errors', $data);
$this->assertArrayNotHasKey('trace', $data['errors'][0]);
}
}
47 changes: 47 additions & 0 deletions src/Laravel/Tests/GraphQlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;

use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;

class GraphQlTest extends TestCase
{
use ApiTestAssertionsTrait;
use RefreshDatabase;
use WithWorkbench;

/**
* @param Application $app
*/
protected function defineEnvironment($app): void
{
tap($app['config'], function (Repository $config): void {
$config->set('api-platform.graphql.enabled', true);
});
}

public function testGetBooks(): void
{
$response = $this->postJson('/api/graphql', ['query' => '{books { edges { node {id, name, publicationDate, author {id, name }}}}}'], ['accept' => ['application/json']]);
$response->assertStatus(200);
$data = $response->json();
$this->assertArrayHasKey('data', $data);
$this->assertArrayNotHasKey('errors', $data);
}
}
4 changes: 3 additions & 1 deletion src/Laravel/workbench/app/Models/Vault.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\Post;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand All @@ -33,7 +34,8 @@
write: false
),
new Delete(middleware: 'auth:sanctum', rules: VaultFormRequest::class, provider: [self::class, 'provide']),
]
],
graphQlOperations: [new Mutation(name: 'update', policy: 'update')]
)]
class Vault extends Model
{
Expand Down
1 change: 1 addition & 0 deletions src/Laravel/workbench/database/factories/VaultFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace Workbench\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Workbench\App\Models\Vault;

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Laravel/workbench/database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Workbench\Database\Factories\SluggableFactory;
use Workbench\Database\Factories\UserFactory;
use Workbench\Database\Factories\WithAccessorFactory;
use Workbench\Database\Factories\VaultFactory;

class DatabaseSeeder extends Seeder
{
Expand All @@ -34,5 +35,6 @@ public function run(): void
SluggableFactory::new()->count(10)->create();
UserFactory::new()->create();
WithAccessorFactory::new()->create();
VaultFactory::new()->count(10)->create();
}
}
4 changes: 4 additions & 0 deletions src/Metadata/GraphQl/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ public function __construct(
?OptionsInterface $stateOptions = null,
array|Parameters|null $parameters = null,
?bool $queryParameterValidationEnabled = null,
mixed $rules = null,
?string $policy = null,
array $extraProperties = [],
) {
parent::__construct(
Expand Down Expand Up @@ -139,6 +141,8 @@ class: $class,
stateOptions: $stateOptions,
parameters: $parameters,
queryParameterValidationEnabled: $queryParameterValidationEnabled,
rules: $rules,
policy: $policy,
extraProperties: $extraProperties
);
}
Expand Down
Loading

0 comments on commit 3dde0f6

Please sign in to comment.