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 @@ + $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) 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 diff --git a/GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php b/GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php new file mode 100644 index 0000000..52a74e7 --- /dev/null +++ b/GraphQL/ExpressionLanguage/Access/BaseAccessFunction.php @@ -0,0 +1,28 @@ +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/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/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/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 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/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 04c13da..c3de0c6 100644 --- a/Resources/config/graphql/Platform.types.yml +++ b/Resources/config/graphql/Platform.types.yml @@ -5,4 +5,8 @@ Platform: _repository: type: Repository resolve: { } - description: "eZ Platform repository API" \ No newline at end of file + 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/policies.yml b/Resources/config/policies.yml new file mode 100644 index 0000000..e16cc3f --- /dev/null +++ b/Resources/config/policies.yml @@ -0,0 +1,5 @@ +graphql: + content_type_view: + - Class + content: + - Class 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" } diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 6a8fe41..76c764c 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -22,3 +22,19 @@ services: - { name: "overblog_graphql.mutation", alias: "DeleteSection", method: "deleteSection" } BD\EzPlatformGraphQLBundle\GraphQL\InputMapper\SearchQueryMapper: ~ + + BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\Access\HasAdminAccessFunction: + tags: ['overblog_graphql.expression_function'] + + BD\EzPlatformGraphQLBundle\GraphQL\ExpressionLanguage\Access\HasEzAccessToOneOfFunction: + tags: ['overblog_graphql.expression_function'] + + 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/Security/CanUser.php b/Security/CanUser.php new file mode 100644 index 0000000..3bc6686 --- /dev/null +++ b/Security/CanUser.php @@ -0,0 +1,50 @@ +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 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