diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e719dcea..60e97f1308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Split up `@can` directive into `@canFind`, `@canModel`, `@canQuery`, `@canResolved` and `@canRoot` https://github.com/nuwave/lighthouse/pull/2483 +- Added `action` and `returnValue` arguments to `@can*` family of directives https://github.com/nuwave/lighthouse/pull/2483 +- Allows using any objects in `@can*` family of directives https://github.com/nuwave/lighthouse/pull/2483 + ## v6.26.1 ### Fixed diff --git a/UPGRADE.md b/UPGRADE.md index 59e24c2287..d5a8c6c14e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -24,6 +24,36 @@ It will prevent the following type of HTTP requests: - `GET` requests - `POST` requests that can be created using HTML forms +### `@can` directive is replaced with `@can*` directives + +The `@can` directive was removed in favor of more specialized directives: +- with `find` field set: `@canFind` +- with `query` field set: `@canQuery` +- with `root` field set: `@canRoot` +- with `resolved` field set: `@canResolved` +- if none of the above are set: `@canModel` + +```diff +type Mutation { +- createPost(input: PostInput! @spread): Post! @can(ability: "create") @create ++ createPost(input: PostInput! @spread): Post! @canModel(ability: "create") @create +- updatePost(input: PostInput! @spread): Post! @can(find: "input.id", ability: "edit") @update ++ updatePost(input: PostInput! @spread): Post! @canFind(find: "input.id", ability: "edit") @update +- deletePosts(ids: [ID!]! @whereKey): [Post!]! @can(query: true, ability: "delete") @delete ++ deletePosts(ids: [ID!]! @whereKey): [Post!]! @canQuery(ability: "delete") @delete +} + +type Query { +- posts: [Post!]! @can(resolved: true, ability: "view") @paginate ++ posts: [Post!]! @canResolved(ability: "view") @paginate +} + +type Post { +- sensitiveInformation: String @can(root: true, ability: "admin") ++ sensitiveInformation: String @canRoot(ability: "admin") +} +``` + ## v5 to v6 ### `messages` on `@rules` and `@rulesForArray` diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index dfeddf97e0..7c47fa4577 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -604,68 +604,86 @@ You can find usage examples of this directive in [the caching docs](../performan ## @can +Deprecated. Use the [@can* family of directives](#can-family-of-directives) instead. + +## @can* family of directives + +All @can* directives have common arguments. These arguments specify how gates are checked and what to do if the user is not authorized. +Each directive has its own set of arguments that specify what to check against. + ```graphql + """ +The ability to check permissions for. """ -Check a Laravel Policy to ensure the current user is authorized to access a field. +ability: String! -When `injectArgs` and `args` are used together, the client given -arguments will be passed before the static args. """ -directive @can( - """ - The ability to check permissions for. - """ - ability: String! +Pass along the client given input data as arguments to `Gate::check`. +""" +injectArgs: Boolean! = false - """ - Check the policy against the model instances returned by the field resolver. - Only use this if the field does not mutate data, it is run before checking. +""" +Statically defined arguments that are passed to `Gate::check`. - Mutually exclusive with `query`, `find`, and `root`. - """ - resolved: Boolean! = false +You may pass arbitrary GraphQL literals, +e.g.: [1, 2, 3] or { foo: "bar" } +""" +args: CanArgs - """ - Specify the class name of the model to use. - This is only needed when the default model detection does not work. - """ - model: String +""" +Action to do if the user is not authorized. +""" +action: CanAction! = EXCEPTION_PASS - """ - Pass along the client given input data as arguments to `Gate::check`. - """ - injectArgs: Boolean! = false +""" +Value to return if the user is not authorized and `action` is `RETURN_VALUE`. +""" +returnValue: CanArgs +""" +``` - """ - Statically defined arguments that are passed to `Gate::check`. +Types are specified as: +```graphql +""" +Any constant literal value: https://graphql.github.io/graphql-spec/draft/#sec-Input-Values +""" +scalar CanArgs - You may pass arbitrary GraphQL literals, - e.g.: [1, 2, 3] or { foo: "bar" } - """ - args: CanArgs +enum CanAction { + """ + Pass exception to the client. + """ + EXCEPTION_PASS - """ - Query for specific model instances to check the policy against, using arguments - with directives that add constraints to the query builder, such as `@eq`. + """ + Throw generic "not authorized" exception to conceal the real error. + """ + EXCEPTION_NOT_AUTHORIZED - Mutually exclusive with `resolved`, `find`, and `root`. - """ - query: Boolean! = false + """ + Return the value specified in `returnValue` argument to conceal the real error. + """ + RETURN_VALUE +} +``` - """ - Apply scopes to the underlying query. - """ - scopes: [String!] +You can find usage examples of these directives in [the authorization docs](../security/authorization.md#restrict-fields-through-policies). + +### @canFind +```graphql +""" +Check a Laravel Policy to ensure the current user is authorized to access a field. + +Query for specific model instances to check the policy against, using primary key(s) from specified argument. +""" +directive @canFind( """ - If your policy checks against specific model instances, specify - the name of the field argument that contains its primary key(s). + Specify the name of the field argument that contains its primary key(s). You may pass the string in dot notation to use nested inputs. - - Mutually exclusive with `resolved`, `query`, and `root`. """ - find: String + find: String! """ Should the query fail when the models of `find` were not found? @@ -673,40 +691,68 @@ directive @can( findOrFail: Boolean! = true """ - If your policy should check against the root value. - - Mutually exclusive with `resolved`, `query`, and `find`. + Apply scopes to the underlying query. """ - root: Boolean! = false + scopes: [String!] ) repeatable on FIELD_DEFINITION +``` +### canModel + +```graphql """ -Any constant literal value: https://graphql.github.io/graphql-spec/draft/#sec-Input-Values +Check a Laravel Policy to ensure the current user is authorized to access a field. + +Check the policy against the root model. """ -scalar CanArgs +directive @canRoot( + """ + The model name to check against. + """ + model: String + +) repeatable on FIELD_DEFINITION ``` -The name of the returned Type `Post` is used as the Model class, however you may overwrite this by -passing the `model` argument: +### @canQuery ```graphql -type Mutation { - createBlogPost(input: PostInput!): BlogPost - @can(ability: "create", model: "App\\Post") -} +""" +Check a Laravel Policy to ensure the current user is authorized to access a field. + +Query for specific model instances to check the policy against, using arguments +with directives that add constraints to the query builder, such as `@eq`. +""" +directive @canQuery( + """ + Apply scopes to the underlying query. + """ + scopes: [String!] +) repeatable on FIELD_DEFINITION ``` -Check the policy against the resolved model instances with the `resolved` argument: +### @canResolved ```graphql -type Query { - fetchUserByEmail(email: String! @eq): User - @can(ability: "view", resolved: true) - @find -} +""" +Check a Laravel Policy to ensure the current user is authorized to access a field. + +Check the policy against the model instances returned by the field resolver. +Only use this if the field does not mutate data, it is run before checking. +""" +directive @canResolved repeatable on FIELD_DEFINITION ``` -You can find usage examples of this directive in [the authorization docs](../security/authorization.md#restrict-fields-through-policies). +### @canRoot + +```graphql +""" +Check a Laravel Policy to ensure the current user is authorized to access a field. + +Check the policy against the root object. +""" +directive @canRoot repeatable on FIELD_DEFINITION +``` ## @clearCache diff --git a/docs/master/eloquent/nested-mutations.md b/docs/master/eloquent/nested-mutations.md index 635d5ac3d2..4d376c9260 100644 --- a/docs/master/eloquent/nested-mutations.md +++ b/docs/master/eloquent/nested-mutations.md @@ -48,7 +48,7 @@ See [this issue](https://github.com/nuwave/lighthouse/issues/900) for further di ## Security considerations Lighthouse has no mechanism for fine-grained permissions of nested mutation operations. -Field directives such as [@can](../api-reference/directives.md#can) apply to the whole field. +Field directives such as [@can*](../api-reference/directives.md#can-family-of-directives) apply to the whole field. Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations. diff --git a/docs/master/security/authorization.md b/docs/master/security/authorization.md index 2f6d583f93..b99b3b3f6f 100644 --- a/docs/master/security/authorization.md +++ b/docs/master/security/authorization.md @@ -57,7 +57,7 @@ limited to seeing just those. ## Restrict fields through policies Lighthouse allows you to restrict field operations to a certain group of users. -Use the [@can](../api-reference/directives.md#can) directive +Use the [@can*](../api-reference/directives.md#can-family-of-directives) family of directives to leverage [Laravel Policies](https://laravel.com/docs/authorization) for authorization. Starting from Laravel 5.7, [authorization of guest users](https://laravel.com/docs/authorization#guest-users) is supported. @@ -66,11 +66,11 @@ Because of this, Lighthouse does **not** validate that the user is authenticated ### Protect mutations As an example, you might want to allow only admin users of your application to create posts. -Start out by defining [@can](../api-reference/directives.md#can) upon a mutation you want to protect: +Start out by defining [@canModel](../api-reference/directives.md#canmodel) upon a mutation you want to protect: ```graphql type Mutation { - createPost(input: PostInput): Post @can(ability: "create") + createPost(input: PostInput): Post @canModel(ability: "create") } ``` @@ -86,18 +86,44 @@ final class PostPolicy } ``` -### Protect specific model instances +### Protect mutations using database queries -For some models, you may want to restrict access for specific instances of a model. -Set the `resolved` argument to `true` to have Lighthouse check permissions against -the resolved model instances. +You can also protect specific models by using the [@canFind](../api-reference/directives.md#canfind) +or [@canQuery](../api-reference/directives.md#canquery) directive. +They will query the database and check the specified policy against the result. + +```graphql +type Mutation { + updatePost(input: UpdatePostInput! @spread): Post! @canFind(ability: "edit", find: "input.id") @update +} + +input PostInput { + id: ID! + title: String +} +``` + +```php +final class PostPolicy +{ + public function edit(User $user, Post $post): bool + { + return $user->id === $post->author_id; + } +} +``` + +### Protect resolved model instances + +For some models, you may want to restrict access for already resolved instance of a model. +Use the [@canResolved](../api-reference/directives.md#canresolved) directive to do so. > This will actually run the field before checking permissions, do not use in mutations. ```graphql type Query { post(id: ID! @whereKey): Post - @can(ability: "view", resolved: true) + @canResolved(ability: "view") @find @softDeletes } @@ -113,14 +139,42 @@ final class PostPolicy } ``` +### Protect fields + +You can protect fields with the [@canRoot](../api-reference/directives.md#canroot) directive. +It checks against the resolved root object. + +This example shows how to restrict reading the `email` field to only the user itself: + +```graphql +type Query { + user(id: ID! @whereKey): User @find +} + +type User { + email: String! @canRoot(ability: "viewEmail") +} +``` + +```php +final class UserPolicy +{ + public function viewEmail(User $actor, User $target): bool + { + return $actor->id === $target->author_id; + } +} +``` + ### Passing additional arguments You can pass additional arguments to the policy checks by specifying them as `args`: ```graphql type Mutation { - createPost(input: PostInput): Post - @can(ability: "create", args: ["FROM_GRAPHQL"]) + createPost(input: CreatePostInput! @spread): Post! + @create + @canModel(ability: "create", args: ["FROM_GRAPHQL"]) } ``` @@ -139,7 +193,7 @@ with the `injectArgs` argument: ```graphql type Mutation { - createPost(title: String!): Post @can(ability: "create", injectArgs: true) + createPost(title: String!): Post @canModel(ability: "create", injectArgs: true) @create } ``` @@ -163,6 +217,26 @@ final class PostPolicy } ``` +### Concealing the existence of a model or other errors + +When a user is not authorized to access a model, you may want to hide the existence of the model. +This can be done by setting action to either EXCEPTION_NOT_AUTHORIZED or RETURN_VALUE. + +In the first case it would always return the generic "not authorized" exception. +In the second case it would return value which you can specify in the `returnValue` argument. + +```graphql +type Query { + user(id: ID! @whereKey): User @find +} + +type User { + banned: Boolean! @canRoot(ability: "admin", action: RETURN_VALUE, returnValue: false) +} +``` + +The `banned` field would return false for all users who are not authorized to access it. + ## Custom field restrictions For applications with role management, it is common to hide some model attributes from a diff --git a/src/Auth/BaseCanDirective.php b/src/Auth/BaseCanDirective.php new file mode 100644 index 0000000000..abb75a6fee --- /dev/null +++ b/src/Auth/BaseCanDirective.php @@ -0,0 +1,161 @@ +directiveArgValue('ability'); + + $fieldValue->wrapResolver(fn (callable $resolver): \Closure => function (mixed $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($resolver, $ability) { + $gate = $this->gate->forUser($context->user()); + $checkArguments = $this->buildCheckArguments($args); + $authorizeModel = fn (mixed $model) => $this->authorizeModel($gate, $ability, $model, $checkArguments); + + try { + return $this->authorizeRequest($root, $args, $context, $resolveInfo, $resolver, $authorizeModel); + } catch (\Throwable $e) { + $action = $this->directiveArgValue('action'); + if ($action === 'EXCEPTION_NOT_AUTHORIZED') { + throw new AuthorizationException(); + } + + if ($action === 'RETURN_VALUE') { + return $this->directiveArgValue('returnValue'); + } + + throw $e; + } + }); + } + + /** + * Authorizes request and resolves the field. + * + * @phpstan-import-type Resolver from \Nuwave\Lighthouse\Schema\Values\FieldValue as Resolver + * + * @param array $args + * @param callable(mixed, array, \Nuwave\Lighthouse\Support\Contracts\GraphQLContext, \Nuwave\Lighthouse\Execution\ResolveInfo): mixed $resolver + * @param callable(mixed): void $authorize + */ + abstract protected function authorizeRequest(mixed $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo, callable $resolver, callable $authorize): mixed; + + /** + * @param string|array $ability + * @param array $arguments + */ + protected function authorizeModel(Gate $gate, string|array $ability, mixed $model, array $arguments): void + { + // The signature of the second argument `$arguments` of `Gate::check` + // should be [modelClassName, additionalArg, additionalArg...] + array_unshift($arguments, $model); + + Utils::applyEach( + static function ($ability) use ($gate, $arguments): void { + $response = $gate->inspect($ability, $arguments); + if ($response->denied()) { + throw new AuthorizationException($response->message(), $response->code()); + } + }, + $ability, + ); + } + + /** + * Additional arguments that are passed to @see Gate::check(). + * + * @param array $args + * + * @return array + */ + protected function buildCheckArguments(array $args): array + { + $checkArguments = []; + + // The injected args come before the static args + if ($this->directiveArgValue('injectArgs')) { + $checkArguments[] = $args; + } + + if ($this->directiveHasArgument('args')) { + $checkArguments[] = $this->directiveArgValue('args'); + } + + return $checkArguments; + } +} diff --git a/src/Auth/CanFindDirective.php b/src/Auth/CanFindDirective.php new file mode 100644 index 0000000000..71ced6f724 --- /dev/null +++ b/src/Auth/CanFindDirective.php @@ -0,0 +1,130 @@ +modelsToCheck($root, $args, $context, $resolveInfo) as $model) { + $authorize($model); + } + + return $resolver($root, $args, $context, $resolveInfo); + } + + /** + * @param array $args + * + * @return iterable<\Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model>> + */ + protected function modelsToCheck(mixed $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): iterable + { + $find = $this->directiveArgValue('find'); + $findValue = Arr::get($args, $find) + ?? throw self::missingKeyToFindModel($find); + + $queryBuilder = $this->getModelClass()::query(); + + $argumentSetDirectives = $resolveInfo->argumentSet->directives; + $directivesContainsForceDelete = $argumentSetDirectives->contains( + Utils::instanceofMatcher(ForceDeleteDirective::class), + ); + if ($directivesContainsForceDelete) { + /** @see \Illuminate\Database\Eloquent\SoftDeletes */ + // @phpstan-ignore-next-line because it involves mixins + $queryBuilder->withTrashed(); + } + + $directivesContainsRestore = $argumentSetDirectives->contains( + Utils::instanceofMatcher(RestoreDirective::class), + ); + if ($directivesContainsRestore) { + /** @see \Illuminate\Database\Eloquent\SoftDeletes */ + // @phpstan-ignore-next-line because it involves mixins + $queryBuilder->onlyTrashed(); + } + + try { + $enhancedBuilder = $resolveInfo->enhanceBuilder( + $queryBuilder, + $this->directiveArgValue('scopes', []), + $root, + $args, + $context, + $resolveInfo, + Utils::instanceofMatcher(TrashedDirective::class), + ); + assert($enhancedBuilder instanceof EloquentBuilder); + + $modelOrModels = $this->directiveArgValue('findOrFail', true) + ? $enhancedBuilder->findOrFail($findValue) + : $enhancedBuilder->find($findValue); + } catch (ModelNotFoundException $modelNotFoundException) { + throw new Error($modelNotFoundException->getMessage()); + } + + if ($modelOrModels instanceof Model) { + return [$modelOrModels]; + } + + if ($modelOrModels === null) { + return []; + } + + return $modelOrModels; + } + + public static function missingKeyToFindModel(string $find): Error + { + return new Error("Got no key to find a model at the expected input path: {$find}."); + } +} diff --git a/src/Auth/CanModelDirective.php b/src/Auth/CanModelDirective.php new file mode 100644 index 0000000000..6eadd7530e --- /dev/null +++ b/src/Auth/CanModelDirective.php @@ -0,0 +1,38 @@ +getModelClass()); + + return $resolver($root, $args, $context, $resolveInfo); + } +} diff --git a/src/Auth/CanQueryDirective.php b/src/Auth/CanQueryDirective.php new file mode 100644 index 0000000000..c8046f454e --- /dev/null +++ b/src/Auth/CanQueryDirective.php @@ -0,0 +1,50 @@ +enhanceBuilder( + $this->getModelClass()::query(), + $this->directiveArgValue('scopes', []), + $root, + $args, + $context, + $resolveInfo, + ) + ->get(); + foreach ($models as $model) { + $authorize($model); + } + + return $resolver($root, $args, $context, $resolveInfo); + } +} diff --git a/src/Auth/CanResolvedDirective.php b/src/Auth/CanResolvedDirective.php new file mode 100644 index 0000000000..7e4ee8e85e --- /dev/null +++ b/src/Auth/CanResolvedDirective.php @@ -0,0 +1,61 @@ +items() + : $modelLike; + + Utils::applyEach(function (mixed $model) use ($authorize): void { + $authorize($model); + }, $modelOrModels); + + return $modelLike; + }, + ); + } + + public function manipulateFieldDefinition(DocumentAST &$documentAST, FieldDefinitionNode &$fieldDefinition, ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType): void + { + if ($parentType->name->value === RootType::MUTATION) { + throw new DefinitionException("Do not use @canResolved on mutation {$fieldDefinition->name->value}, it is unsafe as the resolver will run before checking permissions. Use @canFind or @canQuery instead."); + } + } +} diff --git a/src/Auth/CanRootDirective.php b/src/Auth/CanRootDirective.php new file mode 100644 index 0000000000..4f1814b28b --- /dev/null +++ b/src/Auth/CanRootDirective.php @@ -0,0 +1,32 @@ +schema = /** @lang GraphQL */ ' type Query { - user(id: ID @eq): User + user(id: ID @whereKey): User @can(ability: "view", find: "id") @first } @@ -63,7 +63,7 @@ public function testFailsToFindSpecificModel(): void $this->schema = /** @lang GraphQL */ ' type Query { - user(id: ID @eq): User + user(id: ID @whereKey): User @can(ability: "view", find: "id") @mock } @@ -101,7 +101,7 @@ public function testFailsToFindSpecificModelWithFindOrFailFalse(): void $this->schema = /** @lang GraphQL */ ' type Query { - user(id: ID @eq): User + user(id: ID @whereKey): User @can(ability: "view", find: "id", findOrFail: false) @mock } @@ -212,7 +212,7 @@ public function testThrowsIfNotAuthorized(): void $this->schema = /** @lang GraphQL */ ' type Query { - post(foo: ID! @eq): Post + post(foo: ID! @whereKey): Post @can(ability: "view", find: "foo") @mock } @@ -295,7 +295,7 @@ public function testWorksWithSoftDeletes(): void $this->schema = /** @lang GraphQL */ ' type Query { - task(id: ID! @eq): Task + task(id: ID! @whereKey): Task @can(ability: "adminOnly", find: "id") @softDeletes @find @@ -373,7 +373,7 @@ public function testFailsToFindSpecificModelWithQuery(): void $this->schema = /** @lang GraphQL */ ' type Query { - user(id: ID! @eq): User + user(id: ID! @whereKey): User @can(ability: "view", query: true) @find } @@ -458,7 +458,7 @@ public function testWorksWithSoftDeletesWithQuery(): void $this->schema = /** @lang GraphQL */ ' type Query { - task(id: ID! @eq): Task + task(id: ID! @whereKey): Task @can(ability: "adminOnly", query: true) @softDeletes @find @@ -587,7 +587,7 @@ public function testChecksAgainstMissingResolvedModelWithFind(): void $this->schema = /** @lang GraphQL */ ' type Query { - user(id: ID @eq): User + user(id: ID @whereKey): User @can(ability: "view", resolved: true) @find } diff --git a/tests/Integration/Auth/CanFindDirectiveDBTest.php b/tests/Integration/Auth/CanFindDirectiveDBTest.php new file mode 100644 index 0000000000..a75d36fdc9 --- /dev/null +++ b/tests/Integration/Auth/CanFindDirectiveDBTest.php @@ -0,0 +1,364 @@ +name = UserPolicy::ADMIN; + $this->be($admin); + + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canFind(ability: "view", find: "id") + @first + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(id: $id) { + name + } + } + ', [ + 'id' => $user->getKey(), + ])->assertJson([ + 'data' => [ + 'user' => [ + 'name' => $user->name, + ], + ], + ]); + } + + public function testFailsToFindSpecificModel(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolverExpects( + $this->never(), + ); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canFind(ability: "view", find: "id") + @mock + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user(id: "not-present") { + name + } + } + ')->assertJson([ + 'data' => [ + 'user' => null, + ], + 'errors' => [ + [ + 'message' => 'No query results for model [Tests\Utils\Models\User] not-present', + ], + ], + ]); + } + + public function testFailsToFindSpecificModelConcealException(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolverExpects( + $this->never(), + ); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canFind(ability: "view", find: "id", action: EXCEPTION_NOT_AUTHORIZED) + @mock + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user(id: "not-present") { + name + } + } + ')->assertJson([ + 'data' => [ + 'user' => null, + ], + 'errors' => [ + [ + 'message' => 'This action is unauthorized.', + ], + ], + ]); + } + + public function testFailsToFindSpecificModelWithFindOrFailFalse(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolver(null); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canFind(ability: "view", find: "id", findOrFail: false) + @mock + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user(id: "not-present") { + name + } + } + ')->assertExactJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testThrowsIfFindValueIsNotGiven(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID): User + @canFind(ability: "view", find: "some.path") + @first + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user { + name + } + } + ')->assertGraphQLError(CanDirective::missingKeyToFindModel('some.path')); + } + + public function testFindUsingNestedInputWithDotNotation(): void + { + $user = factory(User::class)->create(); + assert($user instanceof User); + $this->be($user); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(input: FindUserInput!): User + @canFind(ability: "view", find: "input.id") + @first + } + + type User { + name: String! + } + + input FindUserInput { + id: ID! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + user(input: { + id: $id + }) { + name + } + } + ', [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'name' => $user->name, + ], + ], + ]); + } + + public function testThrowsIfNotAuthorized(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $author = factory(User::class)->create(); + assert($author instanceof User); + + $post = factory(Post::class)->make(); + assert($post instanceof Post); + $post->user()->associate($author); + $post->save(); + + $this->mockResolverExpects( + $this->never(), + ); + + $this->schema = /** @lang GraphQL */ ' + type Query { + post(foo: ID! @whereKey): Post + @canFind(ability: "view", find: "foo") + @mock + } + + type Post { + title: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($foo: ID!) { + post(foo: $foo) { + title + } + } + ', [ + 'foo' => $post->id, + ])->assertGraphQLErrorMessage(AuthorizationException::MESSAGE); + } + + public function testHandleMultipleModels(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $postA = factory(Post::class)->make(); + assert($postA instanceof Post); + $postA->user()->associate($admin); + $postA->save(); + + $postB = factory(Post::class)->make(); + assert($postB instanceof Post); + $postB->user()->associate($admin); + $postB->save(); + + $this->schema = /** @lang GraphQL */ ' + type Mutation { + deletePosts(ids: [ID!]! @whereKey): [Post!]! + @canFind(ability: "delete", find: "ids") + @delete + } + + type Post { + title: String! + } + ' . self::PLACEHOLDER_QUERY; + + $this->graphQL(/** @lang GraphQL */ ' + mutation ($ids: [ID!]!) { + deletePosts(ids: $ids) { + title + } + } + ', [ + 'ids' => [$postA->id, $postB->id], + ])->assertJson([ + 'data' => [ + 'deletePosts' => [ + [ + 'title' => $postA->title, + ], + [ + 'title' => $postB->title, + ], + ], + ], + ]); + } + + public function testWorksWithSoftDeletes(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $task = factory(Task::class)->create(); + assert($task instanceof Task); + $task->delete(); + + $this->schema = /** @lang GraphQL */ ' + type Query { + task(id: ID! @whereKey): Task + @canFind(ability: "adminOnly", find: "id") + @softDeletes + @find + } + + type Task { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + task(id: $id, trashed: WITH) { + name + } + } + ', [ + 'id' => $task->id, + ])->assertJson([ + 'data' => [ + 'task' => [ + 'name' => $task->name, + ], + ], + ]); + } +} diff --git a/tests/Integration/Auth/CanQueryDirectiveDBTest.php b/tests/Integration/Auth/CanQueryDirectiveDBTest.php new file mode 100644 index 0000000000..8e63d1b389 --- /dev/null +++ b/tests/Integration/Auth/CanQueryDirectiveDBTest.php @@ -0,0 +1,175 @@ +name = UserPolicy::ADMIN; + $this->be($admin); + + $user = factory(User::class)->create(); + assert($user instanceof User); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(name: String! @eq): User + @canQuery(ability: "view") + @first + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($name: String!) { + user(name: $name) { + name + } + } + ', [ + 'name' => $user->name, + ])->assertJson([ + 'data' => [ + 'user' => [ + 'name' => $user->name, + ], + ], + ]); + } + + public function testFailsToFindSpecificModelWithQuery(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $this->mockResolverExpects( + $this->never(), + ); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canQuery(ability: "view", query: true) + @find + } + + type User { + id: ID! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user(id: "not-present") { + id + } + } + ')->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testHandleMultipleModelsWithQuery(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $postA = factory(Post::class)->make(); + assert($postA instanceof Post); + $postA->user()->associate($admin); + $postA->save(); + + $postB = factory(Post::class)->make(); + assert($postB instanceof Post); + $postB->user()->associate($admin); + $postB->save(); + + $this->schema = /** @lang GraphQL */ ' + type Mutation { + deletePosts(ids: [ID!]! @whereKey): [Post!]! + @canQuery(ability: "delete") + @delete + } + + type Post { + title: String! + } + ' . self::PLACEHOLDER_QUERY; + + $this->graphQL(/** @lang GraphQL */ ' + mutation ($ids: [ID!]!) { + deletePosts(ids: $ids) { + title + } + } + ', [ + 'ids' => [$postA->id, $postB->id], + ])->assertJson([ + 'data' => [ + 'deletePosts' => [ + [ + 'title' => $postA->title, + ], + [ + 'title' => $postB->title, + ], + ], + ], + ]); + } + + public function testWorksWithSoftDeletesWithQuery(): void + { + $admin = new User(); + $admin->name = UserPolicy::ADMIN; + $this->be($admin); + + $task = factory(Task::class)->create(); + assert($task instanceof Task); + $task->delete(); + + $this->schema = /** @lang GraphQL */ ' + type Query { + task(id: ID! @whereKey): Task + @canQuery(ability: "adminOnly") + @softDeletes + @find + } + + type Task { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + query ($id: ID!) { + task(id: $id, trashed: WITH) { + name + } + } + ', [ + 'id' => $task->id, + ])->assertJson([ + 'data' => [ + 'task' => [ + 'name' => $task->name, + ], + ], + ]); + } +} diff --git a/tests/Integration/Auth/CanResolvedDirectiveDBTest.php b/tests/Integration/Auth/CanResolvedDirectiveDBTest.php new file mode 100644 index 0000000000..23fe6b31a6 --- /dev/null +++ b/tests/Integration/Auth/CanResolvedDirectiveDBTest.php @@ -0,0 +1,140 @@ +name = UserPolicy::ADMIN; + $this->be($user); + + $user = factory(User::class)->create(); + + $this->schema = /** @lang GraphQL */ ' + type Query { + users: [User!]! + @canResolved(ability: "view") + @paginate + } + + type User { + name: String + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + users(first: 2) { + data { + name + } + } + } + ')->assertJson([ + 'data' => [ + 'users' => [ + 'data' => [ + [ + 'name' => $user->name, + ], + ], + ], + ], + ]); + } + + public function testChecksAgainstRelation(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $company = factory(Company::class)->create(); + + $user = factory(User::class)->make(); + assert($user instanceof User); + $user->company()->associate($company); + $user->save(); + + $this->schema = /** @lang GraphQL */ ' + type Query { + company: Company @first + } + + type Company { + users: [User!]! + @canResolved(ability: "view") + @hasMany + } + + type User { + name: String + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + company { + users { + name + } + } + } + ')->assertJson([ + 'data' => [ + 'company' => [ + 'users' => [ + [ + 'name' => $user->name, + ], + ], + ], + ], + ]); + } + + public function testChecksAgainstMissingResolvedModelWithFind(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $user = factory(User::class)->create(); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user(id: ID! @whereKey): User + @canResolved(ability: "view") + @find + } + + type User { + name: String! + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + { + user(id: "not-present") { + name + } + } + ')->assertJson([ + 'data' => [ + 'user' => null, + ], + 'errors' => [ + [ + 'message' => 'This action is unauthorized.', + ], + ], + ]); + } +} diff --git a/tests/Unit/Auth/CanDirectiveTestBase.php b/tests/Unit/Auth/CanDirectiveTestBase.php new file mode 100644 index 0000000000..021c07400a --- /dev/null +++ b/tests/Unit/Auth/CanDirectiveTestBase.php @@ -0,0 +1,180 @@ +graphQL($this->getQuery(), ['foo' => $foo]); + } + + public function testThrowsIfNotAuthorized(): void + { + $this->be(new User()); + + $this->schema = $this->getSchema('ability: "adminOnly"'); + + $this->query()->assertGraphQLErrorMessage(AuthorizationException::MESSAGE); + } + + public function testThrowsWithCustomMessageIfNotAuthorized(): void + { + $this->be(new User()); + + $this->schema = $this->getSchema('ability: "superAdminOnly"'); + + $this->query()->assertGraphQLErrorMessage(UserPolicy::SUPER_ADMINS_ONLY_MESSAGE); + } + + public function testThrowsFirstWithCustomMessageIfNotAuthorized(): void + { + $this->be(new User()); + + $this->schema = $this->getSchema('ability: ["superAdminOnly", "adminOnly"]'); + + $this->query()->assertGraphQLErrorMessage(UserPolicy::SUPER_ADMINS_ONLY_MESSAGE); + } + + public function testConcealsCustomMessage(): void + { + $this->be(new User()); + + $this->schema = $this->getSchema('ability: "superAdminOnly", action: EXCEPTION_NOT_AUTHORIZED'); + + $this->query()->assertGraphQLErrorMessage(AuthorizationException::MESSAGE); + } + + public function testReturnsValue(): void + { + $this->schema = $this->getSchema('ability: "superAdminOnly", action: RETURN_VALUE, returnValue: null'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => null, + ], + ]); + } + + public function testPassesAuthIfAuthorized(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "adminOnly"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testAcceptsGuestUser(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "guestOnly"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testPassesMultiplePolicies(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: ["adminOnly", "alwaysTrue"]'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testProcessesTheArgsArgument(): void + { + $this->schema = $this->getSchema('ability: "dependingOnArg", args: [false]'); + + $this->query()->assertGraphQLErrorMessage(AuthorizationException::MESSAGE); + } + + public function testInjectArgsPassesClientArgumentToPolicy(): void + { + $this->be(new User()); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "injectArgs", injectArgs: [true]'); + + $this->query('bar')->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testInjectedArgsAndStaticArgs(): void + { + $this->be(new User()); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "argsWithInjectedArgs", args: { foo: "static" }, injectArgs: true'); + + $this->query('dynamic')->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public static function resolveUser(): User + { + $user = new User(); + $user->name = 'foo'; + $user->email = 'test@example.com'; + + return $user; + } +} diff --git a/tests/Unit/Auth/CanModelDirectiveTest.php b/tests/Unit/Auth/CanModelDirectiveTest.php new file mode 100644 index 0000000000..62faa7d45d --- /dev/null +++ b/tests/Unit/Auth/CanModelDirectiveTest.php @@ -0,0 +1,23 @@ +mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsIfNotAuthorized(); + } + + public function testThrowsWithCustomMessageIfNotAuthorized(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsWithCustomMessageIfNotAuthorized(); + } + + public function testThrowsFirstWithCustomMessageIfNotAuthorized(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsFirstWithCustomMessageIfNotAuthorized(); + } + + public function testReturnsValue(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testReturnsValue(); + } + + public function testProcessesTheArgsArgument(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testProcessesTheArgsArgument(); + } + + public function testChecksAgainstResolvedModels(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "view"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testChecksAgainstObject(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $return = new class() { + public string $name = 'foo'; + }; + $this->mockResolver(fn (): object => $return); + + $this->app + ->make(Gate::class) + ->define('customObject', fn (User $authorizedUser, object $root) => $authorizedUser === $user && $root == $return); + + $this->schema = $this->getSchema('ability: "customObject"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } +} diff --git a/tests/Unit/Auth/CanRootDirectiveTest.php b/tests/Unit/Auth/CanRootDirectiveTest.php new file mode 100644 index 0000000000..bd3260122c --- /dev/null +++ b/tests/Unit/Auth/CanRootDirectiveTest.php @@ -0,0 +1,180 @@ +mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsIfNotAuthorized(); + } + + public function testThrowsWithCustomMessageIfNotAuthorized(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsWithCustomMessageIfNotAuthorized(); + } + + public function testThrowsFirstWithCustomMessageIfNotAuthorized(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testThrowsFirstWithCustomMessageIfNotAuthorized(); + } + + public function testConcealsCustomMessage(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testConcealsCustomMessage(); + } + + public function testProcessesTheArgsArgument(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + parent::testProcessesTheArgsArgument(); + } + + public function testReturnsValue(): void + { + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "superAdminOnly", action: RETURN_VALUE, returnValue: "concealed"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'concealed', + ], + ], + ]); + } + + public function testChecksAgainstModel(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = $this->getSchema('ability: "view"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testChecksAgainstObject(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $return = new class() { + public string $name = 'foo'; + }; + $this->mockResolver(fn (): object => $return); + + $this->app + ->make(Gate::class) + ->define('customObject', fn (User $authorizedUser, object $root) => $authorizedUser === $user && $root == $return); + + $this->schema = $this->getSchema('ability: "customObject"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testChecksAgainstArray(): void + { + $user = new User(); + $user->name = UserPolicy::ADMIN; + $this->be($user); + + $return = ['name' => 'foo']; + $this->mockResolver(fn (): array => $return); + + $this->app + ->make(Gate::class) + ->define('customArray', fn (User $authorizedUser, array $root) => $authorizedUser === $user && $root == $return); + + $this->schema = $this->getSchema('ability: "customArray"'); + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } + + public function testGlobalGate(): void + { + $user = new User(); + $this->be($user); + + $this->app->make(Gate::class)->define('globalAdmin', fn ($authorizedUser) => $authorizedUser === $user); + + $this->mockResolver(fn (): User => $this->resolveUser()); + + $this->schema = /** @lang GraphQL */ ' + type Query { + user: User! + @canRoot(ability: "globalAdmin") + @mock + } + + type User { + name(foo: String): String + } + '; + + $this->query()->assertJson([ + 'data' => [ + 'user' => [ + 'name' => 'foo', + ], + ], + ]); + } +} diff --git a/tests/Utils/Policies/UserPolicy.php b/tests/Utils/Policies/UserPolicy.php index dd917204f2..cba98188b9 100644 --- a/tests/Utils/Policies/UserPolicy.php +++ b/tests/Utils/Policies/UserPolicy.php @@ -47,18 +47,28 @@ public function dependingOnArg(User $viewer, bool $pass): bool return $pass; } - /** @param array $injectedArgs */ - public function injectArgs(User $viewer, array $injectedArgs): bool + /** @param User|array ...$args */ + public function injectArgs(User $viewer, ...$args): bool { + $injectedArgs = $args[0]; + if ($injectedArgs instanceof User) { + $injectedArgs = $args[1]; + } + return $injectedArgs === ['foo' => 'bar']; } - /** - * @param array $injectedArgs - * @param array $staticArgs - */ - public function argsWithInjectedArgs(User $viewer, array $injectedArgs, array $staticArgs): bool + /** @param User|array ...$args */ + public function argsWithInjectedArgs(User $viewer, ...$args): bool { + $injectedArgs = $args[0]; + $staticArgs = $args[1]; + + if ($injectedArgs instanceof User) { + $injectedArgs = $args[1]; + $staticArgs = $args[2]; + } + return $injectedArgs === ['foo' => 'dynamic'] && $staticArgs === ['foo' => 'static']; }