From b932c6f05486ab53952a5845b01a994c6fef2d3b Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Sun, 11 Nov 2018 02:29:04 +0100 Subject: [PATCH 1/8] Prototyped permissions (#13) The `_repository` field will only show up for users with at least one of content/edit, class/update or role/view. Can easily be extended to more granular items. --- .../HasAdminAccessFunction.php | 37 +++++++++++++++++++ Resources/config/graphql/Platform.types.yml | 4 +- Resources/config/services.yml | 3 ++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 GraphQL/ExpressionLanguage/HasAdminAccessFunction.php diff --git a/GraphQL/ExpressionLanguage/HasAdminAccessFunction.php b/GraphQL/ExpressionLanguage/HasAdminAccessFunction.php new file mode 100644 index 0000000..99bfc1a --- /dev/null +++ b/GraphQL/ExpressionLanguage/HasAdminAccessFunction.php @@ -0,0 +1,37 @@ +buildHasAccessCode(["section/view", "class/create", "role/read"]); + } + ); + } + + private function buildHasAccessCode(array $policies) + { + $checks = array_map( + function($policy) { + list($module, $function) = explode('/', $policy); + return sprintf( + '(true === ($access = $pr->hasAccess("%s", "%s")) || is_array($access))', + $module, + $function + ); + }, + $policies + ); + + return sprintf('(function() use ($globalVariable) { + $pr = $globalVariable->get("container")->get("eZ\Publish\API\Repository\PermissionResolver"); + return %s; +})()', implode('||', $checks)); + } +} \ No newline at end of file diff --git a/Resources/config/graphql/Platform.types.yml b/Resources/config/graphql/Platform.types.yml index 04c13da..c58cc23 100644 --- a/Resources/config/graphql/Platform.types.yml +++ b/Resources/config/graphql/Platform.types.yml @@ -5,4 +5,6 @@ Platform: _repository: type: Repository resolve: { } - description: "eZ Platform repository API" \ No newline at end of file + description: "eZ Platform repository API" + public: '@=hasAdminAccess()' + public: '@=hasAdminAccess()' \ No newline at end of file diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 6a8fe41..7260ea0 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -22,3 +22,6 @@ services: - { name: "overblog_graphql.mutation", alias: "DeleteSection", method: "deleteSection" } BD\EzPlatformGraphQLBundle\GraphQL\InputMapper\SearchQueryMapper: ~ + + BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\HasAdminAccessFunction: + tags: ['overblog_graphql.expression_function'] From 8f15573faa6b2958448bec644ef3309ad4dbbea9 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Sun, 11 Nov 2018 12:44:43 +0100 Subject: [PATCH 2/8] Changed expressions to explicitly list policies in 'public' call --- .../BaseAccessFunction.php} | 17 +++--------- .../Access/HasAdminAccessFunction.php | 15 +++++++++++ .../Access/HasEzAccessToOneOfFunction.php | 26 +++++++++++++++++++ Resources/config/graphql/Platform.types.yml | 3 +-- Resources/config/services.yml | 5 +++- 5 files changed, 50 insertions(+), 16 deletions(-) rename GraphQL/ExpressionLanguage/{HasAdminAccessFunction.php => Access/BaseAccessFunction.php} (60%) create mode 100644 GraphQL/ExpressionLanguage/Access/HasAdminAccessFunction.php create mode 100644 GraphQL/ExpressionLanguage/Access/HasEzAccessToOneOfFunction.php diff --git a/GraphQL/ExpressionLanguage/HasAdminAccessFunction.php b/GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php similarity index 60% rename from GraphQL/ExpressionLanguage/HasAdminAccessFunction.php rename to GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php index 99bfc1a..52a74e7 100644 --- a/GraphQL/ExpressionLanguage/HasAdminAccessFunction.php +++ b/GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php @@ -1,21 +1,11 @@ buildHasAccessCode(["section/view", "class/create", "role/read"]); - } - ); - } - - private function buildHasAccessCode(array $policies) + protected function buildHasAccessCode(array $policies) { $checks = array_map( function($policy) { @@ -34,4 +24,5 @@ function($policy) { return %s; })()', implode('||', $checks)); } + } \ No newline at end of file diff --git a/GraphQL/ExpressionLanguage/Access/HasAdminAccessFunction.php b/GraphQL/ExpressionLanguage/Access/HasAdminAccessFunction.php new file mode 100644 index 0000000..42baa57 --- /dev/null +++ b/GraphQL/ExpressionLanguage/Access/HasAdminAccessFunction.php @@ -0,0 +1,15 @@ +buildHasAccessCode(["section/view", "class/create", "role/read"]); + } + ); + } +} \ No newline at end of file diff --git a/GraphQL/ExpressionLanguage/Access/HasEzAccessToOneOfFunction.php b/GraphQL/ExpressionLanguage/Access/HasEzAccessToOneOfFunction.php new file mode 100644 index 0000000..362905b --- /dev/null +++ b/GraphQL/ExpressionLanguage/Access/HasEzAccessToOneOfFunction.php @@ -0,0 +1,26 @@ +buildHasAccessCode( + array_map( + function($value) { + return str_replace('"', '', $value); + }, + func_get_args() + ) + ); + } + ); + } +} \ No newline at end of file diff --git a/Resources/config/graphql/Platform.types.yml b/Resources/config/graphql/Platform.types.yml index c58cc23..5c7009c 100644 --- a/Resources/config/graphql/Platform.types.yml +++ b/Resources/config/graphql/Platform.types.yml @@ -6,5 +6,4 @@ Platform: type: Repository resolve: { } description: "eZ Platform repository API" - public: '@=hasAdminAccess()' - public: '@=hasAdminAccess()' \ No newline at end of file + public: "@=hasEzAccessToOneOf('section/view', 'class/create', 'role/read')" diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 7260ea0..c0d93ad 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -23,5 +23,8 @@ services: BD\EzPlatformGraphQLBundle\GraphQL\InputMapper\SearchQueryMapper: ~ - BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\HasAdminAccessFunction: + BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\Access\HasAdminAccessFunction: + tags: ['overblog_graphql.expression_function'] + + BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\Access\HasEzAccessToOneOfFunction: tags: ['overblog_graphql.expression_function'] From 48b76a00b01967167d677ce8171516abc9978292 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Sun, 11 Nov 2018 15:13:52 +0100 Subject: [PATCH 3/8] Custom graphql/content policy Allows to restrict which content types a user is allowed to see over graphql. ``` DomainGroupContent: type: object fields: articles: type: "[ArticleContent]" public: '@=service("ezplatform_graphql.can_user").viewContentOfType("article")' ``` fixup! Custom graphql/content_type_view policy --- BDEzPlatformGraphQLBundle.php | 13 ++++- .../Security/PolicyProvider.php | 12 +++++ Resources/config/policies.yml | 5 ++ Resources/config/services.yml | 4 ++ Security/CanUser.php | 50 +++++++++++++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 DependencyInjection/Security/PolicyProvider.php create mode 100644 Resources/config/policies.yml create mode 100644 Security/CanUser.php diff --git a/BDEzPlatformGraphQLBundle.php b/BDEzPlatformGraphQLBundle.php index a796e04..f7c6aa5 100644 --- a/BDEzPlatformGraphQLBundle.php +++ b/BDEzPlatformGraphQLBundle.php @@ -1,8 +1,8 @@ addCompilerPass(new Compiler\FieldValueTypesPass()); $container->addCompilerPass(new Compiler\FieldValueBuildersPass()); $container->addCompilerPass(new Compiler\SchemaWorkersPass()); $container->addCompilerPass(new Compiler\SchemaBuildersPass()); + + $this->loadPolicyProviders($container); + } + + private function loadPolicyProviders(ContainerBuilder $container) + { + $extension = $container->getExtension('ezpublish'); + // Add the policy provider. + $extension->addPolicyProvider(new PolicyProvider()); } } diff --git a/DependencyInjection/Security/PolicyProvider.php b/DependencyInjection/Security/PolicyProvider.php new file mode 100644 index 0000000..27179ca --- /dev/null +++ b/DependencyInjection/Security/PolicyProvider.php @@ -0,0 +1,12 @@ +permissionResolver = $permissionResolver; + $this->contentTypeService = $contentTypeService; + } + + public function viewContentOfType($identifier) + { + try { + $contentType = $this->contentTypeService->loadContentTypeByIdentifier($identifier); + } catch (NotFoundException $e) { + throw new UserError("Content type '$identifier' not found'"); + } + + $contentInfo = new ContentInfo(['contentTypeId' => $contentType->id]); + try { + return $this->permissionResolver->canUser(self::MODULE, self::FUNCTION_CONTENT, $contentInfo); + } catch (BadStateException $e) {; + throw new UserError($e->getMessage(), 0, $e); + } catch (InvalidArgumentException $e) { + throw new UserError($e->getMessage(), 0, $e); + } + } +} \ No newline at end of file From 738ebf87d697b5fad78b698c974d41e9f33254e8 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Sun, 11 Nov 2018 15:18:37 +0100 Subject: [PATCH 4/8] Added permissions check of group types with graphql/content --- .../ContentType/AddDomainContentToDomainGroup.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/DomainContent/SchemaWorker/ContentType/AddDomainContentToDomainGroup.php b/DomainContent/SchemaWorker/ContentType/AddDomainContentToDomainGroup.php index c2f5728..8e3c985 100644 --- a/DomainContent/SchemaWorker/ContentType/AddDomainContentToDomainGroup.php +++ b/DomainContent/SchemaWorker/ContentType/AddDomainContentToDomainGroup.php @@ -28,6 +28,7 @@ public function work(array &$schema, array $args) 'type' => sprintf("[%s]", $this->getContentName($contentType)), // @todo Improve description to mention that it is a collection ? 'description' => isset($descriptions['eng-GB']) ? $descriptions['eng-GB'] : 'No description available', + 'public' => $this->getPublicValue($contentType), 'resolve' => sprintf( '@=resolver("DomainContentItemsByTypeIdentifier", ["%s", args])', $contentType->identifier @@ -47,6 +48,7 @@ public function work(array &$schema, array $args) 'type' => $this->getContentName($contentType), 'description' => isset($descriptions['eng-GB']) ? $descriptions['eng-GB'] : 'No description available', 'resolve' => sprintf('@=resolver("DomainContentItem", [args, "%s"])', $contentType->identifier), + 'public' => $this->getPublicValue($contentType), 'args' => [ // @todo How do we constraint this so that it only takes an id of an item of that type ? // same approach than GlobalId ? (-) @@ -140,4 +142,16 @@ private function isFieldDefined(ContentTypeGroup $contentTypeGroup, ContentType [$this->getContentCollectionField($contentType)] ); } + + /** + * @param $contentType + * @return string + */ + protected function getPublicValue($contentType): string + { + return sprintf( + '@=service("ezplatform_graphql.can_user").viewContentOfType("%s")', + $contentType->identifier + ); + } } \ No newline at end of file From e222e4f51fbb3710d62736798b58aaf1dfea8097 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Sun, 11 Nov 2018 18:41:44 +0100 Subject: [PATCH 5/8] Restricted visibility of relation list based on type If a relation list field is typed to one domain item, it is only visible if the user has permission for this type. --- .../RelationListFieldValueBuilder.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/DomainContent/FieldValueBuilder/RelationListFieldValueBuilder.php b/DomainContent/FieldValueBuilder/RelationListFieldValueBuilder.php index b635892..b18bb6e 100644 --- a/DomainContent/FieldValueBuilder/RelationListFieldValueBuilder.php +++ b/DomainContent/FieldValueBuilder/RelationListFieldValueBuilder.php @@ -47,7 +47,19 @@ public function buildDefinition(FieldDefinition $fieldDefinition) $isMultiple ); - return ['type' => $type, 'resolve' => $resolver]; + $field = [ + 'type' => $type, + 'resolve' => $resolver + ]; + + if (isset($contentType)) { + $field['public'] = sprintf( + '@=service("ezplatform_graphql.can_user").viewContentOfType("%s")', + $contentType->identifier + ); + } + + return $field; } private function mapFieldTypeIdentifierToGraphQLType($fieldTypeIdentifier) From bef46bad2c9280d854bf8cde758dbac70c3e40ef Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Mon, 12 Nov 2018 00:40:18 +0100 Subject: [PATCH 6/8] Added viewer field to platform schema Returns the current user: ``` { viewer { id login } } ``` --- GraphQL/Resolver/ViewerResolver.php | 28 +++++++++++++++++++++ Resources/config/graphql/Platform.types.yml | 3 +++ Resources/config/resolvers.yml | 4 +++ 3 files changed, 35 insertions(+) create mode 100644 GraphQL/Resolver/ViewerResolver.php diff --git a/GraphQL/Resolver/ViewerResolver.php b/GraphQL/Resolver/ViewerResolver.php new file mode 100644 index 0000000..18194a0 --- /dev/null +++ b/GraphQL/Resolver/ViewerResolver.php @@ -0,0 +1,28 @@ +repository = $repository; + } + + public function resolveViewer() + { + return $this->repository->sudo( + function (Repository $repository) { + return $repository->getUserService()->loadUser( + $repository->getPermissionResolver()->getCurrentUserReference()->getUserId() + ); + } + ); + } +} \ No newline at end of file diff --git a/Resources/config/graphql/Platform.types.yml b/Resources/config/graphql/Platform.types.yml index 5c7009c..c3de0c6 100644 --- a/Resources/config/graphql/Platform.types.yml +++ b/Resources/config/graphql/Platform.types.yml @@ -7,3 +7,6 @@ Platform: resolve: { } description: "eZ Platform repository API" public: "@=hasEzAccessToOneOf('section/view', 'class/create', 'role/read')" + viewer: + type: User + resolve: "@=resolver('Viewer')" \ No newline at end of file diff --git a/Resources/config/resolvers.yml b/Resources/config/resolvers.yml index ed774a7..4d4b8ff 100644 --- a/Resources/config/resolvers.yml +++ b/Resources/config/resolvers.yml @@ -61,6 +61,10 @@ services: - { name: overblog_graphql.resolver, alias: "UserGroupSubGroups", method: "resolveUserGroupSubGroups" } - { name: overblog_graphql.resolver, alias: "UsersOfGroup", method: "resolveUsersOfGroup" } + BD\EzPlatformGraphQLBundle\GraphQL\Resolver\ViewerResolver: + tags: + - { name: overblog_graphql.resolver, alias: "Viewer", method: "resolveViewer" } + BD\EzPlatformGraphQLBundle\GraphQL\Resolver\ContentTypeResolver: tags: - { name: overblog_graphql.resolver, alias: "ContentTypeById", method: "resolveContentTypeById" } From 567889432a18aa8ec8de519950a32f579c2cd863 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Mon, 12 Nov 2018 00:47:36 +0100 Subject: [PATCH 7/8] Implemented api key based authentication See doc/security.md Requires improvements. --- Resources/config/services.yml | 6 ++ Security/ApiKeyAuthenticator.php | 15 +++++ Security/ApiKeyUserProvider.php | 112 +++++++++++++++++++++++++++++++ doc/security.md | 31 +++++++++ 4 files changed, 164 insertions(+) create mode 100644 Security/ApiKeyAuthenticator.php create mode 100644 Security/ApiKeyUserProvider.php create mode 100644 doc/security.md diff --git a/Resources/config/services.yml b/Resources/config/services.yml index fd199bd..76c764c 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -32,3 +32,9 @@ services: ezplatform_graphql.can_user: autowire: true class: 'BD\EzPlatformGraphQLBundle\Security\CanUser' + + BD\EzPlatformGraphQLBundle\Security\ApiKeyUserProvider: + autowire: true + + BD\EzPlatformGraphQLBundle\Security\ApiKeyAuthenticator: + autowire: true diff --git a/Security/ApiKeyAuthenticator.php b/Security/ApiKeyAuthenticator.php new file mode 100644 index 0000000..114c0b9 --- /dev/null +++ b/Security/ApiKeyAuthenticator.php @@ -0,0 +1,15 @@ +repository = $repository; + } + + public function getUsernameForApiKey($apiKey) + { + $user = $this->findUserWithToken($apiKey); + + if ($user === null) { + throw new CustomUserMessageAuthenticationException( + sprintf('API Key "%s" does not exist.', $apiKey) + ); + } + + return $user->login; + } + + public function loadUserByUsername($username) + { + try { + $user = new SecurityUser( + $this->repository->getUserService()->loadUserByLogin($username), + array('ROLE_USER') + ); + } catch (\Exception $e) { + echo $e->getMessage(); + } + + return $user; + } + + public function refreshUser(UserInterface $user) + { + // this is used for storing authentication in the session + // but in this example, the token is sent in each request, + // so authentication can be stateless. Throwing this exception + // is proper to make things stateless + throw new UnsupportedUserException(); + } + + public function supportsClass($class) + { + return SecurityUser::class === $class; + } + + /** + * @param string $apiKey + * @return ApiUser\null + */ + private function findUserWithToken($apiKey) + { + $filter = new Criterion\LogicalAnd([ + new Criterion\ContentTypeIdentifier('apikey'), + new Criterion\Field('apikey', '=', $apiKey), + ]); + + try { + $apiKeyContent = $this->repository->sudo( + function (Repository $repository) use ($filter) { + return $repository->getSearchService()->findSingle($filter); + } + ); + return $this->loadUserFromApiKeyContentInfo($apiKeyContent->contentInfo); + } catch (\Exception $e) { + return null; + } + } + + private function loadUserFromApiKeyContentInfo(ContentInfo $contentInfo) + { + try { + return $this->repository->sudo( + function (Repository $repository) use ($contentInfo) { + $locationService = $repository->getLocationService(); + return $repository->getUserService()->loadUser( + $locationService->loadLocation( + $locationService->loadLocation($contentInfo->mainLocationId)->parentLocationId + )->contentInfo->id + ); + } + ); + } catch (\Exception $e) { + throw new CustomUserMessageAuthenticationException( + sprintf('No User could be loaded for the API Key') + ); + } + } +} \ No newline at end of file diff --git a/doc/security.md b/doc/security.md new file mode 100644 index 0000000..a192141 --- /dev/null +++ b/doc/security.md @@ -0,0 +1,31 @@ +The GraphQL server comes with an api key based auth mechanism. It uses data from the repository, linked to the user. + +## Firewall setup +Add a rule for the graphql routes: + +```yaml +security: + firewalls: + # ... + + graphql: + pattern: /(graphql|graphiql) + anonymous: ~ + stateless: true + simple_preauth: + authenticator: BD\EzPlatformGraphQLBundle\Security\ApiKeyAuthenticator + provider: api_key_user_provider + + ezpublish_front: + #... +``` + +## Creating api keys +- Create an "apikey" content type, with an "apikey" ezstring field definition. +- Change User to be a container. +- Create an apikey content below a user, with the api key string in the field. + +## Use an API key +``` +curl --globoff -X GET -H "apikey: my-api-key" 'https://example.com/graphql?query={viewer{login}}' +``` \ No newline at end of file From 5ccc637834e754fa7a36fcf173705d07a8e01723 Mon Sep 17 00:00:00 2001 From: Bertrand Dunogier Date: Mon, 12 Nov 2018 10:34:45 +0100 Subject: [PATCH 8/8] Leftover expression language functions --- .../Access/EzCanUserFunction.php | 18 ++++++++++++++++++ .../Access/HasEzAccessToFunction.php | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 GraphQL/ExpressionLanguage/Access/EzCanUserFunction.php create mode 100644 GraphQL/ExpressionLanguage/Access/HasEzAccessToFunction.php diff --git a/GraphQL/ExpressionLanguage/Access/EzCanUserFunction.php b/GraphQL/ExpressionLanguage/Access/EzCanUserFunction.php new file mode 100644 index 0000000..9a978f4 --- /dev/null +++ b/GraphQL/ExpressionLanguage/Access/EzCanUserFunction.php @@ -0,0 +1,18 @@ +buildHasAccessCode([str_replace('"', '', $policy)]); + } + ); + } +} \ No newline at end of file diff --git a/GraphQL/ExpressionLanguage/Access/HasEzAccessToFunction.php b/GraphQL/ExpressionLanguage/Access/HasEzAccessToFunction.php new file mode 100644 index 0000000..d48af33 --- /dev/null +++ b/GraphQL/ExpressionLanguage/Access/HasEzAccessToFunction.php @@ -0,0 +1,18 @@ +buildHasAccessCode([str_replace('"', '', $policy)]); + } + ); + } +} \ No newline at end of file