Commit 0e4ef18c authored by effulgentsia's avatar effulgentsia

Issue #2975334 by amateescu, Wim Leers, plach, effulgentsia, webchick,...

Issue #2975334 by amateescu, Wim Leers, plach, effulgentsia, webchick, timmillwood, yoroy: Prevent changes that would leak into the Live workspace

(cherry picked from commit 4ea4c13f)
parent f0372ebf
......@@ -3,9 +3,9 @@
namespace Drupal\workspace;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -119,13 +119,21 @@ public function entityLoad(array &$entities, $entity_type_id) {
* @see hook_entity_presave()
*/
public function entityPresave(EntityInterface $entity) {
/** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
// Only run if the entity type can belong to a workspace and we are in a
// non-default workspace.
if (!$this->workspaceManager->shouldAlterOperations($entity->getEntityType())) {
$entity_type = $entity->getEntityType();
// Only run if this is not an entity type provided by the Workspace module
// and we are in a non-default workspace
if ($entity_type->getProvider() === 'workspace' || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
return;
}
// Disallow any change to an unsupported entity when we are not in the
// default workspace.
if (!$this->workspaceManager->isEntityTypeSupported($entity_type)) {
throw new \RuntimeException('This entity can only be saved in the default workspace.');
}
/** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
if (!$entity->isNew() && !isset($entity->_isReplicating)) {
// Force a new revision if the entity is not replicating.
$entity->setNewRevision(TRUE);
......@@ -208,6 +216,30 @@ public function entityUpdate(EntityInterface $entity) {
}
}
/**
* Acts on an entity before it is deleted.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being deleted.
*
* @see hook_entity_predelete()
*/
public function entityPredelete(EntityInterface $entity) {
$entity_type = $entity->getEntityType();
// Only run if this is not an entity type provided by the Workspace module
// and we are in a non-default workspace
if ($entity_type->getProvider() === 'workspace' || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
return;
}
// Disallow any change to an unsupported entity when we are not in the
// default workspace.
if (!$this->workspaceManager->isEntityTypeSupported($entity_type)) {
throw new \RuntimeException('This entity can only be deleted in the default workspace.');
}
}
/**
* Updates or creates a WorkspaceAssociation entity for a given entity.
*
......@@ -266,15 +298,26 @@ protected function trackEntity(EntityInterface $entity) {
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
$form_object = $form_state->getFormObject();
if (!$form_object instanceof EntityFormInterface) {
public function entityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
if (!$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) {
return;
}
$entity = $form_object->getEntity();
if (!$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) {
return;
// For supported entity types, signal the fact that this form is safe to use
// in a non-default workspace.
// @see \Drupal\workspace\FormOperations::validateForm()
$form_state->set('workspace_safe', TRUE);
// Add an entity builder to the form which marks the edited entity object as
// a pending revision. This is needed so validation constraints like
// \Drupal\path\Plugin\Validation\Constraint\PathAliasConstraintValidator
// know in advance (before hook_entity_presave()) that the new revision will
// be a pending one.
$active_workspace = $this->workspaceManager->getActiveWorkspace();
if (!$active_workspace->isDefaultWorkspace()) {
$form['#entity_builders'][] = [$this, 'entityFormEntityBuild'];
}
/** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
......@@ -283,7 +326,7 @@ public function formAlter(array &$form, FormStateInterface $form_state, $form_id
// An entity can only be edited in one workspace.
$workspace_id = reset($workspace_ids);
if ($workspace_id !== $this->workspaceManager->getActiveWorkspace()->id()) {
if ($workspace_id !== $active_workspace->id()) {
$workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
$form['#markup'] = $this->t('The content is being edited in the %label workspace.', ['%label' => $workspace->label()]);
......@@ -292,4 +335,13 @@ public function formAlter(array &$form, FormStateInterface $form_state, $form_id
}
}
/**
* Entity builder that marks all supported entities as pending revisions.
*/
public function entityFormEntityBuild($entity_type_id, RevisionableInterface $entity, &$form, FormStateInterface &$form_state) {
// Set the non-default revision flag so that validation constraints are also
// aware that a pending revision is about to be created.
$entity->isDefaultRevision(FALSE);
}
}
......@@ -12,7 +12,7 @@
/**
* Handle activation of a workspace on administrative pages.
*/
class WorkspaceActivateForm extends EntityConfirmFormBase {
class WorkspaceActivateForm extends EntityConfirmFormBase implements WorkspaceFormInterface {
/**
* The workspace entity.
......
......@@ -10,7 +10,7 @@
*
* @internal
*/
class WorkspaceDeleteForm extends ContentEntityDeleteForm {
class WorkspaceDeleteForm extends ContentEntityDeleteForm implements WorkspaceFormInterface {
/**
* The workspace entity.
......
......@@ -14,7 +14,7 @@
/**
* Provides the workspace deploy form.
*/
class WorkspaceDeployForm extends ContentEntityForm {
class WorkspaceDeployForm extends ContentEntityForm implements WorkspaceFormInterface {
/**
* The workspace entity.
......
......@@ -15,7 +15,7 @@
/**
* Form controller for the workspace edit forms.
*/
class WorkspaceForm extends ContentEntityForm {
class WorkspaceForm extends ContentEntityForm implements WorkspaceFormInterface {
/**
* The workspace entity.
......
<?php
namespace Drupal\workspace\Form;
use Drupal\Core\Form\FormInterface;
/**
* Defines interface for workspace forms so they can be easily distinguished.
*
* @internal
*/
interface WorkspaceFormInterface extends FormInterface {}
......@@ -13,7 +13,7 @@
/**
* Provides a form that activates a different workspace.
*/
class WorkspaceSwitcherForm extends FormBase {
class WorkspaceSwitcherForm extends FormBase implements WorkspaceFormInterface {
/**
* The workspace manager.
......
<?php
namespace Drupal\workspace;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\views\Form\ViewsExposedForm;
use Drupal\workspace\Form\WorkspaceFormInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class for reacting to form operations.
*
* @internal
*/
class FormOperations implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The workspace manager service.
*
* @var \Drupal\workspace\WorkspaceManagerInterface
*/
protected $workspaceManager;
/**
* Constructs a new FormOperations instance.
*
* @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
* The workspace manager service.
*/
public function __construct(WorkspaceManagerInterface $workspace_manager) {
$this->workspaceManager = $workspace_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspace.manager')
);
}
/**
* Alters forms to disallow editing in non-default workspaces.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $form_id
* The form ID.
*
* @see hook_form_alter()
*/
public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
// No alterations are needed in the default workspace.
if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
return;
}
// Add an additional validation step for every form if we are in a
// non-default workspace.
$this->addWorkspaceValidation($form);
// If a form has already been marked as safe or not to submit in a
// non-default workspace, we don't have anything else to do.
if ($form_state->has('workspace_safe')) {
return;
}
// No forms are safe to submit in a non-default workspace by default, except
// for the whitelisted ones defined below.
$workspace_safe = FALSE;
// Whitelist a few forms that we know are safe to submit.
$form_object = $form_state->getFormObject();
$is_workspace_form = $form_object instanceof WorkspaceFormInterface;
$is_search_form = in_array($form_object->getFormId(), ['search_block_form', 'search_form'], TRUE);
$is_views_exposed_form = $form_object instanceof ViewsExposedForm;
if ($is_workspace_form || $is_search_form || $is_views_exposed_form) {
$workspace_safe = TRUE;
}
$form_state->set('workspace_safe', $workspace_safe);
}
/**
* Adds our validation handler recursively on each element of a form.
*
* @param array &$element
* An associative array containing the structure of the form.
*/
protected function addWorkspaceValidation(array &$element) {
// Recurse through all children and add our validation handler if needed.
foreach (Element::children($element) as $key) {
if (isset($element[$key]) && $element[$key]) {
$this->addWorkspaceValidation($element[$key]);
}
}
if (isset($element['#validate'])) {
$element['#validate'][] = [$this, 'validateDefaultWorkspace'];
}
}
/**
* Validation handler which sets a validation error for all unsupported forms.
*/
public function validateDefaultWorkspace(array &$form, FormStateInterface $form_state) {
if ($form_state->get('workspace_safe') !== TRUE) {
$form_state->setError($form, $this->t('This form can only be submitted in the default workspace.'));
}
}
}
......@@ -2,7 +2,9 @@
namespace Drupal\Tests\workspace\Kernel;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\entity_test\Entity\EntityTestMulRev;
use Drupal\entity_test\Entity\EntityTestMulRevPub;
use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
......@@ -74,6 +76,7 @@ protected function setUp() {
$this->installSchema('node', ['node_access']);
$this->installEntitySchema('entity_test_mulrev');
$this->installEntitySchema('entity_test_mulrevpub');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
......@@ -373,8 +376,9 @@ public function testWorkspaces() {
public function testEntityQueryRelationship() {
$this->initializeWorkspaceModule();
// Add an entity reference field that targets 'entity_test_mulrev' entities.
$this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrev');
// Add an entity reference field that targets 'entity_test_mulrevpub'
// entities.
$this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrevpub');
// Add an entity reference field that targets 'node' entities so we can test
// references to the same base tables.
......@@ -384,8 +388,8 @@ public function testEntityQueryRelationship() {
$node_1 = $this->createNode([
'title' => 'live node 1',
]);
$entity_test = EntityTestMulRev::create([
'name' => 'live entity_test_mulrev',
$entity_test = EntityTestMulRevPub::create([
'name' => 'live entity_test_mulrevpub',
'non_rev_field' => 'live non-revisionable value',
]);
$entity_test->save();
......@@ -405,7 +409,7 @@ public function testEntityQueryRelationship() {
$node_2->title->value = 'stage node 2';
$node_2->save();
$entity_test->name->value = 'stage entity_test_mulrev';
$entity_test->name->value = 'stage entity_test_mulrevpub';
$entity_test->non_rev_field->value = 'stage non-revisionable value';
$entity_test->save();
......@@ -435,11 +439,15 @@ public function testEntityQueryRelationship() {
->condition('field_test_node.entity.uuid', $node_1->uuid());
// Add conditions for a reference to a different entity type.
// @todo Re-enable the two conditions below when we find a way to not join
// the workspace_association table for every duplicate entity base table
// join.
// @see https://www.drupal.org/project/drupal/issues/2983639
$query
// Check a condition on the revision data table.
->condition('field_test_entity.entity.name', 'stage entity_test_mulrev')
// ->condition('field_test_entity.entity.name', 'stage entity_test_mulrevpub')
// Check a condition on the data table.
->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value')
// ->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value')
// Check a condition on the base table.
->condition('field_test_entity.entity.uuid', $entity_test->uuid());
......@@ -447,6 +455,41 @@ public function testEntityQueryRelationship() {
$this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result);
}
/**
* Tests CRUD operations for unsupported entity types.
*/
public function testDisallowedEntityCRUDInNonDefaultWorkspace() {
$this->initializeWorkspaceModule();
// Create an unsupported entity type in the default workspace.
$this->switchToWorkspace('live');
$entity_test = EntityTestMulRev::create([
'name' => 'live entity_test_mulrev',
]);
$entity_test->save();
// Switch to a non-default workspace and check that any entity type CRUD are
// not allowed.
$this->switchToWorkspace('stage');
// Check updating an existing entity.
$entity_test->name->value = 'stage entity_test_mulrev';
$entity_test->setNewRevision(TRUE);
$this->setExpectedException(EntityStorageException::class, 'This entity can only be saved in the default workspace.');
$entity_test->save();
// Check saving a new entity.
$new_entity_test = EntityTestMulRev::create([
'name' => 'stage entity_test_mulrev',
]);
$this->setExpectedException(EntityStorageException::class, 'This entity can only be saved in the default workspace.');
$new_entity_test->save();
// Check deleting an existing entity.
$this->setExpectedException(EntityStorageException::class, 'This entity can only be deleted in the default workspace.');
$entity_test->delete();
}
/**
* Checks entity load, entity queries and views results for a test scenario.
*
......
......@@ -8,6 +8,7 @@
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
......@@ -17,6 +18,7 @@
use Drupal\workspace\EntityAccess;
use Drupal\workspace\EntityOperations;
use Drupal\workspace\EntityTypeInfo;
use Drupal\workspace\FormOperations;
use Drupal\workspace\ViewsQueryAlter;
/**
......@@ -46,8 +48,13 @@ function workspace_entity_type_build(array &$entity_types) {
* Implements hook_form_alter().
*/
function workspace_form_alter(&$form, FormStateInterface $form_state, $form_id) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
if ($form_state->getFormObject() instanceof EntityFormInterface) {
\Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityFormAlter($form, $form_state, $form_id);
}
\Drupal::service('class_resolver')
->getInstanceFromDefinition(FormOperations::class)
->formAlter($form, $form_state, $form_id);
}
......@@ -87,6 +94,15 @@ function workspace_entity_update(EntityInterface $entity) {
->entityUpdate($entity);
}
/**
* Implements hook_entity_predelete().
*/
function workspace_entity_predelete(EntityInterface $entity) {
return \Drupal::service('class_resolver')
->getInstanceFromDefinition(EntityOperations::class)
->entityPredelete($entity);
}
/**
* Implements hook_entity_access().
*
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment