summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAlex Pott2016-06-25 13:33:37 (GMT)
committerAlex Pott2016-06-25 13:33:37 (GMT)
commit5987069eea84a9b4fda2fbad95b81cfa61c79f97 (patch)
tree83277778e633126b7c5b4e8c71cc7be9ded16966
parent765aa9e70614293f2279fb5e3c8ceba8afcba90a (diff)
Issue #2228141 by juampynr, eiriksm, dawehner, almaudoh, lokapujya, mohit_aghera, clemens.tolboom, manojapare, Lendude, Sonal.Sangale, Wim Leers, alexpott, damiankloip, andypost: Add authentication support to REST views
-rw-r--r--core/modules/rest/config/schema/rest.views.schema.yml7
-rw-r--r--core/modules/rest/rest.install24
-rw-r--r--core/modules/rest/src/Plugin/views/display/RestExport.php95
-rw-r--r--core/modules/rest/src/Tests/Views/StyleSerializerTest.php35
-rw-r--r--core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml23
-rw-r--r--core/modules/rest/tests/src/Unit/CollectRoutesTest.php16
-rw-r--r--core/modules/views/src/Tests/Update/RestExportAuthUpdateTest.php36
-rw-r--r--core/modules/views/tests/fixtures/update/rest-export-with-authentication.php75
-rw-r--r--core/modules/views/tests/modules/views_test_config/test_views/views.view.rest_export_with_authorization.yml216
9 files changed, 523 insertions, 4 deletions
diff --git a/core/modules/rest/config/schema/rest.views.schema.yml b/core/modules/rest/config/schema/rest.views.schema.yml
index a914081..040187a 100644
--- a/core/modules/rest/config/schema/rest.views.schema.yml
+++ b/core/modules/rest/config/schema/rest.views.schema.yml
@@ -3,6 +3,13 @@
views.display.rest_export:
type: views_display_path
label: 'REST display options'
+ mapping:
+ auth:
+ type: sequence
+ label: 'Authentication'
+ sequence:
+ type: string
+ label: 'Authentication Provider'
views.row.data_field:
type: views_row
diff --git a/core/modules/rest/rest.install b/core/modules/rest/rest.install
index 601206d..918b4f5 100644
--- a/core/modules/rest/rest.install
+++ b/core/modules/rest/rest.install
@@ -40,5 +40,29 @@ function rest_update_8201() {
}
/**
+ * Re-save all views with a REST display to add new auth defaults.
+ */
+function rest_update_8202() {
+ $config_factory = \Drupal::configFactory();
+ foreach ($config_factory->listAll('views.view.') as $view_config_name) {
+ $save = FALSE;
+ $view = $config_factory->getEditable($view_config_name);
+ $displays = $view->get('display');
+ foreach ($displays as $display_name => &$display) {
+ if ($display['display_plugin'] == 'rest_export') {
+ if (!isset($display['display_options']['auth'])) {
+ $display['display_options']['auth'] = [];
+ $save = TRUE;
+ }
+ }
+ }
+ if ($save) {
+ $view->set('display', $displays);
+ $view->save(TRUE);
+ }
+ }
+}
+
+/**
* @} End of "defgroup updates-8.1.x-to-8.2.x".
*/
diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php
index fb58513..cf5e232 100644
--- a/core/modules/rest/src/Plugin/views/display/RestExport.php
+++ b/core/modules/rest/src/Plugin/views/display/RestExport.php
@@ -4,6 +4,7 @@ namespace Drupal\rest\Plugin\views\display;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
+use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteProviderInterface;
@@ -78,6 +79,20 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
protected $renderer;
/**
+ * The collector of authentication providers.
+ *
+ * @var \Drupal\Core\Authentication\AuthenticationCollectorInterface
+ */
+ protected $authenticationCollector;
+
+ /**
+ * The authentication providers, keyed by ID.
+ *
+ * @var string[]
+ */
+ protected $authenticationProviders;
+
+ /**
* Constructs a RestExport object.
*
* @param array $configuration
@@ -92,11 +107,14 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
* The state key value store.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
+ * @param string[] $authentication_providers
+ * The authentication providers, keyed by ID.
*/
- public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer) {
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer, array $authentication_providers) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $route_provider, $state);
$this->renderer = $renderer;
+ $this->authenticationProviders = $authentication_providers;
}
/**
@@ -109,7 +127,9 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
$plugin_definition,
$container->get('router.route_provider'),
$container->get('state'),
- $container->get('renderer')
+ $container->get('renderer'),
+ $container->getParameter('authentication_providers')
+
);
}
/**
@@ -200,11 +220,24 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
}
/**
+ * Gets the auth options available.
+ *
+ * @return string[]
+ * An array to use as value for "#options" in the form element.
+ */
+ public function getAuthOptions() {
+ return array_combine($this->authenticationProviders, $this->authenticationProviders);
+ }
+
+ /**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
+ // Options for REST authentication.
+ $options['auth'] = ['default' => []];
+
// Set the default style plugin to 'json'.
$options['style']['contains']['type']['default'] = 'serializer';
$options['row']['contains']['type']['default'] = 'data_entity';
@@ -225,6 +258,9 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
public function optionsSummary(&$categories, &$options) {
parent::optionsSummary($categories, $options);
+ // Authentication.
+ $auth = $this->getOption('auth') ? implode(', ', $this->getOption('auth')) : $this->t('No authentication is set');
+
unset($categories['page'], $categories['exposed']);
// Hide some settings, as they aren't useful for pure data output.
unset($options['show_admin_links'], $options['analyze-theme']);
@@ -239,6 +275,11 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
$options['path']['category'] = 'path';
$options['path']['title'] = $this->t('Path');
+ $options['auth'] = array(
+ 'category' => 'path',
+ 'title' => $this->t('Authentication'),
+ 'value' => views_ui_truncate($auth, 24),
+ );
// Remove css/exposed form settings, as they are not used for the data
// display.
@@ -250,6 +291,34 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
/**
* {@inheritdoc}
*/
+ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
+ parent::buildOptionsForm($form, $form_state);
+ if ($form_state->get('section') === 'auth') {
+ $form['#title'] .= $this->t('The supported authentication methods for this view');
+ $form['auth'] = array(
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Authentication methods'),
+ '#description' => $this->t('These are the supported authentication providers for this view. When this view is requested, the client will be forced to authenticate with one of the selected providers. Make sure you set the appropiate requirements at the <em>Access</em> section since the Authentication System will fallback to the anonymous user if it fails to authenticate. For example: require Access: Role | Authenticated User.'),
+ '#options' => $this->getAuthOptions(),
+ '#default_value' => $this->getOption('auth'),
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitOptionsForm(&$form, FormStateInterface $form_state) {
+ parent::submitOptionsForm($form, $form_state);
+
+ if ($form_state->get('section') == 'auth') {
+ $this->setOption('auth', array_keys(array_filter($form_state->getValue('auth'))));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
public function collectRoutes(RouteCollection $collection) {
parent::collectRoutes($collection);
$view_id = $this->view->storage->id();
@@ -268,6 +337,13 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
// anyway.
$route->setRequirement('_format', implode('|', $formats + ['html']));
}
+ // Add authentication to the route if it was set. If no authentication was
+ // set, the default authentication will be used, which is cookie based by
+ // default.
+ $auth = $this->getOption('auth');
+ if (!empty($auth)) {
+ $route->setOption('_auth', $auth);
+ }
}
}
@@ -348,4 +424,19 @@ class RestExport extends PathPluginBase implements ResponseDisplayPluginInterfac
return $this->view->render();
}
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ $dependencies = parent::calculateDependencies();
+
+ $dependencies += ['module' => []];
+ $modules = array_map(function ($authentication_provider) {
+ return $this->authenticationProviders[$authentication_provider];
+ }, $this->getOption('auth'));
+ $dependencies['module'] = array_merge($dependencies['module'], $modules);
+
+ return $dependencies;
+ }
+
}
diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
index 2350899..50e0e74 100644
--- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
+++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
@@ -40,7 +40,7 @@ class StyleSerializerTest extends PluginTestBase {
*
* @var array
*/
- public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field', 'language');
+ public static $modules = array('views_ui', 'entity_test', 'hal', 'rest_test_views', 'node', 'text', 'field', 'language', 'basic_auth');
/**
* Views used by this test.
@@ -70,6 +70,39 @@ class StyleSerializerTest extends PluginTestBase {
}
/**
+ * Checks that the auth options restricts access to a REST views display.
+ */
+ public function testRestViewsAuthentication() {
+ // Assume the view is hidden behind a permission.
+ $this->drupalGetWithFormat('test/serialize/auth_with_perm', 'json');
+ $this->assertResponse(401);
+
+ // Not even logging in would make it possible to see the view, because then
+ // we are denied based on authentication method (cookie).
+ $this->drupalLogin($this->adminUser);
+ $this->drupalGetWithFormat('test/serialize/auth_with_perm', 'json');
+ $this->assertResponse(403);
+ $this->drupalLogout();
+
+ // But if we use the basic auth authentication strategy, we should be able
+ // to see the page.
+ $url = $this->buildUrl('test/serialize/auth_with_perm');
+ $response = \Drupal::httpClient()->get($url, [
+ 'auth' => [$this->adminUser->getUsername(), $this->adminUser->pass_raw],
+ ]);
+
+ // Ensure that any changes to variables in the other thread are picked up.
+ $this->refreshVariables();
+
+ $headers = $response->getHeaders();
+ $this->verbose('GET request to: ' . $url .
+ '<hr />Code: ' . curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE) .
+ '<hr />Response headers: ' . nl2br(print_r($headers, TRUE)) .
+ '<hr />Response body: ' . (string) $response->getBody());
+ $this->assertResponse(200);
+ }
+
+ /**
* Checks the behavior of the Serializer callback paths and row plugins.
*/
public function testSerializerResponses() {
diff --git a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml
index 34133a5..4a09bbd 100644
--- a/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml
+++ b/core/modules/rest/tests/modules/rest_test_views/test_views/views.view.test_serializer_node_display_field.yml
@@ -27,7 +27,7 @@ display:
access:
type: perm
options:
- perm: 'access content'
+ perm: 'administer views'
cache:
type: tag
query:
@@ -149,3 +149,24 @@ display:
type: serializer
row:
type: data_field
+
+ rest_export_2:
+ display_plugin: rest_export
+ id: rest_export_2
+ display_title: 'REST export 2'
+ position: 2
+ display_options:
+ display_extenders: { }
+ auth:
+ basic_auth: basic_auth
+ path: test/serialize/auth_with_perm
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - request_format
+ - 'user.node_grants:view'
+ - user.permissions
+ tags:
+ - 'config:field.storage.node.body'
diff --git a/core/modules/rest/tests/src/Unit/CollectRoutesTest.php b/core/modules/rest/tests/src/Unit/CollectRoutesTest.php
index 53ebbd5..c55ea3b 100644
--- a/core/modules/rest/tests/src/Unit/CollectRoutesTest.php
+++ b/core/modules/rest/tests/src/Unit/CollectRoutesTest.php
@@ -67,6 +67,8 @@ class CollectRoutesTest extends UnitTestCase {
->getMock();
$container->set('router.route_provider', $route_provider);
+ $container->setParameter('authentication_providers', ['basic_auth' => 'basic_auth']);
+
$state = $this->getMock('\Drupal\Core\State\StateInterface');
$container->set('state', $state);
@@ -76,6 +78,12 @@ class CollectRoutesTest extends UnitTestCase {
$container->set('plugin.manager.views.style', $style_manager);
$container->set('renderer', $this->getMock('Drupal\Core\Render\RendererInterface'));
+ $authentication_collector = $this->getMock('\Drupal\Core\Authentication\AuthenticationCollectorInterface');
+ $container->set('authentication_collector', $authentication_collector);
+ $authentication_collector->expects($this->any())
+ ->method('getSortedProviders')
+ ->will($this->returnValue(['basic_auth' => 'data', 'cookie' => 'data']));
+
\Drupal::setContainer($container);
$this->restExport = RestExport::create($container, array(), "test_routes", array());
@@ -87,6 +95,9 @@ class CollectRoutesTest extends UnitTestCase {
// Set the style option.
$this->restExport->setOption('style', array('type' => 'serializer'));
+ // Set the auth option.
+ $this->restExport->setOption('auth', ['basic_auth']);
+
$display_manager->expects($this->once())
->method('getDefinition')
->will($this->returnValue(array('id' => 'test', 'provider' => 'test')));
@@ -132,6 +143,11 @@ class CollectRoutesTest extends UnitTestCase {
$this->assertEquals(count($requirements_1), 0, 'First route has no requirement.');
$this->assertEquals(count($requirements_2), 2, 'Views route with rest export had the format and method requirements added.');
+
+ // Check auth options.
+ $auth = $this->routes->get('view.test_view.page_1')->getOption('_auth');
+ $this->assertEquals(count($auth), 1, 'View route with rest export has an auth option added');
+ $this->assertEquals($auth[0], 'basic_auth', 'View route with rest export has the correct auth option added');
}
}
diff --git a/core/modules/views/src/Tests/Update/RestExportAuthUpdateTest.php b/core/modules/views/src/Tests/Update/RestExportAuthUpdateTest.php
new file mode 100644
index 0000000..4f3c0c6
--- /dev/null
+++ b/core/modules/views/src/Tests/Update/RestExportAuthUpdateTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\views\Tests\Update;
+
+use Drupal\system\Tests\Update\UpdatePathTestBase;
+
+/**
+ * Ensures that update hook is run properly for REST Export config.
+ *
+ * @group Update
+ */
+class RestExportAuthUpdateTest extends UpdatePathTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setDatabaseDumpFiles() {
+ $this->databaseDumpFiles = [
+ __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz',
+ __DIR__ . '/../../../tests/fixtures/update/rest-export-with-authentication.php',
+ ];
+ }
+
+ /**
+ * Ensures that update hook is run for views module.
+ */
+ public function testUpdate() {
+ $this->runUpdates();
+
+ // Get particular view.
+ $view = \Drupal::entityTypeManager()->getStorage('view')->load('rest_export_with_authorization');
+ $displays = $view->get('display');
+ $this->assertIdentical($displays['rest_export_1']['display_options']['auth']['basic_auth'], 'basic_auth', 'Basic authentication is set as authentication method.');
+ }
+
+}
diff --git a/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php b/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php
new file mode 100644
index 0000000..8215cdc
--- /dev/null
+++ b/core/modules/views/tests/fixtures/update/rest-export-with-authentication.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * @file
+ * Test fixture for \Drupal\views\Tests\Update\RestExportAuthUpdateTest.
+ */
+
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+$config = $connection;
+
+// Set the schema version.
+$connection->insert('key_value')
+ ->fields([
+ 'collection' => 'system.schema',
+ 'name' => 'rest',
+ 'value' => 'i:8000;',
+ ])
+ ->fields([
+ 'collection' => 'system.schema',
+ 'name' => 'serialization',
+ 'value' => 'i:8000;',
+ ])
+ ->fields([
+ 'collection' => 'system.schema',
+ 'name' => 'basic_auth',
+ 'value' => 'i:8000;',
+ ])
+ ->execute();
+
+// Update core.extension.
+$extensions = $connection->select('config')
+ ->fields('config', ['data'])
+ ->condition('collection', '')
+ ->condition('name', 'core.extension')
+ ->execute()
+ ->fetchField();
+$extensions = unserialize($extensions);
+$extensions['module']['rest'] = 0;
+$extensions['module']['serialization'] = 0;
+$extensions['module']['basic_auth'] = 0;
+$connection->update('config')
+ ->fields([
+ 'data' => serialize($extensions),
+ ])
+ ->condition('collection', '')
+ ->condition('name', 'core.extension')
+ ->execute();
+
+$config = [
+ 'link_domain' => '~',
+];
+$data = $connection->insert('config')
+ ->fields([
+ 'name' => 'rest.settings',
+ 'data' => serialize($config),
+ 'collection' => ''
+ ])
+ ->execute();
+
+$connection->insert('config')
+ ->fields([
+ 'name' => 'views.view.rest_export_with_authorization',
+ ])
+ ->execute();
+
+$connection->merge('config')
+ ->condition('name', 'views.view.rest_export_with_authorization')
+ ->condition('collection', '')
+ ->fields([
+ 'data' => serialize(Yaml::decode(file_get_contents('core/modules/views/tests/modules/views_test_config/test_views/views.view.rest_export_with_authorization.yml')))
+ ])
+ ->execute();
diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.rest_export_with_authorization.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.rest_export_with_authorization.yml
new file mode 100644
index 0000000..16ab4b9
--- /dev/null
+++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.rest_export_with_authorization.yml
@@ -0,0 +1,216 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - core.entity_view_mode.node.teaser
+ - user.role.authenticated
+ module:
+ - node
+ - rest
+ - user
+id: rest_export_with_authorization
+label: 'Rest Export'
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+core: 8.x
+display:
+ default:
+ display_plugin: default
+ id: default
+ display_title: Master
+ position: 0
+ display_options:
+ access:
+ type: role
+ options:
+ role:
+ authenticated: authenticated
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_comment: ''
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Apply
+ reset_button: false
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: full
+ options:
+ items_per_page: 10
+ offset: 0
+ id: 0
+ total_pages: null
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ tags:
+ previous: '‹ Previous'
+ next: 'Next ›'
+ first: '« First'
+ last: 'Last »'
+ quantity: 9
+ style:
+ type: default
+ row:
+ type: 'entity:node'
+ options:
+ view_mode: teaser
+ fields:
+ title:
+ id: title
+ table: node_field_data
+ field: title
+ entity_type: node
+ entity_field: title
+ label: ''
+ alter:
+ alter_text: false
+ make_link: false
+ absolute: false
+ trim: false
+ word_boundary: false
+ ellipsis: false
+ strip_tags: false
+ html: false
+ hide_empty: false
+ empty_zero: false
+ settings:
+ link_to_entity: true
+ plugin_id: field
+ relationship: none
+ group_type: group
+ admin_label: ''
+ exclude: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: true
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ filters:
+ status:
+ id: status
+ table: node_field_data
+ field: status
+ relationship: none
+ group_type: group
+ admin_label: ''
+ operator: '='
+ value: false
+ group: 1
+ exposed: false
+ expose:
+ operator_id: ''
+ label: ''
+ description: ''
+ use_operator: false
+ operator: ''
+ identifier: ''
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ is_grouped: false
+ group_info:
+ label: ''
+ description: ''
+ identifier: ''
+ optional: true
+ widget: select
+ multiple: false
+ remember: false
+ default_group: All
+ default_group_multiple: { }
+ group_items: { }
+ plugin_id: boolean
+ entity_type: node
+ entity_field: status
+ sorts:
+ created:
+ id: created
+ table: node_field_data
+ field: created
+ order: DESC
+ entity_type: node
+ entity_field: created
+ plugin_id: date
+ relationship: none
+ group_type: group
+ admin_label: ''
+ exposed: false
+ expose:
+ label: ''
+ granularity: second
+ title: 'Rest Export'
+ header: { }
+ footer: { }
+ empty: { }
+ relationships: { }
+ arguments: { }
+ display_extenders: { }
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - url.query_args
+ - 'user.node_grants:view'
+ - user.roles
+ tags: { }
+ rest_export_1:
+ display_plugin: rest_export
+ id: rest_export_1
+ display_title: 'REST export'
+ position: 2
+ display_options:
+ display_extenders: { }
+ path: unpublished-content
+ auth:
+ basic_auth: basic_auth
+ cache_metadata:
+ max-age: -1
+ contexts:
+ - 'languages:language_content'
+ - 'languages:language_interface'
+ - request_format
+ - 'user.node_grants:view'
+ - user.roles
+ tags: { }