Follow up to #2849827: Move workflow "settings" setters and getters to WorkflowTypeInterface.

Problem/Motivation

Currently we have typical entity access for workflows, with the addition of "delete-state:foo". This is useful because for content_moderation, we don't want users to be able to delete states which are required for integrity.

After #2849827: Move workflow "settings" setters and getters to WorkflowTypeInterface, we delegated the responsibility of all state/transition storage to the workflow type plugins, essentially freeing types to be able to introduces states and transitions which weren't necessarily based on configuration within the workflow entity. Now states and transitions can be based on any 3rd party configuration or even predefined (days of the week? drupal user roles? commerce checkout panes? etc.).

Based on this, is there more scope to expand the workflow entity access system to encompass "edit-state:foo", "edit-transition:foo" and "delete-transition:foo". If I'm providing a workflow type with states based on something external to the workflows entity, restricting "edit" to states will likely make a lot of sense. To see this in action:

1. "drush en workflow_type_test -y"
2. Create a workflow of type "Predefined States Workflow Test Type"
3. See "edit" buttons which doesn't make sense, based on the states being totally pre-determined.

Proposed resolution

Expand the access controls.

Remaining tasks

Patch.

User interface changes

API changes

Data model changes

Comments

Sam152 created an issue. See original summary.

sam152’s picture

I think this would count as api additions, so I don't think it should be a blocker for stable.

sam152’s picture

Issue summary: View changes
sam152’s picture

Issue tags: +Workflow Initiative
sam152’s picture

Component: content_moderation.module » workflows.module
Issue tags: -workflows.module
sam152’s picture

Priority: Normal » Major

Version: 8.4.x-dev » 8.5.x-dev

Drupal 8.4.0-alpha1 will be released the week of July 31, 2017, which means new developments and disruptive changes should now be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

sam152’s picture

It would be good to confirm that adding extra access operations is an API addition only. If that's the case, this can safely be done in 8.5.

wim leers’s picture

So you're saying that:

  1. we're confident that the custom access operation delete-state:foo will continue to exist
  2. we may choose to add more custom access operations: edit-state:foo, delete-transition:foo, edit-transition:foo

IOW: we're confident that we will only ever add more access operations.

If my understanding is correct, then yes: this would be a pure API addition.

sam152’s picture

Picking this back up, am I correct in saying based on #2300677: JSON:API POST/PATCH support for fully validatable config entities not being completed, introducing a more fleshed out permissions model for states and transitions would currently only need to apply access control at the UI level? Given we don't have any special handling for REST and the delete-state:foo opperation, I think that sounds right.

sam152’s picture

Status: Active » Needs review
StatusFileSize
new24.47 KB

Here is what I had in mind for this.

Status: Needs review » Needs work

The last submitted patch, 11: 2896726-state-transition-access-11.patch, failed testing. View results

sam152’s picture

Status: Needs work » Needs review
StatusFileSize
new381 bytes
new24.51 KB
amateescu’s picture

+++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
@@ -0,0 +1,70 @@
+   * The value of '_workflow_access' is used to check to kind of access that
...
+   * a workflow is required. The '_workflow_access' requirement translates into
...
+    $workflow_operation = $route_match->getRouteObject()->getRequirement('_workflows_access');

The documentation says '_workflow_access' but the code uses '_workflows_access' :)

FWIW, I like '_workflow_access' better, I don't see why we would need to match the name of the module instead of the workflow entity itself.

sam152’s picture

Status: Needs review » Needs work

I agree, _workflow_access does seem nicer. NW for that.

timmillwood’s picture

Status: Needs work » Needs review
StatusFileSize
new6.39 KB
new24.5 KB

Changed all instances of _workflows_access to _workflow_access.

In doing so noticed the docblock for \Drupal\workflow_type_test\Plugin\WorkflowType\WorkflowCustomAccessType::workflowAccess was pointing to workflow_type_test_workflows_access when the function in workflow_type_test.module is workflow_type_test_workflow_access.

sam152’s picture

Good catch! This is looking good to me :)

