diff --git a/CHANGELOG.md b/CHANGELOG.md index 058eab2b4..ff1f3821e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Fixed + +- Apply the `action` of `@canResolved` and friends when the field resolves to a promise, such as a batch loaded relation https://github.com/nuwave/lighthouse/pull/2786 + ## v6.69.2 ### Fixed diff --git a/src/Auth/BaseCanDirective.php b/src/Auth/BaseCanDirective.php index 0d158b4f6..3401ec37c 100644 --- a/src/Auth/BaseCanDirective.php +++ b/src/Auth/BaseCanDirective.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Auth; +use GraphQL\Executor\Promise\Adapter\SyncPromise; use Illuminate\Contracts\Auth\Access\Gate; use Nuwave\Lighthouse\Exceptions\AuthorizationException; use Nuwave\Lighthouse\Execution\ResolveInfo; @@ -103,19 +104,15 @@ public function handleField(FieldValue $fieldValue): void try { $resolved = $this->authorizeRequest($root, $args, $context, $resolveInfo, $trackedResolver, $authorizeModel); if ($hasResolved) { - return $resolved; + // A batch loaded relation resolves to a promise, so authorization runs when + // that promise is fulfilled, which is after this try-catch has been left. + // Handle its failure the same way to keep `action` meaningful either way. + return $resolved instanceof SyncPromise + ? $resolved->then(null, fn (\Throwable $throwable): mixed => $this->handleAuthorizationFailure($throwable)) + : $resolved; } } catch (\Throwable $throwable) { - $action = $this->directiveArgValue('action'); - if ($action === 'EXCEPTION_NOT_AUTHORIZED') { - throw new AuthorizationException(AuthorizationException::MESSAGE, $throwable->getCode(), $throwable); - } - - if ($action === 'RETURN_VALUE') { - return $this->directiveArgValue('returnValue'); - } - - throw $throwable; + return $this->handleAuthorizationFailure($throwable); } // Try to resolve the field outside the authorization try-catch block to avoid catching resolver exceptions. @@ -123,6 +120,22 @@ public function handleField(FieldValue $fieldValue): void }); } + /** Apply the configured `action` to an authorization failure. */ + protected function handleAuthorizationFailure(\Throwable $throwable): mixed + { + $action = $this->directiveArgValue('action'); + + if ($action === 'EXCEPTION_NOT_AUTHORIZED') { + throw new AuthorizationException(AuthorizationException::MESSAGE, $throwable->getCode(), $throwable); + } + + if ($action === 'RETURN_VALUE') { + return $this->directiveArgValue('returnValue'); + } + + throw $throwable; + } + /** * Authorizes request and optionally resolves the field. * diff --git a/tests/Integration/Auth/CanResolvedDirectiveDBTest.php b/tests/Integration/Auth/CanResolvedDirectiveDBTest.php index 0d83ad482..12f9cf8a6 100644 --- a/tests/Integration/Auth/CanResolvedDirectiveDBTest.php +++ b/tests/Integration/Auth/CanResolvedDirectiveDBTest.php @@ -100,6 +100,52 @@ public function testChecksAgainstRelation(): void ]); } + public function testReturnValueActionAppliesToBatchloadedRelation(): void + { + $viewer = new User(); + $viewer->name = 'not an admin'; + $this->be($viewer); + + $company = factory(Company::class)->create(); + + $user = factory(User::class)->make(); + $this->assertInstanceOf(User::class, $user); + $user->company()->associate($company); + $user->save(); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Query { + company: Company @first + } + + type Company { + users: [User!] + @canResolved(ability: "adminOnly", action: RETURN_VALUE, returnValue: null) + @hasMany + } + + type User { + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + { + company { + users { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'company' => [ + 'users' => null, + ], + ], + ])->assertJsonMissingPath('errors'); + } + public function testChecksAgainstMissingResolvedModelWithFind(): void { $user = new User();