diff --git a/web/modules/custom/do_content_api/do_content_api.services.yml b/web/modules/custom/do_content_api/do_content_api.services.yml index 8a49d7ae..5748dc30 100644 --- a/web/modules/custom/do_content_api/do_content_api.services.yml +++ b/web/modules/custom/do_content_api/do_content_api.services.yml @@ -3,6 +3,9 @@ services: class: Drupal\do_content_api\Routing\RouteSubscriber tags: - { name: event_subscriber } + Drupal\do_content_api\Hook\EntityCreateAccessHook: + arguments: + - '@request_stack' Drupal\do_content_api\Hook\ModerationPolicyHook: arguments: - '@current_user' diff --git a/web/modules/custom/do_content_api/src/Hook/EntityCreateAccessHook.php b/web/modules/custom/do_content_api/src/Hook/EntityCreateAccessHook.php index af4bea55..a01d965e 100644 --- a/web/modules/custom/do_content_api/src/Hook/EntityCreateAccessHook.php +++ b/web/modules/custom/do_content_api/src/Hook/EntityCreateAccessHook.php @@ -8,12 +8,17 @@ use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Grants paragraph create access to authorised content-authoring clients. */ final class EntityCreateAccessHook { + public function __construct( + protected RequestStack $requestStack, + ) {} + /** * Paragraph bundles that may be created through the authoring API. */ @@ -45,22 +50,29 @@ public function entityCreateAccess(AccountInterface $account, array $context, ?s return AccessResult::neutral(); } + // The paragraphs access handler already grants create access on HTML + // requests, and a forbidden result here would override it for every role + // that bypasses permission checks and so matches the gate below. + if ($this->requestStack->getCurrentRequest()?->getRequestFormat() === 'html') { + return AccessResult::neutral()->addCacheContexts(['request_format']); + } + // Editorial users keep the stock paragraphs access behaviour. if (!$account->hasPermission('use content authoring api')) { - return AccessResult::neutral()->cachePerPermissions(); + return AccessResult::neutral()->cachePerPermissions()->addCacheContexts(['request_format']); } // A bundle-less capability check gets no opinion. if ($entity_bundle === NULL) { - return AccessResult::neutral()->cachePerPermissions(); + return AccessResult::neutral()->cachePerPermissions()->addCacheContexts(['request_format']); } // The paragraphs access handler returns neutral for every non-HTML request // format. Restore create access for the allow-listed bundles and explicitly // deny the rest so no other handler can widen the authoring surface. return in_array($entity_bundle, self::ALLOWED_PARAGRAPH_BUNDLES, TRUE) - ? AccessResult::allowed()->cachePerPermissions() - : AccessResult::forbidden()->cachePerPermissions(); + ? AccessResult::allowed()->cachePerPermissions()->addCacheContexts(['request_format']) + : AccessResult::forbidden()->cachePerPermissions()->addCacheContexts(['request_format']); } } diff --git a/web/modules/custom/do_content_api/tests/src/Unit/Hook/EntityCreateAccessHookTest.php b/web/modules/custom/do_content_api/tests/src/Unit/Hook/EntityCreateAccessHookTest.php index e2259fde..e60259ee 100644 --- a/web/modules/custom/do_content_api/tests/src/Unit/Hook/EntityCreateAccessHookTest.php +++ b/web/modules/custom/do_content_api/tests/src/Unit/Hook/EntityCreateAccessHookTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** * Tests for EntityCreateAccessHook. @@ -22,11 +24,19 @@ class EntityCreateAccessHookTest extends UnitTestCase { * Tests paragraph create-access decisions. */ #[DataProvider('dataProviderEntityCreateAccess')] - public function testEntityCreateAccess(array $context, bool $has_permission, ?string $bundle, string $expected_state): void { + public function testEntityCreateAccess(array $context, bool $has_permission, ?string $bundle, ?string $request_format, string $expected_state): void { // Prepare. $account = $this->createMock(AccountInterface::class); $account->method('hasPermission')->with('use content authoring api')->willReturn($has_permission); - $hook = new EntityCreateAccessHook(); + $request_stack = new RequestStack(); + + if ($request_format !== NULL) { + $request = Request::create('/'); + $request->setRequestFormat($request_format); + $request_stack->push($request); + } + + $hook = new EntityCreateAccessHook($request_stack); // Act. $result = $hook->entityCreateAccess($account, $context, $bundle); @@ -41,13 +51,16 @@ public function testEntityCreateAccess(array $context, bool $has_permission, ?st * Data provider for testEntityCreateAccess(). */ public static function dataProviderEntityCreateAccess(): \Iterator { - yield 'non-paragraph entity type is ignored' => [['entity_type_id' => 'node'], TRUE, 'civictheme_content', 'neutral']; - yield 'missing entity type id is ignored' => [[], TRUE, 'civictheme_content', 'neutral']; - yield 'permitted user, allowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_content', 'allowed']; - yield 'permitted user, nested allowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_accordion_panel', 'allowed']; - yield 'permitted user, disallowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_event_card_ref', 'forbidden']; - yield 'unpermitted user, allowed bundle' => [['entity_type_id' => 'paragraph'], FALSE, 'civictheme_content', 'neutral']; - yield 'permitted user, null bundle' => [['entity_type_id' => 'paragraph'], TRUE, NULL, 'neutral']; + yield 'non-paragraph entity type is ignored' => [['entity_type_id' => 'node'], TRUE, 'civictheme_content', 'api_json', 'neutral']; + yield 'missing entity type id is ignored' => [[], TRUE, 'civictheme_content', 'api_json', 'neutral']; + yield 'permitted user, allowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_content', 'api_json', 'allowed']; + yield 'permitted user, nested allowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_accordion_panel', 'api_json', 'allowed']; + yield 'permitted user, disallowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_event_card_ref', 'api_json', 'forbidden']; + yield 'unpermitted user, allowed bundle' => [['entity_type_id' => 'paragraph'], FALSE, 'civictheme_content', 'api_json', 'neutral']; + yield 'permitted user, null bundle' => [['entity_type_id' => 'paragraph'], TRUE, NULL, 'api_json', 'neutral']; + yield 'html request, disallowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_event_card_ref', 'html', 'neutral']; + yield 'html request, allowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_content', 'html', 'neutral']; + yield 'no request, disallowed bundle' => [['entity_type_id' => 'paragraph'], TRUE, 'civictheme_event_card_ref', NULL, 'forbidden']; } }