diff --git a/core/composer.json b/core/composer.json
index 31bc51a..7d18902 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -152,7 +152,8 @@
         "drupal/user": "self.version",
         "drupal/views": "self.version",
         "drupal/views_ui": "self.version",
-        "drupal/workflows": "self.version"
+        "drupal/workflows": "self.version",
+        "drupal/workspace": "self.version"
     },
     "minimum-stability": "dev",
     "prefer-stable": true,
diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
index 3effe8b..b3d00a0 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
@@ -1088,7 +1088,9 @@ public function createDuplicate() {
 
     $duplicate = clone $this;
     $entity_type = $this->getEntityType();
-    $duplicate->{$entity_type->getKey('id')}->value = NULL;
+    if ($entity_type->hasKey('id')) {
+      $duplicate->{$entity_type->getKey('id')}->value = NULL;
+    }
     $duplicate->enforceIsNew();
 
     // Check if the entity type supports UUIDs and generate a new one if so.
diff --git a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
index ac36411..7eab79d 100644
--- a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
+++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
@@ -316,13 +316,17 @@ public function fieldAccess($operation, FieldDefinitionInterface $field_definiti
     $default = $items ? $items->defaultAccess($operation, $account) : AccessResult::allowed();
 
     // Explicitly disallow changing the entity ID and entity UUID.
-    if ($operation === 'edit') {
+    $entity = $items ? $items->getEntity() : NULL;
+    if ($operation === 'edit' && $entity) {
       if ($field_definition->getName() === $this->entityType->getKey('id')) {
-        return $return_as_object ? AccessResult::forbidden('The entity ID cannot be changed') : FALSE;
+        // String IDs can be set when creating the entity.
+        if (!($entity->isNew() && $field_definition->getType() === 'string')) {
+          return $return_as_object ? AccessResult::forbidden('The entity ID cannot be changed')->addCacheableDependency($entity) : FALSE;
+        }
       }
       elseif ($field_definition->getName() === $this->entityType->getKey('uuid')) {
         // UUIDs can be set when creating an entity.
-        if ($items && ($entity = $items->getEntity()) && !$entity->isNew()) {
+        if (!$entity->isNew()) {
           return $return_as_object ? AccessResult::forbidden('The entity UUID cannot be changed')->addCacheableDependency($entity) : FALSE;
         }
       }
diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
index 0608681..7e9c96b 100644
--- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
+++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
@@ -8,6 +8,7 @@
 use Drupal\taxonomy\Entity\Term;
 use Drupal\Tests\SchemaCheckTestTrait;
 use Drupal\Tests\system\Functional\Module\ModuleTestBase;
+use Drupal\workspace\Entity\Workspace;
 
 /**
  * Tests the largest configuration import possible with all available modules.
@@ -93,6 +94,10 @@ public function testInstallUninstall() {
     $shortcuts = Shortcut::loadMultiple();
     entity_delete_multiple('shortcut', array_keys($shortcuts));
 
+    // Delete any workspaces so the workspace module can be uninstalled.
+    $workspaces = Workspace::loadMultiple();
+    \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+
     system_list_reset();
     $all_modules = system_rebuild_module_data();
 
diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php
index 1051ce4..455cdeb 100644
--- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php
+++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php
@@ -99,7 +99,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       static::$fieldName => [
         [
diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php
index ffce48c..d2aa6e9 100644
--- a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php
+++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php
@@ -99,7 +99,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       static::$fieldName => [
         [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/BlockContent/BlockContentHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/BlockContent/BlockContentHalJsonAnonTest.php
index d4ee9ab..371da68 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/BlockContent/BlockContentHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/BlockContent/BlockContentHalJsonAnonTest.php
@@ -53,7 +53,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php
index 1939e04..60c1635 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php
@@ -117,7 +117,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
index 06f72bf..c224a2a 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
@@ -78,7 +78,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonInternalPropertyNormalizerTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonInternalPropertyNormalizerTest.php
index 819a125..bbc6b38 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonInternalPropertyNormalizerTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonInternalPropertyNormalizerTest.php
@@ -73,7 +73,7 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       'field_test_internal' => [
         [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelHalJsonAnonTest.php
index f95ea5c..d457d65 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelHalJsonAnonTest.php
@@ -80,7 +80,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Feed/FeedHalJsonTestBase.php b/core/modules/hal/tests/src/Functional/EntityResource/Feed/FeedHalJsonTestBase.php
index 4318132..a2adf9f 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Feed/FeedHalJsonTestBase.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Feed/FeedHalJsonTestBase.php
@@ -47,8 +47,8 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
-    return parent::getNormalizedPostEntity() + [
+  protected function getNormalizedPostEntity($which = NULL) {
+    return parent::getNormalizedPostEntity($which) + [
       '_links' => [
         'type' => [
           'href' => $this->baseUrl . '/rest/type/aggregator_feed/aggregator_feed'
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/File/FileHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/File/FileHalJsonAnonTest.php
index ff89f74..975861a 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/File/FileHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/File/FileHalJsonAnonTest.php
@@ -80,7 +80,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Item/ItemHalJsonTestBase.php b/core/modules/hal/tests/src/Functional/EntityResource/Item/ItemHalJsonTestBase.php
index 24157e5..1eaf960 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Item/ItemHalJsonTestBase.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Item/ItemHalJsonTestBase.php
@@ -77,7 +77,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Media/MediaHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Media/MediaHalJsonAnonTest.php
index c71b54e..742aba9 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Media/MediaHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Media/MediaHalJsonAnonTest.php
@@ -163,7 +163,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentHalJsonAnonTest.php
index c4c8d94..f2c76960 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentHalJsonAnonTest.php
@@ -53,7 +53,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php
index a9b3ae1..15f6a6e 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Message/MessageHalJsonAnonTest.php
@@ -32,7 +32,7 @@ class MessageHalJsonAnonTest extends MessageResourceTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
index e218a73..301c90d 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -111,7 +111,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Shortcut/ShortcutHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Shortcut/ShortcutHalJsonAnonTest.php
index c9e8dc0..31ddb23 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Shortcut/ShortcutHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Shortcut/ShortcutHalJsonAnonTest.php
@@ -53,7 +53,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
index 73a1549..7cf1364 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
@@ -52,7 +52,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php
index 7398f28..fd98c93 100644
--- a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php
+++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php
@@ -52,7 +52,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       '_links' => [
         'type' => [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Action/ActionResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Action/ActionResourceTestBase.php
index 76aff4b..7f00bcc 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Action/ActionResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Action/ActionResourceTestBase.php
@@ -82,7 +82,7 @@ protected function getExpectedCacheContexts() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/BaseFieldOverride/BaseFieldOverrideResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/BaseFieldOverride/BaseFieldOverrideResourceTestBase.php
index 1f4b03a..5f8b9e7 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/BaseFieldOverride/BaseFieldOverrideResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/BaseFieldOverride/BaseFieldOverrideResourceTestBase.php
@@ -84,7 +84,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
index 13a4568..f537fb1 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
@@ -104,7 +104,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/BlockContent/BlockContentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/BlockContent/BlockContentResourceTestBase.php
index 5d7329f..bdb4c41 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/BlockContent/BlockContentResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/BlockContent/BlockContentResourceTestBase.php
@@ -144,7 +144,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'type' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/BlockContentType/BlockContentTypeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/BlockContentType/BlockContentTypeResourceTestBase.php
index f96f40e..09f4806 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/BlockContentType/BlockContentTypeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/BlockContentType/BlockContentTypeResourceTestBase.php
@@ -64,7 +64,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php
index 60b5930..bcf67f7 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php
@@ -205,7 +205,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'comment_type' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/CommentType/CommentTypeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/CommentType/CommentTypeResourceTestBase.php
index 9d88211..d6897fe 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/CommentType/CommentTypeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/CommentType/CommentTypeResourceTestBase.php
@@ -70,7 +70,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
index 9fe073b..fc814f7 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
@@ -66,7 +66,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigurableLanguage/ConfigurableLanguageResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigurableLanguage/ConfigurableLanguageResourceTestBase.php
index cf5be74..a589056 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ConfigurableLanguage/ConfigurableLanguageResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigurableLanguage/ConfigurableLanguageResourceTestBase.php
@@ -71,7 +71,7 @@ protected function getExpectedCacheContexts() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ContactForm/ContactFormResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ContactForm/ContactFormResourceTestBase.php
index 51e297a..5cf3cb5 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ContactForm/ContactFormResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ContactForm/ContactFormResourceTestBase.php
@@ -86,7 +86,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsResourceTestBase.php
index a8b3f8c..77c36a4 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ContentLanguageSettings/ContentLanguageSettingsResourceTestBase.php
@@ -75,7 +75,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/DateFormat/DateFormatResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/DateFormat/DateFormatResourceTestBase.php
index 8fce0fe..80031ee 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/DateFormat/DateFormatResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/DateFormat/DateFormatResourceTestBase.php
@@ -69,7 +69,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorResourceTestBase.php
index 4eb4a6e..24dba9d 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Editor/EditorResourceTestBase.php
@@ -158,7 +158,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityFormDisplay/EntityFormDisplayResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityFormDisplay/EntityFormDisplayResourceTestBase.php
index cc4cf58..a4a91ed 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityFormDisplay/EntityFormDisplayResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityFormDisplay/EntityFormDisplayResourceTestBase.php
@@ -134,7 +134,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityFormMode/EntityFormModeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityFormMode/EntityFormModeResourceTestBase.php
index a9035e0..a8bd421 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityFormMode/EntityFormModeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityFormMode/EntityFormModeResourceTestBase.php
@@ -67,7 +67,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
index 2962ef3..18f98dd 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -121,6 +121,13 @@
   protected $entity;
 
   /**
+   * Another entity of the same type used for testing.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $anotherEntity;
+
+  /**
    * The entity storage.
    *
    * @var \Drupal\Core\Entity\EntityStorageInterface
@@ -217,6 +224,22 @@ public function setUp() {
   abstract protected function createEntity();
 
   /**
+   * Creates another entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   Another entity based on $this->entity.
+   */
+  protected function createAnotherEntity() {
+    $entity = $this->entity->createDuplicate();
+    $label_key = $entity->getEntityType()->getKey('label');
+    if ($label_key) {
+      $entity->set($label_key, $entity->label() . '_dupe');
+    }
+    $entity->save();
+    return $entity;
+  }
+
+  /**
    * Returns the expected normalization of the entity.
    *
    * @see ::createEntity()
@@ -230,9 +253,15 @@ public function setUp() {
    *
    * @see ::testPost
    *
+   * @param int|null $which
+   *   An integer indicating which POST entity to generate. (Some entity types
+   *   have a required field that must contain a unique value. To test certain
+   *   edge cases, multiple distinct entities need to be created. This integer
+   *   then indicates which set of entity field values to create.)
+   *
    * @return array
    */
-  abstract protected function getNormalizedPostEntity();
+  abstract protected function getNormalizedPostEntity($which = NULL);
 
   /**
    * Returns the normalized PATCH entity.
@@ -701,8 +730,8 @@ public function testPost() {
     // Try with all of the following request bodies.
     $unparseable_request_body = '!{>}<';
     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
-    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
-    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format);
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(1), static::$format);
+    $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity(), 'label'), static::$format);
     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format);
     $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
 
@@ -861,8 +890,9 @@ public function testPost() {
     }
     $response = $this->request('POST', $url, $request_options);
     $this->assertResourceResponse(201, FALSE, $response);
+    $created_entity = $this->entityStorage->load(static::$secondCreatedEntityId);
     if ($has_canonical_url) {
-      $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
+      $location = $created_entity->toUrl('canonical')->setAbsolute(TRUE)->toString();
       $this->assertSame([$location], $response->getHeader('Location'));
     }
     else {
@@ -870,6 +900,32 @@ public function testPost() {
     }
     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
 
+    if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
+      // 500 when creating an entity with a duplicate UUID.
+      $normalized_entity = $this->getNormalizedPostEntity(2);
+      $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $created_entity->uuid()]];
+      $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
+
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertSame(500, $response->getStatusCode());
+      $this->assertContains('Internal Server Error', (string) $response->getBody());
+
+      // 201 when successfully creating an entity with a new UUID.
+      $normalized_entity = $this->getNormalizedPostEntity(2);
+      $new_uuid = \Drupal::service('uuid')->generate();
+      $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $new_uuid]];
+      $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
+
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceResponse(201, FALSE, $response);
+      $entities = $this->entityStorage->loadByProperties([$created_entity->getEntityType()->getKey('uuid') => $new_uuid]);
+      $new_entity = reset($entities);
+      $this->assertNotNull($new_entity);
+      $new_entity->delete();
+    }
+
     // BC: old default POST URLs have their path updated by the inbound path
     // processor \Drupal\rest\PathProcessor\PathProcessorEntityResourceBC to the
     // new URL, which is derived from the 'create' link template if an entity
@@ -892,6 +948,9 @@ public function testPatch() {
       return;
     }
 
+    // Patch testing requires that another entity of the same type exists.
+    $this->anotherEntity = $this->createAnotherEntity();
+
     $this->initAuthentication();
     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
 
@@ -899,7 +958,7 @@ public function testPatch() {
     $unparseable_request_body = '!{>}<';
     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
     $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
-    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'label'), static::$format);
     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
 
     // The URL and Guzzle request options that will be used in this test. The
@@ -992,6 +1051,18 @@ public function testPatch() {
     $response = $this->request('PATCH', $url, $request_options);
     $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
 
+    // DX: 403 when entity trying to update an entity's ID field.
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'id'), static::$format);;
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'.", $response);
+
+    if ($this->entity->getEntityType()->hasKey('uuid')) {
+      // DX: 403 when entity trying to update an entity's UUID field.
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'uuid'), static::$format);;
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'.", $response);
+    }
+
     // DX: 403 when sending PATCH request with read-only fields.
     // First send all fields (the "maximum normalization"). Assert the expected
     // error message for the first PATCH-protected field. Remove that field from
@@ -1234,15 +1305,26 @@ protected function getEntityResourcePostUrl() {
    *
    * @param array $normalization
    *   An entity normalization.
+   * @param string $entity_key
+   *   The entity key whose normalization to make invalid.
    *
    * @return array
    *   The updated entity normalization, now invalid.
    */
-  protected function makeNormalizationInvalid(array $normalization) {
-    // Add a second label to this entity to make it invalid.
-    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
-    $normalization[$label_field][1]['value'] = 'Second Title';
-
+  protected function makeNormalizationInvalid(array $normalization, $entity_key) {
+    switch ($entity_key) {
+      case 'label':
+        // Add a second label to this entity to make it invalid.
+        $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+        $normalization[$label_field][1]['value'] = 'Second Title';
+        break;
+      case 'id':
+        $normalization[$this->entity->getEntityType()->getKey('id')][0]['value'] = $this->anotherEntity->id();
+        break;
+      case 'uuid':
+        $normalization[$this->entity->getEntityType()->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid();
+        break;
+    }
     return $normalization;
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonInternalPropertyNormalizerTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonInternalPropertyNormalizerTest.php
index 1944718..be475dd 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonInternalPropertyNormalizerTest.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonInternalPropertyNormalizerTest.php
@@ -75,7 +75,7 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return parent::getNormalizedPostEntity() + [
       'field_test_internal' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
index d14ec38..c40b46d 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
@@ -125,7 +125,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'type' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestBundle/EntityTestBundleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestBundle/EntityTestBundleResourceTestBase.php
index f43d877..6ccc0ef 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestBundle/EntityTestBundleResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestBundle/EntityTestBundleResourceTestBase.php
@@ -69,7 +69,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php
index 2257d6c..c5aea4d 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php
@@ -115,7 +115,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'type' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityViewDisplay/EntityViewDisplayResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityViewDisplay/EntityViewDisplayResourceTestBase.php
index ccc3aad..e7963de 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityViewDisplay/EntityViewDisplayResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityViewDisplay/EntityViewDisplayResourceTestBase.php
@@ -91,7 +91,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityViewMode/EntityViewModeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityViewMode/EntityViewModeResourceTestBase.php
index 28cb9b0..7e5ad40 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityViewMode/EntityViewModeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityViewMode/EntityViewModeResourceTestBase.php
@@ -67,7 +67,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php
index 1e79395..d0b8b31 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Feed/FeedResourceTestBase.php
@@ -3,10 +3,10 @@
 namespace Drupal\Tests\rest\Functional\EntityResource\Feed;
 
 use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
-use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
 use Drupal\aggregator\Entity\Feed;
 
-abstract class FeedResourceTestBase extends EntityTestResourceTestBase {
+abstract class FeedResourceTestBase extends EntityResourceTestBase {
 
   use BcTimestampNormalizerUnixTestTrait;
 
@@ -23,6 +23,11 @@
   /**
    * {@inheritdoc}
    */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   */
   protected function setUpAuthorization($method) {
     switch ($method) {
       case 'GET':
@@ -134,7 +139,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'title' => [
         [
@@ -143,7 +148,7 @@ protected function getNormalizedPostEntity() {
       ],
       'url' => [
         [
-          'value' => 'http://example.com/feed'
+          'value' => 'http://example.com/feed' . $which
         ]
       ],
       'refresh' => [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigResourceTestBase.php
index 05c05f4..a586a5c 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/FieldConfig/FieldConfigResourceTestBase.php
@@ -92,7 +92,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/FieldStorageConfig/FieldStorageConfigResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/FieldStorageConfig/FieldStorageConfigResourceTestBase.php
index aafc253..2b73c70 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/FieldStorageConfig/FieldStorageConfigResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/FieldStorageConfig/FieldStorageConfigResourceTestBase.php
@@ -71,7 +71,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/File/FileResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/File/FileResourceTestBase.php
index c63853e..4149a43 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/File/FileResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/File/FileResourceTestBase.php
@@ -168,7 +168,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'uid' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/FilterFormat/FilterFormatResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/FilterFormat/FilterFormatResourceTestBase.php
index 4d65e00..d48a489 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/FilterFormat/FilterFormatResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/FilterFormat/FilterFormatResourceTestBase.php
@@ -81,7 +81,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ImageStyle/ImageStyleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ImageStyle/ImageStyleResourceTestBase.php
index ccd68d9..7bee2e3 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ImageStyle/ImageStyleResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ImageStyle/ImageStyleResourceTestBase.php
@@ -95,7 +95,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php
index f217b97..0cdb593 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Item/ItemResourceTestBase.php
@@ -81,6 +81,20 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
+  protected function createAnotherEntity() {
+    $entity = $this->entity->createDuplicate();
+    $entity->setLink('https://www.exmaple.org/');
+    $label_key = $entity->getEntityType()->getKey('label');
+    if ($label_key) {
+      $entity->set($label_key, $entity->label() . '_dupe');
+    }
+    $entity->save();
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   protected function getExpectedNormalizedEntity() {
     $feed = Feed::load($this->entity->getFeedId());
 
@@ -125,7 +139,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'fid' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Media/MediaResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Media/MediaResourceTestBase.php
index 1875d00..b357873 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Media/MediaResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Media/MediaResourceTestBase.php
@@ -218,7 +218,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'bundle' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/MediaType/MediaTypeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/MediaType/MediaTypeResourceTestBase.php
index ac72737..3ca3826 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/MediaType/MediaTypeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/MediaType/MediaTypeResourceTestBase.php
@@ -71,7 +71,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Menu/MenuResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Menu/MenuResourceTestBase.php
index dd7f552..970df90 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Menu/MenuResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Menu/MenuResourceTestBase.php
@@ -62,7 +62,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php
index 0b1f967..ae3fde8 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/MenuLinkContent/MenuLinkContentResourceTestBase.php
@@ -71,7 +71,7 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'title' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php
index 3f1cab9..0d951a2 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Message/MessageResourceTestBase.php
@@ -70,7 +70,7 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'subject' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
index 492ff64..27a2183 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
@@ -187,7 +187,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'type' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php
index c374bfb..7c911b6 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php
@@ -72,7 +72,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/RdfMapping/RdfMappingResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/RdfMapping/RdfMappingResourceTestBase.php
index c826be0..a872ec9 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/RdfMapping/RdfMappingResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/RdfMapping/RdfMappingResourceTestBase.php
@@ -109,7 +109,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ResponsiveImageStyle/ResponsiveImageStyleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ResponsiveImageStyle/ResponsiveImageStyleResourceTestBase.php
index 4221314..1165e89 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ResponsiveImageStyle/ResponsiveImageStyleResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ResponsiveImageStyle/ResponsiveImageStyleResourceTestBase.php
@@ -115,7 +115,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigResourceTestBase.php
index a19e2ef..712d5e1 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/RestResourceConfig/RestResourceConfigResourceTestBase.php
@@ -87,7 +87,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
index ee719c4..95b2d56 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
@@ -62,7 +62,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/SearchPage/SearchPageResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/SearchPage/SearchPageResourceTestBase.php
index eca9ca8..7bec14d 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/SearchPage/SearchPageResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/SearchPage/SearchPageResourceTestBase.php
@@ -78,7 +78,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Shortcut/ShortcutResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Shortcut/ShortcutResourceTestBase.php
index 36be5b5..fdd178f 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Shortcut/ShortcutResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Shortcut/ShortcutResourceTestBase.php
@@ -120,7 +120,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'title' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetResourceTestBase.php
index 97ae1a6..5b5f9ff 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ShortcutSet/ShortcutSetResourceTestBase.php
@@ -81,7 +81,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
index e0a1581..a501ee3 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
@@ -143,7 +143,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'vid' => [
         [
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Tour/TourResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Tour/TourResourceTestBase.php
index 294e3ee..b6fce4c 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Tour/TourResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Tour/TourResourceTestBase.php
@@ -96,7 +96,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
index d25bf2f..59fbe0f 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
@@ -82,6 +82,17 @@ protected function createEntity() {
   /**
    * {@inheritdoc}
    */
+  protected function createAnotherEntity() {
+    /** @var \Drupal\user\UserInterface $user */
+    $user = $this->entity->createDuplicate();
+    $user->setUsername($user->label() . '_dupe');
+    $user->save();
+    return $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   protected function getExpectedNormalizedEntity() {
     return [
       'uid' => [
@@ -117,7 +128,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     return [
       'name' => [
         [
@@ -244,6 +255,49 @@ protected function assertRpcLogin($username, $password) {
   }
 
   /**
+   * Tests PATCHing security-sensitive base fields to change other users.
+   */
+  public function testPatchSecurityOtherUser() {
+    // The anonymous user is never allowed to modify other users.
+    if (!static::$auth) {
+      $this->markTestSkipped();
+    }
+
+    $this->initAuthentication();
+    $this->provisionEntityResource();
+
+    /** @var \Drupal\user\UserInterface $user */
+    $user = $this->account;
+    $original_normalization = array_diff_key($this->serializer->normalize($user, static::$format), ['changed' => TRUE]);
+
+    // Since this test must be performed by the user that is being modified,
+    // we cannot use $this->getUrl().
+    $url = $user->toUrl()->setOption('query', ['_format' => static::$format]);
+    $request_options = [
+      RequestOptions::HEADERS => ['Content-Type' => static::$mimeType],
+    ];
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+    $normalization = $original_normalization;
+    $normalization['mail'] = [['value' => 'new-email@example.com']];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+    // Try changing user 1's email.
+    $user1 = [
+      'mail' => [['value' => 'another_email_address@example.com']],
+      'uid' => [['value' => 1]],
+      'name' => [['value' => 'another_user_name']],
+      'pass' => [['existing' => $this->account->passRaw]],
+      'uuid' => [['value' => '2e9403a4-d8af-4096-a116-624710140be0']],
+    ] + $original_normalization;
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($user1, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    // Ensure the email address has not changed.
+    $this->assertEquals('admin@example.com', $this->entityStorage->loadUnchanged(1)->getEmail());
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'uid'.", $response);
+  }
+
+  /**
    * {@inheritdoc}
    */
   protected function getExpectedUnauthorizedAccessMessage($method) {
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php
index dbf74b1..2281d33 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php
@@ -41,4 +41,12 @@ public function testPatchDxForSecuritySensitiveBaseFields() {
     $this->markTestSkipped();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchSecurityOtherUser() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
 }
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
index d1ef16e..3e01ce0 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
@@ -36,4 +36,12 @@ public function testPatchDxForSecuritySensitiveBaseFields() {
     $this->markTestSkipped();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchSecurityOtherUser() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
 }
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/View/ViewResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/View/ViewResourceTestBase.php
index 26ec7bb..9ebbc1f 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/View/ViewResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/View/ViewResourceTestBase.php
@@ -83,7 +83,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
index 5d03512..efb97e1 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
@@ -62,7 +62,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
index 4d8f30d..aacce4f 100644
--- a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
+++ b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\workspace\Entity\Workspace;
 
 /**
  * Install/uninstall core module and confirm table creation/deletion.
@@ -147,6 +148,12 @@ public function testInstallUninstall() {
         $this->preUninstallForum();
       }
 
+      // Delete all workspaces before uninstall.
+      if ($name == 'workspace') {
+        $workspaces = Workspace::loadMultiple();
+        \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+      }
+
       $now_installed_list = \Drupal::moduleHandler()->getModuleList();
       $added_modules = array_diff(array_keys($now_installed_list), array_keys($was_installed_list));
       while ($added_modules) {
diff --git a/core/modules/views/src/Plugin/views/query/Sql.php b/core/modules/views/src/Plugin/views/query/Sql.php
index 2771a3d..6577a49 100644
--- a/core/modules/views/src/Plugin/views/query/Sql.php
+++ b/core/modules/views/src/Plugin/views/query/Sql.php
@@ -33,7 +33,7 @@ class Sql extends QueryPluginBase {
   /**
    * A list of tables in the order they should be added, keyed by alias.
    */
-  protected $tableQueue = [];
+  public $tableQueue = [];
 
   /**
    * Holds an array of tables and counts added so that we can create aliases
diff --git a/core/modules/workflows/tests/src/Functional/Rest/WorkflowResourceTestBase.php b/core/modules/workflows/tests/src/Functional/Rest/WorkflowResourceTestBase.php
index ce796a4..bb08021 100644
--- a/core/modules/workflows/tests/src/Functional/Rest/WorkflowResourceTestBase.php
+++ b/core/modules/workflows/tests/src/Functional/Rest/WorkflowResourceTestBase.php
@@ -100,7 +100,7 @@ protected function getExpectedNormalizedEntity() {
   /**
    * {@inheritdoc}
    */
-  protected function getNormalizedPostEntity() {
+  protected function getNormalizedPostEntity($which = NULL) {
     // @todo Update in https://www.drupal.org/node/2300677.
   }
 
diff --git a/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
new file mode 100644
index 0000000..b5e21ce
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
@@ -0,0 +1,15 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.workspace.deploy
+  module:
+    - workspace
+id: workspace.workspace.deploy
+targetEntityType: workspace
+bundle: workspace
+mode: deploy
+content: {  }
+hidden:
+  uid: true
+  upstream: true
diff --git a/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
new file mode 100644
index 0000000..1e02853
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - workspace
+id: workspace.deploy
+label: Deploy
+targetEntityType: workspace
+cache: true
diff --git a/core/modules/workspace/css/workspace.toolbar.css b/core/modules/workspace/css/workspace.toolbar.css
new file mode 100644
index 0000000..81ee1f9
--- /dev/null
+++ b/core/modules/workspace/css/workspace.toolbar.css
@@ -0,0 +1,54 @@
+/**
+ * @file
+ * Styling for Workspace module's toolbar tab.
+ */
+
+/* Tab appearance. */
+.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab {
+  float: right; /* LTR */
+  background-color: #e09600;
+}
+[dir="rtl"] .toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab {
+  float: left;
+}
+.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab.is-live {
+  background-color: #77b259;
+}
+
+.toolbar .toolbar-bar .workspace-toolbar-tab .toolbar-item {
+  margin: 0;
+}
+
+.toolbar .toolbar-icon-workspace:before {
+  background-image: url("../icons/ffffff/workspace.svg");
+}
+
+/* Manage workspaces link */
+.toolbar .toolbar-tray-vertical .manage-workspaces {
+  text-align: right; /* LTR */
+  padding: 1em;
+}
+[dir="rtl"] .toolbar .toolbar-tray-vertical .manage-workspaces {
+  text-align: left;
+}
+.toolbar .toolbar-tray-horizontal .manage-workspaces {
+  float: right; /* LTR */
+}
+[dir="rtl"] .toolbar .toolbar-tray-horizontal .manage-workspaces {
+  float: left;
+}
+
+/* Individual workspace links */
+.toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+  border-left: 1px solid #dddddd; /* LTR */
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+  border-left: 0 none;
+  border-right: 1px solid #dddddd;
+}
+.toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+  border-right: 1px solid #dddddd; /* LTR */
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+  border-left: 1px solid #dddddd;
+}
diff --git a/core/modules/workspace/icons/ffffff/workspace.svg b/core/modules/workspace/icons/ffffff/workspace.svg
new file mode 100644
index 0000000..299ff26
--- /dev/null
+++ b/core/modules/workspace/icons/ffffff/workspace.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><g fill="#FFF"><path d="M14 12L16 12 16 0 4 0 4 2 14 2 14 12ZM0 4L12 4 12 16 0 16 0 4Z"/></g></g></svg>
diff --git a/core/modules/workspace/src/Annotation/RepositoryHandler.php b/core/modules/workspace/src/Annotation/RepositoryHandler.php
new file mode 100644
index 0000000..4ac8eb6
--- /dev/null
+++ b/core/modules/workspace/src/Annotation/RepositoryHandler.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\workspace\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a RepositoryHandler annotation object.
+ *
+ * @Annotation
+ */
+class RepositoryHandler extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The human-readable name of the repository handler plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $label;
+
+  /**
+   * A short description of the repository handler plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $description;
+
+  /**
+   * The human-readable category.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $category = '';
+
+  /**
+   * Whether the repository handles a remote destination or not.
+   *
+   * @var bool
+   */
+  public $remote;
+
+}
diff --git a/core/modules/workspace/src/Entity/ContentWorkspace.php b/core/modules/workspace/src/Entity/ContentWorkspace.php
new file mode 100644
index 0000000..cfa1377
--- /dev/null
+++ b/core/modules/workspace/src/Entity/ContentWorkspace.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+
+/**
+ * Defines the Content workspace entity.
+ *
+ * @ContentEntityType(
+ *   id = "content_workspace",
+ *   label = @Translation("Content workspace"),
+ *   label_singular = @Translation("content workspace"),
+ *   label_plural = @Translation("content workspaces"),
+ *   label_count = @PluralTranslation(
+ *     singular = "@count content workspace",
+ *     plural = "@count content workspaces"
+ *   ),
+ *   base_table = "content_workspace",
+ *   revision_table = "content_workspace_revision",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "uuid" = "uuid",
+ *   }
+ * )
+ *
+ * @internal
+ *   This entity is marked internal because it should not be used directly to
+ *   alter the workspace an entity belongs to.
+ */
+class ContentWorkspace extends ContentEntityBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['workspace'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(t('workspace'))
+      ->setDescription(t('The workspace of the referenced content.'))
+      ->setSetting('target_type', 'workspace')
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE)
+      ->addConstraint('workspace', []);
+
+    $fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
+      ->setLabel(t('Content entity type ID'))
+      ->setDescription(t('The ID of the content entity type this workspace is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['content_entity_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(t('Content entity ID'))
+      ->setDescription(t('The ID of the content entity this workspace is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(t('Content entity revision ID'))
+      ->setDescription(t('The revision ID of the content entity this workspace is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    return $fields;
+  }
+
+}
diff --git a/core/modules/workspace/src/Entity/ReplicationLog.php b/core/modules/workspace/src/Entity/ReplicationLog.php
new file mode 100644
index 0000000..5eb2196
--- /dev/null
+++ b/core/modules/workspace/src/Entity/ReplicationLog.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\workspace\ReplicationLogInterface;
+
+/**
+ * The replication log entity type.
+ *
+ * @ContentEntityType(
+ *   id = "replication_log",
+ *   label = @Translation("Replication log"),
+ *   handlers = {
+ *     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
+ *   },
+ *   base_table = "replication_log",
+ *   revision_table = "replication_log_revision",
+ *   fieldable = FALSE,
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "uuid" = "uuid",
+ *     "revision" = "revision_id",
+ *   },
+ * )
+ *
+ * @internal
+ *   This entity is marked internal because it should only be created, read,
+ *   updated, or deleted, by the content replication process.
+ */
+class ReplicationLog extends ContentEntityBase implements ReplicationLogInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getHistory() {
+    return $this->get('history')->getValue();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addHistory(array $history) {
+    // We need to wrap the passed-in argument in another array in order for it
+    // to be set as the first item (i.e. delta = 0) of the field item list.
+    $histories = array_merge([$history], $this->getHistory());
+    $this->set('history', $histories);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSessionId() {
+    return $this->get('session_id')->value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setSessionId($session_id) {
+    $this->set('session_id', $session_id);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSourceLastSequence() {
+    return $this->get('source_last_sequence')->value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setSourceLastSequence($source_last_sequence) {
+    $this->set('source_last_sequence', $source_last_sequence);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function loadOrCreate($id) {
+    $entity = static::load($id);
+    if ($entity === FALSE) {
+      $entity = static::create(['id' => $id]);
+    }
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['id'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Replication log ID'))
+      ->setDescription(new TranslatableMarkup('The replication log ID.'))
+      ->setReadOnly(TRUE)
+      ->setRequired(TRUE)
+      ->setSetting('max_length', 128)
+      ->setSetting('is_ascii', TRUE);
+
+    $fields['history'] = BaseFieldDefinition::create('replication_history')
+      ->setLabel(new TranslatableMarkup('Replication log history'))
+      ->setDescription(new TranslatableMarkup('The version id of the test entity.'))
+      ->setReadOnly(TRUE)
+      ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+
+    $fields['session_id'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Replication session ID'))
+      ->setDescription(new TranslatableMarkup('The unique session ID of the last replication. Shortcut to the session_id in the last history item.'))
+      ->setReadOnly(TRUE);
+
+    $fields['source_last_sequence'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Last processed checkpoint'))
+      ->setDescription(new TranslatableMarkup('The last processed checkpoint. Shortcut to the source_last_sequence in the last history item.'))
+      ->setReadOnly(TRUE);
+
+    $fields['status'] = BaseFieldDefinition::create('boolean')
+      ->setLabel(new TranslatableMarkup('Status'))
+      ->setDescription(new TranslatableMarkup('Replication status'))
+      ->setDefaultValue(TRUE)
+      ->setReadOnly(TRUE);
+
+    return $fields;
+  }
+
+}
diff --git a/core/modules/workspace/src/Entity/Workspace.php b/core/modules/workspace/src/Entity/Workspace.php
new file mode 100644
index 0000000..77ad66f
--- /dev/null
+++ b/core/modules/workspace/src/Entity/Workspace.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityChangedTrait;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\user\UserInterface;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceInterface;
+
+/**
+ * The workspace entity class.
+ *
+ * @ContentEntityType(
+ *   id = "workspace",
+ *   label = @Translation("Workspace"),
+ *   label_collection = @Translation("Workspaces"),
+ *   label_singular = @Translation("workspace"),
+ *   label_plural = @Translation("workspaces"),
+ *   label_count = @PluralTranslation(
+ *     singular = "@count workspace",
+ *     plural = "@count workspaces"
+ *   ),
+ *   handlers = {
+ *     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
+ *     "list_builder" = "\Drupal\workspace\WorkspaceListBuilder",
+ *     "access" = "Drupal\workspace\WorkspaceAccessControlHandler",
+ *     "route_provider" = {
+ *       "html" = "\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ *     },
+ *     "form" = {
+ *       "default" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "add" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "edit" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "activate" = "\Drupal\workspace\Form\WorkspaceActivateForm",
+ *       "deploy" = "\Drupal\workspace\Form\WorkspaceDeployForm",
+ *     },
+ *   },
+ *   admin_permission = "administer workspaces",
+ *   base_table = "workspace",
+ *   revision_table = "workspace_revision",
+ *   data_table = "workspace_field_data",
+ *   revision_data_table = "workspace_field_revision",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "uuid" = "uuid",
+ *     "label" = "label",
+ *     "uid" = "uid",
+ *   },
+ *   links = {
+ *     "add-form" = "/admin/config/workflow/workspace/add",
+ *     "edit-form" = "/admin/config/workflow/workspace/{workspace}/edit",
+ *     "activate-form" = "/admin/config/workflow/workspace/{workspace}/activate",
+ *     "deploy-form" = "/admin/config/workflow/workspace/{workspace}/deploy",
+ *     "collection" = "/admin/config/workflow/workspace",
+ *   },
+ * )
+ */
+class Workspace extends ContentEntityBase implements WorkspaceInterface {
+
+  use EntityChangedTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['id'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Workaspace ID'))
+      ->setDescription(new TranslatableMarkup('The workspace ID.'))
+      ->setSetting('max_length', 128)
+      ->setRequired(TRUE)
+      ->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[\da-z_$()+-\/]*$/']]);
+
+    $fields['label'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Workaspace name'))
+      ->setDescription(new TranslatableMarkup('The workspace name.'))
+      ->setRevisionable(TRUE)
+      ->setSetting('max_length', 128)
+      ->setRequired(TRUE);
+
+    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(new TranslatableMarkup('Owner'))
+      ->setDescription(new TranslatableMarkup('The workspace owner.'))
+      ->setRevisionable(TRUE)
+      ->setSetting('target_type', 'user')
+      ->setDefaultValueCallback('Drupal\workspace\Entity\Workspace::getCurrentUserId')
+      ->setDisplayOptions('form', [
+        'type' => 'entity_reference_autocomplete',
+        'weight' => 5,
+      ])
+      ->setDisplayConfigurable('form', TRUE);
+
+    $fields['changed'] = BaseFieldDefinition::create('changed')
+      ->setLabel(new TranslatableMarkup('Changed'))
+      ->setDescription(new TranslatableMarkup('The time that the workspace was last edited.'))
+      ->setRevisionable(TRUE);
+
+    $fields['created'] = BaseFieldDefinition::create('created')
+      ->setLabel(new TranslatableMarkup('Created'))
+      ->setDescription(new TranslatableMarkup('The UNIX timestamp of when the workspace has been created.'));
+
+    $fields['upstream'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Target workspace'))
+      ->setDescription(new TranslatableMarkup('The workspace to push to and pull from.'))
+      ->setRevisionable(TRUE)
+      ->setRequired(TRUE)
+      ->setDisplayOptions('form', [
+        'type' => 'workspace_upstream',
+        'weight' => 4,
+      ])
+      ->setDisplayConfigurable('form', TRUE)
+      ->addPropertyConstraints('value', [
+        'Upstream' => [],
+      ]);
+
+    return $fields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRepositoryHandlerPlugin() {
+    if (($upstream = $this->upstream->value) && $upstream !== RepositoryHandlerInterface::EMPTY_VALUE) {
+      return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance($upstream);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLocalRepositoryHandlerPlugin() {
+    return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance('local_workspace:' . $this->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCreatedTime($created) {
+    $this->set('created', (int) $created);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getStartTime() {
+    return $this->get('created')->value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwner() {
+    return $this->get('uid')->entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwner(UserInterface $account) {
+    $this->set('uid', $account->id());
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwnerId() {
+    return $this->get('uid')->target_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwnerId($uid) {
+    $this->set('uid', $uid);
+    return $this;
+  }
+
+  /**
+   * Default value callback for 'uid' base field definition.
+   *
+   * @see ::baseFieldDefinitions()
+   *
+   * @return int[]
+   *   An array containing the ID of the current user.
+   */
+  public static function getCurrentUserId() {
+    return [\Drupal::currentUser()->id()];
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityAccess.php b/core/modules/workspace/src/EntityAccess.php
new file mode 100644
index 0000000..4d3966d
--- /dev/null
+++ b/core/modules/workspace/src/EntityAccess.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Service wrapper for hooks relating to entity access control.
+ */
+class EntityAccess implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityAccess instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * Implements a hook bridge for hook_entity_access().
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to check access for.
+   * @param string $operation
+   *   The operation being performed.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   *
+   * @see hook_entity_access()
+   */
+  public function entityOperationAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // Workspaces themselves are handled by their own access handler and we
+    // should not try to do any access checks for entity types that can not
+    // belong to a workspace.
+    if ($entity->getEntityTypeId() === 'workspace' || !$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())) {
+      return AccessResult::neutral();
+    }
+
+    return $this->bypassAccessResult($account);
+  }
+
+  /**
+   * Implements a hook bridge for hook_entity_create_access().
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   * @param array $context
+   *   The context of the access check.
+   * @param string $entity_bundle
+   *   The bundle of the entity.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   *
+   * @see hook_entity_create_access()
+   */
+  public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) {
+    // Workspaces themselves are handled by their own access handler and we
+    // should not try to do any access checks for entity types that can not
+    // belong to a workspace.
+    $entity_type = $this->entityTypeManager->getDefinition($context['entity_type_id']);
+    if ($entity_type->id() === 'workspace' || !$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type)) {
+      return AccessResult::neutral();
+    }
+
+    return $this->bypassAccessResult($account);
+  }
+
+  /**
+   * Checks the 'bypass' permissions.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   */
+  protected function bypassAccessResult(AccountInterface $account) {
+    // This approach assumes that the current "global" active workspace is
+    // correct, ie, if you're "in" a given workspace then you get ALL THE PERMS
+    // to ALL THE THINGS! That's why this is a dangerous permission.
+    $active_workspace = $this->workspaceManager->getActiveWorkspace(TRUE);
+
+    return AccessResult::allowedIfHasPermission($account, 'bypass entity access workspace ' . $active_workspace->id())
+      ->orIf(
+        AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id())
+          ->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace'))
+      );
+  }
+
+  /**
+   * Returns an array of workspace-specific permissions.
+   *
+   * Note: This approach assumes that a site will have only a small number of
+   * workspace entities, under a dozen. If there are many dozens of workspaces
+   * defined then this approach will have scaling issues.
+   *
+   * @return array
+   *   The workspace permissions.
+   */
+  public function workspacePermissions() {
+    $perms = [];
+
+    foreach ($this->entityTypeManager->getStorage('workspace')->loadMultiple() as $workspace) {
+      /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+      $perms += $this->createWorkspaceViewPermission($workspace)
+      + $this->createWorkspaceEditPermission($workspace)
+      + $this->createWorkspaceBypassPermission($workspace);
+    }
+
+    return $perms;
+  }
+
+  /**
+   * Derives the view permission for a specific workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace from which to derive the permission.
+   *
+   * @return array
+   *   A single-item array with the permission to define.
+   */
+  protected function createWorkspaceViewPermission(WorkspaceInterface $workspace) {
+    $perms['view workspace ' . $workspace->id()] = [
+      'title' => $this->t('View the %workspace workspace', ['%workspace' => $workspace->label()]),
+      'description' => $this->t('View the %workspace workspace and content within it', ['%workspace' => $workspace->label()]),
+    ];
+
+    return $perms;
+  }
+
+  /**
+   * Derives the edit permission for a specific workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace from which to derive the permission.
+   *
+   * @return array
+   *   A single-item array with the permission to define.
+   */
+  protected function createWorkspaceEditPermission(WorkspaceInterface $workspace) {
+    $perms['edit workspace ' . $workspace->id()] = [
+      'title' => $this->t('Edit the %workspace workspace', ['%workspace' => $workspace->label()]),
+      'description' => $this->t('Edit the %workspace workspace itself', ['%workspace' => $workspace->label()]),
+    ];
+
+    return $perms;
+  }
+
+  /**
+   * Derives the delete permission for a specific workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace from which to derive the permission.
+   *
+   * @return array
+   *   A single-item array with the permission to define.
+   */
+  protected function createWorkspaceBypassPermission(WorkspaceInterface $workspace) {
+    $perms['bypass entity access workspace ' . $workspace->id()] = [
+      'title' => $this->t('Bypass content entity access in %workspace workspace', ['%workspace' => $workspace->label()]),
+      'description' => $this->t('Allow all Edit/Update/Delete permissions for all content in the %workspace workspace', ['%workspace' => $workspace->label()]),
+      'restrict access' => TRUE,
+    ];
+
+    return $perms;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Query.php b/core/modules/workspace/src/EntityQuery/Query.php
new file mode 100644
index 0000000..c3aa4e0
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Query.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\Query as BaseQuery;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Query extends BaseQuery {
+
+  use QueryTrait {
+    prepare as traitPrepare;
+  }
+
+  /**
+   * Stores the SQL expressions used to build the SQL query.
+   *
+   * The array is keyed by the expression alias and the values are the actual
+   * expressions.
+   *
+   * @var array
+   *   An array of expressions.
+   */
+  protected $sqlExpressions = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    $this->traitPrepare();
+
+    // If the prepare() method from the trait decided that we need to alter this
+    // query, we need to re-define the the key fields for fetchAllKeyed() as SQL
+    // expressions.
+    if ($active_workspace = $this->sqlQuery->getMetaData('active_workspace')) {
+      $id_field = $this->entityType->getKey('id');
+      $revision_field = $this->entityType->getKey('revision');
+
+      // Since the query is against the base table, we have to take into account
+      // that the revision ID might come from the content_workspace
+      // relationship, and, as a consequence, the revision ID field is no longer
+      // a simple SQL field but an expression.
+      $this->sqlFields = [];
+      $this->sqlExpressions[$revision_field] = "COALESCE(content_workspace.content_entity_revision_id, base_table.$revision_field)";
+      $this->sqlExpressions[$id_field] = "base_table.$id_field";
+    }
+
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function finish() {
+    foreach ($this->sqlExpressions as $alias => $expression) {
+      $this->sqlQuery->addExpression($expression, $alias);
+    }
+    return parent::finish();
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryAggregate.php b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
new file mode 100644
index 0000000..6e75d1a
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\QueryAggregate as BaseQueryAggregate;
+
+/**
+ * Alters aggregate entity queries to use a workspace revision if possible.
+ */
+class QueryAggregate extends BaseQueryAggregate {
+
+  use QueryTrait {
+    prepare as traitPrepare;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    $this->traitPrepare();
+
+    // Throw away the ID fields.
+    $this->sqlFields = [];
+    return $this;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryTrait.php b/core/modules/workspace/src/EntityQuery/QueryTrait.php
new file mode 100644
index 0000000..f86f788
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryTrait.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\workspace\WorkspaceManager;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Provides workspace-specific helpers for altering entity queries.
+ */
+trait QueryTrait {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a Query object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param string $conjunction
+   *   - AND: all of the conditions on the query need to match.
+   *   - OR: at least one of the conditions on the query need to match.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection to run the query against.
+   * @param array $namespaces
+   *   List of potential namespaces of the classes belonging to this query.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(EntityTypeInterface $entity_type, $conjunction, Connection $connection, array $namespaces, WorkspaceManagerInterface $workspace_manager) {
+    parent::__construct($entity_type, $conjunction, $connection, $namespaces);
+
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    parent::prepare();
+
+    // Do not alter entity revision queries.
+    // @todo How about queries for the latest revision? Should we alter them to
+    //   look for the latest workspace-specific revision?
+    if ($this->allRevisions) {
+      return $this;
+    }
+
+    // Only alter the query if the active workspace is not the default one and
+    // the entity type is supported.
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    if ($active_workspace !== WorkspaceManager::DEFAULT_WORKSPACE && $this->workspaceManager->entityTypeCanBelongToWorkspaces($this->entityType)) {
+      $this->sqlQuery->addMetaData('active_workspace', $active_workspace);
+      $this->sqlQuery->addMetaData('simple_query', FALSE);
+
+      // LEFT JOIN 'content_workspace' to the base table of the query so we can
+      // properly include live content along with a possible workspace-specific
+      // revision.
+      $id_field = $this->entityType->getKey('id');
+      $this->sqlQuery->leftJoin('content_workspace', 'content_workspace', "%alias.content_entity_type_id = '{$this->entityTypeId}' AND %alias.content_entity_id = base_table.$id_field AND %alias.workspace = '$active_workspace'");
+    }
+
+    return $this;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/SqlQueryFactory.php b/core/modules/workspace/src/EntityQuery/SqlQueryFactory.php
new file mode 100644
index 0000000..39bef05
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/SqlQueryFactory.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryBase;
+use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Workspace specific entity query implementation.
+ */
+class SqlQueryFactory extends BaseQueryFactory {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a SqlQueryFactory object.
+   *
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection used by the entity query.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
+    $this->connection = $connection;
+    $this->workspaceManager = $workspace_manager;
+    $this->namespaces = QueryBase::getNamespaces($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'Query');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Tables.php b/core/modules/workspace/src/EntityQuery/Tables.php
new file mode 100644
index 0000000..71a176a
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Tables.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Query\SelectInterface;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Entity\Query\Sql\Tables as BaseTables;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Tables extends BaseTables {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Content workspace table array, key is base table name, value is alias.
+   *
+   * @var array
+   */
+  protected $contentWorkspaceTables = [];
+
+  /**
+   * Keeps track of the entity type IDs for each base table of the query.
+   *
+   * The array is keyed by the base table alias and the values are entity type
+   * IDs.
+   *
+   * @var array
+   */
+  protected $baseTablesEntityType = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(SelectInterface $sql_query) {
+    parent::__construct($sql_query);
+
+    $this->workspaceManager = \Drupal::service('workspace.manager');
+
+    // The join between the first 'content_workspace' table and base table of
+    // the query is done \Drupal\workspace\EntityQuery\QueryTrait::prepare(), so
+    // we need to initialize its entry manually.
+    if ($active_workspace = $this->sqlQuery->getMetaData('active_workspace')) {
+      $this->contentWorkspaceTables['base_table'] = 'content_workspace';
+      $this->baseTablesEntityType['base_table'] = $this->sqlQuery->getMetaData('entity_type');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addField($field, $type, $langcode) {
+    // The parent method uses shared and dedicated revision tables only when the
+    // entity query is instructed to query all revisions. However, if we are
+    // looking for workspace-specific revisions, we have to force the parent
+    // method to always pick the revision tables if the field being queried is
+    // revisionable.
+    if ($active_workspace = $this->sqlQuery->getMetaData('active_workspace')) {
+      $this->sqlQuery->addMetaData('all_revisions', TRUE);
+    }
+
+    $alias = parent::addField($field, $type, $langcode);
+
+    // Restore the 'all_revisions' metadata because we don't want to interfere
+    // with the rest of the query.
+    if ($active_workspace) {
+      $this->sqlQuery->addMetaData('all_revisions', FALSE);
+    }
+
+    return $alias;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function addJoin($type, $table, $join_condition, $langcode, $delta = NULL) {
+    if ($active_workspace = $this->sqlQuery->getMetaData('active_workspace')) {
+      // The join condition for a shared or dedicated field table is in the form
+      // of "%alias.$id_field = $base_table.$id_field". Whenever we join a field
+      // table we have to check:
+      // 1) if $base_table is of an entity type that can belong to a workspace;
+      // 2) if $id_field is the revision key of that entity type or the special
+      // 'revision_id' string used when joining dedicated field tables.
+      // If those two conditions are met, we have to update the join condition
+      // to also look for a possible workspace-specific revision using COALESCE.
+      $condition_parts = explode(' = ', $join_condition);
+      list($base_table, $id_field) = explode('.', $condition_parts[1]);
+
+      if (isset($this->baseTablesEntityType[$base_table])) {
+        $entity_type_id = $this->baseTablesEntityType[$base_table];
+        $revision_key = $this->entityManager->getDefinition($entity_type_id)->getKey('revision');
+
+        if ($id_field === $revision_key || $id_field === 'revision_id') {
+          $content_workspace_table = $this->contentWorkspaceTables[$base_table];
+          $join_condition = "{$condition_parts[0]} = COALESCE($content_workspace_table.content_entity_revision_id, {$condition_parts[1]})";
+        }
+      }
+    }
+
+    return parent::addJoin($type, $table, $join_condition, $langcode, $delta);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
+    $next_base_table_alias = parent::addNextBaseTable($entity_type, $table, $sql_column, $field_storage);
+
+    $active_workspace = $this->sqlQuery->getMetaData('active_workspace');
+    if ($active_workspace && $this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type)) {
+      $this->addContentWorkspaceJoin($entity_type->id(), $next_base_table_alias, $active_workspace);
+    }
+
+    return $next_base_table_alias;
+  }
+
+  /**
+   * Adds a new join to the 'content_workspace' table for an entity base table.
+   *
+   * This method assumes that the active workspace has already been determined
+   * to be a non-default workspace.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type whose base table we are joining.
+   * @param string $base_table_alias
+   *   The alias of the entity type's base table.
+   * @param string $active_workspace
+   *   The active workspace.
+   *
+   * @return string
+   *   The alias of the joined table.
+   */
+  public function addContentWorkspaceJoin($entity_type_id, $base_table_alias, $active_workspace) {
+    if (!isset($this->contentWorkspaceTables[$base_table_alias])) {
+      $entity_type = $this->entityManager->getDefinition($entity_type_id);
+      $id_field = $entity_type->getKey('id');
+
+      // LEFT join the Content Workspace entity's table so we can properly
+      // include live content along with a possible workspace-specific revision.
+      $this->contentWorkspaceTables[$base_table_alias] = $this->sqlQuery->leftJoin('content_workspace', NULL, "%alias.content_entity_type_id = '$entity_type_id' AND %alias.content_entity_id = $base_table_alias.$id_field AND %alias.workspace = '$active_workspace'");
+
+      $this->baseTablesEntityType[$base_table_alias] = $entity_type->id();
+    }
+    return $this->contentWorkspaceTables[$base_table_alias];
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
new file mode 100644
index 0000000..93697c6
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Handle activation of a workspace on administrative pages.
+ */
+class WorkspaceActivateForm extends EntityConfirmFormBase {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The workspace replication manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceActivateForm.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager, MessengerInterface $messenger) {
+    $this->workspaceManager = $workspace_manager;
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('workspace.manager'),
+      $container->get('messenger')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->t('Activate the %workspace workspace.', ['%workspace' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->entity->toUrl('collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildForm($form, $form_state);
+
+    // Content entity forms do not use the parent's #after_build callback.
+    unset($form['#after_build']);
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    try {
+      $this->workspaceManager->setActiveWorkspace($this->entity);
+      $this->messenger->addMessage($this->t("@workspace is now the active workspace.", ['@workspace' => $this->entity->label()]));
+      $form_state->setRedirectUrl($this->entity->toUrl('collection'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addError($e->getMessage());
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceDeployForm.php b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
new file mode 100644
index 0000000..34364e7
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Provides the workspace deploy form.
+ */
+class WorkspaceDeployForm extends ContentEntityForm {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceDeployForm.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+   *   The entity type bundle service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+    parent::__construct($entity_manager, $entity_type_bundle_info, $time);
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity.manager'),
+      $container->get('messenger'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+
+    /* @var \Drupal\workspace\WorkspaceInterface $workspace */
+    $workspace = $this->entity;
+
+    // We can not deploy if we do not have an upstream workspace.
+    if (!$workspace->getRepositoryHandlerPlugin()) {
+      throw new BadRequestHttpException();
+    }
+
+    $form['help'] = [
+      '#markup' => $this->t('Deploy all %source_upstream_label content to %target_upstream_label, or refresh %source_upstream_label with content from %target_upstream_label.', ['%source_upstream_label' => $workspace->getLocalRepositoryHandlerPlugin()->getLabel(), '%target_upstream_label' => $workspace->getRepositoryHandlerPlugin()->getLabel()]),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function actions(array $form, FormStateInterface $form_state) {
+    $elements = parent::actions($form, $form_state);
+
+    $upstream_label = $this->entity->getRepositoryHandlerPlugin()->getLabel();
+
+    $elements['submit']['#value'] = $this->t('Deploy to @upstream', ['@upstream' => $upstream_label]);
+    $elements['submit']['#submit'] = ['::submitForm', '::deploy'];
+    $elements['update'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Refresh from @upstream', ['@upstream' => $upstream_label]),
+      '#submit' => ['::submitForm', '::update'],
+    ];
+    $elements['cancel'] = [
+      '#type' => 'link',
+      '#title' => $this->t('Cancel'),
+      '#attributes' => ['class' => ['button']],
+      '#url' => $this->entity->toUrl('collection'),
+    ];
+
+    return $elements;
+  }
+
+  /**
+   * Form submission handler; deploys the content to the upstream workspace.
+   *
+   * @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.
+   */
+  public function deploy(array &$form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    try {
+      $repository_handler = $workspace->getRepositoryHandlerPlugin();
+      $repository_handler->replicate($workspace->getLocalRepositoryHandlerPlugin(), $repository_handler);
+      $this->messenger->addMessage($this->t('Successful deployment.'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addMessage($this->t('Deployment error'), 'error');
+    }
+  }
+
+  /**
+   * Form submission handler; pulls the upstream content into a workspace.
+   *
+   * @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.
+   */
+  public function update(array &$form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    try {
+      $repository_handler = $workspace->getRepositoryHandlerPlugin();
+      $repository_handler->replicate($repository_handler, $workspace->getLocalRepositoryHandlerPlugin());
+      $this->messenger->addMessage($this->t('Update successful.'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addMessage($this->t('Update error'), 'error');
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceForm.php b/core/modules/workspace/src/Form/WorkspaceForm.php
new file mode 100644
index 0000000..93226f0
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceForm.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form controller for the workspace edit forms.
+ */
+class WorkspaceForm extends ContentEntityForm {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceForm.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+   *   The entity type bundle service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+    parent::__construct($entity_manager, $entity_type_bundle_info, $time);
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity.manager'),
+      $container->get('messenger'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    if ($this->operation == 'edit') {
+      $form['#title'] = $this->t('Edit workspace %label', ['%label' => $workspace->label()]);
+    }
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $workspace->label(),
+      '#required' => TRUE,
+    ];
+
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#title' => $this->t('Workspace ID'),
+      '#maxlength' => 255,
+      '#default_value' => $workspace->id(),
+      '#disabled' => !$workspace->isNew(),
+      '#machine_name' => [
+        'exists' => '\Drupal\workspace\Entity\Workspace::load',
+      ],
+      '#element_validate' => [],
+    ];
+
+    return parent::form($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditedFieldNames(FormStateInterface $form_state) {
+    return array_merge([
+      'label',
+      'id',
+    ], parent::getEditedFieldNames($form_state));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
+    // Manually flag violations of fields not handled by the form display. This
+    // is necessary as entity form displays only flag violations for fields
+    // contained in the display.
+    $field_names = [
+      'label',
+      'id',
+    ];
+    foreach ($violations->getByFields($field_names) as $violation) {
+      list($field_name) = explode('.', $violation->getPropertyPath(), 2);
+      $form_state->setErrorByName($field_name, $violation->getMessage());
+    }
+    parent::flagViolations($violations, $form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+    $workspace->setNewRevision(TRUE);
+    $status = $workspace->save();
+
+    \Drupal::service('plugin.manager.workspace.repository_handler')->clearCachedDefinitions();
+
+    $info = ['%info' => $workspace->label()];
+    $context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()];
+    $logger = $this->logger('workspace');
+
+    if ($status == SAVED_UPDATED) {
+      $logger->notice('@type: updated %info.', $context);
+      $this->messenger->addMessage($this->t('Workspace %info has been updated.', $info));
+    }
+    else {
+      $logger->notice('@type: added %info.', $context);
+      $this->messenger->addMessage($this->t('Workspace %info has been created.', $info));
+    }
+
+    if ($workspace->id()) {
+      $form_state->setValue('id', $workspace->id());
+      $form_state->set('id', $workspace->id());
+      $redirect = $this->currentUser()->hasPermission('administer workspaces') ? $workspace->toUrl('collection') : Url::fromRoute('<front>');
+      $form_state->setRedirectUrl($redirect);
+    }
+    else {
+      $this->messenger->addError($this->t('The workspace could not be saved.'));
+      $form_state->setRebuild();
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
new file mode 100644
index 0000000..51fc22e
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form that activates a different workspace.
+ */
+class WorkspaceSwitcherForm extends FormBase {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceSwitcherForm.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
+    $this->workspaceManager = $workspace_manager;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('workspace.manager'),
+      $container->get('entity_type.manager'),
+      $container->get('messenger')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'workspace_switcher_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $workspaces = $this->entityTypeManager->getStorage('workspace')->loadMultiple();
+    $workspace_labels = [];
+    foreach ($workspaces as $workspace) {
+      $workspace_labels[$workspace->id()] = $workspace->label();
+    }
+
+    $active_workspace = $this->workspaceManager->getActiveWorkspace(TRUE);
+    unset($workspace_labels[$active_workspace->id()]);
+
+    $form['current'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Current workspace'),
+      '#markup' => $active_workspace->label(),
+      '#wrapper_attributes' => [
+        'class' => ['container-inline'],
+      ],
+    ];
+
+    $form['workspace_id'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Select workspace'),
+      '#required' => TRUE,
+      '#options' => $workspace_labels,
+      '#wrapper_attributes' => [
+        'class' => ['container-inline'],
+      ],
+    ];
+
+    $form['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Activate'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $id = $form_state->getValue('workspace_id');
+
+    // Ensure the workspace by that id exists.
+    /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+    $workspace = $this->entityTypeManager->getStorage('workspace')->load($id);
+    if (!$workspace) {
+      $form_state->setErrorByName('workspace_id', $this->t('This workspace does not exist.'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $id = $form_state->getValue('workspace_id');
+
+    /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+    $workspace = $this->entityTypeManager->getStorage('workspace')->load($id);
+
+    try {
+      $this->workspaceManager->setActiveWorkspace($workspace);
+      $this->messenger->addMessage($this->t("@workspace is now the active workspace.", ['@workspace' => $workspace->label()]));
+      $form_state->setRedirect('<front>');
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addError($e->getMessage());
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
new file mode 100644
index 0000000..72b8a6c
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\workspace\WorkspaceInterface;
+use Drupal\workspace\WorkspaceManager;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines the default workspace negotiator.
+ */
+class DefaultWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Request $request) {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getWorkspaceId(Request $request) {
+    return WorkspaceManager::DEFAULT_WORKSPACE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setWorkspace(WorkspaceInterface $workspace) {}
+
+}
diff --git a/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
new file mode 100644
index 0000000..3771995
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Drupal\user\PrivateTempStoreFactory;
+use Drupal\workspace\WorkspaceManager;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines the session workspace negotiator.
+ */
+class SessionWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The tempstore factory.
+   *
+   * @var \Drupal\user\PrivateTempStore
+   */
+  protected $tempstore;
+
+  /**
+   * The default workspace ID.
+   *
+   * @var string
+   */
+  protected $defaultWorkspaceId;
+
+  /**
+   * Constructor.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\user\PrivateTempStoreFactory $tempstore_factory
+   *   The tempstore factory.
+   */
+  public function __construct(AccountInterface $current_user, PrivateTempStoreFactory $tempstore_factory) {
+    $this->currentUser = $current_user;
+    $this->tempstore = $tempstore_factory->get('workspace.negotiator.session');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Request $request) {
+    // This negotiator only applies if the current user is authenticated,
+    // i.e. a session exists.
+    return $this->currentUser->isAuthenticated();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getWorkspaceId(Request $request) {
+    $workspace_id = $this->tempstore->get('active_workspace_id');
+    return $workspace_id ?: WorkspaceManager::DEFAULT_WORKSPACE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setWorkspace(WorkspaceInterface $workspace) {
+    $this->tempstore->set('active_workspace_id', $workspace->id());
+  }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
new file mode 100644
index 0000000..615dd1f
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Workspace negotiators provide a way to get the active workspace.
+ *
+ * WorkspaceManager acts as the service collector for Workspace negotiators.
+ */
+interface WorkspaceNegotiatorInterface {
+
+  /**
+   * Checks whether the negotiator applies or not.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request.
+   *
+   * @return bool
+   *   TRUE if the negotiator applies for the current request, FALSE otherwise.
+   */
+  public function applies(Request $request);
+
+  /**
+   * Gets the ID of the active workspace.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request.
+   *
+   * @return string
+   *   The workspace ID.
+   */
+  public function getWorkspaceId(Request $request);
+
+  /**
+   * Sets the active workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace entity.
+   */
+  public function setWorkspace(WorkspaceInterface $workspace);
+
+}
diff --git a/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php b/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php
new file mode 100644
index 0000000..3e9c4ec
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Block/WorkspaceBlock.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Block;
+
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a 'Workspace switcher' block.
+ *
+ * @Block(
+ *   id = "workspace_switcher_block",
+ *   admin_label = @Translation("Workspace switcher"),
+ *   category = @Translation("Workspace"),
+ * )
+ */
+class WorkspaceBlock extends BlockBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The form builder.
+   *
+   * @var \Drupal\Core\Form\FormBuilderInterface
+   */
+  protected $formBuilder;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a new WorkspaceBlock instance.
+   *
+   * @param array $configuration
+   *   The plugin configuration.
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param mixed $plugin_definition
+   *   The plugin definition.
+   * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+   *   The form builder.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->formBuilder = $form_builder;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('form_builder'),
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    $build = [
+      'form' => $this->formBuilder->getForm('Drupal\workspace\Form\WorkspaceSwitcherForm'),
+      '#cache' => [
+        'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(),
+        'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(),
+      ],
+    ];
+    return $build;
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php b/core/modules/workspace/src/Plugin/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php
new file mode 100644
index 0000000..fc147d2
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Deriver/LocalWorkspaceRepositoryHandlerDeriver.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a local repository handler plugin for each workspace.
+ */
+class LocalWorkspaceRepositoryHandlerDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  /**
+   * The workspace entity storage handler.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $workspaceStorage;
+
+  /**
+   * Constructs a new LocalWorkspaceRepositoryHandlerDeriver.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $workspace_storage
+   *   The workspace entity storage handler.
+   */
+  public function __construct(EntityStorageInterface $workspace_storage) {
+    $this->workspaceStorage = $workspace_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager')->getStorage('workspace')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $this->derivatives = [];
+
+    // Provide a local repository handler plugin for each workspace.
+    foreach ($this->workspaceStorage->loadMultiple() as $workspace_id => $workspace) {
+      $this->derivatives[$workspace_id] = $base_plugin_definition;
+      $this->derivatives[$workspace_id]['label'] = $workspace->label();
+      $this->derivatives[$workspace_id]['category'] = $base_plugin_definition['label'];
+    }
+    return $this->derivatives;
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php
new file mode 100644
index 0000000..8bc1dc0
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Field/FieldType/ReplicationHistoryItem.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Field\FieldType;
+
+use Drupal\Core\Field\FieldItemBase;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\TypedData\DataDefinition;
+
+/**
+ * Defines the 'replication_history' entity field type.
+ *
+ * For each replication a history should be maintained. The only required field
+ * is session_id which is a unique ID for the replication. The recorded_sequence
+ * field is another important field, it stores the sequence ID of the last
+ * entity replicated. It is where replication is started from next time, and
+ * therefore defaults to 0, denoting to start from the first sequence ID. All
+ * other fields are for informational purposes which can be used for user
+ * messages, logs, or an audit trail.
+ *
+ * @FieldType(
+ *   id = "replication_history",
+ *   label = @Translation("Replication history"),
+ *   description = @Translation("History information for a replication."),
+ *   no_ui = TRUE
+ * )
+ */
+class ReplicationHistoryItem extends FieldItemBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function mainPropertyName() {
+    return 'session_id';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
+    $properties['entity_write_failures'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Write failures'))
+      ->setDescription(new TranslatableMarkup('Number of failed entity writes'))
+      ->setRequired(FALSE);
+
+    $properties['entities_read'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Entities read'))
+      ->setDescription(new TranslatableMarkup('Number of entities read.'))
+      ->setRequired(FALSE);
+
+    $properties['entities_written'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Entities written'))
+      ->setDescription(new TranslatableMarkup('Number of entities written.'))
+      ->setRequired(FALSE);
+
+    $properties['end_last_sequence'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('End sequence'))
+      ->setDescription(new TranslatableMarkup('Sequence ID where the replication ended.'))
+      ->setRequired(FALSE);
+
+    $properties['end_time'] = DataDefinition::create('datetime_iso8601')
+      ->setLabel(new TranslatableMarkup('End time'))
+      ->setDescription(new TranslatableMarkup('Date and time when replication ended.'))
+      ->setRequired(FALSE);
+
+    $properties['recorded_sequence'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Recorded sequence'))
+      ->setDescription(new TranslatableMarkup('Recorded intermediate sequence.'))
+      ->setRequired(FALSE);
+
+    $properties['session_id'] = DataDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Session ID'))
+      ->setDescription(new TranslatableMarkup('Unique session ID for the replication.'))
+      ->setRequired(TRUE);
+
+    $properties['start_last_sequence'] = DataDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Start sequence'))
+      ->setDescription(new TranslatableMarkup('Sequence ID where the replication started.'))
+      ->setRequired(FALSE);
+
+    $properties['start_time'] = DataDefinition::create('datetime_iso8601')
+      ->setLabel(new TranslatableMarkup('Start time'))
+      ->setDescription(new TranslatableMarkup('Date and time when replication started.'))
+      ->setRequired(FALSE);
+
+    return $properties;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function schema(FieldStorageDefinitionInterface $field_definition) {
+    return [
+      'columns' => [
+        'entity_write_failures' => [
+          'type' => 'int',
+          'unsigned' => TRUE,
+          'not null' => FALSE,
+        ],
+        'entities_read' => [
+          'type' => 'int',
+          'unsigned' => TRUE,
+          'not null' => FALSE,
+        ],
+        'entities_written' => [
+          'type' => 'int',
+          'unsigned' => TRUE,
+          'not null' => FALSE,
+        ],
+        'end_last_sequence' => [
+          'type' => 'int',
+          'size' => 'big',
+          'not null' => FALSE,
+        ],
+        'end_time' => [
+          'type' => 'varchar',
+          'length' => 50,
+          'not null' => FALSE,
+        ],
+        'recorded_sequence' => [
+          'type' => 'int',
+          'size' => 'big',
+          'not null' => FALSE,
+          'default' => 0,
+        ],
+        'session_id' => [
+          'type' => 'varchar',
+          'length' => 128,
+          'not null' => TRUE,
+        ],
+        'start_last_sequence' => [
+          'type' => 'int',
+          'size' => 'big',
+          'not null' => FALSE,
+        ],
+        'start_time' => [
+          'type' => 'varchar',
+          'length' => 50,
+          'not null' => FALSE,
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php b/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php
new file mode 100644
index 0000000..37110c6
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Field/FieldWidget/WorkspaceUpstreamWidget.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Field\FieldWidget;
+
+use Drupal\Component\Plugin\PluginManagerInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\WidgetBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'workspace_upstream' widget.
+ *
+ * @FieldWidget(
+ *   id = "workspace_upstream",
+ *   label = @Translation("Workspace upstream widget"),
+ *   description = @Translation("A Workspace upstream plugin field widget."),
+ *   field_types = {
+ *     "string"
+ *   }
+ * )
+ */
+class WorkspaceUpstreamWidget extends WidgetBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The upstream plugin manager.
+   *
+   * @var \Drupal\workspace\RepositoryHandlerManager
+   */
+  protected $upstreamPluginManager;
+
+  /**
+   * Constructs a new ModerationStateWidget object.
+   *
+   * @param string $plugin_id
+   *   Plugin id.
+   * @param mixed $plugin_definition
+   *   Plugin definition.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   Field definition.
+   * @param array $settings
+   *   Field settings.
+   * @param array $third_party_settings
+   *   Third party settings.
+   * @param \Drupal\Component\Plugin\PluginManagerInterface $upstream_plugin_manager
+   *   The upstream plugin manager.
+   */
+  public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, PluginManagerInterface $upstream_plugin_manager) {
+    parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+    $this->upstreamPluginManager = $upstream_plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $plugin_id,
+      $plugin_definition,
+      $configuration['field_definition'],
+      $configuration['settings'],
+      $configuration['third_party_settings'],
+      $container->get('plugin.manager.workspace.repository_handler')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+    // Gather the list of upstreams grouped by category.
+    $upstream_options = [];
+    foreach ($this->upstreamPluginManager->getGroupedDefinitions() as $category => $upstream_plugin_definitions) {
+      foreach ($upstream_plugin_definitions as $plugin_id => $plugin_definition) {
+        // Do not include the local workspace itself as an option.
+        if ($plugin_id !== 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . $items->getEntity()->id()) {
+          $upstream_options[$category][$plugin_id] = $plugin_definition['label'];
+        }
+      }
+    }
+
+    // The default ('Live') workspace can not have another local workspace as an
+    // upstream value, so we need to remove all options from the
+    // 'Local workspace' category.
+    if ($items->getEntity()->id() === WorkspaceManager::DEFAULT_WORKSPACE) {
+      unset($upstream_options['Local workspace']);
+    }
+
+    // In case we don't have any options to display, just provide the existing
+    // value. This can happen for example when editing the 'Live' workspace and
+    // the only available repository handler plugin is 'local_workspace'.
+    if (!$upstream_options) {
+      $element += [
+        '#type' => 'value',
+        '#value' => isset($items[$delta]->value) ? $items[$delta]->value : RepositoryHandlerInterface::EMPTY_VALUE,
+      ];
+    }
+    else {
+      $element += [
+        '#type' => 'select',
+        '#default_value' => isset($items[$delta]->value) ? $items[$delta]->value : 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . WorkspaceManager::DEFAULT_WORKSPACE,
+        '#options' => $upstream_options,
+      ];
+
+      // Simplify the form and use radio buttons if we only have one category to
+      // display.
+      if (count($upstream_options) == 1) {
+        $element['#type'] = 'radios';
+        $element['#options'] = reset($upstream_options);
+      }
+    }
+
+    return ['value' => $element];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function isApplicable(FieldDefinitionInterface $field_definition) {
+    // We only want to have this widget available for the 'upstream' base field
+    // from the Workspace entity type.
+    return $field_definition->getTargetEntityTypeId() === 'workspace';
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php b/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php
new file mode 100644
index 0000000..2699219
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/RepositoryHandler/LocalWorkspaceRepositoryHandler.php
@@ -0,0 +1,257 @@
+<?php
+
+namespace Drupal\workspace\Plugin\RepositoryHandler;
+
+use Drupal\Core\Database\Connection;
+use Drupal\workspace\Entity\ReplicationLog;
+use Drupal\workspace\ReplicationLogInterface;
+use Drupal\workspace\RepositoryHandlerBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceManager;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a repository handler plugin that provides local content replication.
+ *
+ * This plugin provides the ability to replicate content between workspaces that
+ * are defined in the same Drupal installation.
+ *
+ * @RepositoryHandler(
+ *   id = "local_workspace",
+ *   label = @Translation("Local workspace"),
+ *   description = @Translation("A workspace that is defined in the local Drupal installation."),
+ *   remote = FALSE,
+ *   deriver = "Drupal\workspace\Plugin\Deriver\LocalWorkspaceRepositoryHandlerDeriver",
+ * )
+ */
+class LocalWorkspaceRepositoryHandler extends RepositoryHandlerBase implements RepositoryHandlerInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * The local workspace entity for the upstream.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $upstreamWorkspace;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs a new LocalWorkspaceRepositoryHandler.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager, Connection $database) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeManager = $entity_type_manager;
+    $this->workspaceManager = $workspace_manager;
+    $this->database = $database;
+    $this->upstreamWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->getDerivativeId());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager'),
+      $container->get('workspace.manager'),
+      $container->get('database')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLabel() {
+    return $this->upstreamWorkspace->label();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    $this->dependencies = parent::calculateDependencies();
+    $this->addDependency($this->upstreamWorkspace->getConfigDependencyKey(), $this->upstreamWorkspace->getConfigDependencyName());
+
+    return $this->dependencies;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function replicate(RepositoryHandlerInterface $source, RepositoryHandlerInterface $target) {
+    // Replicating content from one workspace to another on the same site
+    // roughly follows the CouchDB replication protocol.
+    // @see http://docs.couchdb.org/en/2.1.0/replication/protocol.html
+    $source_workspace = $this->entityTypeManager->getStorage('workspace')->load($source->getDerivativeId());
+    $target_workspace = $this->entityTypeManager->getStorage('workspace')->load($target->getDerivativeId());
+    $start_time = new \DateTime();
+    $session_id = \md5((\microtime(TRUE) * 1000000));
+    // @todo Figure out if we want to include more information in the
+    // replication log ID.
+    // @see http://docs.couchdb.org/en/2.0.0/replication/protocol.html#generate-replication-id
+    $replication_id = hash('sha256', $source_workspace->id() . $target_workspace->id());
+
+    // Load or create the Replication Log entity based on the replication ID.
+    $replication_log = ReplicationLog::loadOrCreate($replication_id);
+
+    // Get the current active workspace, so we can set it back as the active
+    // after the replication has completed.
+    $current_active = $this->workspaceManager->getActiveWorkspace(TRUE);
+
+    // Set the source as the active workspace, so we can fetch all the entities
+    // relative to the source workspace.
+    $this->workspaceManager->setActiveWorkspace($source_workspace);
+
+    $content_workspace_ids = [];
+    foreach ($this->workspaceManager->getSupportedEntityTypes() as $entity_type_id => $entity_type) {
+      // Get all entity revision IDs for all entities which are in only one
+      // of either the source or the target workspaces. We assume that this
+      // means the revision is in the source, but not the target, and the
+      // revision has not been replicated yet.
+      $select = $this->database
+        ->select('content_workspace_revision', 'cwr')
+        ->fields('cwr', ['content_entity_revision_id']);
+      $select->condition('content_entity_type_id', $entity_type_id);
+      $select->condition('workspace', [$source_workspace->id(), $target_workspace->id()], 'IN');
+      $select->groupBy('content_entity_revision_id');
+      $select->having('count(workspace) < :workspaces', [':workspaces' => 2]);
+      $revision_difference = $select->execute()->fetchCol();
+
+      if (!empty($revision_difference)) {
+        // Get the content workspace IDs for all of the entity revision IDs
+        // which are not yet in the target workspace.
+        $content_workspace_ids[$entity_type_id] = $this->entityTypeManager
+          ->getStorage('content_workspace')
+          ->getQuery()
+          ->allRevisions()
+          ->condition('content_entity_type_id', $entity_type_id)
+          ->condition('content_entity_revision_id', $revision_difference, 'IN')
+          ->condition('workspace', $source_workspace->id())
+          ->execute();
+      }
+    }
+
+    $entities = [];
+    foreach ($content_workspace_ids as $entity_type_id => $ids) {
+      foreach ($ids as $revision_id => $entity_id) {
+        // Get the content workspace entity for revision that is in the source
+        // workspace.
+        /** @var \Drupal\Core\Entity\ContentEntityInterface $content_workspace */
+        $content_workspace = $this->entityTypeManager->getStorage('content_workspace')->loadRevision($revision_id);
+        if (WorkspaceManager::DEFAULT_WORKSPACE === $target_workspace->id()) {
+          // If the target workspace is the default workspace (generally 'live')
+          // the revision needs to be set to the default revision.
+          /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity */
+          $entity = $this->entityTypeManager
+            ->getStorage($content_workspace->content_entity_type_id->value)
+            ->loadRevision($content_workspace->content_entity_revision_id->value);
+          $entity->_isReplicating = TRUE;
+          $entity->isDefaultRevision(TRUE);
+          $entities[] = $entity;
+        }
+        else {
+          // If the target workspace is not the default workspace, the content
+          // workspace link entity can simply be updated with the target
+          // workspace.
+          $content_workspace->setNewRevision(TRUE);
+          $content_workspace->workspace->target_id = $target_workspace->id();
+          $content_workspace->save();
+        }
+      }
+    }
+
+    // Only switch to the target workspace and save entities if there are some
+    // to save.
+    if (!empty($entities)) {
+      // Before saving set the active workspace to the target.
+      $this->workspaceManager->setActiveWorkspace($target_workspace);
+      // Save each revision on the target workspace.
+      foreach ($entities as $entity) {
+        $entity->save();
+      }
+    }
+
+    // Switch back to the original active workspace, so that the user performing
+    // the replication is back on the workspace they started on.
+    $this->workspaceManager->setActiveWorkspace($current_active);
+
+    // Update the replication log entity by adding the completed replication to
+    // the history.
+    return $this->updateReplicationLog($replication_log, [
+      'entity_write_failures' => 0,
+      'end_time' => (new \DateTime())->format('D, d M Y H:i:s e'),
+      'session_id' => $session_id,
+      'start_last_sequence' => $this->getLastSequenceId($replication_log),
+      'start_time' => $start_time->format('D, d M Y H:i:s e'),
+    ]);
+  }
+
+  /**
+   * Gets the last sequence ID from a replication log entity.
+   *
+   * @param \Drupal\workspace\ReplicationLogInterface $replication_log
+   *   The replication log entity to get the last sequence ID from.
+   *
+   * @return int
+   *   The last sequence ID for the replication log.
+   */
+  protected function getLastSequenceId(ReplicationLogInterface $replication_log) {
+    $history = $replication_log->getHistory();
+    return isset($history[0]['recorded_sequence']) ? $history[0]['recorded_sequence'] : 0;
+  }
+
+  /**
+   * Updates the replication log entity with the given history.
+   *
+   * @param \Drupal\workspace\ReplicationLogInterface $replication_log
+   *   The replication log entity to be updated.
+   * @param array $history
+   *   The new history items.
+   *
+   * @return \Drupal\workspace\ReplicationLogInterface
+   *   The updated replication log entity.
+   */
+  protected function updateReplicationLog(ReplicationLogInterface $replication_log, array $history) {
+    $replication_log->addHistory($history);
+    $replication_log->save();
+    return $replication_log;
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php b/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php
new file mode 100644
index 0000000..84f0a91
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/Upstream.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Checks if the upstream value is valid.
+ *
+ * @Constraint(
+ *   id = "Upstream",
+ *   label = @Translation("Upstream value", context = "Validation")
+ * )
+ */
+class Upstream extends Constraint {
+
+  /**
+   * The default violation message.
+   *
+   * @var string
+   */
+  public $message = 'The target workspace can not be the same as the local workspace.';
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/UpstreamValidator.php b/core/modules/workspace/src/Plugin/Validation/Constraint/UpstreamValidator.php
new file mode 100644
index 0000000..2f3eebf
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/UpstreamValidator.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\Core\TypedData\Validation\TypedDataAwareValidatorTrait;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Checks if the upstream value is valid.
+ */
+class UpstreamValidator extends ConstraintValidator {
+
+  use TypedDataAwareValidatorTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($value, Constraint $constraint) {
+    if (!isset($value)) {
+      return;
+    }
+
+    /** @var \Drupal\Core\Field\FieldItemInterface $field_item */
+    $field_item = $this->getTypedData()->getParent();
+    $entity = $field_item->getEntity();
+
+    if (!$entity->isNew() && $value === 'local_workspace' . PluginBase::DERIVATIVE_SEPARATOR . $entity->id()) {
+      $this->context->addViolation($constraint->message);
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/ReplicationLogInterface.php b/core/modules/workspace/src/ReplicationLogInterface.php
new file mode 100644
index 0000000..6dd41dd
--- /dev/null
+++ b/core/modules/workspace/src/ReplicationLogInterface.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+
+/**
+ * Defines an interface for the Replication log entity type.
+ */
+interface ReplicationLogInterface extends ContentEntityInterface {
+
+  /**
+   * Gets the entire replication history.
+   *
+   * @return array
+   *   List of history values.
+   */
+  public function getHistory();
+
+  /**
+   * Adds a new set of data to the replication history.
+   *
+   * @param array $history
+   *   An array of values for the 'history' field.
+   *
+   * @return $this
+   */
+  public function addHistory(array $history);
+
+  /**
+   * Gets the session ID.
+   *
+   * @return string
+   *   The session ID.
+   */
+  public function getSessionId();
+
+  /**
+   * Sets the session ID.
+   *
+   * @param string $session_id
+   *   The session ID to set.
+   *
+   * @return $this
+   */
+  public function setSessionId($session_id);
+
+  /**
+   * Gets the last processed checkpoint.
+   *
+   * @return string
+   *   The last processed checkpoint.
+   */
+  public function getSourceLastSequence();
+
+  /**
+   * Sets the last processed checkpoint.
+   *
+   * @param string $source_last_sequence
+   *   The last processed checkpoint.
+   *
+   * @return $this
+   */
+  public function setSourceLastSequence($source_last_sequence);
+
+  /**
+   * Loads an existing replication log or creates one if necessary.
+   *
+   * @param string $id
+   *   The ID of the replication log entity.
+   *
+   * @return $this
+   */
+  public static function loadOrCreate($id);
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerBase.php b/core/modules/workspace/src/RepositoryHandlerBase.php
new file mode 100644
index 0000000..818d01a
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerBase.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\DependencyTrait;
+use Drupal\Core\Plugin\PluginBase;
+
+/**
+ * Defines a base RepositoryHandler plugin implementation.
+ *
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see \Drupal\workspace\RepositoryHandlerManager
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see plugin_api
+ */
+abstract class RepositoryHandlerBase extends PluginBase implements RepositoryHandlerInterface {
+
+  use DependencyTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLabel() {
+    return $this->getPluginDefinition()['label'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->getPluginDefinition()['description'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isRemote() {
+    return $this->getPluginDefinition()['remote'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    return [];
+  }
+
+  /**
+   * Replicates content from a source repository to a target repository.
+   *
+   * @param \Drupal\workspace\RepositoryHandlerInterface $source
+   *   The repository handler to replicate from.
+   * @param \Drupal\workspace\RepositoryHandlerInterface $target
+   *   The repository handler to replicate to.
+   *
+   * @return \Drupal\workspace\ReplicationLogInterface
+   *   The replication log for the replication.
+   */
+  abstract public function replicate(RepositoryHandlerInterface $source, RepositoryHandlerInterface $target);
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerInterface.php b/core/modules/workspace/src/RepositoryHandlerInterface.php
new file mode 100644
index 0000000..916263f
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerInterface.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\DerivativeInspectionInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+
+/**
+ * RepositoryHandler plugins handle content replication.
+ *
+ * The replication will use data from the target repository handler plugin to
+ * merge the content between the source and the target. For example an internal
+ * replication might just need the workspace IDs, but a contrib module
+ * performing an external replication may need hostname, port, username,
+ * password etc.
+ */
+interface RepositoryHandlerInterface extends PluginInspectionInterface, DerivativeInspectionInterface {
+
+  /**
+   * Default empty value for repository handler fields.
+   */
+  const EMPTY_VALUE = '_none';
+
+  /**
+   * Returns the label of the repository handler.
+   *
+   * This is used as a form label where a user selects the replication target.
+   *
+   * @return string
+   *   The label text, which could be a plain string or an object that can be
+   *   cast to a string.
+   */
+  public function getLabel();
+
+  /**
+   * Returns the repository handler plugin description.
+   *
+   * @return string
+   *   The description text, which could be a plain string or an object that can
+   *   be cast to a string.
+   */
+  public function getDescription();
+
+  /**
+   * Returns whether the repository handler is remote or not.
+   *
+   * @return bool
+   *   TRUE if the repository handler is remote, FALSE otherwise.
+   */
+  public function isRemote();
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerManager.php b/core/modules/workspace/src/RepositoryHandlerManager.php
new file mode 100644
index 0000000..92777b1
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerManager.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\CategorizingPluginManagerInterface;
+use Drupal\Core\Plugin\CategorizingPluginManagerTrait;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+
+/**
+ * Provides a Repository Handler Manager for Repository Handlers.
+ *
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see plugin_api
+ */
+class RepositoryHandlerManager extends DefaultPluginManager implements CategorizingPluginManagerInterface {
+
+  use CategorizingPluginManagerTrait;
+
+  /**
+   * Constructs a new RepositoryHandlerManager.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/RepositoryHandler', $namespaces, $module_handler, 'Drupal\workspace\RepositoryHandlerInterface', 'Drupal\workspace\Annotation\RepositoryHandler');
+    $this->alterInfo('workspace_repository_handler_info');
+    $this->setCacheBackend($cache_backend, 'workspace_repository_handler');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processDefinition(&$definition, $plugin_id) {
+    parent::processDefinition($definition, $plugin_id);
+    $this->processDefinitionCategory($definition);
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessControlHandler.php b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
new file mode 100644
index 0000000..dfbfc39
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Defines the access control handler for the workspace entity type.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceAccessControlHandler extends EntityAccessControlHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    $operations = [
+      'view' => ['any' => 'view any workspace', 'own' => 'view own workspace'],
+      'update' => ['any' => 'edit any workspace', 'own' => 'edit own workspace'],
+    ];
+
+    // It is not possible to delete a workspace yet.
+    if ($operation === 'delete') {
+      return AccessResult::forbidden();
+    }
+
+    $permission_operation = $operation === 'update' ? 'edit' : 'view';
+    // The default workspace is always viewable, no matter what.
+    return AccessResult::allowedIf($operation == 'view' && $entity->id() == WorkspaceManager::DEFAULT_WORKSPACE)->addCacheableDependency($entity)
+      // Or if the user has permission to access any workspace at all.
+      ->orIf(AccessResult::allowedIfHasPermission($account, $operations[$operation]['any']))
+      // Or if it's their own workspace, and they have permission to access
+      // their own workspace.
+      ->orIf(
+        AccessResult::allowedIf($entity->getOwnerId() == $account->id())->addCacheableDependency($entity)
+          ->andIf(AccessResult::allowedIfHasPermission($account, $operations[$operation]['own']))
+      )
+      ->orIf(AccessResult::allowedIfHasPermission($account, $permission_operation . ' workspace ' . $entity->id()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+    return AccessResult::allowedIfHasPermission($account, 'create workspace');
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessException.php b/core/modules/workspace/src/WorkspaceAccessException.php
new file mode 100644
index 0000000..210ddae
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessException;
+
+/**
+ * Exception thrown when trying to switch to an inaccessible workspace.
+ */
+class WorkspaceAccessException extends AccessException {
+
+}
diff --git a/core/modules/workspace/src/WorkspaceCacheContext.php b/core/modules/workspace/src/WorkspaceCacheContext.php
new file mode 100644
index 0000000..d7d010b
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceCacheContext.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\Context\CacheContextInterface;
+
+/**
+ * Defines the WorkspaceCacheContext service, for "per workspace" caching.
+ *
+ * Cache context ID: 'workspace'.
+ */
+class WorkspaceCacheContext implements CacheContextInterface {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new WorkspaceCacheContext service.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager) {
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t('Workspace');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext() {
+    return $this->workspaceManager->getActiveWorkspace();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata($type = NULL) {
+    return new CacheableMetadata();
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceInterface.php b/core/modules/workspace/src/WorkspaceInterface.php
new file mode 100644
index 0000000..8b7a0c4
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceInterface.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\user\EntityOwnerInterface;
+
+/**
+ * Defines an interface for the workspace entity type.
+ */
+interface WorkspaceInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
+
+  /**
+   * Gets an instance of the repository handler configured for the workspace.
+   *
+   * @return \Drupal\workspace\RepositoryHandlerInterface|null
+   *   An upstream plugin object or NULL if there is no upstream configured for
+   *   the workspace. A NULL return value can only be returned for the default
+   *   (i.e. Live) workspace.
+   */
+  public function getRepositoryHandlerPlugin();
+
+  /**
+   * Gets an instance of the local repository handler plugin for the workspace.
+   *
+   * @return \Drupal\workspace\Plugin\RepositoryHandler\LocalWorkspaceRepositoryHandler
+   *   A local upstream plugin object.
+   */
+  public function getLocalRepositoryHandlerPlugin();
+
+  /**
+   * Sets the workspace creation timestamp.
+   *
+   * @param int $timestamp
+   *   The workspace creation timestamp.
+   *
+   * @return $this
+   */
+  public function setCreatedTime($timestamp);
+
+  /**
+   * Returns the workspace creation timestamp.
+   *
+   * @return int
+   *   Creation timestamp of the workspace.
+   */
+  public function getStartTime();
+
+}
diff --git a/core/modules/workspace/src/WorkspaceListBuilder.php b/core/modules/workspace/src/WorkspaceListBuilder.php
new file mode 100644
index 0000000..d075e30
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceListBuilder.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityListBuilder;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class to build a listing of workspace entities.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceListBuilder extends EntityListBuilder {
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityListBuilder object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage class.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager) {
+    parent::__construct($entity_type, $storage);
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('entity.manager')->getStorage($entity_type->id()),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Workspace');
+    $header['uid'] = $this->t('Owner');
+    $header['status'] = $this->t('Status');
+
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    $row['label'] = $entity->label() . ' (' . $entity->id() . ')';
+    $row['owner'] = $entity->getOwner()->getDisplayname();
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    $row['status'] = $active_workspace == $entity->id() ? $this->t('Active') : $this->t('Inactive');
+
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOperations(EntityInterface $entity) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    $operations = parent::getDefaultOperations($entity);
+    if (isset($operations['edit'])) {
+      $operations['edit']['query']['destination'] = $entity->toUrl('collection')->toString();
+    }
+
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    if ($entity->id() != $active_workspace) {
+      $operations['activate'] = [
+        'title' => $this->t('Set Active'),
+        'weight' => 20,
+        'url' => $entity->toUrl('activate-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+      ];
+    }
+
+    if ($entity->getRepositoryHandlerPlugin()) {
+      $operations['deploy'] = [
+        'title' => $this->t('Deploy content'),
+        'weight' => 20,
+        'url' => $entity->toUrl('deploy-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+      ];
+    }
+
+    return $operations;
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManager.php b/core/modules/workspace/src/WorkspaceManager.php
new file mode 100644
index 0000000..c20bb51
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManager.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\workspace\Entity\ContentWorkspace;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Provides the workspace manager.
+ */
+class WorkspaceManager implements WorkspaceManagerInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The default workspace ID.
+   */
+  const DEFAULT_WORKSPACE = 'live';
+
+  /**
+   * An array of entity type IDs that can not belong to a workspace.
+   *
+   * By default, only entity types which are revisionable and publishable can
+   * belong to a workspace.
+   *
+   * @var string[]
+   */
+  protected $blacklist = [
+    'content_workspace',
+    'replication_log',
+    'workspace',
+  ];
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected $currentUser;
+
+  /**
+   * A list of workspace negotiators.
+   *
+   * @var \Drupal\workspace\Negotiator\WorkspaceNegotiatorInterface[]
+   */
+  protected $negotiators = [];
+
+  /**
+   * A list of workspace negotiators sorted by their priority.
+   *
+   * @var \Drupal\workspace\Negotiator\WorkspaceNegotiatorInterface[]
+   */
+  protected $sortedNegotiators;
+
+  /**
+   * A logger instance.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The class resolver.
+   *
+   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+   */
+  protected $classResolver;
+
+  /**
+   * The workspace negotiator service IDs.
+   *
+   * @var array
+   */
+  protected $negotiatorIds;
+
+  /**
+   * Constructs a new WorkspaceManager.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   A logger instance.
+   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
+   *   The class resolver.
+   * @param array $negotiator_ids
+   *   The workspace negotiator service IDs.
+   */
+  public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $current_user, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) {
+    $this->requestStack = $request_stack;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->currentUser = $current_user;
+    $this->logger = $logger;
+    $this->classResolver = $class_resolver;
+    $this->negotiatorIds = $negotiator_ids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type) {
+    if (!in_array($entity_type->id(), $this->blacklist, TRUE)
+      && is_a($entity_type->getClass(), EntityPublishedInterface::class, TRUE)
+      && $entity_type->isRevisionable()) {
+      return TRUE;
+    }
+    $this->blacklist[] = $entity_type->id();
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSupportedEntityTypes() {
+    $entity_types = [];
+    foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
+      if ($this->entityTypeCanBelongToWorkspaces($entity_type)) {
+        $entity_types[$entity_type_id] = $entity_type;
+      }
+    }
+    return $entity_types;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo {@link https://www.drupal.org/node/2600382 Access check.}
+   */
+  public function getActiveWorkspace($object = FALSE) {
+    $request = $this->requestStack->getCurrentRequest();
+    foreach ($this->negotiatorIds as $negotiator_id) {
+      $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+      if ($negotiator->applies($request)) {
+        if ($workspace_id = $negotiator->getWorkspaceId($request)) {
+          if ($object) {
+            return $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+          }
+          else {
+            return $workspace_id;
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace) {
+    // If the current user doesn't have access to view the workspace, they
+    // shouldn't be allowed to switch to it.
+    // @todo Could this be handled better?
+    if (!$workspace->access('view') && ($workspace->id() != static::DEFAULT_WORKSPACE)) {
+      $this->logger->error('Denied access to view workspace {workspace}', ['workspace' => $workspace->label()]);
+      throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
+    }
+
+    // Set the workspace on the proper negotiator.
+    $request = $this->requestStack->getCurrentRequest();
+    foreach ($this->negotiatorIds as $negotiator_id) {
+      $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+      if ($negotiator->applies($request)) {
+        $negotiator->setWorkspace($workspace);
+        break;
+      }
+    }
+
+    $supported_entity_types = $this->getSupportedEntityTypes();
+    foreach ($supported_entity_types as $supported_entity_type) {
+      $this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache();
+    }
+
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function updateOrCreateFromEntity(EntityInterface $entity) {
+    // If the entity is not new, check if there's an existing ContentWorkspace
+    // entity for it.
+    if (!$entity->isNew()) {
+      $content_workspaces = $this->entityTypeManager
+        ->getStorage('content_workspace')
+        ->loadByProperties([
+          'content_entity_type_id' => $entity->getEntityTypeId(),
+          'content_entity_id' => $entity->id(),
+        ]);
+
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $content_workspace */
+      $content_workspace = reset($content_workspaces);
+    }
+
+    // If there was a ContentWorkspace entry create a new revision, otherwise
+    // create a new entity with the type and ID.
+    if (!empty($content_workspace)) {
+      $content_workspace->setNewRevision(TRUE);
+    }
+    else {
+      $content_workspace = ContentWorkspace::create([
+        'content_entity_type_id' => $entity->getEntityTypeId(),
+        'content_entity_id' => $entity->id(),
+      ]);
+    }
+
+    // Add the revision ID and the workspace ID.
+    $content_workspace->set('content_entity_revision_id', $entity->getRevisionId());
+    $content_workspace->set('workspace', $this->getActiveWorkspace());
+
+    // Save without updating the content entity.
+    $content_workspace->save();
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManagerInterface.php b/core/modules/workspace/src/WorkspaceManagerInterface.php
new file mode 100644
index 0000000..89fb309
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManagerInterface.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+
+/**
+ * Provides an interface for managing Workspaces.
+ */
+interface WorkspaceManagerInterface {
+
+  /**
+   * Returns whether an entity type can belong to a workspace or not.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to check.
+   *
+   * @return bool
+   *   TRUE if the entity type can belong to a workspace, FALSE otherwise.
+   */
+  public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type);
+
+  /**
+   * Returns an array of entity types that can belong to workspaces.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeInterface[]
+   *   The entity types what can belong to workspaces.
+   */
+  public function getSupportedEntityTypes();
+
+  /**
+   * Gets the active workspace.
+   *
+   * @param bool $object
+   *   TRUE for the active workspace to be returned as an object, FALSE
+   *   otherwise.
+   *
+   * @return \Drupal\workspace\WorkspaceInterface|string
+   *   The active workspace entity object or workspace ID, depending on the
+   *   $object parameter.
+   */
+  public function getActiveWorkspace($object = FALSE);
+
+  /**
+   * Sets the active workspace via the workspace negotiators.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace to set as active.
+   *
+   * @return $this
+   *
+   * @throws \Drupal\workspace\WorkspaceAccessException
+   *   Thrown when the current user doesn't have access to view the workspace.
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace);
+
+  /**
+   * Update or create a ContentWorkspace entity from another entity.
+   *
+   * If the passed-in entity can belong to a workspace and already has a
+   * ContentWorkspace entity, then a new revision of this will be created with
+   * the new information. Otherwise, a new ContentWorkspace entity is created to
+   * store the passed-in entity's information.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to update or create from.
+   */
+  public function updateOrCreateFromEntity(EntityInterface $entity);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceServiceProvider.php b/core/modules/workspace/src/WorkspaceServiceProvider.php
new file mode 100644
index 0000000..d30f520
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceServiceProvider.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+
+/**
+ * Defines a service provider for the workspace module.
+ */
+class WorkspaceServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    // Add the 'workspace' cache context as required.
+    $renderer_config = $container->getParameter('renderer.config');
+    $renderer_config['required_cache_contexts'][] = 'workspace';
+    $container->setParameter('renderer.config', $renderer_config);
+
+    // Switch core's SQL entity query factory to our own so we can reliably
+    // alter entity queries.
+    // @todo Do the same for the pgsql entity query backend override.
+    $definition = $container->getDefinition('entity.query.sql');
+    $definition->setClass('Drupal\workspace\EntityQuery\SqlQueryFactory');
+    $definition->addArgument($container->getDefinition('workspace.manager'));
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
new file mode 100644
index 0000000..04e2510
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * Test workspace entities for unauthenticated JSON requests.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonAnonTest extends WorkspaceResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
new file mode 100644
index 0000000..dbae2d5
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests via basic auth.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonBasicAuthTest extends WorkspaceResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
new file mode 100644
index 0000000..f77dbf2
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests with cookie authentication.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonCookieTest extends WorkspaceResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
new file mode 100644
index 0000000..7298e2e
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Base class for workspace EntityResource tests.
+ */
+abstract class WorkspaceResourceTestBase extends EntityResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'workspace';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = ['changed'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $firstCreatedEntityId = 'running_on_faith';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $secondCreatedEntityId = 'running_on_faith_1';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view workspace layla']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['view workspace layla', 'create workspace']);
+        break;
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['view workspace layla', 'edit workspace layla']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['view workspace layla', 'delete workspace layla']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $workspace = Workspace::create([
+      'id' => 'layla',
+      'label' => 'Layla',
+      'upstream' => 'local_workspace:live',
+    ]);
+    $workspace->save();
+    return $workspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity() {
+    $workspace = $this->entity->createDuplicate();
+    $workspace->id = 'layla_dupe';
+    $workspace->label = 'Layla_dupe';
+    $workspace->save();
+    return $workspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'created' => [
+        $this->formatExpectedTimestampItemValues((int) $this->entity->getStartTime()),
+      ],
+      'changed' => [
+        $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+      ],
+      'id' => [
+        [
+          'value' => 'layla',
+        ],
+      ],
+      'label' => [
+        [
+          'value' => 'Layla',
+        ],
+      ],
+      'revision_id' => [
+        [
+          'value' => 3,
+        ],
+      ],
+      'uid' => [
+        [
+          'target_id' => (int) $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/' . $author->id(),
+        ],
+      ],
+      'upstream' => [
+        [
+          'value' => 'local_workspace:live',
+        ],
+      ],
+      'uuid' => [
+        [
+          'value' => $this->entity->uuid()
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity($which = NULL) {
+    return [
+      'id' => [
+        [
+          'value' => 'running_on_faith' . ($which === 1 ? '_1' : ''),
+        ],
+      ],
+      'label' => [
+        [
+          'value' => 'Running on faith',
+        ],
+      ],
+      'upstream' => [
+        [
+          'value' => 'local_workspace:stage',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPatchEntity() {
+    return [
+      'label' => [
+        [
+          'value' => 'Running on faith',
+        ],
+      ],
+      'upstream' => [
+        [
+          'value' => 'local_workspace:stage',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
+      return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+
+    switch ($method) {
+      case 'GET':
+        return "The 'view workspace layla' permission is required.";
+        break;
+      case 'POST':
+        return "The 'create workspace' permission is required.";
+        break;
+      case 'PATCH':
+        return "The 'edit workspace layla' permission is required.";
+        break;
+      case 'DELETE':
+        return "The 'delete workspace layla' permission is required.";
+        break;
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    return parent::getExpectedUnauthorizedAccessCacheability()->addCacheTags($this->entity->getCacheTags());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDelete() {
+    // @todo Workspaces can not yet be deleted.
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
similarity index 54%
copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
index d1ef16e..5004f53 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
@@ -1,16 +1,18 @@
 <?php
 
-namespace Drupal\Tests\rest\Functional\EntityResource\User;
+namespace Drupal\Tests\workspace\Functional\EntityResource;
 
-use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
 use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
 
 /**
- * @group rest
+ * Test workspace entities for unauthenticated XML requests.
+ *
+ * @group workspace
  */
-class UserXmlCookieTest extends UserResourceTestBase {
+class WorkspaceXmlAnonTest extends WorkspaceResourceTestBase {
 
-  use CookieResourceTestTrait;
+  use AnonResourceTestTrait;
   use XmlEntityNormalizationQuirksTrait;
 
   /**
@@ -26,12 +28,7 @@ class UserXmlCookieTest extends UserResourceTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $auth = 'cookie';
-
-  /**
-   * {@inheritdoc}
-   */
-  public function testPatchDxForSecuritySensitiveBaseFields() {
+  public function testPatchPath() {
     // Deserialization of the XML format is not supported.
     $this->markTestSkipped();
   }
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
similarity index 72%
copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php
copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
index dbf74b1..ac6f3c5 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlBasicAuthTest.php
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
@@ -1,14 +1,16 @@
 <?php
 
-namespace Drupal\Tests\rest\Functional\EntityResource\User;
+namespace Drupal\Tests\workspace\Functional\EntityResource;
 
 use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
 use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
 
 /**
- * @group rest
+ * Test workspace entities for XML requests with cookie authentication.
+ *
+ * @group workspace
  */
-class UserXmlBasicAuthTest extends UserResourceTestBase {
+class WorkspaceXmlBasicAuthTest extends WorkspaceResourceTestBase {
 
   use BasicAuthResourceTestTrait;
   use XmlEntityNormalizationQuirksTrait;
@@ -36,7 +38,7 @@ class UserXmlBasicAuthTest extends UserResourceTestBase {
   /**
    * {@inheritdoc}
    */
-  public function testPatchDxForSecuritySensitiveBaseFields() {
+  public function testPatchPath() {
     // Deserialization of the XML format is not supported.
     $this->markTestSkipped();
   }
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
similarity index 71%
copy from core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
copy to core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
index d1ef16e..c8a23d1 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserXmlCookieTest.php
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
@@ -1,14 +1,16 @@
 <?php
 
-namespace Drupal\Tests\rest\Functional\EntityResource\User;
+namespace Drupal\Tests\workspace\Functional\EntityResource;
 
 use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
 use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
 
 /**
- * @group rest
+ * Test workspace entities for XML requests.
+ *
+ * @group workspace
  */
-class UserXmlCookieTest extends UserResourceTestBase {
+class WorkspaceXmlCookieTest extends WorkspaceResourceTestBase {
 
   use CookieResourceTestTrait;
   use XmlEntityNormalizationQuirksTrait;
@@ -31,7 +33,7 @@ class UserXmlCookieTest extends UserResourceTestBase {
   /**
    * {@inheritdoc}
    */
-  public function testPatchDxForSecuritySensitiveBaseFields() {
+  public function testPatchPath() {
     // Deserialization of the XML format is not supported.
     $this->markTestSkipped();
   }
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
new file mode 100644
index 0000000..a3b5342
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\simpletest\BlockCreationTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests access bypass permission controls on workspaces.
+ *
+ * @group workspace
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ */
+class WorkspaceBypassTest extends BrowserTestBase {
+  use WorkspaceTestUtilities;
+  use BlockCreationTrait {
+    placeBlock as drupalPlaceBlock;
+  }
+
+  public static $modules = ['node', 'user', 'block', 'workspace'];
+
+  /**
+   * Verifies that a user can edit anything in a workspace with a specific perm.
+   */
+  public function testBypassSpecificWorkspace() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+    ];
+
+    $this->createNodeType('Test', 'test');
+    $this->setupWorkspaceSwitcherBlock();
+
+    $ditka = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($ditka);
+
+    $vanilla_node = $this->createNodeThroughUi('Vanilla node', 'test');
+
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+    $this->switchToWorkspace($bears);
+
+    // Now create a node in the Bears workspace, as the owner of that workspace.
+    $ditka_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
+    $ditka_bears_node_id = $ditka_bears_node->id();
+
+    // Create a new user that should be able to edit anything in the Bears
+    // workspace.
+    $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view workspace ' . $bears->id(), 'bypass entity access workspace ' . $bears->id()]));
+    $this->drupalLogin($lombardi);
+    $this->switchToWorkspace($bears);
+
+    // Because Lombardi has the bypass permission, he should be able to create
+    // and edit any node.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $session = $this->getSession();
+    $this->assertEquals(200, $session->getStatusCode());
+
+    $lombardi_bears_node = $this->createNodeThroughUi('Lombardi Bears node', 'test');
+    $lombardi_bears_node_id = $lombardi_bears_node->id();
+
+    $this->drupalLogin($ditka);
+    $this->switchToWorkspace($bears);
+
+    $this->drupalGet('/node/' . $lombardi_bears_node_id . '/edit');
+    $session = $this->getSession();
+    $this->assertEquals(403, $session->getStatusCode());
+
+    // Create a new user that should NOT be able to edit anything in the Bears
+    // workspace.
+    $belichick = $this->drupalCreateUser(array_merge($permissions, ['view workspace ' . $bears->id()]));
+    $this->drupalLogin($belichick);
+    $this->switchToWorkspace($bears);
+
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $session = $this->getSession();
+    $this->assertEquals(403, $session->getStatusCode());
+
+  }
+
+  /**
+   * Verifies that a user can edit anything in a workspace they own.
+   */
+  public function testBypassOwnWorkspace() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+      'bypass entity access own workspace',
+    ];
+
+    $this->createNodeType('Test', 'test');
+    $this->setupWorkspaceSwitcherBlock();
+
+    $ditka = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($ditka);
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+    $this->switchToWorkspace($bears);
+
+    // Now create a node in the Bears workspace, as the owner of that workspace.
+    $ditka_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
+    $ditka_bears_node_id = $ditka_bears_node->id();
+
+    // Editing both nodes should be possible.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $session = $this->getSession();
+    $this->assertEquals(200, $session->getStatusCode());
+
+    // Create a new user that should be able to edit anything in the Bears
+    // workspace.
+    $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view workspace ' . $bears->id()]));
+    $this->drupalLogin($lombardi);
+    $this->switchToWorkspace($bears);
+
+    // Because editor 2 has the bypass permission, he should be able to create
+    // and edit any node.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $session = $this->getSession();
+    $this->assertEquals(403, $session->getStatusCode());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
new file mode 100644
index 0000000..2db3006
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceCacheContext;
+
+/**
+ * Tests the workspace cache context.
+ *
+ * @group workspace
+ * @group Cache
+ */
+class WorkspaceCacheContextTest extends BrowserTestBase {
+
+  use AssertPageCacheContextsAndTagsTrait;
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block', 'node', 'workspace'];
+
+  /**
+   * Tests the 'workspace' cache context.
+   */
+  public function testWorkspaceCacheContext() {
+    $this->dumpHeaders = TRUE;
+
+    $renderer = \Drupal::service('renderer');
+    $cache_contexts_manager = \Drupal::service("cache_contexts_manager");
+
+    // Check that the 'workspace' cache context is present when the module is
+    // installed.
+    $this->drupalGet('<front>');
+    $this->assertCacheContext('workspace');
+
+    $cache_context = new WorkspaceCacheContext(\Drupal::service('workspace.manager'));
+    $this->assertSame('live', $cache_context->getContext());
+
+    // Create a node and check that its render array contains the proper cache
+    // context.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $node = $this->createNode();
+
+    // Get a fully built entity view render array.
+    $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+    // Render it so the default cache contexts are applied.
+    $renderer->renderRoot($build);
+    $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+    $this->assertTrue(in_array('[workspace]=live', $cid_parts, TRUE));
+
+    // Test that a cache entry is created.
+    $cid = implode(':', $cid_parts);
+    $bin = $build['#cache']['bin'];
+    $this->assertTrue($this->container->get('cache.' . $bin)->get($cid), 'The entity render element has been cached.');
+
+    // Switch to the 'stage' workspace and check that the correct workspace
+    // cache context is used.
+    $test_user = $this->drupalCreateUser(['view any workspace']);
+    $this->drupalLogin($test_user);
+
+    $this->setupWorkspaceSwitcherBlock();
+    $stage = Workspace::load('stage');
+    $this->switchToWorkspace($stage);
+
+    $cache_context = new WorkspaceCacheContext(\Drupal::service('workspace.manager'));
+    $this->assertSame('stage', $cache_context->getContext());
+
+    $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+    // Render it so the default cache contexts are applied.
+    $renderer->renderRoot($build);
+    $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+    $this->assertTrue(in_array('[workspace]=stage', $cid_parts, TRUE));
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php
new file mode 100644
index 0000000..507342f
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceIndividualPermissionsTest.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ *
+ * @runTestsInSeparateProcesses
+ *
+ * @preserveGlobalState disabled
+ */
+class WorkspaceIndividualPermissionsTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * Verifies that a user can create and edit only their own workspace.
+   */
+  public function testEditIndividualWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user with permission to edit that workspace,
+    // specifically.
+    $editor2 = $this->drupalCreateUser(array_merge($permissions, ['edit workspace ' . $bears->id()]));
+
+    $this->drupalLogin($editor2);
+    $session = $this->getSession();
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+  }
+
+  /**
+   * Verifies that a user can view a specific workspace.
+   */
+  public function testViewIndividualWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user and create a workspace.
+    $editor2 = $this->drupalCreateUser(array_merge($permissions, ['view workspace ' . $bears->id()]));
+
+    $this->drupalLogin($editor2);
+    $session = $this->getSession();
+
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    // Load the activate form for the Bears workspace. It should work, because
+    // the user has the permission specific to that workspace.
+    $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    // But editor 1 cannot view the Packers workspace.
+    $this->drupalLogin($editor1);
+    $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+    $this->assertEquals(403, $session->getStatusCode());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
new file mode 100644
index 0000000..895f65b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspacePermissionsTest extends BrowserTestBase {
+  use WorkspaceTestUtilities;
+
+  /**
+   * The modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * Verifies that a user can create but not edit a workspace.
+   *
+   * @throws \Behat\Mink\Exception\ElementNotFoundException
+   */
+  public function testCreateWorkspace() {
+    $editor = $this->drupalCreateUser([
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+    ]);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor);
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We shouldn't be able to do so, since
+    // we don't have edit permissions.
+    /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $etm */
+    $etm = \Drupal::service('entity_type.manager');
+    /** @var \Drupal\workspace\WorkspaceInterface $bears */
+    $entity_list = $etm->getStorage('workspace')->loadByProperties(['label' => 'Bears']);
+    $bears = current($entity_list);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(403);
+
+    // @todo add Deletion checks once there's a UI for deletion.
+  }
+
+  /**
+   * Verifies that a user can create and edit only their own workspace.
+   */
+  public function testEditOwnWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We should be able to do so.
+    $bears = Workspace::load('bears');
+
+    $session = $this->getSession();
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    $page = $session->getPage();
+    $page->fillField('label', 'Bears again');
+    $page->fillField('id', 'bears');
+    $page->findButton(t('Save'))->click();
+    $session->getPage()->hasContent('Bears again (bears)');
+
+    // Now login as a different user and ensure they don't have edit access,
+    // and vice versa.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $session = $this->getSession();
+
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertEquals(403, $session->getStatusCode());
+  }
+
+  /**
+   * Verifies that a user can edit any workspace.
+   */
+  public function testEditAnyWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We should be able to do so.
+    $bears = Workspace::load('bears');
+
+    $session = $this->getSession();
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    $page = $session->getPage();
+    $page->fillField('label', 'Bears again');
+    $page->fillField('id', 'bears');
+    $page->findButton(t('Save'))->click();
+    $session->getPage()->hasContent('Bears again (bears)');
+
+    // Now login as a different user and ensure they don't have edit access,
+    // and vice versa.
+    $admin = $this->drupalCreateUser(array_merge($permissions, ['edit any workspace']));
+
+    $this->drupalLogin($admin);
+    $session = $this->getSession();
+
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertEquals(200, $session->getStatusCode());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
new file mode 100644
index 0000000..8b640b4
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\simpletest\BlockCreationTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests workspace switching functionality.
+ *
+ * @group workspace
+ */
+class WorkspaceSwitcherTest extends BrowserTestBase {
+  use WorkspaceTestUtilities;
+  use BlockCreationTrait {
+    placeBlock as drupalPlaceBlock;
+  }
+
+  public static $modules = ['block', 'workspace'];
+
+  /**
+   * Test switching workspace via the switcher block and admin page.
+   */
+  public function testSwitchingWorkspaces() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+      'bypass entity access own workspace',
+    ];
+
+    $this->setupWorkspaceSwitcherBlock();
+
+    $mayer = $this->drupalCreateUser($permissions);
+    $this->drupalLogin($mayer);
+
+    $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures');
+    $this->switchToWorkspace($vultures);
+
+    $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity');
+
+    $this->drupalGet('/admin/config/workflow/workspace/' . $gravity->id() . '/activate');
+
+    $session = $this->getSession();
+    $this->assertEquals(200, $session->getStatusCode());
+    $page = $session->getPage();
+    $page->findButton(t('Confirm'))->click();
+
+    $session->getPage()->findLink($gravity->label());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
new file mode 100644
index 0000000..dcbda85
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test the workspace entity.
+ *
+ * @group workspace
+ */
+class WorkspaceTest extends BrowserTestBase {
+  use WorkspaceTestUtilities;
+
+  public static $modules = ['workspace'];
+
+  /**
+   * A test user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $editor1;
+
+  /**
+   * A test user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $editor2;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'edit any workspace',
+    ];
+
+    $this->editor1 = $this->drupalCreateUser($permissions);
+    $this->editor2 = $this->drupalCreateUser($permissions);
+  }
+
+  /**
+   * Test creating a workspace with special characters.
+   */
+  public function testSpecialCharacters() {
+    $this->drupalLogin($this->editor1);
+
+    // Test a valid workspace name.
+    $this->createWorkspaceThroughUi('Workspace 1', 'a0_$()+-/');
+
+    // Test and invalid workspace name.
+    $this->drupalGet('/admin/config/workflow/workspace/add');
+    $session = $this->getSession();
+    $this->assertEquals(200, $session->getStatusCode());
+    $page = $session->getPage();
+    $page->fillField('label', 'workspace2');
+    $page->fillField('id', 'A!"£%^&*{}#~@?');
+    $page->findButton(t('Save'))->click();
+    $session->getPage()->hasContent("This value is not valid");
+  }
+
+  /**
+   * Test changing the owner of a workspace.
+   */
+  public function testWorkspaceOwner() {
+    $this->drupalLogin($this->editor1);
+
+    $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+      'id' => 'test_workspace',
+      'label' => 'Test workspace',
+      'upstream[0][value]' => 'local_workspace:live',
+    ], 'Save');
+
+    $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+    $test_workspace = $storage->load('test_workspace');
+    $this->assertEquals($this->editor1->id(), $test_workspace->getOwnerId());
+
+    $this->drupalPostForm('/admin/config/workflow/workspace/test_workspace/edit', [
+      'uid[0][target_id]' => $this->editor2->getUsername(),
+    ], 'Save');
+
+    $test_workspace = $storage->loadUnchanged('test_workspace');
+    $this->assertEquals($this->editor2->id(), $test_workspace->getOwnerId());
+  }
+
+  /**
+   * Tests that editing a workspace creates a new revision.
+   */
+  public function testWorkspaceFormRevisions() {
+    $this->drupalLogin($this->editor1);
+    $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+
+    // The current live workspace entity should be revision 1.
+    $live_workspace = $storage->load('live');
+    $this->assertEquals('1', $live_workspace->getRevisionId());
+
+    // Re-save the live workspace via the UI to create revision 3.
+    $this->drupalPostForm($live_workspace->url('edit-form'), [], 'Save');
+    $live_workspace = $storage->loadUnchanged('live');
+    $this->assertEquals('3', $live_workspace->getRevisionId());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
new file mode 100644
index 0000000..14475fb
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\node\Entity\NodeType;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceInterface;
+
+/**
+ * Utility methods for use in BrowserTestBase tests.
+ *
+ * This trait will not work if not used in a child of BrowserTestBase.
+ */
+trait WorkspaceTestUtilities {
+
+  /**
+   * Loads a single entity by its label.
+   *
+   * The UI approach to creating an entity doesn't make it easy to know what
+   * the ID is, so this lets us make paths for an entity after it's created.
+   *
+   * @param string $type
+   *   The type of entity to load.
+   * @param string $label
+   *   The label of the entity to load.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity.
+   */
+  protected function getOneEntityByLabel($type, $label) {
+    /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
+    $entity_type_manager = \Drupal::service('entity_type.manager');
+    $property = $entity_type_manager->getDefinition($type)->getKey('label');
+    $entity_list = $entity_type_manager->getStorage($type)->loadByProperties([$property => $label]);
+    $entity = current($entity_list);
+    if (!$entity) {
+      $this->fail("No {$type} entity named {$label} found.");
+    }
+
+    return $entity;
+  }
+
+  /**
+   * Creates a new Workspace through the UI.
+   *
+   * @param string $label
+   *   The label of the workspace to create.
+   * @param string $id
+   *   The ID of the workspace to create.
+   * @param string $upstream
+   *   The ID of the replication handler plugin of the workspace.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The workspace that was just created.
+   *
+   * @throws \Behat\Mink\Exception\ElementNotFoundException
+   */
+  protected function createWorkspaceThroughUi($label, $id, $upstream = 'local_workspace:live') {
+    $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+      'id' => $id,
+      'label' => $label,
+      'upstream[0][value]' => $upstream,
+    ], 'Save');
+
+    $this->getSession()->getPage()->hasContent("$label ($id)");
+
+    return Workspace::load($id);
+  }
+
+  /**
+   * Adds the workspace switcher block to the site.
+   *
+   * This is necessary for switchToWorkspace() to function correctly.
+   */
+  protected function setupWorkspaceSwitcherBlock() {
+    // Add the block to the sidebar.
+    $this->drupalPlaceBlock('workspace_switcher_block', [
+      'id' => 'workspaceswitcher',
+      'region' => 'sidebar_first',
+      'label' => 'Workspace switcher',
+    ]);
+
+    // Confirm the block shows on the front page.
+    $this->drupalGet('<front>');
+    $page = $this->getSession()->getPage();
+
+    $this->assertTrue($page->hasContent('Workspace switcher'));
+  }
+
+  /**
+   * Sets a given workspace as "active" for subsequent requests.
+   *
+   * This assumes that the switcher block has already been setup by calling
+   * setupWorkspaceSwitcherBlock().
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace to set active.
+   */
+  protected function switchToWorkspace(WorkspaceInterface $workspace) {
+    /** @var \Drupal\workspace\WorkspaceManager $workspace_manager */
+    $workspace_manager = \Drupal::service('workspace.manager');
+    if ($workspace_manager->getActiveWorkspace() !== $workspace->id()) {
+      // Switch the system under test to the specified workspace.
+      /** @var \Drupal\Tests\WebAssert $session */
+      $session = $this->assertSession();
+      $session->buttonExists('Activate');
+      $this->drupalPostForm(NULL, ['workspace_id' => $workspace->id()], t('Activate'));
+      $session->pageTextContains($workspace->label() . ' is now the active workspace.');
+
+      // Switch the test runner's context to the specified workspace.
+      \Drupal::service('workspace.manager')->setActiveWorkspace($workspace);
+
+      // If we don't do both of those, test runner utility methods will not be
+      // run in the same workspace as the system under test, and you'll be left
+      // wondering why your test runner cannot find content you just created.
+    }
+  }
+
+  /**
+   * Creates a new node type.
+   *
+   * @param string $label
+   *   The human-readable label of the type to create.
+   * @param string $machine_name
+   *   The machine name of the type to create.
+   */
+  protected function createNodeType($label, $machine_name) {
+    $node_type = NodeType::create([
+      'type' => $machine_name,
+      'label' => $label,
+    ]);
+    $node_type->save();
+  }
+
+  /**
+   * Creates a node by "clicking" buttons.
+   *
+   * @param string $label
+   *   The label of the Node to create.
+   * @param string $bundle
+   *   The bundle of the Node to create.
+   * @param bool $publish
+   *   The publishing status to set.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The Node that was just created.
+   *
+   * @throws \Behat\Mink\Exception\ElementNotFoundException
+   */
+  protected function createNodeThroughUi($label, $bundle, $publish = TRUE) {
+    $this->drupalGet('/node/add/' . $bundle);
+
+    /** @var \Behat\Mink\Session $session */
+    $session = $this->getSession();
+    $this->assertSession()->statusCodeEquals(200);
+
+    /** @var \Behat\Mink\Element\DocumentElement $page */
+    $page = $session->getPage();
+    $page->fillField('Title', $label);
+    if ($publish) {
+      $page->findButton(t('Save'))->click();
+    }
+    else {
+      $page->uncheckField('Published');
+      $page->findButton(t('Save'))->click();
+    }
+
+    $session->getPage()->hasContent("{$label} has been created");
+
+    return $this->getOneEntityByLabel('node', $label);
+  }
+
+  /**
+   * Determine if the content list has an entity's label.
+   *
+   * This assertion can be used to validate a particular entity exists in the
+   * current workspace.
+   */
+  protected function isLabelInContentOverview($label) {
+    $this->drupalGet('/admin/content');
+    $session = $this->getSession();
+    $this->assertSession()->statusCodeEquals(200);
+    $page = $session->getPage();
+    return $page->hasContent($label);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
new file mode 100644
index 0000000..a0ed21b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests uninstalling the Workspace module.
+ *
+ * @group workspace
+ */
+class WorkspaceUninstallTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $profile = 'standard';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * Tests deleting workspace entities and uninstalling Workspace module.
+   */
+  public function testUninstallingWorkspace() {
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('/admin/modules/uninstall');
+    $session = $this->assertSession();
+    $session->linkExists('Remove workspaces');
+    $this->clickLink('Remove workspaces');
+    $session->pageTextContains('Are you sure you want to delete all workspaces?');
+    $this->drupalPostForm('/admin/modules/uninstall/entity/workspace', [], 'Delete all workspaces');
+    $this->drupalPostForm('admin/modules/uninstall', ['uninstall[workspace]' => TRUE], 'Uninstall');
+    $this->drupalPostForm(NULL, [], 'Uninstall');
+    $session->pageTextContains('The selected modules have been uninstalled.');
+    $session->pageTextNotContains('Workspace');
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
new file mode 100644
index 0000000..16d13ef
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\simpletest\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ */
+class WorkspaceViewTest extends BrowserTestBase {
+  use WorkspaceTestUtilities;
+
+  public static $modules = ['workspace', 'workspace'];
+
+  /**
+   * Verifies that a user can view their own workspace.
+   */
+  public function testViewOwnWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user and create a workspace.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $session = $this->getSession();
+
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    // Load the activate form for the Bears workspace. It should fail because
+    // the workspace belongs to someone else.
+    $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+    $this->assertEquals(403, $session->getStatusCode());
+
+    // But editor 2 should be able to activate the Packers workspace.
+    $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+    $this->assertEquals(200, $session->getStatusCode());
+  }
+
+  /**
+   * Verifies that a user can view any workspace.
+   */
+  public function testViewAnyWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'view any workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user and create a workspace.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $session = $this->getSession();
+
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    // Load the activate form for the Bears workspace. This user should be
+    // able to see both workspaces because of the "view any" permission.
+    $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+    $this->assertEquals(200, $session->getStatusCode());
+
+    // But editor 2 should be able to activate the Packers workspace.
+    $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+    $this->assertEquals(200, $session->getStatusCode());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
new file mode 100644
index 0000000..ebc1f09
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests access on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceAccessTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'user',
+    'system',
+    'workspace',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('system', ['sequences']);
+
+    $this->installEntitySchema('workspace');
+    $this->installEntitySchema('user');
+
+    // User 1.
+    $this->createUser();
+  }
+
+  /**
+   * Test cases for testWorkspaceAccess().
+   *
+   * @return array
+   *   An array of operations and permissions to test with.
+   */
+  public function operationCases() {
+    return [
+      ['create', 'create workspace'],
+      ['create', 'view workspace oak'],
+      ['create', 'view any workspace'],
+      ['create', 'view own workspace'],
+      ['create', 'edit workspace oak'],
+      ['create', 'edit any workspace'],
+      ['create', 'edit own workspace'],
+      ['view', 'create workspace'],
+      ['view', 'view workspace oak'],
+      ['view', 'view any workspace'],
+      ['view', 'view own workspace'],
+      ['view', 'edit workspace oak'],
+      ['view', 'edit any workspace'],
+      ['view', 'edit own workspace'],
+      ['update', 'create workspace'],
+      ['update', 'view workspace oak'],
+      ['update', 'view any workspace'],
+      ['update', 'view own workspace'],
+      ['update', 'edit workspace oak'],
+      ['update', 'edit any workspace'],
+      ['update', 'edit own workspace'],
+      ['delete', 'create workspace'],
+      ['delete', 'view workspace oak'],
+      ['delete', 'view any workspace'],
+      ['delete', 'view own workspace'],
+      ['delete', 'edit workspace oak'],
+      ['delete', 'edit any workspace'],
+      ['delete', 'edit own workspace'],
+    ];
+  }
+
+  /**
+   * Verifies all workspace roles have the correct access for the operation.
+   *
+   * @param string $operation
+   *   The operation to test with.
+   * @param string $permission
+   *   The permission to test with.
+   *
+   * @dataProvider operationCases
+   */
+  public function testWorkspaceAccess($operation, $permission) {
+    $user = $this->createUser();
+    $this->setCurrentUser($user);
+    $workspace = Workspace::create(['id' => 'oak']);
+    $workspace->save();
+    $role = $this->createRole([$permission]);
+    $user->addRole($role);
+    $operation_permission = $operation === 'update' ? 'edit' : $operation;
+    if (strpos($permission, $operation_permission) === FALSE || $operation === 'delete') {
+      $this->assertFalse($workspace->access($operation, $user));
+    }
+    else {
+      $this->assertTrue($workspace->access($operation, $user));
+    }
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
new file mode 100644
index 0000000..06a2d24
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
@@ -0,0 +1,633 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\entity_test\Entity\EntityTestMulRev;
+use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\views\Tests\ViewResultAssertionTrait;
+use Drupal\views\Views;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests a complete deployment scenario across different workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceIntegrationTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use EntityReferenceTestTrait;
+  use NodeCreationTrait;
+  use UserCreationTrait;
+  use ViewResultAssertionTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * An array of test workspaces, keyed by workspace ID.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface[]
+   */
+  protected $workspaces = [];
+
+  /**
+   * Creation timestamp that should be incremented for each new entity.
+   *
+   * @var int
+   */
+  protected $createdTimestamp;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'entity_test',
+    'field',
+    'filter',
+    'node',
+    'text',
+    'user',
+    'system',
+    'views',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+
+    $this->installConfig(['filter', 'node', 'system']);
+
+    $this->installSchema('system', ['key_value_expire', 'sequences']);
+    $this->installSchema('node', ['node_access']);
+
+    $this->installEntitySchema('entity_test_mulrev');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+
+    $this->createContentType(['type' => 'page']);
+
+    $this->setCurrentUser($this->createUser(['administer nodes']));
+
+    // Create two nodes, a published and an unpublished one, so we can test the
+    // behavior of the module with default/existing content.
+    $this->createdTimestamp = \Drupal::time()->getRequestTime();
+    $this->createNode(['title' => 'live - 1 - r1 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+    $this->createNode(['title' => 'live - 2 - r2 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+  }
+
+  /**
+   * Enables the Workspace module and creates two workspaces.
+   */
+  protected function initializeWorkspaceModule() {
+    // Enable the Workspace module here instead of the static::$modules array so
+    // we can test it with default content.
+    $this->enableModules(['workspace']);
+    $this->container = \Drupal::getContainer();
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+
+    $this->installEntitySchema('workspace');
+    $this->installEntitySchema('content_workspace');
+    $this->installEntitySchema('replication_log');
+
+    // Create two workspaces by default, 'live' and 'stage'.
+    $this->workspaces['live'] = Workspace::create(['id' => 'live']);
+    $this->workspaces['live']->save();
+    $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'upstream' => 'local_workspace:live']);
+    $this->workspaces['stage']->save();
+
+    $permissions = [
+      'administer nodes',
+      'create workspace',
+      'edit any workspace',
+      'view any workspace',
+    ];
+    $this->setCurrentUser($this->createUser($permissions));
+  }
+
+  /**
+   * Tests various scenarios for creating and deploying content in workspaces.
+   */
+  public function testWorkspaces() {
+    $this->initializeWorkspaceModule();
+
+    // Notes about the structure of the test scenarios:
+    // - 'default_revision' indicates the entity revision that should be
+    //   returned by entity_load(), non-revision entity queries and non-revision
+    //   views *in a given workspace*, it does not indicate what is actually
+    //   stored in the base entity tables.
+    $test_scenarios = [];
+
+    // A multi-dimensional array keyed by the workspace ID, then by the entity
+    // and finally by the revision ID.
+    //
+    // In the initial state we have only the two revisions that were created
+    // before the Workspace module was installed.
+    $revision_state = [
+      'live' => [
+        1 => [
+          1 => [
+            'title' => 'live - 1 - r1 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+        2 => [
+          2 => [
+            'title' => 'live - 2 - r2 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+      'stage' => [
+        1 => [
+          1 => [
+            'title' => 'live - 1 - r1 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+        2 => [
+          2 => [
+            'title' => 'live - 2 - r2 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ];
+    $test_scenarios['initial_state'] = $revision_state;
+
+    // Unpublish node 1 in 'stage'. The new revision is also added to 'live' but
+    // it is not the default revision.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        1 => [
+          3 => [
+            'title' => 'stage - 1 - r3 - unpublished',
+            'status' => FALSE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        1 => [
+          1 => ['default_revision' => FALSE],
+          3 => [
+            'title' => 'stage - 1 - r3 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['unpublish_node_1_in_stage'] = $revision_state;
+
+    // Publish node 2 in 'stage'. The new revision is also added to 'live' but
+    // it is not the default revision.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        2 => [
+          4 => [
+            'title' => 'stage - 2 - r4 - published',
+            'status' => TRUE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        2 => [
+          2 => ['default_revision' => FALSE],
+          4 => [
+            'title' => 'stage - 2 - r4 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['publish_node_2_in_stage'] = $revision_state;
+
+    // Adding a new unpublished node on 'stage' should create a single
+    // unpublished revision on both 'stage' and 'live'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        3 => [
+          5 => [
+            'title' => 'stage - 3 - r5 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+      'stage' => [
+        3 => [
+          5 => [
+            'title' => 'stage - 3 - r5 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['add_unpublished_node_in_stage'] = $revision_state;
+
+    // Adding a new published node on 'stage' should create two revisions, an
+    // unpublished revision on 'live' and a published one on 'stage'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        4 => [
+          6 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+          7 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => TRUE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        4 => [
+          6 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => FALSE,
+            'default_revision' => FALSE,
+          ],
+          7 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['add_published_node_in_stage'] = $revision_state;
+
+    // Deploying 'stage' to 'live' should simply make the latest revisions in
+    // 'stage' the default ones in 'live'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        1 => [
+          1 => ['default_revision' => FALSE],
+          3 => ['default_revision' => TRUE],
+        ],
+        2 => [
+          2 => ['default_revision' => FALSE],
+          4 => ['default_revision' => TRUE],
+        ],
+        // Node 3 has a single revision for both 'stage' and 'live' and it is
+        // already the default revision in both of them.
+        4 => [
+          6 => ['default_revision' => FALSE],
+          7 => ['default_revision' => TRUE],
+        ],
+      ],
+    ]);
+    $test_scenarios['deploy_stage_to_live'] = $revision_state;
+
+    // Check the initial state after the module was installed.
+    $this->assertWorkspaceStatus($test_scenarios['initial_state'], 'node');
+
+    // Unpublish node 1 in 'stage'.
+    $this->switchToWorkspace('stage');
+    $node = $this->entityTypeManager->getStorage('node')->load(1);
+    $node->setTitle('stage - 1 - r3 - unpublished');
+    $node->setUnpublished();
+    $node->save();
+    $this->assertWorkspaceStatus($test_scenarios['unpublish_node_1_in_stage'], 'node');
+
+    // Publish node 2 in 'stage'.
+    $this->switchToWorkspace('stage');
+    $node = $this->entityTypeManager->getStorage('node')->load(2);
+    $node->setTitle('stage - 2 - r4 - published');
+    $node->setPublished();
+    $node->save();
+    $this->assertWorkspaceStatus($test_scenarios['publish_node_2_in_stage'], 'node');
+
+    // Add a new unpublished node on 'stage'.
+    $this->switchToWorkspace('stage');
+    $this->createNode(['title' => 'stage - 3 - r5 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+    $this->assertWorkspaceStatus($test_scenarios['add_unpublished_node_in_stage'], 'node');
+
+    // Add a new published node on 'stage'.
+    $this->switchToWorkspace('stage');
+    $this->createNode(['title' => 'stage - 4 - r6 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+    $this->assertWorkspaceStatus($test_scenarios['add_published_node_in_stage'], 'node');
+
+    // Deploy 'stage' to 'live'.
+    $repository_handler = $this->workspaces['stage']->getRepositoryHandlerPlugin();
+    $repository_handler->replicate($this->workspaces['stage']->getLocalRepositoryHandlerPlugin(), $repository_handler);
+    $this->assertWorkspaceStatus($test_scenarios['deploy_stage_to_live'], 'node');
+  }
+
+  /**
+   * Tests the Entity Query relationship API with workspaces.
+   */
+  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 'node' entities so we can test
+    // references to the same base tables.
+    $this->createEntityReferenceField('node', 'page', 'field_test_node', 'Test node reference', 'node');
+
+    $this->switchToWorkspace('live');
+    $node_1 = $this->createNode([
+      'title' => 'live node 1'
+    ]);
+    $entity_test = EntityTestMulRev::create([
+      'name' => 'live entity_test_mulrev',
+      'non_rev_field' => 'live non-revisionable value',
+    ]);
+    $entity_test->save();
+
+    $node_2 = $this->createNode([
+      'title' => 'live node 2',
+      'field_test_entity' => $entity_test->id(),
+      'field_test_node' => $node_1->id(),
+    ]);
+
+    // Switch to the 'stage' workspace and change some values for the referenced
+    // entities.
+    $this->switchToWorkspace('stage');
+    $node_1->title->value = 'stage node 1';
+    $node_1->save();
+
+    $node_2->title->value = 'stage node 2';
+    $node_2->save();
+
+    $entity_test->name->value = 'stage entity_test_mulrev';
+    $entity_test->non_rev_field->value = 'stage non-revisionable value';
+    $entity_test->save();
+
+    // Make sure that we're requesting the default revision.
+    $query = $this->entityTypeManager->getStorage('node')->getQuery();
+    $query->currentRevision();
+
+    $query
+      // Check a condition on the revision data table.
+      ->condition('title', 'stage node 2')
+      // Check a condition on the revision table.
+      ->condition('revision_uid', $node_2->getRevisionUserId())
+      // Check a condition on the data table.
+      ->condition('type', $node_2->bundle())
+      // Check a condition on the base table.
+      ->condition('uuid', $node_2->uuid());
+
+    // Add conditions for a reference to the same entity type.
+    $query
+      // Check a condition on the revision data table.
+      ->condition('field_test_node.entity.title', 'stage node 1')
+      // Check a condition on the revision table.
+      ->condition('field_test_node.entity.revision_uid', $node_1->getRevisionUserId())
+      // Check a condition on the data table.
+      ->condition('field_test_node.entity.type', $node_1->bundle())
+      // Check a condition on the base table.
+      ->condition('field_test_node.entity.uuid', $node_1->uuid());
+
+    // Add conditions for a reference to a different entity type.
+    $query
+      // Check a condition on the revision data table.
+      ->condition('field_test_entity.entity.name', 'stage entity_test_mulrev')
+      // Check a condition on the data table.
+      ->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());
+
+    $result = $query->execute();
+    $this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result);
+  }
+
+  /**
+   * Checks entity load, entity queries and views results for a test scenario.
+   *
+   * @param array $expected
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type that is being tested.
+   */
+  protected function assertWorkspaceStatus(array $expected, $entity_type_id) {
+    $expected = $this->flattenExpectedValues($expected, $entity_type_id);
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    foreach ($expected as $workspace_id => $expected_values) {
+      $this->switchToWorkspace($workspace_id);
+
+      // Check that default revisions are swapped with the workspace revision.
+      $this->assertEntityLoad($expected_values, $entity_type_id);
+
+      // Check that non-default revisions are not changed.
+      $this->assertEntityRevisionLoad($expected_values, $entity_type_id);
+
+      // Check that entity queries return the correct results.
+      $this->assertEntityQuery($expected_values, $entity_type_id);
+
+      // Check that the 'Frontpage' view only shows published content that is
+      // also considered as the default revision in the given workspace.
+      $expected_frontpage = array_filter($expected_values, function ($expected_value) {
+        return $expected_value['status'] === TRUE && $expected_value['default_revision'] === TRUE;
+      });
+      // The 'Frontpage' view will output nodes in reverse creation order.
+      usort($expected_frontpage, function ($a, $b) {
+        return $b['nid'] - $a['nid'];
+      });
+      $view = Views::getView('frontpage');
+      $view->execute();
+      $this->assertIdenticalResultset($view, $expected_frontpage, ['nid' => 'nid']);
+
+      $rendered_view = $view->render('page_1');
+      $output = \Drupal::service('renderer')->renderRoot($rendered_view);
+      $this->setRawContent($output);
+      foreach ($expected_values as $expected_entity_values) {
+        if ($expected_entity_values[$entity_keys['published']] === TRUE && $expected_entity_values['default_revision'] === TRUE) {
+          $this->assertRaw($expected_entity_values[$entity_keys['label']]);
+        }
+        // Node 4 will always appear in the 'stage' workspace because it has
+        // both an unpublished revision as well as a published one.
+        elseif ($workspace_id != 'stage' && $expected_entity_values[$entity_keys['id']] != 4) {
+          $this->assertNoRaw($expected_entity_values[$entity_keys['label']]);
+        }
+      }
+    }
+  }
+
+  /**
+   * Asserts that default revisions are properly swapped in a workspace.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityLoad(array $expected_values, $entity_type_id) {
+    // Filter the expected values so we can check only the default revisions.
+    $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+      return $expected_value['default_revision'] === TRUE;
+    });
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    // Check \Drupal\Core\Entity\EntityStorageInterface::loadMultiple().
+    /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+    $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple(array_column($expected_default_revisions, $id_key));
+    foreach ($expected_default_revisions as $expected_default_revision) {
+      $entity_id = $expected_default_revision[$id_key];
+      $this->assertEquals($expected_default_revision[$revision_key], $entities[$entity_id]->getRevisionId());
+      $this->assertEquals($expected_default_revision[$label_key], $entities[$entity_id]->label());
+      $this->assertEquals($expected_default_revision[$published_key], $entities[$entity_id]->isPublished());
+    }
+
+    // Check \Drupal\Core\Entity\EntityStorageInterface::loadUnchanged().
+    foreach ($expected_default_revisions as $expected_default_revision) {
+      /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+      $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadUnchanged($expected_default_revision[$id_key]);
+      $this->assertEquals($expected_default_revision[$revision_key], $entity->getRevisionId());
+      $this->assertEquals($expected_default_revision[$label_key], $entity->label());
+      $this->assertEquals($expected_default_revision[$published_key], $entity->isPublished());
+    }
+  }
+
+  /**
+   * Asserts that non-default revisions are not changed.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityRevisionLoad(array $expected_values, $entity_type_id) {
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+    $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_column($expected_values, $revision_key));
+    foreach ($expected_values as $expected_revision) {
+      $revision_id = $expected_revision[$revision_key];
+      $this->assertEquals($expected_revision[$id_key], $entities[$revision_id]->id());
+      $this->assertEquals($expected_revision[$revision_key], $entities[$revision_id]->getRevisionId());
+      $this->assertEquals($expected_revision[$label_key], $entities[$revision_id]->label());
+      $this->assertEquals($expected_revision[$published_key], $entities[$revision_id]->isPublished());
+    }
+  }
+
+  /**
+   * Asserts that entity queries are giving the correct results in a workspace.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityQuery(array $expected_values, $entity_type_id) {
+    $storage = $this->entityTypeManager->getStorage($entity_type_id);
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    // Filter the expected values so we can check only the default revisions.
+    $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+      return $expected_value['default_revision'] === TRUE;
+    });
+
+    // Check entity query counts.
+    $result = $storage->getQuery()->count()->execute();
+    $this->assertEquals(count($expected_default_revisions), $result);
+
+    $result = $storage->getAggregateQuery()->count()->execute();
+    $this->assertEquals(count($expected_default_revisions), $result);
+
+    // Check entity queries with no conditions.
+    $result = $storage->getQuery()->execute();
+    $expected_result = array_combine(array_column($expected_default_revisions, $revision_key), array_column($expected_default_revisions, $id_key));
+    $this->assertEquals($expected_result, $result);
+
+    // Check querying each revision individually.
+    foreach ($expected_values as $expected_value) {
+      $query = $storage->getQuery();
+      $query
+        ->condition($entity_keys['id'], $expected_value[$id_key])
+        ->condition($entity_keys['label'], $expected_value[$label_key])
+        ->condition($entity_keys['published'], $expected_value[$published_key]);
+
+      // If the entity is not expected to be the default revision, we need to
+      // query all revisions if we want to find it.
+      if (!$expected_value['default_revision']) {
+        $query->allRevisions();
+      }
+
+      $result = $query->execute();
+      $this->assertEquals([$expected_value[$revision_key] => $expected_value[$id_key]], $result);
+    }
+  }
+
+  /**
+   * Sets a given workspace as active.
+   *
+   * @param string $workspace_id
+   *   The ID of the workspace to switch to.
+   */
+  protected function switchToWorkspace($workspace_id) {
+    /** @var \Drupal\workspace\WorkspaceManager $workspace_manager */
+    $workspace_manager = \Drupal::service('workspace.manager');
+    if ($workspace_manager->getActiveWorkspace() !== $workspace_id) {
+      // Switch the test runner's context to the specified workspace.
+      $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+      \Drupal::service('workspace.manager')->setActiveWorkspace($workspace);
+    }
+  }
+
+  /**
+   * Flattens the expectations array defined by testWorkspaces().
+   *
+   * @param array $expected
+   *   An array as defined by testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type that is being tested.
+   *
+   * @return array
+   *   An array where all the entity IDs and revision IDs are merged inside each
+   *   expected values array.
+   */
+  protected function flattenExpectedValues(array $expected, $entity_type_id) {
+    $flattened = [];
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    foreach ($expected as $workspace_id => $workspace_values) {
+      foreach ($workspace_values as $entity_id => $entity_revisions) {
+        foreach ($entity_revisions as $revision_id => $revision_values) {
+          $flattened[$workspace_id][] = [$entity_keys['id'] => $entity_id, $entity_keys['revision'] => $revision_id] + $revision_values;
+        }
+      }
+    }
+
+    return $flattened;
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
new file mode 100644
index 0000000..08c14fc
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\rest\Entity\RestResourceConfig;
+use Drupal\rest\RestResourceConfigInterface;
+
+/**
+ * Tests REST module with internal workspace entity types.
+ *
+ * @group workspace
+ */
+class WorkspaceInternalResourceTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user', 'serialization', 'rest', 'workspace'];
+
+  /**
+   * Tests enabling content workspaces for REST throws an exception.
+   *
+   * @see workspace_rest_resource_alter()
+   */
+  public function testCreateContentWorkspaceResource() {
+    $this->setExpectedException(PluginNotFoundException::class, 'The "entity:content_workspace" plugin does not exist.');
+    RestResourceConfig::create([
+      'id' => 'entity.content_workspace',
+      'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+      'configuration' => [
+        'methods' => ['GET'],
+        'formats' => ['json'],
+        'authentication' => ['cookie'],
+      ],
+    ])
+      ->enable()
+      ->save();
+  }
+
+  /**
+   * Tests enabling replication logs for REST throws an exception.
+   *
+   * @see workspace_rest_resource_alter()
+   */
+  public function testCreateReplicationLogResource() {
+    $this->setExpectedException(PluginNotFoundException::class, 'The "entity:replication_log" plugin does not exist.');
+    RestResourceConfig::create([
+      'id' => 'entity.replication_log',
+      'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+      'configuration' => [
+        'methods' => ['GET'],
+        'formats' => ['json'],
+        'authentication' => ['cookie'],
+      ],
+    ])
+      ->enable()
+      ->save();
+  }
+
+}
diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml
new file mode 100644
index 0000000..9599e07
--- /dev/null
+++ b/core/modules/workspace/workspace.info.yml
@@ -0,0 +1,9 @@
+name: Workspace
+type: module
+description: 'Provides the ability to have multiple workspaces on a single site to facilitate things like full-site preview and content staging.'
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: entity.workspace.collection
+dependencies:
+ - user
diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install
new file mode 100644
index 0000000..0b3549e
--- /dev/null
+++ b/core/modules/workspace/workspace.install
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * @file
+ * Contains install, update and uninstall functions for the workspace module.
+ */
+
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\RepositoryHandlerInterface;
+
+/**
+ * Implements hook_install().
+ */
+function workspace_install() {
+  // Set the owner of these default workspaces to be first user which which has
+  // the 'administrator' role. This way we avoid hard coding user ID 1 for sites
+  // that prefer to not give it any special meaning.
+  $admin_roles = \Drupal::entityTypeManager()->getStorage('user_role')->getQuery()
+    ->condition('is_admin', TRUE)
+    ->execute();
+  if (!empty($admin_roles)) {
+    $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery()
+      ->condition('roles', $admin_roles, 'IN')
+      ->condition('status', 1)
+      ->sort('uid', 'ASC')
+      ->range(0, 1);
+    $result = $query->execute();
+  }
+
+  // Default to user ID 1 if we could not find any other administrator users.
+  $owner_id = !empty($result) ? reset($result) : 1;
+
+  // Create two workspaces by default, 'live' and 'stage'.
+  Workspace::create([
+    'id' => 'live',
+    'label' => 'Live',
+    'upstream' => RepositoryHandlerInterface::EMPTY_VALUE,
+    'uid' => $owner_id,
+  ])->save();
+
+  Workspace::create([
+    'id' => 'stage',
+    'label' => 'Stage',
+    'upstream' => 'local_workspace:live',
+    'uid' => $owner_id,
+  ])->save();
+}
diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml
new file mode 100644
index 0000000..c7aad42
--- /dev/null
+++ b/core/modules/workspace/workspace.libraries.yml
@@ -0,0 +1,5 @@
+drupal.workspace.toolbar:
+  version: VERSION
+  css:
+    theme:
+      css/workspace.toolbar.css: {}
diff --git a/core/modules/workspace/workspace.link_relation_types.yml b/core/modules/workspace/workspace.link_relation_types.yml
new file mode 100644
index 0000000..d591603
--- /dev/null
+++ b/core/modules/workspace/workspace.link_relation_types.yml
@@ -0,0 +1,8 @@
+# Workspace extension relation types.
+# See https://tools.ietf.org/html/rfc5988#section-4.2.
+activate-form:
+  uri: https://drupal.org/link-relations/activate-form
+  description: A form where a workspace can be activated.
+deploy-form:
+  uri: https://drupal.org/link-relations/deploy-form
+  description: A form where a workspace can be deployed.
diff --git a/core/modules/workspace/workspace.links.action.yml b/core/modules/workspace/workspace.links.action.yml
new file mode 100644
index 0000000..9f22598
--- /dev/null
+++ b/core/modules/workspace/workspace.links.action.yml
@@ -0,0 +1,5 @@
+entity.workspace.add_form:
+  route_name: entity.workspace.add_form
+  title: 'Add workspace'
+  appears_on:
+    - entity.workspace.collection
diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml
new file mode 100644
index 0000000..c7faefb
--- /dev/null
+++ b/core/modules/workspace/workspace.links.menu.yml
@@ -0,0 +1,5 @@
+entity.workspace.collection:
+  title: 'Workspaces'
+  parent: system.admin_config_workflow
+  description: 'Create and manage workspaces.'
+  route_name: entity.workspace.collection
diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module
new file mode 100644
index 0000000..b28590f
--- /dev/null
+++ b/core/modules/workspace/workspace.module
@@ -0,0 +1,685 @@
+<?php
+
+/**
+ * @file
+ * Provides full-site preview functionality for content staging.
+ */
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\views\Plugin\views\query\QueryPluginBase;
+use Drupal\views\Plugin\views\query\Sql;
+use Drupal\views\ViewExecutable;
+use Drupal\workspace\EntityAccess;
+use Drupal\workspace\WorkspaceManager;
+
+/**
+ * Implements hook_help().
+ */
+function workspace_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    // Main module help for the workspace module.
+    case 'help.page.workspace':
+      $output = '';
+      $output .= '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The Workspace module allows workspaces to be defined and switched between. Content is then assigned to the active workspace when created. For more information, see the <a href=":workspace">online documentation for the Workspace module</a>.', [':workspace' => 'https://www.drupal.org/node/2824024']) . '</p>';
+      return $output;
+  }
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function workspace_entity_load(array &$entities, $entity_type_id) {
+  /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+  $workspace_manager = \Drupal::service('workspace.manager');
+  $entity_type_manager = \Drupal::entityTypeManager();
+
+  // Don't alter the loaded entities if the entity type can not belong to a
+  // workspace.
+  if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity_type_manager->getDefinition($entity_type_id))) {
+    return;
+  }
+
+  // Don't alter the loaded entities if the active workspace is the default one.
+  $active_workspace = $workspace_manager->getActiveWorkspace();
+  if ($active_workspace == WorkspaceManager::DEFAULT_WORKSPACE) {
+    return;
+  }
+
+  // Get a list of revision IDs for entities that have a revision set for the
+  // current active workspace. If an entity has multiple revisions set for a
+  // workspace, only the one with the highest ID is returned.
+  $entity_ids = array_keys($entities);
+  $max_revision_id = 'max_content_entity_revision_id';
+  $results = $entity_type_manager
+    ->getStorage('content_workspace')
+    ->getAggregateQuery()
+    ->allRevisions()
+    ->aggregate('content_entity_revision_id', 'MAX', NULL, $max_revision_id)
+    ->groupBy('content_entity_id')
+    ->condition('content_entity_type_id', $entity_type_id)
+    ->condition('content_entity_id', $entity_ids, 'IN')
+    ->condition('workspace', $active_workspace, '=')
+    ->execute();
+
+  // Since hook_entity_load() is called on both regular entity load as well as
+  // entity revision load, we need to prevent infinite recursion by checking
+  // whether the default revisions were already swapped with the workspace
+  // revision.
+  // @todo This recursion protection should be removed when
+  //   https://www.drupal.org/project/drupal/issues/2928888 is resolved.
+  if ($results) {
+    foreach ($results as $key => $result) {
+      if ($entities[$result['content_entity_id']]->getRevisionId() == $result[$max_revision_id]) {
+        unset($results[$key]);
+      }
+    }
+  }
+
+  if ($results) {
+    /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
+    $storage = $entity_type_manager->getStorage($entity_type_id);
+
+    // Swap out every entity which has a revision set for the current active
+    // workspace.
+    $swap_revision_ids = array_column($results, $max_revision_id);
+    foreach ($storage->loadMultipleRevisions($swap_revision_ids) as $revision) {
+      $entities[$revision->id()] = $revision;
+    }
+  }
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function workspace_entity_presave(EntityInterface $entity) {
+  /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+  /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+  $workspace_manager = \Drupal::service('workspace.manager');
+
+  // Only run if the entity type can belong to a workspace and we are in a
+  // non-default workspace.
+  if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+     || $workspace_manager->getActiveWorkspace() === WorkspaceManager::DEFAULT_WORKSPACE) {
+    return;
+  }
+
+  // Force a new revision if the entity is not replicating.
+  if (!$entity->isNew() && !isset($entity->_isReplicating)) {
+    $entity->setNewRevision(TRUE);
+
+    // All entities in the non-default workspace are pending revisions,
+    // regardless of their publishing status. This means that when creating
+    // a published pending revision in a non-default workspace it will also be
+    // a published pending revision in the default workspace, however, it will
+    // become the default revision only when it is replicated to the default
+    // workspace.
+    $entity->isDefaultRevision(FALSE);
+  }
+
+  // When a new published entity is inserted in a non-default workspace, we
+  // actually want two revisions to be saved:
+  // - An unpublished default revision in the default ('live') workspace.
+  // - A published pending revision in the current workspace.
+  if ($entity->isNew() && $entity->isPublished()) {
+    // Keep track of the publishing status for workspace_entity_insert() and
+    // unpublish the default revision.
+    $entity->_initialPublished = TRUE;
+    $entity->setUnpublished();
+  }
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function workspace_entity_insert(EntityInterface $entity) {
+  /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+  /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+  $workspace_manager = \Drupal::service('workspace.manager');
+
+  // Only run if the entity type can belong to a workspace and we are in a
+  // non-default workspace.
+  if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+     || $workspace_manager->getActiveWorkspace() === WorkspaceManager::DEFAULT_WORKSPACE) {
+    return;
+  }
+
+  // Handle the case when a new published entity was created in a non-default
+  // workspace and create a published pending revision for it.
+  if (isset($entity->_initialPublished)) {
+    // Operate on a clone to avoid changing the entity prior to subsequent
+    // hook_entity_insert() implementations.
+    $pending_revision = clone $entity;
+    $pending_revision->setPublished();
+    $pending_revision->isDefaultRevision(FALSE);
+    $pending_revision->save();
+  }
+  else {
+    $workspace_manager->updateOrCreateFromEntity($entity);
+  }
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function workspace_entity_update(EntityInterface $entity) {
+  /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+  $workspace_manager = \Drupal::service('workspace.manager');
+
+  // Only run if the entity type can belong to a workspace and we are in a
+  // non-default workspace.
+  if (!$workspace_manager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+     || $workspace_manager->getActiveWorkspace() === WorkspaceManager::DEFAULT_WORKSPACE) {
+    return;
+  }
+
+  $workspace_manager->updateOrCreateFromEntity($entity);
+}
+
+/**
+ * Implements hook_entity_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityAccess::class)
+    ->entityOperationAccess($entity, $operation, $account);
+}
+
+/**
+ * Implements hook_entity_create_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_create_access(AccountInterface $account, array $context, $entity_bundle) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityAccess::class)
+    ->entityCreateAccess($account, $context, $entity_bundle);
+}
+
+/**
+ * Implements hook_views_query_alter().
+ */
+function workspace_views_query_alter(ViewExecutable $view, QueryPluginBase $query) {
+  /** @var \Drupal\workspace\WorkspaceManagerInterface $workspace_manager */
+  $workspace_manager = \Drupal::service('workspace.manager');
+
+  // Don't alter any views queries if we're in the default workspace.
+  $active_workspace = $workspace_manager->getActiveWorkspace();
+  if ($active_workspace == WorkspaceManager::DEFAULT_WORKSPACE) {
+    return;
+  }
+
+  // Don't alter any non-sql views queries.
+  if (!$query instanceof Sql) {
+    return;
+  }
+
+  /** @var \Drupal\views\ViewsData $views_data */
+  $views_data = \Drupal::service('views.views_data');
+
+  // Find out what entity types are represented in this query.
+  $entity_type_ids = [];
+  /** @var \Drupal\views\Plugin\views\query\Sql $query */
+  foreach ($query->relationships as $info) {
+    $table_data = $views_data->get($info['base']);
+    if (empty($table_data['table']['entity type'])) {
+      continue;
+    }
+    $entity_type_id = $table_data['table']['entity type'];
+    // This construct ensures each entity type exists only once.
+    $entity_type_ids[$entity_type_id] = $entity_type_id;
+  }
+
+  $entity_type_definitions = \Drupal::entityTypeManager()->getDefinitions();
+  foreach ($entity_type_ids as $entity_type_id) {
+    if ($workspace_manager->entityTypeCanBelongToWorkspaces($entity_type_definitions[$entity_type_id])) {
+      _workspace_views_query_alter_entity_type($view, $query, $entity_type_definitions[$entity_type_id]);
+    }
+  }
+}
+
+/**
+ * Alters the entity type tables for a Views query.
+ *
+ * @param \Drupal\views\ViewExecutable $view
+ *   The view object about to be processed.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ *   The query plugin object for the query.
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ *   The entity type definition.
+ *
+ * @internal
+ */
+function _workspace_views_query_alter_entity_type(ViewExecutable $view, Sql $query, EntityTypeInterface $entity_type) {
+  // This is only called after we determined that this entity type is involved
+  // in the query, and that a non-default workspace is in use.
+  /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
+  $table_mapping = \Drupal::entityTypeManager()->getStorage($entity_type->id())->getTableMapping();
+  $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($entity_type->id());
+  $dedicated_field_storage_definitions = array_filter($field_storage_definitions, function ($definition) use ($table_mapping) {
+    return $table_mapping->requiresDedicatedTableStorage($definition);
+  });
+  $dedicated_field_data_tables = array_map(function ($definition) use ($table_mapping) {
+    return $table_mapping->getDedicatedDataTableName($definition);
+  }, $dedicated_field_storage_definitions);
+
+  $move_workspace_tables = [];
+  foreach ($query->tableQueue as $alias => &$table_info) {
+    // If we reach the content_workspace array item before any candidates, then
+    // we do not need to move it.
+    if ($table_info['table'] == 'content_workspace') {
+      break;
+    }
+
+    // Any dedicated field table is a candidate.
+    if ($field_name = array_search($table_info['table'], $dedicated_field_data_tables, TRUE)) {
+      $relationship = $table_info['relationship'];
+
+      // There can be reverse relationships used. If so, Workspace can't do
+      // anything with them. Detect this and skip.
+      if ($table_info['join']->field != 'entity_id') {
+        continue;
+      }
+
+      // Get the dedicated revision table name.
+      $new_table_name = $table_mapping->getDedicatedRevisionTableName($field_storage_definitions[$field_name]);
+
+      // Now add the content_workspace table.
+      $content_workspace_table = _workspace_ensure_content_workspace_table($entity_type->id(), $query, $relationship);
+
+      // Update the join to use our COALESCE.
+      $revision_field = $entity_type->getKey('revision');
+      $table_info['join']->leftTable = NULL;
+      $table_info['join']->leftField = "COALESCE($content_workspace_table.content_entity_revision_id, $relationship.$revision_field)";
+
+      // Update the join and the table info to our new table name, and to join
+      // on the revision key.
+      $table_info['table'] = $new_table_name;
+      $table_info['join']->table = $new_table_name;
+      $table_info['join']->field = 'revision_id';
+
+      // Finally, if we added the content_workspace table we have to move it in
+      // the table queue so that it comes before this field.
+      if (empty($move_workspace_tables[$content_workspace_table])) {
+        $move_workspace_tables[$content_workspace_table] = $alias;
+      }
+    }
+  }
+
+  // JOINs must be in order. i.e, any tables you mention in the ON clause of a
+  // JOIN must appear prior to that JOIN. Since we're modifying a JOIN in place,
+  // and adding a new table, we must ensure that the new table appears prior to
+  // this one. So we recorded at what index we saw that table, and then use
+  // array_splice() to move the content_workspace table join to the correct
+  // position.
+  foreach ($move_workspace_tables as $content_workspace_table => $alias) {
+    _workspace_move_entity_table($query, $content_workspace_table, $alias);
+  }
+
+  $base_entity_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable();
+
+  $base_fields = array_diff($table_mapping->getFieldNames($entity_type->getBaseTable()), [$entity_type->getKey('langcode')]);
+  $revisionable_fields = array_diff($table_mapping->getFieldNames($entity_type->getRevisionDataTable()), $base_fields);
+
+  // Go through and look to see if we have to modify fields and filters.
+  foreach ($query->fields as &$field_info) {
+    // Some fields don't actually have tables, meaning they're formulae and
+    // whatnot. At this time we are going to ignore those.
+    if (empty($field_info['table'])) {
+      continue;
+    }
+
+    // Dereference the alias into the actual table.
+    $table = $query->tableQueue[$field_info['table']]['table'];
+    if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
+      $relationship = $query->tableQueue[$field_info['table']]['alias'];
+      $alias = _workspace_ensure_revision_table($entity_type, $query, $relationship);
+      if ($alias) {
+        // Change the base table to use the revision table instead.
+        $field_info['table'] = $alias;
+      }
+    }
+  }
+
+  $relationships = [];
+  // Build a list of all relationships that might be for our table.
+  foreach ($query->relationships as $relationship => $info) {
+    if ($info['base'] == $base_entity_table) {
+      $relationships[] = $relationship;
+    }
+  }
+
+  // Now we have to go through our where clauses and modify any of our fields.
+  foreach ($query->where as &$clauses) {
+    foreach ($clauses['conditions'] as &$where_info) {
+      // Build a matrix of our possible relationships against fields we need to
+      // switch.
+      foreach ($relationships as $relationship) {
+        foreach ($revisionable_fields as $field) {
+          if (is_string($where_info['field']) && $where_info['field'] == "$relationship.$field") {
+            $alias = _workspace_ensure_revision_table($entity_type, $query, $relationship);
+            if ($alias) {
+              // Change the base table to use the revision table instead.
+              $where_info['field'] = "$alias.$field";
+            }
+          }
+        }
+      }
+    }
+  }
+
+  // @todo Handle $query->orderby, $query->groupby, $query->having, $query->count_field
+}
+
+/**
+ * Adds the 'content_workspace' table to a views query.
+ *
+ * @param string $entity_type_id
+ *   The ID of the entity type to join.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ *   The query plugin object for the query.
+ * @param string $relationship
+ *   The primary table alias this table is related to.
+ *
+ * @return string
+ *   The alias of the 'content_workspace' table.
+ *
+ * @internal
+ */
+function _workspace_ensure_content_workspace_table($entity_type_id, Sql $query, $relationship) {
+  if (isset($query->tables[$relationship]['content_workspace'])) {
+    return $query->tables[$relationship]['content_workspace']['alias'];
+  }
+
+  $table_data = \Drupal::service('views.views_data')->get($query->relationships[$relationship]['base']);
+
+  // Construct the join.
+  $definition = [
+    'table' => 'content_workspace',
+    'field' => 'content_entity_id',
+    'left_table' => $relationship,
+    'left_field' => $table_data['table']['base']['field'],
+    'extra' => [
+      [
+        'field' => 'content_entity_type_id',
+        'value' => $entity_type_id,
+      ],
+      [
+        'field' => 'workspace',
+        'value' => \Drupal::service('workspace.manager')->getActiveWorkspace(),
+      ],
+    ],
+    'type' => 'LEFT',
+  ];
+
+  $join = \Drupal::service('plugin.manager.views.join')->createInstance('standard', $definition);
+  $join->adjusted = TRUE;
+
+  return $query->queueTable('content_workspace', $relationship, $join);
+}
+
+/**
+ * Adds the revision table of an entity type to a query object.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ *   The entity type definition.
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ *   The query plugin object for the query.
+ * @param string $relationship
+ *   The name of the relationship.
+ *
+ * @return string
+ *   The alias of the relationship.
+ *
+ * @internal
+ */
+function _workspace_ensure_revision_table(EntityTypeInterface $entity_type, Sql $query, $relationship) {
+  // Get the alias for the 'content_workspace' table we chain off of in the
+  // COALESCE.
+  $content_workspace_table = _workspace_ensure_content_workspace_table($entity_type->id(), $query, $relationship);
+
+  // Get the name of the revision table and revision key.
+  $base_revision_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
+  $revision_field = $entity_type->getKey('revision');
+
+  // If the table was already added and has a join against the same field on
+  // the revision table, reuse that rather than adding a new join.
+  if (isset($query->tables[$relationship][$base_revision_table])) {
+    $alias = $query->tables[$relationship][$base_revision_table]['alias'];
+    if (isset($query->tableQueue[$alias]['join']->field) && $query->tableQueue[$alias]['join']->field == $revision_field) {
+      // If this table previously existed, but was not added by us, we need
+      // to modify the join and make sure that 'content_workspace' comes first.
+      if (empty($query->tableQueue[$alias]['join']->workspace_adjusted)) {
+        $query->tableQueue[$alias]['join'] = _workspace_get_revision_table_join($relationship, $base_revision_table, $revision_field, $content_workspace_table);
+        // We also have to ensure that our 'content_workspace' comes before
+        // this.
+        _workspace_move_entity_table($query, $content_workspace_table, $alias);
+      }
+
+      return $alias;
+    }
+  }
+
+  // Construct a new join.
+  $join = _workspace_get_revision_table_join($relationship, $base_revision_table, $revision_field, $content_workspace_table);
+  return $query->queueTable($base_revision_table, $relationship, $join);
+}
+
+/**
+ * Fetches a join for a revision table using the 'content_workspace' table.
+ *
+ * @param string $relationship
+ *   The relationship to use in the view.
+ * @param string $table
+ *   The table name.
+ * @param string $field
+ *   The field to join on.
+ * @param string $content_workspace_table
+ *   The alias of the 'content_workspace' table joined to the main entity table.
+ *
+ * @return \Drupal\views\Plugin\views\join\JoinPluginInterface
+ *   An adjusted views join object to add to the query.
+ *
+ * @internal
+ */
+function _workspace_get_revision_table_join($relationship, $table, $field, $content_workspace_table) {
+  $definition = [
+    'table' => $table,
+    'field' => $field,
+    // Making this explicitly null allows the left table to be a formula.
+    'left_table' => NULL,
+    'left_field' => "COALESCE($content_workspace_table.content_entity_revision_id, $relationship.$field)",
+  ];
+
+  $join = \Drupal::service('plugin.manager.views.join')->createInstance('standard', $definition);
+  $join->adjusted = TRUE;
+  $join->workspace_adjusted = TRUE;
+
+  return $join;
+}
+
+/**
+ * Moves a 'content_workspace' table to appear before the given alias.
+ *
+ * Because Workspace chains possibly pre-existing tables onto the
+ * 'content_workspace' table, we have to ensure that the 'content_workspace'
+ * table appears in the query before the alias it's chained on or the SQL is
+ * invalid. This uses array_slice() to reconstruct the table queue of the query.
+ *
+ * @param \Drupal\views\Plugin\views\query\Sql $query
+ *   The SQL query object.
+ * @param string $content_workspace_table
+ *   The alias of the 'content_workspace' table.
+ * @param string $alias
+ *   The alias of the table it needs to appear before.
+ *
+ * @internal
+ */
+function _workspace_move_entity_table(Sql $query, $content_workspace_table, $alias) {
+  $keys = array_keys($query->tableQueue);
+  $current_index = array_search($content_workspace_table, $keys);
+  $index = array_search($alias, $keys);
+
+  // If it's already before our table, we don't need to move it, as we could
+  // accidentally move it forward.
+  if ($current_index < $index) {
+    return;
+  }
+  $splice = [$content_workspace_table => $query->tableQueue[$content_workspace_table]];
+  unset($query->tableQueue[$content_workspace_table]);
+
+  // Now move the item to the proper location in the array. Don't use
+  // array_splice() because that breaks indices.
+  $query->tableQueue = array_slice($query->tableQueue, 0, $index, TRUE) +
+    $splice +
+    array_slice($query->tableQueue, $index, NULL, TRUE);
+}
+
+/**
+ * Implements hook_rest_resource_alter().
+ */
+function workspace_rest_resource_alter(&$definitions) {
+  // ContentWorkspace and ReplicationLog are internal entity types, therefore
+  // they should not be exposed via REST.
+  unset($definitions['entity:content_workspace']);
+  unset($definitions['entity:replication_log']);
+}
+
+/**
+ * Implements hook_toolbar().
+ */
+function workspace_toolbar() {
+  $items = [];
+
+  /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+  $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(TRUE);
+
+  // When the module is uninstalled, there is no active workspace anymore.
+  if (!$active_workspace) {
+    return $items;
+  }
+
+  $items['workspace'] = [
+    '#cache' => [
+      'contexts' => [
+        'user.permissions',
+      ],
+    ],
+  ];
+
+  $current_user = \Drupal::currentUser();
+  if (!$current_user->hasPermission('administer workspaces')
+    || !$current_user->hasPermission('view own workspace')
+    || !$current_user->hasPermission('view any workspace')) {
+    return $items;
+  }
+
+  $configure_link = NULL;
+  if ($current_user->hasPermission('administer workspaces')) {
+    $configure_link = [
+      '#type' => 'link',
+      '#title' => t('Manage workspaces'),
+      '#url' => $active_workspace->toUrl('collection'),
+      '#options' => ['attributes' => ['class' => ['manage-workspaces']]],
+    ];
+  }
+
+  $items['workspace'] = [
+    '#type' => 'toolbar_item',
+    'tab' => [
+      '#type' => 'link',
+      '#title' => $active_workspace->label(),
+      '#url' => $active_workspace->toUrl('collection'),
+      '#attributes' => [
+        'title' => t('Switch workspace'),
+        'class' => ['toolbar-icon', 'toolbar-icon-workspace'],
+      ],
+    ],
+    'tray' => [
+      '#heading' => t('Workspaces'),
+      'workspaces' => workspace_renderable_links(),
+      'configure' => $configure_link,
+    ],
+    '#wrapper_attributes' => [
+      'class' => ['workspace-toolbar-tab'],
+    ],
+    '#attached' => [
+      'library' => ['workspace/drupal.workspace.toolbar'],
+    ],
+    '#weight' => 500,
+  ];
+
+  // Add a special class to the wrapper if we are in the default workspace so we
+  // can highlight it with a different color.
+  if ($active_workspace->id() === WorkspaceManager::DEFAULT_WORKSPACE) {
+    $items['workspace']['#wrapper_attributes']['class'][] = 'is-live';
+  }
+
+  return $items;
+}
+
+/**
+ * Returns an array of workspace activation form links, suitable for rendering.
+ *
+ * @return array
+ *   A render array containing links to the workspace activation form.
+ */
+function workspace_renderable_links() {
+  $entity_type_manager = \Drupal::entityTypeManager();
+  /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+  $entity_repository = \Drupal::service('entity.repository');
+  /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+  $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace(TRUE);
+
+  $links = $cache_tags = [];
+  foreach ($entity_type_manager->getStorage('workspace')->loadMultiple() as $workspace) {
+    $workspace = $entity_repository->getTranslationFromContext($workspace);
+
+    // Add the 'is-active' class for the currently active workspace.
+    $options = [];
+    if ($workspace->id() === $active_workspace->id()) {
+      $options['attributes']['class'][] = 'is-active';
+    }
+
+    // Get the URL of the workspace activation form and display it in a modal.
+    $url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $workspace->id()], $options);
+    if ($url->access()) {
+      $links[$workspace->id()] = [
+        'type' => 'link',
+        'title' => $workspace->label(),
+        'url' => $url,
+        'attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'modal',
+          'data-dialog-options' => Json::encode([
+            'width' => 500,
+          ]),
+        ],
+      ];
+      $cache_tags = Cache::mergeTags($cache_tags, $workspace->getCacheTags());
+    }
+  }
+
+  if (!empty($links)) {
+    $links = [
+      '#theme' => 'links__toolbar_workspaces',
+      '#links' => $links,
+      '#attributes' => [
+        'class' => ['toolbar-menu'],
+      ],
+      '#cache' => [
+        'tags' => $cache_tags,
+      ],
+    ];
+  }
+
+  return $links;
+}
diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml
new file mode 100644
index 0000000..36d21aa
--- /dev/null
+++ b/core/modules/workspace/workspace.permissions.yml
@@ -0,0 +1,28 @@
+administer workspaces:
+  title: Administer workspaces
+
+create workspace:
+  title: Create a new workspace
+
+view own workspace:
+  title: View own workspace
+
+view any workspace:
+  title: View any workspace
+
+edit own workspace:
+  title: Edit own workspace
+
+edit any workspace:
+  title: Edit any workspace
+
+bypass entity access own workspace:
+  title: Bypass content entity access in own workspace
+  description: Allow all Edit/Update/Delete permissions for all content entities in a workspace owned by the user.
+  restrict access: TRUE
+
+update any workspace from upstream:
+  title: Update any workspace from upstream
+
+permission_callbacks:
+  - Drupal\workspace\EntityAccess::workspacePermissions
diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml
new file mode 100644
index 0000000..2226501
--- /dev/null
+++ b/core/modules/workspace/workspace.routing.yml
@@ -0,0 +1,27 @@
+entity.workspace.collection:
+  path: '/admin/config/workflow/workspace'
+  defaults:
+    _title: 'Workspaces'
+    _entity_list: 'workspace'
+  requirements:
+    _permission: 'administer workspaces+edit any workspace'
+
+entity.workspace.activate_form:
+  path: '/admin/config/workflow/workspace/{workspace}/activate'
+  defaults:
+    _entity_form: 'workspace.activate'
+    _title: 'Activate Workspace'
+  options:
+    _admin_route: TRUE
+  requirements:
+    _entity_access: 'workspace.view'
+
+entity.workspace.deploy_form:
+  path: '/admin/config/workflow/workspace/{workspace}/deploy'
+  defaults:
+    _entity_form: 'workspace.deploy'
+    _title: 'Deploy Workspace'
+  options:
+    _admin_route: TRUE
+  requirements:
+    _permission: 'administer workspaces'
diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml
new file mode 100644
index 0000000..3118ee4
--- /dev/null
+++ b/core/modules/workspace/workspace.services.yml
@@ -0,0 +1,26 @@
+services:
+  workspace.manager:
+    class: Drupal\workspace\WorkspaceManager
+    arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@logger.channel.workspace', '@class_resolver']
+    tags:
+      - { name: service_id_collector, tag: workspace_negotiator }
+  plugin.manager.workspace.repository_handler:
+    class: Drupal\workspace\RepositoryHandlerManager
+    parent: default_plugin_manager
+  workspace.negotiator.default:
+    class: Drupal\workspace\Negotiator\DefaultWorkspaceNegotiator
+    tags:
+      - { name: workspace_negotiator, priority: 0 }
+  workspace.negotiator.session:
+    class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator
+    arguments: ['@current_user', '@user.private_tempstore']
+    tags:
+      - { name: workspace_negotiator, priority: 100 }
+  cache_context.workspace:
+    class: Drupal\workspace\WorkspaceCacheContext
+    arguments: ['@workspace.manager']
+    tags:
+      - { name: cache.context }
+  logger.channel.workspace:
+    parent: logger.channel_base
+    arguments: ['cron']
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php
index b3af27f..8545efe 100644
--- a/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityAccessControlHandlerTest.php
@@ -8,6 +8,7 @@
 use Drupal\Core\Entity\EntityAccessControlHandler;
 use Drupal\Core\Session\AnonymousUserSession;
 use Drupal\entity_test\Entity\EntityTest;
+use Drupal\entity_test\Entity\EntityTestStringId;
 use Drupal\entity_test\Entity\EntityTestDefaultAccess;
 use Drupal\entity_test\Entity\EntityTestNoUuid;
 use Drupal\entity_test\Entity\EntityTestLabel;
@@ -18,6 +19,7 @@
 /**
  * Tests the entity access control handler.
  *
+ * @coversDefaultClass \Drupal\Core\Entity\EntityAccessControlHandler
  * @group Entity
  */
 class EntityAccessControlHandlerTest extends EntityLanguageTestBase {
@@ -30,6 +32,7 @@ public function setUp() {
 
     $this->installEntitySchema('entity_test_no_uuid');
     $this->installEntitySchema('entity_test_rev');
+    $this->installEntitySchema('entity_test_string_id');
   }
 
   /**
@@ -293,4 +296,73 @@ public function testHooks() {
     $this->assertEqual($state->get('entity_test_entity_test_access'), TRUE);
   }
 
+  /**
+   * Tests the default access handling for the ID and UUID fields.
+   *
+   * @covers ::fieldAccess
+   * @dataProvider providerTestFieldAccess
+   */
+  public function testFieldAccess($entity_class, array $entity_create_values, $expected_id_create_access) {
+    // Set up a non-admin user that is allowed to create and update test
+    // entities.
+    \Drupal::currentUser()->setAccount($this->createUser(['uid' => 2], ['administer entity_test content']));
+
+    // Create the entity to test field access with.
+    $entity = $entity_class::create($entity_create_values);
+
+    // On newly-created entities, field access must allow setting the UUID
+    // field.
+    $this->assertTrue($entity->get('uuid')->access('edit'));
+    $this->assertTrue($entity->get('uuid')->access('edit', NULL, TRUE)->isAllowed());
+    // On newly-created entities, field access will not allow setting the ID
+    // field if the ID is of type serial. It will allow access if it is of type
+    // string.
+    $this->assertEquals($expected_id_create_access, $entity->get('id')->access('edit'));
+    $this->assertEquals($expected_id_create_access, $entity->get('id')->access('edit', NULL, TRUE)->isAllowed());
+
+    // Save the entity and check that we can not update the ID or UUID fields
+    // anymore.
+    $entity->save();
+
+    // If the ID has been set as part of the create ensure it has been set
+    // correctly.
+    if (isset($entity_create_values['id'])) {
+      $this->assertSame($entity_create_values['id'], $entity->id());
+    }
+    // The UUID is hard-coded by the data provider.
+    $this->assertSame('60e3a179-79ed-4653-ad52-5e614c8e8fbe', $entity->uuid());
+    $this->assertFalse($entity->get('uuid')->access('edit'));
+    $access_result = $entity->get('uuid')->access('edit', NULL, TRUE);
+    $this->assertTrue($access_result->isForbidden());
+    $this->assertEquals('The entity UUID cannot be changed', $access_result->getReason());
+
+    // Ensure the ID is still not allowed to be edited.
+    $this->assertFalse($entity->get('id')->access('edit'));
+    $access_result = $entity->get('id')->access('edit', NULL, TRUE);
+    $this->assertTrue($access_result->isForbidden());
+    $this->assertEquals('The entity ID cannot be changed', $access_result->getReason());
+  }
+
+  public function providerTestFieldAccess() {
+    return [
+      'serial ID entity' => [
+        EntityTest::class,
+        [
+          'name' => 'A test entity',
+          'uuid' => '60e3a179-79ed-4653-ad52-5e614c8e8fbe',
+        ],
+        FALSE
+      ],
+      'string ID entity' => [
+        EntityTestStringId::class,
+        [
+          'id' => 'a_test_entity',
+          'name' => 'A test entity',
+          'uuid' => '60e3a179-79ed-4653-ad52-5e614c8e8fbe',
+        ],
+        TRUE
+      ],
+    ];
+  }
+
 }
