Skip to content

Commit

Permalink
Update authorization in GraphqlController
Browse files Browse the repository at this point in the history
  • Loading branch information
ttrig committed Aug 5, 2024
1 parent a458f7b commit 97e29ab
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 51 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
37 changes: 31 additions & 6 deletions src/Http/Controllers/GraphqlController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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',
]);
}
}
4 changes: 2 additions & 2 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 73 additions & 43 deletions tests/Feature/GraphqlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Butler\Service\Testing\Concerns\InteractsWithAuthentication;
use Butler\Service\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class GraphqlTest extends TestCase
{
Expand All @@ -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)
Expand Down

0 comments on commit 97e29ab

Please sign in to comment.