diff --git a/modules/graphql_compose_mutations/README.md b/modules/graphql_compose_mutations/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a9be8596c293e9b8916c924d723e5b064a87a6bd --- /dev/null +++ b/modules/graphql_compose_mutations/README.md @@ -0,0 +1,10 @@ +# Generic Mutations for graphql_compose + +## Available GraphQL mutations + +- genericMutation + +## Available GraphQL queries + +- operationsByEntityType +- permissions diff --git a/modules/graphql_compose_mutations/graphql/generic_mutation.base.graphqls b/modules/graphql_compose_mutations/graphql/generic_mutation.base.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..7040c48b58cfc8c05eab1ecc9ba3d0d52f15ebef --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/generic_mutation.base.graphqls @@ -0,0 +1,109 @@ +""" +Helper input for String values. +""" +input keyValueString { + field: String! + value: String +} + +""" +Metadata values. +""" +input genericMetadata { + """ + The entity type (node, user, profile, group etc). + """ + entity_type: EntityType! + + """ + The bundle of the entity. Actually, we do not need this on DELETE. + """ + entity_bundle: String! + + """ + The operation to perform + """ + operation: Operation! + + """ + The id of the entity (if action is not CREATE) + """ + id: Int + + """ + The parent entity type (node, user, profile, group etc) if exists. + """ + parent_entity_type: EntityType + + """ + The parent entity ID if exists. + """ + parent_entity_id: Int +} + +""" +The mutation available operations +""" +enum Operation { + DEFAULT + CREATE + UPDATE + DELETE +} + +""" +The mutation available entity types +""" +enum EntityType { + ACTIVITY + COMMENT + COMMERCE_ORDER + COMMERCE_PRODUCT + CONTACT_MESSAGE + EVENT_ENROLLMENT + FLAGGING + GROUP + GROUP_CONTENT + MEDIA + MESSAGE + NODE + POST + PRIVATE_MESSAGE + PRIVATE_MESSAGE_THREAD + PROFILE + TAXONOMY_TERM + USER + VOTE +} + +""" +A generic object containing any Drupal entity all values +""" +type AnyEntity { + id: Int + uuid: String + entity_type: String + entity_bundle: String + values: Json +} + +type GenericEntityResponse implements Response { + errors: [Violation] + success: Boolean + entity: AnyEntity +} + +scalar Violation + +""" +The Json FieldType +See graphql_compose/src/Plugin/GraphQLCompose/FieldType/JsonItem.php +""" +scalar Json + +""" +ToDo: There is already a graphql Response... Why do we need this? +""" +interface Response { + errors: [Violation] +} diff --git a/modules/graphql_compose_mutations/graphql/generic_mutation.extension.graphqls b/modules/graphql_compose_mutations/graphql/generic_mutation.extension.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..912a47c0e7337ff77e69c6043048e7f3ab8bb304 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/generic_mutation.extension.graphqls @@ -0,0 +1,6 @@ +extend type Mutation { + """ + Generic entity mutation + """ + genericMutation(data: Json!, metadata: genericMetadata!): GenericEntityResponse +} diff --git a/modules/graphql_compose_mutations/graphql/operations_by_entity_type.base.graphqls b/modules/graphql_compose_mutations/graphql/operations_by_entity_type.base.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..27879eebccf2c0500d51d8917119229189e1d0e2 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/operations_by_entity_type.base.graphqls @@ -0,0 +1,4 @@ +type OperationsByEntityTypeResponse implements Response { + errors: [Violation] + operations: Json +} diff --git a/modules/graphql_compose_mutations/graphql/operations_by_entity_type.extension.graphqls b/modules/graphql_compose_mutations/graphql/operations_by_entity_type.extension.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..33f944c7b0dd04150762e8cd9e2531dfe7fbedd0 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/operations_by_entity_type.extension.graphqls @@ -0,0 +1,8 @@ +extend type Query { + """ + Query to get operations by entity_type + """ + operationsByEntityType( + entity_type: EntityType!, + ): OperationsByEntityTypeResponse +} diff --git a/modules/graphql_compose_mutations/graphql/permissions.base.graphqls b/modules/graphql_compose_mutations/graphql/permissions.base.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..adcd6067c7d224b023fcbd422b7729ba1264c0fe --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/permissions.base.graphqls @@ -0,0 +1,14 @@ +""" +The permissions available operations, extended +""" +enum OperationExtended { + VIEW + CREATE + UPDATE + DELETE +} + +type PermissionsResponse implements Response { + errors: [Violation] + success: Boolean +} diff --git a/modules/graphql_compose_mutations/graphql/permissions.extension.graphqls b/modules/graphql_compose_mutations/graphql/permissions.extension.graphqls new file mode 100644 index 0000000000000000000000000000000000000000..feba89b190458346f8f737651c17ccf238948323 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql/permissions.extension.graphqls @@ -0,0 +1,14 @@ +extend type Query { + """ + Query to get operation permissions by uid + """ + permissions( + operation: OperationExtended!, + uid: String!, + entity_id: String, + entity_type: EntityType!, + entity_bundle: String!, + parent_entity_type: EntityType, + parent_entity_id: String, + ): PermissionsResponse +} diff --git a/modules/graphql_compose_mutations/graphql_compose_mutations.info.yml b/modules/graphql_compose_mutations/graphql_compose_mutations.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..32e98101d53ee3571ddb0770a9bdb1ac782dfb94 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql_compose_mutations.info.yml @@ -0,0 +1,9 @@ +name: "GraphQL Compose: Mutations" +description: "Enable mutations for any Entity to the schema." +type: module +core_version_requirement: ^10.1 || ^11 +php: 8.1 +dependencies: + - graphql:graphql + - graphql_compose:graphql_compose +package: GraphQL Compose diff --git a/modules/graphql_compose_mutations/graphql_compose_mutations.module b/modules/graphql_compose_mutations/graphql_compose_mutations.module new file mode 100644 index 0000000000000000000000000000000000000000..f449561e5e09254ef1fe5070fd1cb94b028ca841 --- /dev/null +++ b/modules/graphql_compose_mutations/graphql_compose_mutations.module @@ -0,0 +1,156 @@ +<?php + +/** + * @file + * Extends module graphql_compose_mutations. + */ + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\graphql\GraphQL\Execution\FieldContext; +use Drupal\graphql_compose\Plugin\GraphQLCompose\GraphQLComposeFieldTypeInterface; + +/** + * Implements hook_entity_base_field_info(). + */ +function graphql_compose_mutations_entity_base_field_info(EntityTypeInterface $entity_type): ?array { + $fields = []; + $machine_names = graphql_compose_mutations_get_machine_names(); + + foreach ($machine_names as $machine_name) { + $field_type = str_contains($machine_name, "_user_") ? "boolean" : "string"; + $fields[$machine_name] = BaseFieldDefinition::create($field_type) + ->setLabel(t('System field value of @name', ["@name" => $machine_name])) + ->setDescription(t('System field value of @name field', ["@name" => $machine_name])) + ->setComputed(TRUE) + ->setRequired(FALSE) + ->setReadOnly(TRUE) + ->setDefaultValue([]); + } + + return $fields; +} + +/** + * Implements hook_graphql_compose_field_results_alter(). + */ +function graphql_compose_mutations_graphql_compose_field_results_alter(array &$results, $entity, GraphQLComposeFieldTypeInterface $plugin, FieldContext $context):void { + $field_definition = $plugin->getFieldDefinition(); + $field_name = $field_definition->getName(); + $entity_type_id = $entity->getEntityTypeId(); + $current_uid = Drupal::currentUser()->id(); + + if ($entity instanceof EntityInterface) { + $uid = ""; + $parent_entity_type = ""; + $parent_entity_id = ""; + + // Common case of author. + if (property_exists($entity, "uid") && $entity->get("uid")->getValue()) { + if (isset($entity->get("uid")->getValue()[0])) { + $uid = $entity->get("uid")->getValue()[0]["target_id"]; + } + } + // Entity type activity, post, vote. + if (in_array($entity_type_id, ["activity", "post", "vote"]) && property_exists($entity, "user_id") && $entity->get("user_id")->getValue()) { + if (isset($entity->get("user_id")->getValue()[0])) { + $uid = $entity->get("user_id")->getValue()[0]["target_id"]; + } + } + // Entity type private_message + // private_message_thread has "members" array to get participants. + if ($entity_type_id === "private_message" && property_exists($entity, "owner") && $entity->get("owner")->getValue()) { + if (isset($entity->get("owner")->getValue()[0])) { + $uid = $entity->get("owner")->getValue()[0]["target_id"]; + } + } + + $entity_id = $entity->id(); + $system_entity_type = $entity->getEntityTypeId(); + $system_entity_bundle = $entity->bundle(); + $system_entity_bundle_object = \Drupal::service('entity_type.bundle.info')->getBundleInfo($system_entity_type); + + if (isset($system_entity_bundle_object[$system_entity_bundle])) { + $system_entity_bundle_label = $system_entity_bundle_object[$system_entity_bundle]['label']; + } + else { + $system_entity_bundle_label = ""; + } + + // Some entities need the Parent to check for Permissions. + if ($entity_type_id === "vote") { + $parent_entity_type = $$entity->get("entity_type")->toString(); + $parent_entity_id = $$entity->get("entity_id")->toString(); + } + + // Get Permissions to update, delete current entity. + $permissions = \Drupal::service("graphql_compose_mutations.user_permissions"); + $permissions_update = $permissions->userCanDoActionOnEntityByType( + "update", + $current_uid, + $entity_id, + $system_entity_type, + $system_entity_bundle, + $parent_entity_type, + $parent_entity_id + ); + $permissions_delete = $permissions->userCanDoActionOnEntityByType( + "delete", + $current_uid, + $entity_id, + $system_entity_type, + $system_entity_bundle, + $parent_entity_type, + $parent_entity_id + ); + + $values = [ + "system_entity_id" => $entity->id(), + "system_entity_type" => $system_entity_type, + "system_entity_bundle" => $system_entity_bundle, + "system_entity_bundle_label" => $system_entity_bundle_label, + "system_entity_user_is_owner" => $current_uid == $uid, + "system_entity_user_has_update_access" => $permissions_update["access"], + "system_entity_user_has_delete_access" => $permissions_delete["access"], + ]; + + $machine_names = graphql_compose_mutations_get_machine_names(); + foreach ($machine_names as $machine_name) { + if ($field_name === $machine_name) { + $results = [$values[$machine_name]]; + } + } + } +} + +/** + * Implements hook_graphql_compose_entity_base_fields_alter(). + */ +function graphql_compose_mutations_graphql_compose_entity_base_fields_alter(array &$fields, string $entity_type_id): void { + $machine_names = graphql_compose_mutations_get_machine_names(); + + foreach ($machine_names as $machine_name) { + $fields[$machine_name] = [ + "required" => FALSE, + ]; + } +} + +/** + * Return the available machine names. + * + * @return array + * The array of the fields. + */ +function graphql_compose_mutations_get_machine_names(): array { + return [ + "system_entity_id", + "system_entity_type", + "system_entity_bundle", + "system_entity_bundle_label", + "system_entity_user_is_owner", + "system_entity_user_has_update_access", + "system_entity_user_has_delete_access", + ]; +} diff --git a/modules/graphql_compose_mutations/graphql_compose_mutations.services.yml b/modules/graphql_compose_mutations/graphql_compose_mutations.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..04115f07df9805cf281559559b5e6ca0b61e9f9b --- /dev/null +++ b/modules/graphql_compose_mutations/graphql_compose_mutations.services.yml @@ -0,0 +1,9 @@ +services: + graphql_compose_mutations.user_token_subscriber: + class: Drupal\graphql_compose_mutations\EventSubscriber\UserTokenInvalidationSubscriber + tags: + - { name: event_subscriber } + graphql_compose_mutations.user_permissions: + class: Drupal\graphql_compose_mutations\Services\UserPermissions + arguments: + [ '@module_handler','@current_user', '@entity_type.manager', '@logger.channel.graphql', '@entity_type.bundle.info' ] diff --git a/modules/graphql_compose_mutations/src/EventSubscriber/UserTokenInvalidationSubscriber.php b/modules/graphql_compose_mutations/src/EventSubscriber/UserTokenInvalidationSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..b229423d5e2f362007ad8b46d9b1ebb61d422223 --- /dev/null +++ b/modules/graphql_compose_mutations/src/EventSubscriber/UserTokenInvalidationSubscriber.php @@ -0,0 +1,41 @@ +<?php + +namespace Drupal\graphql_compose_mutations\EventSubscriber; + +use Drupal\simple_oauth\Event\UserUpdateTokenInvalidationEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Event subscriber for user token invalidation events. + * + * Stolen from https://dgo.to/2946882#comment-15751851 + */ +class UserTokenInvalidationSubscriber implements EventSubscriberInterface { + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + UserUpdateTokenInvalidationEvent::class => 'onUserUpdateTokenInvalidation', + ]; + } + + /** + * Reacts to the UserUpdateTokenInvalidationEvent. + * + * @param \Drupal\simple_oauth\Event\UserUpdateTokenInvalidationEvent $event + * The event object. + */ + public function onUserUpdateTokenInvalidation(UserUpdateTokenInvalidationEvent $event): void { + // TRUE if the user's roles, password or status has changed. + $shouldInvalidate = $event->haveUserAccessCharacteristicsChanged(); + $event->setInvalidateAccessTokens($shouldInvalidate); + + if ($shouldInvalidate) { + // Invalidate refresh tokens when access characteristics change. + $event->setInvalidateRefreshTokens(TRUE); + } + } + +} diff --git a/modules/graphql_compose_mutations/src/GraphQL/Response/GenericEntityResponse.php b/modules/graphql_compose_mutations/src/GraphQL/Response/GenericEntityResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..bfac409f4a56b35b8a6c9258c74663f1b96961cc --- /dev/null +++ b/modules/graphql_compose_mutations/src/GraphQL/Response/GenericEntityResponse.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\GraphQL\Response; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\graphql\GraphQL\Response\Response; + +/** + * Type of response used when a generic entity is returned. + */ +class GenericEntityResponse extends Response { + + /** + * The entity to be served. + * + * @var \Drupal\Core\Entity\EntityInterface|null + */ + protected ?EntityInterface $genericEntity = NULL; + + /** + * The success value to be served. + * + * @var bool + */ + protected bool $success = FALSE; + + /** + * Sets the entity data. + * + * @param \Drupal\Core\Entity\EntityInterface|null $genericEntity + * The entity to be served. + */ + public function setGenericEntity(?EntityInterface $genericEntity): void { + $this->genericEntity = $genericEntity; + } + + /** + * Gets the array values to be served. + * + * @return array|null + * The array values to be served. + */ + public function getGenericEntity(): ?array { + $entity = $this->genericEntity; + if ($entity) { + return [ + "uuid" => $entity->uuid(), + "values" => $entity->toArray(), + "id" => $entity->id(), + "entity_type" => $entity->getEntityTypeId(), + "entity_bundle" => $entity->bundle(), + ]; + } + return NULL; + } + + /** + * Gets the success. + * + * @return bool + * The success to be served. + */ + public function getSuccess(): bool { + return $this->success; + } + + /** + * Sets the success. + * + * @param bool $success + * The success value. + */ + public function setSuccess(bool $success): void { + $this->success = $success; + } + +} diff --git a/modules/graphql_compose_mutations/src/GraphQL/Response/OperationsByEntityTypeResponse.php b/modules/graphql_compose_mutations/src/GraphQL/Response/OperationsByEntityTypeResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..7337efafc6d615c767761b5d9ab732584a278d04 --- /dev/null +++ b/modules/graphql_compose_mutations/src/GraphQL/Response/OperationsByEntityTypeResponse.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\GraphQL\Response; + +use Drupal\graphql\GraphQL\Response\Response; + +/** + * Type of response used when an OperationsByEntityType is returned. + */ +class OperationsByEntityTypeResponse extends Response { + + /** + * The operations to be served. + * + * @var array|null + */ + protected ?array $operations; + + /** + * Sets the entity data. + * + * @param array|null $operations + * The operations to be served. + */ + public function setOperations(?array $operations): void { + $this->operations = $operations; + } + + /** + * Gets the array values to be served. + * + * @return array|null + * The array values to be served. + */ + public function getOperations(): ?array { + return $this->operations ?? NULL; + } + +} diff --git a/modules/graphql_compose_mutations/src/GraphQL/Response/PermissionsResponse.php b/modules/graphql_compose_mutations/src/GraphQL/Response/PermissionsResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..e0889a9dc64f64d1353710997e56460717948ece --- /dev/null +++ b/modules/graphql_compose_mutations/src/GraphQL/Response/PermissionsResponse.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\GraphQL\Response; + +use Drupal\graphql\GraphQL\Response\Response; + +/** + * Type of response used when a permission is returned. + */ +class PermissionsResponse extends Response { + /** + * The access value to be served. + * + * @var bool + */ + protected bool $access = FALSE; + + /** + * Gets the access. + * + * @return bool + * The access to be served. + */ + public function getAccess(): bool { + return $this->access; + } + + /** + * Sets the access. + * + * @param bool $access + * The access value. + */ + public function setAccess(bool $access): void { + $this->access = $access; + } + + /** + * Sets the success value. + */ + public function getSuccess(): bool { + return $this->access === TRUE; + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/GenericMutationProducer.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/GenericMutationProducer.php new file mode 100644 index 0000000000000000000000000000000000000000..e5be32d8bcfa7da1b0b9e81d889e9d8b840e75c6 --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/GenericMutationProducer.php @@ -0,0 +1,519 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\DataProducer; + +use Drupal\Component\Utility\Xss; +use Drupal\Core\Config\ImmutableConfig; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityStorageException; +use Drupal\Core\Entity\EntityTypeBundleInfo; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Logger\LoggerChannelFactory; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; +use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\GenericEntityResponse; +use Drupal\graphql_compose_mutations\Services\UserPermissions; +use Drupal\graphql_compose_mutations\Services\UserPermissionsInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use function Symfony\Component\String\u; + +/** + * This is a generic mutation class used by the SchemaExtension. + * + * @DataProducer( + * id = "generic_mutation_producer", + * name = @Translation("Mutation"), + * description = @Translation("Generic entity mutation extension."), + * produces = @ContextDefinition("any", + * label = @Translation("Any"), + * ), + * consumes = { + * "data" = @ContextDefinition("any", + * label = @Translation("Entity related data"), + * ), + * "metadata" = @ContextDefinition("any", + * label = @Translation("Generic mutation metadata"), + * ), + * }, + * ) + */ +class GenericMutationProducer extends DataProducerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected AccountInterface $currentUser; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The entity type bundle info. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfo + */ + protected EntityTypeBundleInfo $entityTypeBundleInfo; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected EntityFieldManagerInterface $entityFieldManager; + + /** + * The Drupal logger factory service. + * + * @var \Drupal\Core\Logger\LoggerChannelFactory + */ + protected LoggerChannelFactory $loggerChannelFactory; + + /** + * The current module user_permissions service. + * + * @var \Drupal\graphql_compose_mutations\Services\UserPermissionsInterface + */ + protected UserPermissionsInterface $userPermissions; + + /** + * Graphql conpose configuration to perform the fields mapping. + * + * @var \Drupal\Core\Config\ImmutableConfig + */ + protected ImmutableConfig $graphqlComposeConfiguration; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = new self( + $configuration, + $plugin_id, + $plugin_definition, + ); + + $instance->currentUser = $container->get('current_user'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->entityTypeBundleInfo = $container->get('entity_type.bundle.info'); + $instance->entityFieldManager = $container->get('entity_field.manager'); + $instance->loggerChannelFactory = $container->get('logger.factory'); + $instance->userPermissions = $container->get('graphql_compose_mutations.user_permissions'); + $instance->graphqlComposeConfiguration = $container->get('config.factory')->get('graphql_compose.settings'); + + return $instance; + } + + /** + * A reusable generic mutation. + * + * @param array $data + * The mutation data. + * @param array $metadata + * Several, entity related metadata required to create the mutation. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $field_context + * The cache context. + * + * @return \Drupal\graphql_compose_mutations\GraphQL\Response\GenericEntityResponse|null + * The updated or new Entity or a GraphQL response. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function resolve(array $data, array $metadata, FieldContext $field_context): ?GenericEntityResponse { + $response = new GenericEntityResponse(); + + // node, group, message, profile etc. + // @todo Limit allowed types from graphql_compose settings + // This is already done on .graphqls files. + $entity_type = strtolower($metadata["entity_type"]); + $entity_bundle = strtolower($metadata["entity_bundle"]); + $parent_entity_type = isset($metadata["parent_entity_type"]) ? strtolower($metadata["parent_entity_type"]) : NULL; + $parent_entity_id = isset($metadata["parent_entity_id"]) ? (int) $metadata["parent_entity_id"] : NULL; + $operation = strtolower($metadata["operation"]); + if ($operation === "default") { + $operation = "create"; + } + + $id = isset($metadata["id"]) ? (int) $metadata["id"] : NULL; + // Default user. We can override this on the $data input argument. + $user = $this->currentUser; + $uid = $user->id(); + $entity = NULL; + $custom_uid = NULL; + $response->setSuccess(FALSE); + $final_values = []; + // @todo Check if there are other owner like fields on custom entity types. + $user_ids = [ + "uid", + "user_id", + ]; + + // Add default uid to the data array and the metadata. + if (in_array($operation, ["update", "delete"]) && $entity_type === "user" && !$id) { + $id = $uid; + } + + // Protect some entities from being deleted. + if ($operation === "delete" && in_array($entity_type, ["user", "profile"]) && (int) $id === 1) { + $protected_user = $this->t('User with uid @uid is protected', [ + "@uid" => $uid, + ]); + $response->addViolation($protected_user); + return $response; + } + + // Get parent entity (if we need it. E.g. for the votingapi entities). + if ($parent_entity_type && $parent_entity_id) { + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + if (!$parent_entity) { + $missing_parent_entity = $this->t('Parent entity with id: @id and type: @type does not exist', [ + "@id" => $parent_entity_id, + "@type" => $parent_entity_type, + ]); + $response->addViolation($missing_parent_entity); + return $response; + } + + // For some Mutations we can get some fields from metadata. + if ($entity_type === "vote") { + if (!isset($data["entity_type"]) && !isset($data["entity_id"])) { + $data["entity_type"] = $parent_entity_type; + $data["entity_id"] = $parent_entity_id; + } + + if ($operation === "create" && !isset($data["value"])) { + $data["value"] = 1; + } + } + } + + // Get custom uid from data values. + foreach ($user_ids as $user_id_field) { + foreach ($data as $field => $value) { + if ($field === $user_id_field) { + $custom_uid = (int) $value; + } + } + } + + if ($custom_uid && $custom_uid != $uid) { + $uid = $custom_uid; + + // Load another User. + $user = $this->entityTypeManager + ->getStorage("user") + ->load($uid); + + if (!$user) { + $missing_user = $this->t('User with uid @uid does not exist', [ + "@uid" => $uid, + ]); + $response->addViolation($missing_user); + return $response; + } + } + + $revision_log = $this->t("GraphQL entry. Operation: @operation, Type: @entity_type, Bundle: @entity_bundle, Uid: @uid", [ + '@operation' => $operation, + '@entity_type' => $entity_type, + '@entity_bundle' => $entity_bundle, + '@uid' => $uid, + ]); + $final_values["revision_log"] = $revision_log; + + // Check entity bundle is valid. + $entity_bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type)); + + if (!$entity_bundle or !in_array($entity_bundle, $entity_bundles)) { + $entity_bundles_error = $this->t('Entity bundle is required or is not correct. Please use one of @entity_bundles', [ + "@entity_bundles" => implode(", ", $entity_bundles), + ]); + $response->addViolation($entity_bundles_error); + return $response; + } + + // Check permissions early. We use a Service for that. + // This also checks if there is an existing entity. + $access = $this->userPermissions->userCanDoActionOnEntityByType( + $operation, + $user->id(), + $id, + $entity_type, + $entity_bundle, + $parent_entity_type, + $parent_entity_id, + ); + + if ($access["access"] === FALSE) { + $access_error = $access["error"] ?? $this->t('GenericMutationProducer: You do not have permission to perform this operation.'); + $response->addViolation($access_error); + return $response; + } + + if ($operation !== "create") { + $entity = $this->entityTypeManager + ->getStorage($entity_type) + ->load($id); + } + + // @todo Should we add the line below? Or we need an object as parameter? + // $field_context->addCacheableDependency($access); + // Delete operation (does not need values) but may have errors. + if ($operation === "delete") { + try { + $delete_status = $entity->delete(); + } + catch (\Exception $e) { + $message = $e->getMessage(); + $response->addViolation($message); + return $response; + } + + if ($delete_status instanceof EntityStorageException) { + $delete_error = $delete_status->getMessage(); + $response->addViolation($delete_error); + } + else { + $response->setSuccess(TRUE); + } + + $response->setGenericEntity(NULL); + return $response; + } + + // For non delete operations check additional values exist on input. + if (empty($data)) { + // If we have no entity values throw an error. + $missing_data = $this->t('Missing data values.'); + $response->addViolation($missing_data); + return $response; + } + + // Final fields mapping + // Get all the entity fields for this bundle. + $entity_fields = []; + $read_only_fields_to_keep = [ + "system_entity_is_from_api", + ]; + $entity_fields_all = $this->entityFieldManager->getFieldDefinitions($entity_type, $entity_bundle); + // Remove computed fields from graphql_compose_* modules. + foreach ($entity_fields_all as $field_name => $field) { + $field_array = $field->toArray(); + $provider = $field_array["provider"] ?? ""; + $is_computed = $field_array["computed"] ?? FALSE; + $is_graphql_compose = $provider && str_starts_with($provider, "graphql_compose"); + if (!($is_computed && $is_graphql_compose)) { + $entity_fields[$field_name] = $field; + } + // Exceptions for some special read only fields. + if (in_array($field_name, $read_only_fields_to_keep)) { + $entity_fields[$field_name] = $field; + } + } + $entity_fields_keys = array_keys($entity_fields); + + $fields_mapping = $this->getFieldsMapping($entity_type, $entity_bundle); + + // Check if $data contains "bad" fields and throw an error. + foreach ($data as $field => $value) { + if (!in_array($field, $entity_fields_keys) && !in_array($field, $fields_mapping)) { + $bad_field = $this->t('Field "@field" is not valid key for data.', [ + "@field" => $field, + ]); + $response->addViolation($bad_field); + return $response; + } + } + + + // @todo Validate complex or composite values (e.g. entity_reference) + // @todo Find a way to save the mapping to a config yml + // E.g. the body field needs ["value" => "", "format" => "basic_html"]. + // @codingStandards + foreach ($entity_fields_keys as $machine_name) { + $field_name = $fields_mapping[$machine_name] ?? $machine_name; + + // Do not process computed fields. + if (!empty($data[$field_name])) { + $value = $data[$field_name]; + if (!is_array($value)) { + $value = Xss::filter($value); + } + $final_values[$machine_name] = $value; + } + } + + // Special treatment with the uid. Some entity types have another field. + if (in_array("uid", $entity_fields_keys) && $operation === "create") { + $final_values["uid"] = $uid; + } + if (in_array("user_id", $entity_fields_keys)) { + $final_values["user_id"] = $uid; + } + else { + unset($final_values["user_id"]); + } + + // Clean up manual added values. + if (!isset($entity_fields["revision_log"])) { + unset($final_values["revision_log"]); + } + + try { + if ($operation === "create") { + $entity = $this->createEntity($entity_type, $entity_bundle, $final_values); + } + else { + if ($entity instanceof ContentEntityInterface) { + $this->updateEntityValues($entity, $final_values); + } + } + } + catch (\Exception $e) { + $message = $e->getMessage(); + $response->addViolation($message); + return $response; + } + + // Validate entity. + if ($entity instanceof ContentEntityInterface) { + $violations = $entity->validate(); + if ($violations->count() > 0) { + /** @var \Symfony\Component\Validator\ConstraintViolation $violation */ + foreach ($violations as $violation) { + $response->addViolation($violation->getMessage(), ['property' => $violation->getPropertyPath()]); + } + return $response; + } + } + + try { + $save_status = $entity->save(); + } + catch (\Exception $e) { + $message = $e->getMessage(); + $response->addViolation($message); + return $response; + } + + // If an error occurred (wrong values etc) we do not get Int values. + // Type "private_message_thread" returns NULL on UPDATE action. + // @todo May need to provide a patch on the module private_message itself. + if ($entity_type === "private_message_thread") { + if ($operation !== "create") { + $entity = $this->entityTypeManager + ->getStorage($entity_type) + ->load($id); + if (!$entity) { + $save_error = "Error on entity Save for " . $revision_log; + $this->loggerChannelFactory->get("graphql_compose_mutations") + ->error($save_error); + $response->addViolation($save_error); + return $response; + } + } + else { + if ($entity->id()) { + // Manual set $save_status to 1 for the new private_message_thread. + $save_status = 1; + } + } + } + elseif ($save_status !== 1 && $save_status !== 2) { + // 1: SAVED_NEW, 2: SAVED_UPDATED. + $save_error = "Wrong Save status number: " . $save_status . " on entity Save for " . $revision_log; + $this->loggerChannelFactory->get("graphql_compose_mutations")->error($save_error); + $response->addViolation($save_error); + return $response; + } + + // @todo Should we add cache context for the entity? + // $field_context->addCacheableDependency($entity); + // Pass results to the response. + $response->setGenericEntity($entity); + if (empty($response->getViolations())) { + $response->setSuccess(TRUE); + } + + return $response; + } + + /** + * Create the entity. + * + * @param string $entity_type + * Entity type. + * @param string $entity_bundle + * Entity bundle. + * @param array $values + * Values. + */ + protected function createEntity($entity_type, $entity_bundle, $values) { + + // Some entity types do not use "type" as the bundle machine name. + // We need to get this through the entityTypeManager. + $storage = $this->entityTypeManager->getStorage($entity_type); + $type_keys = $storage->getEntityType()->getKeys(); + $type_machine_name = $type_keys["bundle"] ?? ""; + + return $storage->create([$type_machine_name => $entity_bundle] + $values); + } + + /** + * Update the values of an existing entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * Entity. + * @param array $values + * Values. + */ + protected function updateEntityValues(ContentEntityInterface $entity, array $values) { + // Add extra values and save the entity. + foreach ($values as $machine_name => $value) { + $entity->set($machine_name, $value); + } + } + + /** + * Gets the field map between graphql api names and machine names. + * + * @param string $entity_type + * Entity type. + * @param string $entity_bundle + * Entity bundle. + * + * @return array + * They key is the name_sdl, and the value is the machine name. + */ + protected function getFieldsMapping(string $entity_type, string $entity_bundle) { + if ($this->graphqlComposeConfiguration->get('entity_config.' . $entity_type . '.' . $entity_bundle . '.enabled')) { + $fields_config = $this->graphqlComposeConfiguration->get('field_config.' . $entity_type . '.' . $entity_bundle) ?? []; + $fields_mapping = []; + foreach ($fields_config as $field_name => $configuration) { + $fields_mapping[$field_name] = !empty($configuration['enabled']) ? $configuration['name_sdl'] ?? u($field_name) + ->trimPrefix('field_') + ->camel() + ->toString() : $field_name; + } + } + else { + $fields_mapping = []; + } + + return $fields_mapping; + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/OperationsByEntityTypeProducer.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/OperationsByEntityTypeProducer.php new file mode 100644 index 0000000000000000000000000000000000000000..06ee386e36bc4d989baaf2c63d6e74fccc14024b --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/OperationsByEntityTypeProducer.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\DataProducer; + +use Drupal\Core\Entity\EntityTypeBundleInfo; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\OperationsByEntityTypeResponse; +use Drupal\graphql_compose_mutations\Services\UserPermissions; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * This is a helper class that returns the allowed operations by entity type. + * + * @DataProducer( + * id = "operations_by_entity_type_producer", + * name = @Translation("Operations by Entity Type"), + * description = @Translation("a helper class that returns allowed operations by entity type."), + * produces = @ContextDefinition("any", + * label = @Translation("Any"), + * ), + * consumes = { + * "entity_type" = @ContextDefinition("any", + * label = @Translation("Entity type"), + * ), + * }, + * ) + */ +class OperationsByEntityTypeProducer extends DataProducerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected AccountInterface $currentUser; + + /** + * The entity type bundle info. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfo + */ + protected EntityTypeBundleInfo $entityTypeBundleInfo; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The current module user_permissions service. + * + * @var \Drupal\graphql_compose_mutations\Services\UserPermissions + */ + protected UserPermissions $userPermissions; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = new self( + $configuration, + $plugin_id, + $plugin_definition, + ); + + $instance->currentUser = $container->get('current_user'); + $instance->entityTypeBundleInfo = $container->get('entity_type.bundle.info'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->userPermissions = $container->get('graphql_compose_mutations.user_permissions'); + + return $instance; + } + + /** + * A query to return allowed operations by entity type. + * + * @param string $entity_type + * The entity_type. + * + * @return \Drupal\graphql_compose_mutations\GraphQL\Response\OperationsByEntityTypeResponse|null + * The OperationsByEntityType response. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + public function resolve(string $entity_type): ?OperationsByEntityTypeResponse { + $response = new OperationsByEntityTypeResponse(); + $results = []; + $user = $this->currentUser; + $uid = $user->id(); + $entity_type = strtolower($entity_type); + // Some entity types do not have bundles. So they have the type as bundle. + $bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type) ?? [$entity_type]; + // @todo add more operations if needed. + $operations = [ + "create", + ]; + + if (!$entity_type) { + $entity_type_error = $this->t('Entity type is required.'); + $response->addViolation($entity_type_error); + return $response; + } + + foreach ($bundles as $bundle_key => $bundle_values) { + $bundle_entity_type = $this->entityTypeManager->getDefinition($entity_type)->getBundleEntityType(); + $bundle_description = ""; + $bundle_label = $bundle_key; + + if ($bundle_entity_type) { + $bundle_definition = $this->entityTypeManager + ->getStorage($bundle_entity_type) + ->load($bundle_key); + $bundle_description = $bundle_definition->get("description"); + $bundle_label = $bundle_definition->label(); + } + + foreach ($operations as $operation) { + $label = $operation . " " . $bundle_label; + if ($operation === "create") { + $label = "New " . $bundle_label; + } + // Important. The "create" operation does not need an entity_id. + // But we should make it more generic for other operations. + // In same cases it needs the Parent entity. + $access = $this->userPermissions->userCanDoActionOnEntityByType( + $operation, + $uid, + "", + $entity_type, + (string) $bundle_key, + "", + "" + ); + $results[$entity_type][$bundle_key][$operation] = [ + "access" => $access["access"], + "label" => $label, + "description" => $bundle_description, + ]; + } + } + + if (empty($response->getViolations())) { + $response->setOperations($results); + } + + return $response; + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/PermissionsProducer.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/PermissionsProducer.php new file mode 100644 index 0000000000000000000000000000000000000000..8aa4059dd5fe436ef9d9502ff2389de70f3b73a3 --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/DataProducer/PermissionsProducer.php @@ -0,0 +1,174 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\DataProducer; + +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Logger\LoggerChannelFactory; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\PermissionsResponse; +use Drupal\graphql_compose_mutations\Services\UserPermissions; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * This is a helper class that checks for User operations. + * + * @DataProducer( + * id = "permissions_producer", + * name = @Translation("Permissions"), + * description = @Translation("a helper class that checks for user operations."), + * produces = @ContextDefinition("any", + * label = @Translation("Any"), + * ), + * consumes = { + * "operation" = @ContextDefinition("any", + * label = @Translation("Operation"), + * ), + * "uid" = @ContextDefinition("any", + * label = @Translation("The User uid"), + * ), + * "entity_id" = @ContextDefinition("any", + * label = @Translation("Entity ID"), + * required = FALSE, + * ), + * "entity_type" = @ContextDefinition("any", + * label = @Translation("Entity type"), + * ), + * "entity_bundle" = @ContextDefinition("any", + * label = @Translation("Entity bundle"), + * ), + * "parent_entity_type" = @ContextDefinition("any", + * label = @Translation("Parent Entity type (optional)"), + * default_value = "", + * required = FALSE, + * ), + * "parent_entity_id" = @ContextDefinition("any", + * label = @Translation("Parent Entity ID (optional)"), + * default_value = "", + * required = FALSE, + * ), + * }, + * ) + */ +class PermissionsProducer extends DataProducerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The current module user_permissions service. + * + * @var \Drupal\graphql_compose_mutations\Services\UserPermissions + */ + protected UserPermissions $userPermissions; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The Drupal logger factory service. + * + * @var \Drupal\Core\Logger\LoggerChannelFactory + */ + protected LoggerChannelFactory $loggerChannelFactory; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = new self( + $configuration, + $plugin_id, + $plugin_definition, + ); + + $instance->userPermissions = $container->get('graphql_compose_mutations.user_permissions'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->loggerChannelFactory = $container->get('logger.factory'); + + return $instance; + } + + /** + * A query to check for operation permissions by uid. + * + * @return \Drupal\graphql_compose_mutations\GraphQL\Response\PermissionsResponse|null + * The access Permissions response. + */ + public function resolve( + string $operation, + mixed $uid, + mixed $entity_id, + string $entity_type, + string $entity_bundle, + mixed $parent_entity_type, + mixed $parent_entity_id, + ): ?PermissionsResponse { + $response = new PermissionsResponse(); + $response->setAccess(FALSE); + + $entity_type = strtolower($entity_type); + $entity_bundle = strtolower($entity_bundle); + $operation = strtolower($operation); + + $parent_entity_type = $parent_entity_type ? strtolower($parent_entity_type) : NULL; + $parent_entity_id = $parent_entity_id ? (int) $parent_entity_id : NULL; + + if ($parent_entity_type && $parent_entity_id) { + try { + $parent = $this->entityTypeManager->getStorage($parent_entity_type) + ->load($parent_entity_id); + if (!$parent) { + $missing_parent_entity = $this->t('Parent entity with id @uid and type @type does not exist', [ + "@id" => $parent_entity_id, + "@type" => $parent_entity_type, + ]); + $response->addViolation($missing_parent_entity); + return $response; + } + } + catch (InvalidPluginDefinitionException | PluginNotFoundException $e) { + $message = $e->getMessage(); + $this->loggerChannelFactory->get("graphql_compose_mutations")->error($message); + $response->addViolation($message); + return $response; + } + } + + // Get access array from Service. + try { + $access = $this->userPermissions->userCanDoActionOnEntityByType( + $operation, + $uid, + $entity_id, + $entity_type, + $entity_bundle, + $parent_entity_type, + $parent_entity_id, + ); + + // Disallowed. + if ($access["access"] === FALSE && isset($access["error"])) { + $access_error = $access["error"]; + $response->addViolation($access_error); + return $response; + } + + $response->setAccess($access["access"]); + } + catch (InvalidPluginDefinitionException | PluginNotFoundException $e) { + $message = $e->getMessage(); + $this->loggerChannelFactory->get("graphql_compose_mutations")->error($message); + $response->addViolation($message); + return $response; + } + + return $response; + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/GenericMutationExtension.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/GenericMutationExtension.php new file mode 100644 index 0000000000000000000000000000000000000000..91c0c19bd4f71736b6315dfeabde66082f861fcd --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/GenericMutationExtension.php @@ -0,0 +1,56 @@ +<?php + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\SchemaExtension; + +use Drupal\graphql\GraphQL\ResolverBuilder; +use Drupal\graphql\GraphQL\ResolverRegistryInterface; +use Drupal\graphql\Plugin\GraphQL\SchemaExtension\SdlSchemaExtensionPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\GenericEntityResponse; + +/** + * Generic Mutation schema. + * + * @SchemaExtension( + * id = "generic_mutation", + * name = "A generic mutation extension", + * description = "A simple extension to mutate any entity.", + * schema = "graphql_compose" + * ) + */ +class GenericMutationExtension extends SdlSchemaExtensionPluginBase { + + /** + * {@inheritdoc} + */ + public function registerResolvers(ResolverRegistryInterface $registry): void { + $builder = new ResolverBuilder(); + + $registry->addFieldResolver('GenericEntityResponse', 'errors', + $builder->callback(function (GenericEntityResponse $response) { + return $response->getViolations(); + }) + ); + + $registry->addFieldResolver('GenericEntityResponse', 'success', + $builder->callback(function (GenericEntityResponse $response) { + return $response->getSuccess(); + }) + ); + + $registry->addFieldResolver('GenericEntityResponse', 'entity', + $builder->callback(function (GenericEntityResponse $response) { + return $response->getGenericEntity(); + }) + ); + + $registry->addFieldResolver( + 'Mutation', + 'genericMutation', + $builder->produce('generic_mutation_producer') + ->map('data', $builder->fromArgument('data')) + ->map('metadata', $builder->fromArgument('metadata')) + ); + + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/OperationsByEntityTypeExtension.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/OperationsByEntityTypeExtension.php new file mode 100644 index 0000000000000000000000000000000000000000..376b95d55a8d4ba37fa695993f3236335d329d07 --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/OperationsByEntityTypeExtension.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\SchemaExtension; + +use Drupal\graphql\GraphQL\ResolverBuilder; +use Drupal\graphql\GraphQL\ResolverRegistryInterface; +use Drupal\graphql\Plugin\GraphQL\SchemaExtension\SdlSchemaExtensionPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\OperationsByEntityTypeResponse; + +/** + * Generic Mutation schema. + * + * @SchemaExtension( + * id = "operations_by_entity_type", + * name = "Get operations by entity type", + * description = "A query to get operations by entity type extension.", + * schema = "graphql_compose" + * ) + */ +class OperationsByEntityTypeExtension extends SdlSchemaExtensionPluginBase { + + /** + * {@inheritdoc} + */ + public function registerResolvers(ResolverRegistryInterface $registry): void { + $builder = new ResolverBuilder(); + + $registry->addFieldResolver('OperationsByEntityTypeResponse', 'errors', + $builder->callback(function (OperationsByEntityTypeResponse $response) { + return $response->getViolations(); + }) + ); + + $registry->addFieldResolver('OperationsByEntityTypeResponse', 'operations', + $builder->callback(function (OperationsByEntityTypeResponse $response) { + return $response->getOperations(); + }) + ); + + $registry->addFieldResolver( + 'Query', + 'operationsByEntityType', + $builder->produce('operations_by_entity_type_producer') + ->map('entity_type', $builder->fromArgument('entity_type')) + ->map('parent_entity_type', $builder->fromArgument('parent_entity_type')) + ->map('parent_entity_id', $builder->fromArgument('parent_entity_id')) + ); + } + +} diff --git a/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/PermissionsExtension.php b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/PermissionsExtension.php new file mode 100644 index 0000000000000000000000000000000000000000..54feb403e6ce031e5d2a444a7ee6014d0a43424c --- /dev/null +++ b/modules/graphql_compose_mutations/src/Plugin/GraphQL/SchemaExtension/PermissionsExtension.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\graphql_compose_mutations\Plugin\GraphQL\SchemaExtension; + +use Drupal\graphql\GraphQL\ResolverBuilder; +use Drupal\graphql\GraphQL\ResolverRegistryInterface; +use Drupal\graphql\Plugin\GraphQL\SchemaExtension\SdlSchemaExtensionPluginBase; +use Drupal\graphql_compose_mutations\GraphQL\Response\PermissionsResponse; + +/** + * Permissions Query schema. + * + * @SchemaExtension( + * id = "permissions", + * name = "A permissions query extension", + * description = "A simple extension to get permissions.", + * schema = "graphql_compose" + * ) + */ +class PermissionsExtension extends SdlSchemaExtensionPluginBase { + + /** + * {@inheritdoc} + */ + public function registerResolvers(ResolverRegistryInterface $registry): void { + $builder = new ResolverBuilder(); + + $registry->addFieldResolver('PermissionsResponse', 'errors', + $builder->callback(function (PermissionsResponse $response) { + return $response->getViolations(); + }) + ); + + $registry->addFieldResolver('PermissionsResponse', 'success', + $builder->callback(function (PermissionsResponse $response) { + return $response->getSuccess(); + }) + ); + + $registry->addFieldResolver( + 'Query', + 'permissions', + $builder->produce('permissions_producer') + ->map('operation', $builder->fromArgument('operation')) + ->map('uid', $builder->fromArgument('uid')) + ->map('entity_id', $builder->fromArgument('entity_id')) + ->map('entity_type', $builder->fromArgument('entity_type')) + ->map('entity_bundle', $builder->fromArgument('entity_bundle')) + ->map('parent_entity_type', $builder->fromArgument('parent_entity_type')) + ->map('parent_entity_id', $builder->fromArgument('parent_entity_id')) + ); + } + +} diff --git a/modules/graphql_compose_mutations/src/Services/UserPermissions.php b/modules/graphql_compose_mutations/src/Services/UserPermissions.php new file mode 100644 index 0000000000000000000000000000000000000000..402e228a34cf9611e0376bcbdc39101d91845ce4 --- /dev/null +++ b/modules/graphql_compose_mutations/src/Services/UserPermissions.php @@ -0,0 +1,655 @@ +<?php + +namespace Drupal\graphql_compose_mutations\Services; + +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; +use Drupal\Core\Datetime\DrupalDateTime; +use Drupal\Core\Entity\EntityTypeBundleInfo; +use Drupal\Core\Entity\EntityTypeManager; +use Drupal\Core\Extension\ModuleHandler; +use Drupal\Core\Logger\LoggerChannel; +use Drupal\Core\Logger\LoggerChannelInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\node\NodeInterface; +use Drupal\user\UserInterface; + +/** + * A helper service to get User permissions by operation. + */ +class UserPermissions implements UserPermissionsInterface { + /** + * The User operations allowed to check for. + * + * @var array + * + * @todo This is a duplicate of enum OperationExtended at generic_mutation.base.graphqls + * @todo Maybe we could use the existing GraphQL data producer "entity_access". + * @todo Add more non-core operation verbs: "like, dislike, flag, unflag, enroll, comment, message etc" + */ + public array $operations = [ + // Core operations. + "view", + "create", + "update", + "delete", + ]; + + /** + * The ModuleHandler service. + * + * @var \Drupal\Core\Extension\ModuleHandler + */ + protected ModuleHandler $moduleHandler; + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected AccountInterface $currentUser; + + /** + * Drupal\Core\Entity\EntityTypeManager definition. + * + * @var \Drupal\Core\Entity\EntityTypeManager + */ + protected EntityTypeManager $entityTypeManager; + + /** + * Drupal\Core\Entity\EntityTypeBundleInfo definition. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfo + */ + protected EntityTypeBundleInfo $entityTypeBundleInfo; + + /** + * The logger service. + * + * @var \Drupal\Core\Logger\LoggerChannelInterface + */ + protected LoggerChannelInterface $logger; + + /** + * The Service constructor. + * + * @param \Drupal\Core\Extension\ModuleHandler $moduleHandler + * The ModuleHandler service. + * @param \Drupal\Core\Session\AccountInterface $currentUser + * The current user. + * @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager + * The entity_type.manager service. + * @param \Drupal\Core\Logger\LoggerChannel $logger + * The logger.channel.graphql service. + * @param \Drupal\Core\Entity\EntityTypeBundleInfo $entityTypeBundleInfo + * The entity_type.manager service. + */ + public function __construct( + ModuleHandler $moduleHandler, + AccountInterface $currentUser, + EntityTypeManager $entityTypeManager, + LoggerChannel $logger, + EntityTypeBundleInfo $entityTypeBundleInfo, + ) { + // Get required services. + $this->moduleHandler = $moduleHandler; + $this->currentUser = $currentUser; + $this->entityTypeManager = $entityTypeManager; + $this->logger = $logger; + $this->entityTypeBundleInfo = $entityTypeBundleInfo; + } + + /** + * Checks if an entity type (e.g. node, taxonomy_term) exists. + * + * @param string $entity_type + * The entity type to check for. + * + * @return bool + * Return if entity type exists. + */ + private function entityTypeExists(string $entity_type):bool { + $entity_types = $this->entityTypeBundleInfo->getAllBundleInfo(); + $entity_type_keys = array_keys($entity_types); + + return in_array($entity_type, $entity_type_keys); + } + + /** + * Checks if an entity bundle (e.g. event, page of type Node) exists. + * + * @param string $entity_type + * The entity type to check for. + * @param string $entity_bundle + * The entity bundle to check for. + * + * @return bool + * Return if entity bundle of this type exists. + */ + private function entityBundleOfTypeExists(string $entity_type, string $entity_bundle):bool { + $entity_bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type); + $entity_bundles_keys = array_keys($entity_bundles); + + return in_array($entity_bundle, $entity_bundles_keys); + } + + /** + * Get generic permissions manually added. This is a helper method. + * + * @param string $entity_bundle + * The entity bundle to get the generic permissions for. + * + * @return array + * Array of manually added permissions. + */ + private function getGenericPermissionsByEntityBundle(string $entity_bundle): array { + // Unfortunately, modules do not use a pattern for permissions. + // We have to generate this list manually... + // @todo only add permissions if entity types exist. + $permissions = [ + "comment" => [ + "administer comments", + "post comments", + "create " . $entity_bundle . " comment", + ], + "contact_message" => [ + "administer contact forms", + "access site-wide contact form", + ], + "message" => [ + // @todo This is a system entity so there are no other permissions. + "administer messages", + ], + "node" => [ + "administer nodes", + "bypass node access", + "create " . $entity_bundle . " content", + ], + "group" => [ + "administer group", + "bypass create group access", + "bypass group access", + "manage all groups", + "create " . $entity_bundle . " group", + ], + "private_message" => [ + "administer private messages", + "use private messaging system", + ], + "profile" => [ + "administer profile", + "create " . $entity_bundle . " profile", + ], + "taxonomy_term" => [ + "administer taxonomy", + "create terms in " . $entity_bundle, + ], + ]; + + // Module flag. + if ($this->moduleHandler->moduleExists('flag')) { + $permissions['flagging'] = [ + "flag " . $entity_bundle, + "unflag " . $entity_bundle, + ]; + } + + // Module contact_storage. + if ($this->moduleHandler->moduleExists('contact_storage')) { + $permissions['contact_storage'] = [ + "administer contact forms", + "access site-wide contact form", + ]; + } + + // Open Social additional conditions. + if ($this->moduleHandler->moduleExists('activity_creator')) { + $permissions['activity'] = [ + "administer activity entities", + "add activity entities", + ]; + } + + if ($this->moduleHandler->moduleExists('social_event')) { + $permissions['event_enrollment'] = [ + "administer event enrollment entities", + "add event enrollment entities", + ]; + } + + if ($this->moduleHandler->moduleExists('social_private_message')) { + $permissions['private_message'][] = "create private messages thread"; + } + + if ($this->moduleHandler->moduleExists('social_post')) { + $permissions['post'] = [ + "administer post entities", + "add post entities", + "add " . $entity_bundle . " post entities", + ]; + } + + ksort($permissions); + + return $permissions; + } + + /** + * Load user from uid. We use the entityTypeManager for dependency injection. + * + * @param mixed $uid + * The uid. + * + * @return \Drupal\user\UserInterface|null + * The final User object. + */ + private function loadAccountFromUid(mixed $uid): ?UserInterface { + try { + $user_storage = $this->entityTypeManager->getStorage("user"); + $account = $user_storage->load($uid); + if ($account instanceof UserInterface) { + return $account; + } + } + catch (InvalidPluginDefinitionException | PluginNotFoundException $e) { + $this->logger->error($e->getMessage()); + } + return NULL; + } + + /** + * Check if a user (loaded from uid) has a permission to CREATE. + * + * @param mixed $uid + * The uid. + * @param string $entity_type + * The entity type (node, post, taxonomy_term, etc) + * @param string $entity_bundle + * The entity bundle. + * @param mixed $parent_entity_type + * The parent entity type that may be related to this operation. E.g. for + * vote entity types this may be the Node or Comment we are voting for. + * @param mixed $parent_entity_id + * The parent entity ID. See above. + * + * @return bool + * Return final access. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function userCanCreateEntityByType( + mixed $uid, + string $entity_type, + string $entity_bundle, + mixed $parent_entity_type, + mixed $parent_entity_id, + ):bool { + $final_access = FALSE; + $account = $this->loadAccountFromUid($uid); + + if ($entity_type === "group_content") { + if (!$parent_entity_type || !$parent_entity_id) { + return FALSE; + } + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + // phpcs:disable + if ($parent_entity instanceof \Drupal\group\Entity\GroupInterface) { + // phpcs:enable + /** @var \Drupal\group\Entity\GroupInterface $parent_entity */ + // If this is a try to CREATE a new Group Membership. + if (str_contains($entity_bundle, "-group_membership")) { + // @todo Check field allow_request value also. + $owner_id = $parent_entity->getOwner()->id(); + // This is the owner already. + if ((int) $owner_id === (int) $uid) { + return FALSE; + } + + $group_content = $this->entityTypeManager + ->getStorage("group_content") + ->loadByProperties([ + "type" => $entity_bundle, + "gid" => $parent_entity_id, + "entity_id" => $uid, + ]); + + // There is already a Group Membership. Skip. + if ($group_content) { + return FALSE; + } + + return TRUE; + } + + // @todo What are the Permissions by Group for the other bundles? + // @todo Maybe there is a more dedicated Service to handle this. + return TRUE; + } + else { + return FALSE; + } + } + + if ($entity_type === "event_enrollment") { + if ($this->moduleHandler->moduleExists('social_event')) { + if (!$parent_entity_type || !$parent_entity_id) { + return FALSE; + } + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + if (!$parent_entity instanceof NodeInterface) { + return FALSE; + } + else { + /** @var \Drupal\social_event\Entity\Node\Event $parent_entity */ + $event_is_enroll_enabled = $parent_entity->isEnrollmentEnabled(); + if (!$event_is_enroll_enabled) { + return FALSE; + } + + $event_finished = $this->socialEventHasBeenFinished($parent_entity); + if ($event_finished) { + return FALSE; + } + + // Get enrollment for this Parent entity and this User. + $enrollment = $this->entityTypeManager + ->getStorage("event_enrollment") + ->loadByProperties([ + "field_event" => $parent_entity_id, + "field_account" => $uid, + ]); + // Already enrolled to the Event. + if ($enrollment) { + return FALSE; + } + } + } + } + + if ($entity_type === "vote") { + if (!$parent_entity_type || !$parent_entity_id) { + return FALSE; + } + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + return like_and_dislike_can_vote($account, $entity_bundle, $parent_entity); + } + + if ($entity_type === "flagging") { + if (!$parent_entity_type || !$parent_entity_id) { + return FALSE; + } + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + if (!$parent_entity) { + return FALSE; + } + /** @var \Drupal\Core\Entity\EntityInterface $parent_entity */ + $flagging_entity = $this->entityTypeManager->getStorage($entity_type) + ->loadByProperties([ + "flag_id" => $entity_bundle, + "uid" => $uid, + "entity_type" => $parent_entity_type, + "entity_id" => $parent_entity_id, + ]); + + if ($flagging_entity) { + return FALSE; + } + } + + if ((int) $uid === 1) { + return TRUE; + } + + $roles = $account->getRoles(); + foreach ($roles as $role_id) { + /** @var \Drupal\user\Entity\Role $role */ + $role = $this->entityTypeManager->getStorage("user_role")->load($role_id); + if ($role->isAdmin()) { + return TRUE; + } + } + + // Core entity access calculations. + $access_handler = $this->entityTypeManager->getAccessControlHandler($entity_type); + $access_result = (bool) $access_handler->createAccess($entity_bundle, $account); + if ($access_result) { + return TRUE; + } + + // Final, manual added access checks. + $create_permissions = $this->getGenericPermissionsByEntityBundle($entity_bundle); + if (isset($create_permissions[$entity_type])) { + foreach ($create_permissions[$entity_type] as $permission) { + $access = $account->hasPermission($permission); + if ($access === TRUE) { + $final_access = TRUE; + } + } + } + + return $final_access; + } + + /** + * {@inheritdoc} + */ + public function userCanDoActionOnEntityByType( + string $operation, + mixed $uid, + mixed $entity_id, + string $entity_type, + mixed $entity_bundle, + mixed $parent_entity_type, + mixed $parent_entity_id, + ):array { + $current_user = $this->currentUser; + $final_access = FALSE; + $account = $this->loadAccountFromUid($uid); + $operations = $this->operations; + $entity_type_exists = $this->entityTypeExists($entity_type); + $entity_bundle_exists = $this->entityBundleOfTypeExists($entity_type, $entity_bundle); + + if (!in_array($operation, $operations)) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Operation is not allowed.", + ]; + } + + if (!$account) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Account for uid " . $uid . " does not exist.", + ]; + } + + if (!$entity_type_exists) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Entity type" . $entity_type . " does not exist.", + ]; + } + + if (!$entity_bundle_exists) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Entity bundle " . $entity_bundle . " of type " . $entity_type . " does not exist.", + ]; + } + + if (in_array($operation, ["update", "delete"]) && $entity_type === "user" && (int) $uid !== (int) $current_user->id()) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Only owner of the User account can delete-update the account.", + ]; + } + + if ($operation === "create") { + $types_with_parent = [ + "event_enrollment", + "flagging", + "group_content", + "vote", + ]; + + if (in_array($entity_type, $types_with_parent) && (!$parent_entity_type || !$parent_entity_id)) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Values for parent_entity_type and parent_entity_id fields are required for type: " . $entity_type, + ]; + } + + $access = $this->userCanCreateEntityByType($uid, $entity_type, $entity_bundle, $parent_entity_type, $parent_entity_id); + $result = [ + "access" => $access, + ]; + + if (!$access) { + $result["error"] = "userCanDoActionOnEntityByType: Access denied for uid " . $uid; + } + return $result; + } + + // Load existing entity. + try { + $entity = $this->entityTypeManager + ->getStorage($entity_type) + ->load($entity_id); + if ($entity) { + if ($entity_type === "group_content") { + if ($operation === "delete") { + /** @var \Drupal\group\Entity\GroupContent $entity */ + $gc_id = $entity->get("entity_id")->getString(); + if ((int) $gc_id === (int) $uid) { + return [ + "access" => TRUE, + ]; + } + } + } + + if ($entity_type === "flagging") { + if ($operation === "delete") { + /** @var \Drupal\flag\Entity\Flagging $entity */ + $flagging_uid = $entity->getOwner()->id(); + if ((int) $flagging_uid === (int) $uid) { + return [ + "access" => TRUE, + ]; + } + } + } + + if ($entity_type === "event_enrollment") { + if ($operation === "delete") { + /** @var \Drupal\social_event\Entity\EventEnrollment $entity */ + $enrollment_uid = $entity->getOwner()->id(); + if ((int) $enrollment_uid === (int) $uid) { + return [ + "access" => TRUE, + ]; + } + } + } + + // Special override for the votingapi module. Check access early. + if ($entity_type === "vote") { + if (!$parent_entity_type || !$parent_entity_id) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Parent entity is required for bundle: " . $entity_bundle . " of type: " . $entity_type, + ]; + } + $parent_entity = $this->entityTypeManager->getStorage($parent_entity_type)->load($parent_entity_id); + if ($parent_entity) { + if ($operation === "delete") { + /** @var \Drupal\votingapi\Entity\Vote $entity */ + $vote_uid = $entity->get("user_id")->getString(); + if ($vote_uid !== $uid) { + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType. Vote entity with id: " . $entity_id . " is not from this User.", + ]; + } + } + $final_access = like_and_dislike_can_vote($account, $entity_bundle, $parent_entity); + } + } + + // private_message_thread. + if ($entity_type === "private_message_thread") { + /** @var \Drupal\private_message\Entity\PrivateMessageThread $entity */ + $members = $entity->getMembersId(); + if (in_array($current_user->id(), $members)) { + $final_access = TRUE; + } + + if ($current_user->hasPermission("use private messaging system")) { + $final_access = TRUE; + } + } + + // Initial try to get access and exit with success. + if (!$final_access) { + $final_access = $entity->access($operation, $account); + } + if ($final_access === TRUE) { + return [ + "access" => TRUE, + ]; + } + + $result = [ + "access" => $final_access, + ]; + + if (!$final_access && !isset($result["error"])) { + $result["error"] = "userCanDoActionOnEntityByType. Entity exists. Access denied for uid " . $uid; + } + return $result; + } + else { + $entity_error = 'userCanDoActionOnEntityByType. Entity of type: ' . $entity_type . ', bundle: ' . $entity_bundle . ' with ID: ' . $entity_id . ' does not exist.'; + $this->logger->warning($entity_error); + return [ + "access" => FALSE, + "error" => $entity_error, + ]; + } + } + catch (InvalidPluginDefinitionException | PluginNotFoundException $e) { + $this->logger->error($e->getMessage()); + return [ + "access" => FALSE, + "error" => "userCanDoActionOnEntityByType | " . $e->getMessage(), + ]; + } + } + + /** + * {@inheritdoc} + */ + public function socialEventHasBeenFinished(NodeInterface $node): bool { + $current_time = new DrupalDateTime(); + + /** @var \Drupal\Core\Datetime\DrupalDateTime $check_end_date */ + $check_end_date = $node->get('field_event_date_end')->isEmpty() + // @phpstan-ignore-next-line + ? $node->get('field_event_date')->date + // @phpstan-ignore-next-line + : $node->get('field_event_date_end')->date; + + if (!$check_end_date instanceof DrupalDateTime) { + return FALSE; + } + + $check_all_day = !$node->get('field_event_all_day')->isEmpty() + ? $node->get('field_event_all_day')->getString() + : NULL; + + return $current_time->getTimestamp() > $check_end_date->getTimestamp() && + !($check_all_day && $check_end_date->format('Y-m-d') === $current_time->format('Y-m-d')); + } + +} diff --git a/modules/graphql_compose_mutations/src/Services/UserPermissionsInterface.php b/modules/graphql_compose_mutations/src/Services/UserPermissionsInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..abebc1d601d99c60bca17f6962c3b253b14a1f06 --- /dev/null +++ b/modules/graphql_compose_mutations/src/Services/UserPermissionsInterface.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\graphql_compose_mutations\Services; + +use Drupal\node\NodeInterface; + +/** + * A helper service to get User permissions by operation. + */ +interface UserPermissionsInterface { + + /** + * Check if a user (loaded from uid) has a permission to CREATE. + * + * @param string $operation + * The operation to check for,. + * @param mixed $uid + * The uid. + * @param mixed $entity_id + * The entity id to load. + * @param string $entity_type + * The entity type (node, post, taxonomy_term etc.) + * @param string $entity_bundle + * The entity bundle. + * @param mixed $parent_entity_type + * The parent entity type that may be related to this operation. E.g. for + * vote entity types this may be the Node or Comment we are voting for. + * @param mixed $parent_entity_id + * The parent entity ID. See above. + * + * @return array + * Return final access array with useful info. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function userCanDoActionOnEntityByType( + string $operation, + mixed $uid, + mixed $entity_id, + string $entity_type, + mixed $entity_bundle, + mixed $parent_entity_type, + mixed $parent_entity_id, + ) : array; + + /** + * Function to determine if an event has been finished. + * + * This is a pure copy from Class social_event/src/SocialEventTrait.php + * Unfortunately, we can use a private method from a Trait. + * + * @param \Drupal\node\NodeInterface $node + * The event to check for. + * + * @return bool + * TRUE if the evens is finished/completed. + */ + public function socialEventHasBeenFinished(NodeInterface $node): bool; + +}