diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc2026..8522945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING**: Don't merge framework configuration. +- **BREAKING**: Update how authorization is done in GraphqlController. ### Removed - **BREAKING**: Middleware Authenticate. diff --git a/README.md b/README.md index 519452e..7b7467e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ $consumer->createToken(['query'], 'my read-only token'); // allow "mutation" operations only $consumer->createToken(['mutation'], 'my write-only token'); +// allow specific operations +$consumer->createToken(['query:ping'], 'my "ping" token'); +$consumer->createToken(['query', 'mutation:start'], 'my "start" token'); + // allow any operations $consumer->createToken(['*'], 'my full-access token'); $consumer->createToken(['query', 'mutation', 'subscription'], 'my graphql token'); diff --git a/src/Http/Controllers/GraphqlController.php b/src/Http/Controllers/GraphqlController.php index 23495aa..cbb233d 100644 --- a/src/Http/Controllers/GraphqlController.php +++ b/src/Http/Controllers/GraphqlController.php @@ -6,8 +6,11 @@ use Butler\Graphql\Concerns\HandlesGraphqlRequests; use GraphQL\Language\AST\DocumentNode; +use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Type\Schema; use Illuminate\Auth\Middleware\Authenticate; +use Illuminate\Support\Facades\Gate; class GraphqlController extends Controller { @@ -20,11 +23,33 @@ public function __construct() protected function beforeExecutionHook(Schema $schema, DocumentNode $source): void { - collect($source->toArray(true)['definitions'] ?? null) - ->pluck('operation') - ->unique() - ->filter() - ->whenEmpty(fn () => abort(400, 'Invalid operation.')) - ->each(fn ($operation) => $this->authorize('graphql', $operation)); + $this->authorizeQuery($source); + } + + protected function authorizeQuery(DocumentNode $source): void + { + collect($source->definitions) + ->filter(fn ($definition) => $definition instanceof OperationDefinitionNode) + ->each(function (OperationDefinitionNode $definition) { + $operation = $definition->operation; + + if (Gate::allows('graphql', $operation)) { + return; + } + + collect($definition->selectionSet->selections) + ->map(fn (FieldNode $node) => $node->name->value) + ->reject(fn (string $type) => $this->isIntrospectionType($operation, $type)) + ->each(fn (string $type) => Gate::authorize('graphql', "{$operation}:{$type}")); + }); + } + + protected function isIntrospectionType(string $operation, string $type): bool + { + return $operation === 'query' && in_array(strtolower($type), [ + '__schema', + '__type', + '__typename', + ]); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6e28f65..9fd5110 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -227,9 +227,9 @@ protected function loadPublishing() public function defineGateAbilities() { - Gate::define('graphql', function ($user, $operation) { + Gate::define('graphql', function ($user, $ability) { if ($user instanceof HasAccessTokens) { - return $user->tokenCan($operation); + return $user->tokenCan($ability); } return false; diff --git a/tests/Feature/GraphqlTest.php b/tests/Feature/GraphqlTest.php index 8fa2599..539e497 100644 --- a/tests/Feature/GraphqlTest.php +++ b/tests/Feature/GraphqlTest.php @@ -6,6 +6,7 @@ use Butler\Service\Testing\Concerns\InteractsWithAuthentication; use Butler\Service\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class GraphqlTest extends TestCase { @@ -20,64 +21,93 @@ public function test_as_guest() ]); } - public function test_query_for_consumer_without_query_ability_is_forbidden() + #[DataProvider('queryAbilitiesProvider')] + public function test_query(bool $expectOk, array $abilities) { - $this->actingAsConsumer(abilities: []) - ->graphql('{ ping }') - ->assertForbidden() - ->assertExactJson([ - 'message' => 'This action is unauthorized.', - ]); + $this->actingAsConsumer(abilities: $abilities)->graphql('{ ping }')->when( + $expectOk, + fn ($response) => $response + ->assertOk() + ->assertJsonPath('data.ping', 'pong'), + fn ($response) => $response + ->assertForbidden() + ->assertExactJson(['message' => 'This action is unauthorized.']), + ); } - public function test_query_for_consumer_with_query_ability_is_allowed() + public static function queryAbilitiesProvider() { - $this->actingAsConsumer(abilities: ['query']) - ->graphql('{ ping }') - ->assertOk() - ->assertJsonPath('data.ping', 'pong'); - } + return [ + [false, []], + [false, ['foobar']], + [false, ['query:pong']], + [false, ['mutation']], + [false, ['mutation:stop']], - public function test_query_for_consumer_with_all_abilities_is_allowed() - { - $this->actingAsConsumer(abilities: ['*']) - ->graphql('{ ping }') - ->assertOk() - ->assertJsonPath('data.ping', 'pong'); + [true, ['*']], + [true, ['query']], + [true, ['query:ping']], + [true, ['query:ping', 'query']], + [true, ['query:ping', 'mutation']], + ]; } - public function test_mutation_for_consumer_without_mutation_ability_is_forbidden() + #[DataProvider('mutationAbilitiesProvider')] + public function test_mutation(bool $expectOk, array $abilities) { - $this->actingAsConsumer(abilities: ['query']) - ->graphql('mutation { start }') - ->assertForbidden() - ->assertExactJson([ - 'message' => 'This action is unauthorized.', - ]); + $this->actingAsConsumer(abilities: $abilities)->graphql('mutation { start }')->when( + $expectOk, + fn ($response) => $response + ->assertOk() + ->assertJsonPath('data.start', 'started'), + fn ($response) => $response + ->assertForbidden() + ->assertExactJson(['message' => 'This action is unauthorized.']), + ); } - public function test_mutation_for_consumer_with_mutation_ability_is_allowed() + public static function mutationAbilitiesProvider() { - $this->actingAsConsumer(abilities: ['mutation']) - ->graphql('mutation { start }') - ->assertOk() - ->assertJsonPath('data.start', 'started'); - } + return [ + [false, []], + [false, ['foobar']], + [false, ['mutation:stop']], + [false, ['query']], + [false, ['query:stop']], - public function test_mutation_for_consumer_with_all_abilities_is_allowed() - { - $this->actingAsConsumer(abilities: ['*']) - ->graphql('mutation { start }') - ->assertOk() - ->assertJsonPath('data.start', 'started'); + [true, ['*']], + [true, ['mutation']], + [true, ['mutation:start']], + [true, ['mutation:start', 'mutation']], + [true, ['mutation:start', 'query']], + ]; } - public function test_query_without_operation_is_not_allowed() + public function test_introspection_is_allowed_without_abilities() { - $this->actingAsConsumer() - ->graphql('fragment Foo on __Bar { baz }') - ->assertStatus(400) - ->assertExactJson(['message' => 'Invalid operation.']); + $this->actingAsConsumer(abilities: []) + ->graphql(<<<'GQL' + { + __schema { + types { + ...someTypeFragment + } + } + __type(name: "Query") { + name + } + __typename + } + + fragment someTypeFragment on __Type { + name + } + GQL, + ) + ->assertOk() + ->assertJsonPath('data.__schema.types.0.name', 'Query') + ->assertJsonPath('data.__type.name', 'Query') + ->assertJsonPath('data.__typename', 'Query'); } private function graphql($query)