amateescu’s picture

  1. +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/WorkflowCustomAccessType.php
    @@ -0,0 +1,69 @@
    +        'cant_update' => [
    +          'label' => 'Cant Update State',
    ...
    +        'cant_delete' => [
    +          'label' => 'Cant Delete State',
    ...
    +        'cant_update' => [
    +          'label' => 'Cant Update Transition',
    +          'to' => 'cant_update',
    ...
    +            'cant_update',
    ...
    +        'cant_delete' => [
    +          'label' => 'Cant Delete Transition',
    +          'to' => 'cant_delete',
    ...
    +            'cant_delete',
    

    Maybe "cant" => "can_not" is better english here? :P

    Also, do we need to test the positive checks as well? (e.g. "can update", "can update transition", etc.)

  2. +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/WorkflowCustomAccessType.php
    @@ -0,0 +1,69 @@
    +   * Implements hook_ENTITY_TYPE_access().
    
    +++ b/core/modules/workflows/tests/modules/workflow_type_test/workflow_type_test.module
    @@ -26,3 +31,13 @@ function workflow_type_test_set_definitions($definitions) {
    + * Implements hook_ENTITY_TYPE_access().
    

    Can we add " for the Workflow entity type" here to make it more clear that we're only doing it for one entity type?

  3. +++ b/core/modules/workflows/tests/src/Functional/WorkflowCustomStateTransitionAccessTest.php
    @@ -0,0 +1,89 @@
    +   * Modules to enable.
    +   *
    +   * @var array
    

    This can be just {@inheritdoc}.

sam152’s picture

  1. We test the list of paths in the positive and negative here.
    +++ b/core/modules/workflows/tests/src/Functional/WorkflowCustomStateTransitionAccessTest.php
    @@ -0,0 +1,89 @@
    +    $forbidden_paths = [
    +      'admin/config/workflow/workflows/manage/test_type/state/cant_delete/delete',
    +      'admin/config/workflow/workflows/manage/test_type/state/cant_update',
    +      'admin/config/workflow/workflows/manage/test_type/transition/cant_update',
    +      'admin/config/workflow/workflows/manage/test_type/transition/cant_delete/delete',
    +      'admin/config/workflow/workflows/manage/test_type/add_state',
    +      'admin/config/workflow/workflows/manage/test_type/add_transition',
    +    ];
    +    // Until the list of forbidden operations have been set, the admin user
    +    // should be able to access all the forbidden paths.
    +    foreach ($forbidden_paths as $forbidden_path) {
    +      $this->drupalGet($forbidden_path);
    +      $this->assertSession()->statusCodeEquals(200);
    +    }
    +
    +    // Update the forbidden operations which deny access to the actions
    +    // represented by the above paths.
    +    $this->container->get('state')->set('workflow_type_test_forbidden_operations', [
    +      'update-state:cant_update',
    +      'delete-state:cant_delete',
    +      'update-transition:cant_update',
    +      'delete-transition:cant_delete',
    +      'add-state',
    +      'add-transition',
    +    ]);
    +    foreach ($forbidden_paths as $forbidden_path) {
    +      $this->drupalGet($forbidden_path);
    +      $this->assertSession()->statusCodeEquals(403);
    +    }
    
  2. Good point.
  3. Didn't know you could do this for props.
sam152’s picture

StatusFileSize
new1.4 KB
new24.5 KB
sam152’s picture

StatusFileSize
new3.77 KB
new24.56 KB

Whoops, missed 1.1. This is what happens when two bits of feedback are put in the one bullet point ;)

amateescu’s picture

Status: Needs review » Reviewed & tested by the community

Awesome, looks great now :)

jibran’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record

Found some nits other than that patch looks great.

  1. +++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
    @@ -0,0 +1,70 @@
    +      return $parameters->get('workflow')->access($workflow_operation, $account, TRUE);
    ...
    +    if ($workflow = $parameters->get('workflow') && $type = $parameters->get(sprintf('workflow_%s', $matches['type']))) {
    +      return $parameters->get('workflow')->access(sprintf('%s:%s', $workflow_operation, $type), $account, TRUE);
    

    Can we please store workflow in local var?

  2. +++ b/core/modules/workflows/workflows.routing.yml
    @@ -44,4 +44,4 @@ entity.workflow.delete_transition_form:
       requirements:
    -    _entity_access: 'workflow.edit'
    +    _workflow_access: 'delete-transition'
    

    Can we have a change notice for this please?

sam152’s picture

Status: Needs work » Needs review
StatusFileSize
new1.31 KB
new24.52 KB

Local variable does make this a bit more readable.

I think a change record could mostly be pulled from the docs in the patch, I don't have time to put one together today, but can look at it later in the week.

sam152’s picture

Title: Review the entity access model for workflow states and transitions. » Expand the entity access model for workflow states and transitions.
jibran’s picture

Thanks, for addressing the feedback.

+++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
@@ -0,0 +1,71 @@
+    if ($workflow && $matches['operation'] === 'add') {
...
+    if ($workflow && $type = $parameters->get(sprintf('workflow_%s', $matches['type']))) {

The first condition of if statement is same do you think we should make it nested?

wim leers’s picture

  1. +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/WorkflowCustomAccessType.php
    @@ -0,0 +1,69 @@
    +        'can_not_update' => [
    +          'label' => 'Can Not Update State',
    

    Supernit: I think it's "cannot", not "can not"? #nonnativespeaker

  2. +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/WorkflowCustomAccessType.php
    @@ -0,0 +1,69 @@
    +    if (in_array($operation, $forbidden_operations)) {
    +      return AccessResult::forbidden();
    +    }
    +    return AccessResult::neutral();
    

    Supernit: (about both strictness and legibility)

    return in_array($operation, $forbidden_operations, TRUE)
      ? AccessResult::forbidden()
      : AccessResult::neutral();
    
  3. +++ b/core/modules/workflows/tests/src/Functional/WorkflowCustomStateTransitionAccessTest.php
    @@ -0,0 +1,87 @@
    +  public function testCustomWorkflowAccessOperations() {
    

    What's interesting here, is that despite us making changes it to the stored "state", to change the list of forbidden operations, the 200s turn into 403s automatically, without clearing any caches.

    How is that possible?

  4. +++ b/core/modules/workflows/workflows.services.yml
    @@ -4,7 +4,7 @@ services:
    -      - { name: access_check, applies_to: _workflow_state_delete_access }
    +      - { name: access_check, applies_to: _workflow_access }
    

    What about custom routes using this access checker? Why are we able to not provide BC?

sam152’s picture

Thanks for the review!

  • #26.1, not a fan of the extra nesting.
  • #27.1 it seems you are correct, good catch.
  • #27.2, this format looks good.
  • #27.3, I'll put a debugger in there sometime this week and figure out why they are uncacheable.
  • #27.4, the WorkflowDeleteAccessCheck class itself is marked @internal.
wim leers’s picture

#28 👍, and #27.4: 👌

sam152’s picture

StatusFileSize
new4.35 KB
new24.49 KB

Addressing the code feedback from #27.

For some reason I thought EntityAccessControlHandler had its own caching, but it's just a static cache. I think then I'll have to look at the whole response object, see why it's not cacheable.

sam152’s picture

I started looking into #27.3. My findings were as follows:

The request to these pages were uncacheable for the following reasons:

  1. The CSRF token in the form builder adds a high cardinality cache context, "session".
  2. It also adds max-age 0 when rendering the token.
  3. The DenyAdminRoutes cache response policy prevents these pages from being cached too.

I implemented the following in an attempt to make the page cacheable:

diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php
index be000dcc25..51ef531189 100644
--- a/core/lib/Drupal/Core/Form/FormBuilder.php
+++ b/core/lib/Drupal/Core/Form/FormBuilder.php
@@ -661,11 +661,11 @@ public function renderPlaceholderFormAction() {
   public function renderFormTokenPlaceholder($placeholder) {
     return [
       '#markup' => $this->csrfToken->get($placeholder),
-      '#cache' => [
-        'contexts' => [
-          'session',
-        ],
-      ],
+//      '#cache' => [
+//        'contexts' => [
+//          'session',
+//        ],
+//      ],
     ];
   }
 
@@ -778,9 +778,9 @@ public function prepareForm($form_id, &$form, FormStateInterface &$form_state) {
               ]
             ]
           ],
-          '#cache' => [
-            'max-age' => 0,
-          ],
+//          '#cache' => [
+//            'max-age' => 0,
+//          ],
         ];
       }
     }
diff --git a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
index 241999233d..7a7136b026 100644
--- a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
+++ b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
@@ -21,9 +21,9 @@ services:
     tags:
       - { name: service_collector, tag: dynamic_page_cache_response_policy, call: addPolicy}
     lazy: true
-  dynamic_page_cache_deny_admin_routes:
-    class: Drupal\dynamic_page_cache\PageCache\ResponsePolicy\DenyAdminRoutes
-    arguments: ['@current_route_match']
-    public: false
-    tags:
-      - { name: dynamic_page_cache_response_policy }
+#  dynamic_page_cache_deny_admin_routes:
+#    class: Drupal\dynamic_page_cache\PageCache\ResponsePolicy\DenyAdminRoutes
+#    arguments: ['@current_route_match']
+#    public: false
+#    tags:
+#      - { name: dynamic_page_cache_response_policy }

This created a cache entry in cache_dynamic_page_cache:

mysql> select cid from test93734581cache_dynamic_page_cache where data LIKE '%Are you sure%';
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| cid                                                                                                                                                                                                                                                             |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| response:[languages:language_interface]=en:[request_format]=html:[route]=entity.workflow.delete_state_formd76ce31db3e6bd858bccb75b8d0f9eef2f51d7caef3f0de1bf0b0476ea295fe3:[theme]=classy:[url.query_args:_wrapper_fnQw7_zLurFxypKu7-AN1Yn-LVVlx638uaJlXebLYP6M |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

Then in \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::onRouteMatch I expected this to be delivered on the second hit. Stepping into render cache from here, into \Drupal\Core\Render\RenderCache::get the cid used to lookup the cache hit was the following:

response:[request_format]=html:[route]=system.40335786c7117b4e38d0f169239752ce71158266ae2f6e4aa230fbbb87bd699c0e3

This obviously doesn't match the entry in the DB, so not sure why it was unable to derive the correct cache ID. I figured that it was possibly due to the fact this is an unconventional code path, ie, the safeguards I commented out probably exist for a reason and undoing them is undefined behaviour. Possibly a bug in render cache or dynamic_page_cache too?

At this point I decided to conclude my adventure, I hope you'll understand :P

sam152’s picture

@Wim Leers any chance of a sign off on the cacheability part? I think we're back to RTBC if that's all good.

sam152’s picture

Issue tags: -Needs change record

Added the change record here: https://www.drupal.org/node/2929327

jibran’s picture

Patch looks ready to me. Just some minor nits.

  1. +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/WorkflowCustomAccessType.php
    @@ -0,0 +1,68 @@
    +    $forbidden_operations = \Drupal::state()->get('workflow_type_test_forbidden_operations', []);
    

    This can be injected.

  2. +++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
    @@ -0,0 +1,71 @@
    +    $workflow = $parameters->get('workflow');
    ...
    +      return $workflow->access($workflow_operation, $account, TRUE);
    ...
    +      return $workflow->access(sprintf('%s:%s', $workflow_operation, $type), $account, TRUE);
    ...
    +    return AccessResult::neutral();
    

    I think we should add this to cache context as well.

  3. +++ b/core/modules/workflows/tests/src/Functional/WorkflowCustomStateTransitionAccessTest.php
    @@ -0,0 +1,87 @@
    +      $this->assertSession()->statusCodeEquals(200);
    ...
    +      $this->assertSession()->statusCodeEquals(403);
    

    We can avoid calling assertSession() again and again in the loop if we'll create a local variable.

sam152’s picture

1. I tried this and it made the test plugin more confusing because of the naming conflict between state storage and workflow states.
2. I think the entity access system will add all the contexts required to cache these properly. What the new test cases indicate is that nothing beyond the users permissions are currently being used to make a decision about access, so an access check for update-state:foo could be cached for all entities. This isn't the case for some of the existing operations like delete-state:foo. It checks things like the required states, number of states etc, so the Workflow entity is added as a cacheable dependency. As far as caching the access checks per 'route' context, this must be implied somewhere else, given it's not something \Drupal\Core\Entity\EntityAccessCheck::access adds either. Not sure where though.
3. This is a style thing and one that isn't widely accepted. 100 instances of \$(.*)= \$this->assertSession(), 500 instances of $this->assertSession()->.

sam152’s picture

Anyone interested in reviewing this again? I believe all the points have been addressed.

Version: 8.5.x-dev » 8.6.x-dev

Drupal 8.5.0-alpha1 will be released the week of January 17, 2018, which means new developments and disruptive changes should now be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

sam152’s picture

Status: Needs review » Reviewed & tested by the community

Tentatively back to RTBC given mostly nits were fixed in #30 and I think I've addressed most of the feedback.

larowlan’s picture

Status: Reviewed & tested by the community » Needs review

@Wim Leers any chance of a sign off on the cacheability part? I think we're back to RTBC if that's all good

Did that ever happen?

+++ /dev/null
@@ -1,57 +0,0 @@
-class WorkflowDeleteAccessCheck implements AccessInterface {

Although this can be removed, because it is internal - can we use this as a BC shim around the new access check and mark it deprecated instead?

See https://www.drupal.org/core/deprecation#internal

sam152’s picture

StatusFileSize
new6.21 KB
new27.59 KB

Thanks for taking the time to review this again @larowlan. I've restored the original access check as a BC shim with deprecations.

Re: signing off, I wasn't able to get in touch with @Wim Leers for a thumbs up however I did a deep dive into why our test didn't require any cache clears in #31.

timmillwood’s picture

@Sam152 - #41 looks good, and I would RTBC, but I guess we should get a review from @Wim Leers first.

wim leers’s picture

#32, in December 2017, or >3 months ago…:

@Wim Leers any chance of a sign off on the cacheability part? I think we're back to RTBC if that's all good.

I'm so sorry :( I never saw these notifications!


#31: of course, admin routes aren't cached by Dynamic Page Cache. Sorry for not having thought of that before asking my question.


Also re-reviewed the patch:

  1. +++ b/core/modules/workflows/src/WorkflowDeleteAccessCheck.php
    @@ -2,56 +2,48 @@
    +    @trigger_error('Using the _workflow_state_delete_access check is deprecated in Drupal 8.6.0 and will be removed before Drupal 9.0.0, use _workflow_access instead. As an internal API _workflow_state_delete_access may also be removed in a minor release.', E_USER_DEPRECATED);
    

    ❤️

  2. +++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
    @@ -0,0 +1,84 @@
    +   * is required in the route for the access check to be applied. For add, only
    

    "For add"?

  3. +++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
    @@ -0,0 +1,84 @@
    +    if (!preg_match('/^(?<operation>add|update|delete)-(?<type>state|transition)$/', $workflow_operation, $matches)) {
    

    Woah, TIL! That's capturing + labeling the matches, right?

  4. +++ b/core/modules/workflows/workflows.routing.yml
    @@ -4,7 +4,7 @@ entity.workflow.add_state_form:
    -    _entity_access: 'workflow.edit'
    +    _workflow_access: 'add-state'
    

    Did we ever considering doing

    _entity_access:
     'workflow.edit:add-state'

    ? i.e. custom entity operations? That's totally a supported thing!

That last question is the only reason I'm not moving this to RTBC just yet. Point 2 is a nit.

sam152’s picture

StatusFileSize
new1.18 KB
new27.6 KB

Thanks for looking at this again @Wim Leers.

  1. Woohoo!
  2. Tried to clear this up.
  3. Yep, named captured groups are the best!
  4. Using _workflow_access ends up translating into a custom entity operation, it's just a case of if _workflow_access or _entity_access manages that for us. Personally even if both can do the job, I think it's nice to be able to use 'add-state' alongside 'update-state' and 'delete-state' which _entity_access can't do for us.
sam152’s picture

Does anyone have a strong opinion that _workflow_access should not support "add" operations alongside update and delete if the equivalent also works with _entity_access? Personally I think it reads better from a consistency perspective and it's not a large amount of additional code to maintain.

amateescu’s picture

Status: Needs review » Reviewed & tested by the community

Personally I think it reads better from a consistency perspective and it's not a large amount of additional code to maintain.

I agree, so the patch looks ready to me :)

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 43: 2896726-43.patch, failed testing. View results

tacituseu’s picture

Status: Needs work » Reviewed & tested by the community

Unrelated failure.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

larowlan’s picture

Adding review credits

larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Fixed on commit

diff --git a/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
index a33915ca86..33b378cfee 100644
--- a/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
+++ b/core/modules/workflows/src/WorkflowStateTransitionOperationsAccessCheck.php
@@ -48,7 +48,7 @@ class WorkflowStateTransitionOperationsAccessCheck implements AccessInterface {
    *   An access result.
    *
    * @throws \Exception
-   *  Throws an exception when a route is defined with an invalid operation.
+   *   Throws an exception when a route is defined with an invalid operation.
    */
   public function access(RouteMatchInterface $route_match, AccountInterface $account) {
     $workflow_operation = $this->getOperation($route_match);

Committed 5b224a4 and pushed to 8.7.x. Thanks!

Published change record.

  • larowlan committed 5b224a4 on 8.7.x
    Issue #2896726 by Sam152, timmillwood, Wim Leers, amateescu, jibran,...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.