From f6e529deb0cbd169a2da473fc1eb03f999851f29 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Tue, 18 Feb 2025 15:53:37 +0100
Subject: [PATCH 01/10] Issue #3507450: Introduce access policy + cache context
 for oauth2 scopes

---
 simple_oauth.services.yml                     | 13 ++-
 .../SimpleOauthAuthenticationProvider.php     | 99 +++++--------------
 src/Authentication/TokenAuthUser.php          | 39 ++++----
 src/Entity/Oauth2Scope.php                    |  9 ++
 src/Oauth2AccessPolicy.php                    | 47 +++++++++
 src/Oauth2ScopeCacheContext.php               | 57 +++++++++++
 src/Oauth2ScopeInterface.php                  |  8 ++
 src/Oauth2ScopeProvider.php                   | 19 ++++
 src/Oauth2ScopeProviderInterface.php          |  8 ++
 src/Plugin/ScopeGranularity/Permission.php    |  7 ++
 src/Plugin/ScopeGranularity/Role.php          | 28 ++++++
 src/Plugin/ScopeGranularityInterface.php      |  8 ++
 .../ScopeGranularity/TestGranularity.php      |  7 ++
 .../SimpleOauthAuthenticationTest.php         | 11 ++-
 14 files changed, 267 insertions(+), 93 deletions(-)
 create mode 100644 src/Oauth2AccessPolicy.php
 create mode 100644 src/Oauth2ScopeCacheContext.php

diff --git a/simple_oauth.services.yml b/simple_oauth.services.yml
index af6635a..f568b8b 100644
--- a/simple_oauth.services.yml
+++ b/simple_oauth.services.yml
@@ -26,6 +26,8 @@ services:
       - '@psr7.http_foundation_factory'
       - '@path.validator'
       - '@router.route_provider'
+      - '@request_stack'
+      - '@permission_checker'
     tags:
       - { name: authentication_provider, provider_id: oauth2, global: TRUE, priority: 35 }
   simple_oauth.page_cache_request_policy.disallow_oauth2_token_requests:
@@ -38,7 +40,16 @@ services:
     arguments:
       - '@Drupal\simple_oauth\EventSubscriber\ExceptionLoggingSubscriber.inner'
       - '@logger.channel.simple_oauth'
-
+  cache_context.oauth2_scopes:
+    class: Drupal\simple_oauth\Oauth2ScopeCacheContext
+    arguments: [ '@current_user' ]
+    tags:
+      - { name: cache.context }
+  access_policy.simple_oauth:
+    class: Drupal\simple_oauth\Oauth2AccessPolicy
+    arguments: [ '@simple_oauth.oauth2_scope.provider' ]
+    tags:
+      - { name: access_policy }
   simple_oauth.normalizer.oauth2_token:
     class: Drupal\simple_oauth\Normalizer\TokenEntityNormalizer
     arguments: [ '@entity_type.manager' ]
diff --git a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
index 8132304..87497dc 100644
--- a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
+++ b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
@@ -4,6 +4,7 @@ namespace Drupal\simple_oauth\Authentication\Provider;
 
 use Drupal\Core\Authentication\AuthenticationProviderInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\PermissionCheckerInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\Path\PathValidatorInterface;
 use Drupal\Core\Routing\RouteProviderInterface;
@@ -15,6 +16,7 @@ use League\OAuth2\Server\Exception\OAuthServerException;
 use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
 use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
 
 /**
  * OAuth2 authentication provider.
@@ -36,90 +38,39 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa
 
   use StringTranslationTrait;
 
-  /**
-   * The resource server factory.
-   *
-   * @var \Drupal\simple_oauth\Server\ResourceServerFactoryInterface
-   */
-  protected ResourceServerFactoryInterface $resourceServerFactory;
-
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected EntityTypeManagerInterface $entityTypeManager;
-
-  /**
-   * The request policy.
-   *
-   * @var \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface
-   */
-  protected SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy;
-
-  /**
-   * The HTTP message factory.
-   *
-   * @var \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface
-   */
-  protected HttpMessageFactoryInterface $httpMessageFactory;
-
-  /**
-   * The HTTP foundation factory.
-   *
-   * @var \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface
-   */
-  protected HttpFoundationFactoryInterface $httpFoundationFactory;
-
-  /**
-   * The path validator service.
-   *
-   * @var \Drupal\Core\Path\PathValidatorInterface
-   */
-  protected PathValidatorInterface $pathValidator;
-
-  /**
-   * The route provider service.
-   *
-   * @var \Drupal\Core\Routing\RouteProviderInterface
-   */
-  protected RouteProviderInterface $routeProvider;
-
   /**
    * Constructs an HTTP basic authentication provider object.
    *
-   * @param \Drupal\simple_oauth\Server\ResourceServerFactoryInterface $resource_server_factory
+   * @param \Drupal\simple_oauth\Server\ResourceServerFactoryInterface $resourceServerFactory
    *   The resource server factory.
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
    *   The entity type manager service.
-   * @param \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface $page_cache_request_policy
+   * @param \Drupal\simple_oauth\PageCache\SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy
    *   The page cache request policy.
-   * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $http_message_factory
+   * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $httpMessageFactory
    *   The HTTP message factory.
-   * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $http_foundation_factory
+   * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $httpFoundationFactory
    *   The HTTP foundation factory.
    * @param \Drupal\Core\Path\PathValidatorInterface $path_validator
    *   The path validator service.
    * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
    *   The route provider service.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
+   *   The request stack.
+   * @param \Drupal\Core\Session\PermissionCheckerInterface $permissionChecker
+   *   The permission checker service.
    */
   public function __construct(
-    ResourceServerFactoryInterface $resource_server_factory,
-    EntityTypeManagerInterface $entity_type_manager,
-    SimpleOauthRequestPolicyInterface $page_cache_request_policy,
-    HttpMessageFactoryInterface $http_message_factory,
-    HttpFoundationFactoryInterface $http_foundation_factory,
-    PathValidatorInterface $path_validator,
-    RouteProviderInterface $route_provider,
-  ) {
-    $this->resourceServerFactory = $resource_server_factory;
-    $this->entityTypeManager = $entity_type_manager;
-    $this->oauthPageCacheRequestPolicy = $page_cache_request_policy;
-    $this->httpMessageFactory = $http_message_factory;
-    $this->httpFoundationFactory = $http_foundation_factory;
-    $this->pathValidator = $path_validator;
-    $this->routeProvider = $route_provider;
-  }
+    protected readonly ResourceServerFactoryInterface $resourceServerFactory,
+    protected readonly EntityTypeManagerInterface $entityTypeManager,
+    protected readonly SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy,
+    protected readonly HttpMessageFactoryInterface $httpMessageFactory,
+    protected readonly HttpFoundationFactoryInterface $httpFoundationFactory,
+    protected readonly PathValidatorInterface $path_validator,
+    protected readonly RouteProviderInterface $route_provider,
+    protected readonly RequestStack $requestStack,
+    protected readonly PermissionCheckerInterface $permissionChecker,
+  ) {}
 
   /**
    * {@inheritdoc}
@@ -175,8 +126,12 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa
       'value' => $auth_request->get('oauth_access_token_id'),
     ]);
     $token = reset($tokens);
-
-    $account = new TokenAuthUser($token);
+    $account = new TokenAuthUser(
+      $this->permissionChecker,
+      $token,
+      $this->httpMessageFactory,
+      $this->requestStack
+    );
 
     // Revoke the access token for the blocked user.
     if ($account->isBlocked() && $account->isAuthenticated()) {
diff --git a/src/Authentication/TokenAuthUser.php b/src/Authentication/TokenAuthUser.php
index 36853ea..f93f3a7 100644
--- a/src/Authentication/TokenAuthUser.php
+++ b/src/Authentication/TokenAuthUser.php
@@ -7,10 +7,13 @@ use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\consumers\Entity\Consumer;
+use Drupal\Core\Session\PermissionCheckerInterface;
 use Drupal\simple_oauth\Entity\Oauth2TokenInterface;
 use Drupal\user\Entity\User;
 use Drupal\user\UserInterface;
 use League\OAuth2\Server\Exception\OAuthServerException;
+use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
 
 /**
  * The decorated user class with token information.
@@ -26,13 +29,6 @@ class TokenAuthUser implements TokenAuthUserInterface {
    */
   protected $subject;
 
-  /**
-   * The bearer token.
-   *
-   * @var \Drupal\simple_oauth\Entity\Oauth2TokenInterface
-   */
-  protected Oauth2TokenInterface $token;
-
   /**
    * The activated consumer instance.
    *
@@ -43,24 +39,33 @@ class TokenAuthUser implements TokenAuthUserInterface {
   /**
    * Constructs a TokenAuthUser object.
    *
+   * @param \Drupal\Core\Session\PermissionCheckerInterface $permissionChecker
+   *   The permission checker service.
    * @param \Drupal\simple_oauth\Entity\Oauth2TokenInterface $token
    *   The underlying token.
+   * @param \Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface $httpMessageFactory
+   *   The HTTP message factory.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
+   *   The request stack.
    *
    * @throws \League\OAuth2\Server\Exception\OAuthServerException
    *   When there is no user.
    */
-  public function __construct(Oauth2TokenInterface $token) {
+  public function __construct(
+    protected readonly PermissionCheckerInterface $permissionChecker,
+    protected readonly Oauth2TokenInterface $token,
+    protected readonly HttpMessageFactoryInterface $httpMessageFactory,
+    protected readonly RequestStack $requestStack,
+  ) {
     $this->consumer = $token->get('client')->entity;
 
     if (!$this->subject = $token->get('auth_user_id')->entity) {
       $this->subject = $this->consumer->get('user_id')->entity;
     }
     if (!$this->subject) {
-      $server_request = \Drupal::service('psr7.http_message_factory')
-        ->createRequest(\Drupal::request());
+      $server_request = $httpMessageFactory->createRequest($requestStack->getCurrentRequest());
       throw OAuthServerException::invalidClient($server_request);
     }
-    $this->token = $token;
   }
 
   /**
@@ -81,18 +86,18 @@ class TokenAuthUser implements TokenAuthUserInterface {
    * {@inheritdoc}
    */
   public function hasPermission($permission) {
+    if (!is_string($permission)) {
+      @trigger_error('Calling ' . __METHOD__ . '() with a $permission parameter of type other than string is deprecated in drupal:10.3.0 and will cause an error in drupal:11.0.0. See https://www.drupal.org/node/3411485', E_USER_DEPRECATED);
+      return FALSE;
+    }
     // When the 'auth_user_id' isn't available on the token (which can happen
     // with the 'client credentials' grant type):
     // has permission checks are then only performed on the scopes.
     if ($this->token->get('auth_user_id')->isEmpty()) {
-      return $this->token->hasPermission($permission);
-    }
-    // User #1 has all permissions.
-    if ((int) $this->id() === 1) {
-      return TRUE;
+      return $this->permissionChecker->hasPermission($permission, $this);
     }
 
-    return $this->token->hasPermission($permission) && $this->subject->hasPermission($permission);
+    return $this->permissionChecker->hasPermission($permission, $this) && $this->subject->hasPermission($permission);
   }
 
   /* ---------------------------------------------------------------------------
diff --git a/src/Entity/Oauth2Scope.php b/src/Entity/Oauth2Scope.php
index 618f318..7aeab01 100644
--- a/src/Entity/Oauth2Scope.php
+++ b/src/Entity/Oauth2Scope.php
@@ -257,6 +257,15 @@ class Oauth2Scope extends ConfigEntityBase implements Oauth2ScopeEntityInterface
     return $granularityCollection->get($this->granularity_id);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(): array {
+    $granularity = $this->getGranularity();
+    assert($granularity instanceof ScopeGranularityInterface);
+    return $granularity->getPermissions();
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php
new file mode 100644
index 0000000..ec29edf
--- /dev/null
+++ b/src/Oauth2AccessPolicy.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\simple_oauth;
+
+use Drupal\Core\Session\AccessPolicyBase;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Session\CalculatedPermissionsItem;
+use Drupal\Core\Session\RefinableCalculatedPermissionsInterface;
+use Drupal\simple_oauth\Authentication\TokenAuthUserInterface;
+
+/**
+ * Grants permissions based on OAuth2 scopes.
+ */
+final class Oauth2AccessPolicy extends AccessPolicyBase {
+
+  public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculatePermissions(AccountInterface $account, string $scope): RefinableCalculatedPermissionsInterface {
+    $calculated_permissions = parent::calculatePermissions($account, $scope);
+
+    if (!$account instanceof TokenAuthUserInterface) {
+      return $calculated_permissions;
+    }
+
+    $token = $account->getToken();
+    foreach ($token->get('scopes')->getScopes() as $oauth2_scope) {
+      $calculated_permissions
+        ->addItem(new CalculatedPermissionsItem($this->scopeProvider->getPermissions($oauth2_scope)))
+        ->addCacheableDependency($oauth2_scope);
+    }
+
+    return $calculated_permissions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPersistentCacheContexts(): array {
+    return ['oauth2_scopes'];
+  }
+
+}
diff --git a/src/Oauth2ScopeCacheContext.php b/src/Oauth2ScopeCacheContext.php
new file mode 100644
index 0000000..d89ca2e
--- /dev/null
+++ b/src/Oauth2ScopeCacheContext.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\simple_oauth;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\Context\CalculatedCacheContextInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\simple_oauth\Authentication\TokenAuthUserInterface;
+
+/**
+ * Defines the Oauth2ScopeCacheContext service, for "per scope" caching.
+ */
+class Oauth2ScopeCacheContext implements CalculatedCacheContextInterface {
+
+  /**
+   * Constructs a new Oauth2ScopeCacheContext class.
+   *
+   * @param \Drupal\Core\Session\AccountProxyInterface $account
+   *   The current user.
+   */
+  public function __construct(protected AccountProxyInterface $account) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t("OAuth2 scopes");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext($oauth2_scope = NULL) {
+    $account = $this->account->getAccount();
+    if (!$account instanceof TokenAuthUserInterface) {
+      return '';
+    }
+
+    $token = $account->getToken();
+    $scope_names = array_map(function (Oauth2ScopeInterface $scope) {
+      return $scope->getName();
+    }, $token->get('scopes')->getScopes());
+
+    if ($oauth2_scope === NULL) {
+      return implode(',', $scope_names);
+    }
+    return (in_array($oauth2_scope, $scope_names) ? 'true' : 'false');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata($scope = NULL) {
+    return (new CacheableMetadata())->setCacheTags(['user:' . $this->account->id()]);
+  }
+
+}
diff --git a/src/Oauth2ScopeInterface.php b/src/Oauth2ScopeInterface.php
index 2c66196..d0e8125 100644
--- a/src/Oauth2ScopeInterface.php
+++ b/src/Oauth2ScopeInterface.php
@@ -99,4 +99,12 @@ interface Oauth2ScopeInterface {
    */
   public function getGranularity(): ?ScopeGranularityInterface;
 
+  /**
+   * Get the referenced permissions.
+   *
+   * @return array
+   *   Returns the permissions.
+   */
+  public function getPermissions(): array;
+
 }
diff --git a/src/Oauth2ScopeProvider.php b/src/Oauth2ScopeProvider.php
index fb64182..4b8913e 100644
--- a/src/Oauth2ScopeProvider.php
+++ b/src/Oauth2ScopeProvider.php
@@ -94,6 +94,25 @@ class Oauth2ScopeProvider implements Oauth2ScopeProviderInterface {
     return FALSE;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(Oauth2ScopeInterface $scope): array {
+    if (!$scope->isUmbrella()) {
+      $granularity = $scope->getGranularity();
+      assert($granularity instanceof ScopeGranularityInterface);
+      return $granularity->getPermissions();
+    }
+
+    $permissions = [];
+    $children = $this->loadChildren($scope->id());
+    foreach ($children as $child) {
+      $permissions = array_unique(array_merge($permissions, $child->getPermissions()));
+    }
+
+    return $permissions;
+  }
+
   /**
    * Adds a permission to the flatten permission tree.
    *
diff --git a/src/Oauth2ScopeProviderInterface.php b/src/Oauth2ScopeProviderInterface.php
index 2b09874..53adc01 100644
--- a/src/Oauth2ScopeProviderInterface.php
+++ b/src/Oauth2ScopeProviderInterface.php
@@ -20,4 +20,12 @@ interface Oauth2ScopeProviderInterface extends Oauth2ScopeAdapterInterface {
    */
   public function scopeHasPermission(string $permission, Oauth2ScopeInterface $scope): bool;
 
+  /**
+   * Get the referenced permissions.
+   *
+   * @return array
+   *   Returns the permissions.
+   */
+  public function getPermissions(Oauth2ScopeInterface $scope): array;
+
 }
diff --git a/src/Plugin/ScopeGranularity/Permission.php b/src/Plugin/ScopeGranularity/Permission.php
index ba7c4e9..467cf1d 100644
--- a/src/Plugin/ScopeGranularity/Permission.php
+++ b/src/Plugin/ScopeGranularity/Permission.php
@@ -76,6 +76,13 @@ class Permission extends ScopeGranularityBase implements ContainerFactoryPluginI
     return $this->getConfiguration()['permission'] === $permission;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(): array {
+    return [$this->getConfiguration()['permission']];
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Plugin/ScopeGranularity/Role.php b/src/Plugin/ScopeGranularity/Role.php
index 924a736..805adc5 100644
--- a/src/Plugin/ScopeGranularity/Role.php
+++ b/src/Plugin/ScopeGranularity/Role.php
@@ -11,6 +11,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\simple_oauth\Attribute\ScopeGranularity;
 use Drupal\simple_oauth\Oauth2ScopeInterface;
 use Drupal\simple_oauth\Plugin\ScopeGranularityBase;
+use Drupal\user\RoleInterface;
 use Drupal\user\RoleStorage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -87,6 +88,33 @@ class Role extends ScopeGranularityBase implements ContainerFactoryPluginInterfa
     return $role_storage->isPermissionInRoles($permission, $rolesToCheck);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(): array {
+    $role = $this->getConfiguration()['role'];
+
+    $lockedRoles = [
+      AccountInterface::AUTHENTICATED_ROLE,
+      AccountInterface::ANONYMOUS_ROLE,
+    ];
+    $rolesToCheck = !in_array($role, $lockedRoles)
+      ? [AccountInterface::AUTHENTICATED_ROLE, $role]
+      : [$role];
+
+    $role_storage = $this->entityTypeManager->getStorage('user_role');
+    assert($role_storage instanceof RoleStorage);
+
+    $permissions = [];
+    foreach ($rolesToCheck as $roleToCheck) {
+      $role = $role_storage->load($roleToCheck);
+      assert($role instanceof RoleInterface);
+      $permissions = array_unique(array_merge($permissions, $role->getPermissions()));
+    }
+
+    return $permissions;
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Plugin/ScopeGranularityInterface.php b/src/Plugin/ScopeGranularityInterface.php
index eb94b9b..36d6b3f 100644
--- a/src/Plugin/ScopeGranularityInterface.php
+++ b/src/Plugin/ScopeGranularityInterface.php
@@ -32,4 +32,12 @@ interface ScopeGranularityInterface extends ConfigurableInterface, PluginFormInt
    */
   public function hasPermission(string $permission): bool;
 
+  /**
+   * Returns a list of permissions assigned to the scope.
+   *
+   * @return array
+   *   The permissions assigned to the scope.
+   */
+  public function getPermissions(): array;
+
 }
diff --git a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
index e483fdb..619c0d9 100644
--- a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
+++ b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
@@ -58,6 +58,13 @@ class TestGranularity extends ScopeGranularityBase {
     return TRUE;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(): array {
+
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
index fbd771d..98c30dd 100644
--- a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
+++ b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
@@ -8,6 +8,7 @@ use Drupal\Core\PageCache\RequestPolicyInterface;
 use Drupal\Core\Path\PathValidatorInterface;
 use Drupal\Core\Routing\RouteProviderInterface;
 use Drupal\Core\Url;
+use Drupal\Core\Session\PermissionCheckerInterface;
 use Drupal\TestTools\Random;
 use Drupal\Tests\UnitTestCase;
 use Drupal\simple_oauth\Authentication\Provider\SimpleOauthAuthenticationProvider;
@@ -19,6 +20,7 @@ use Symfony\Component\Routing\Route;
 use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
 use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
 
 /**
  * @coversDefaultClass \Drupal\simple_oauth\Authentication\Provider\SimpleOauthAuthenticationProvider
@@ -85,10 +87,11 @@ class SimpleOauthAuthenticationTest extends UnitTestCase {
     // Store the Prophecy mock and reveal it for the strongly typed property.
     $this->pathValidatorMock = $this->prophesize(PathValidatorInterface::class);
     $this->pathValidator = $this->pathValidatorMock->reveal();
-
     $this->routeProviderMock = $this->prophesize(RouteProviderInterface::class);
     $this->routeProvider = $this->routeProviderMock->reveal();
-
+    $request_stack = $this->prophesize(RequestStack::class);
+    $permission_checker = $this->prophesize(PermissionCheckerInterface::class);
+  
     $this->provider = new SimpleOauthAuthenticationProvider(
       $resource_server_factory->reveal(),
       $entity_type_manager->reveal(),
@@ -96,7 +99,9 @@ class SimpleOauthAuthenticationTest extends UnitTestCase {
       $http_message_factory->reveal(),
       $http_foundation_factory->reveal(),
       $this->pathValidator,
-      $this->routeProvider
+      $this->routeProvider,
+      $request_stack->reveal(),
+      $permission_checker->reveal()
     );
   }
 
-- 
GitLab


From a81b8e21da2880e0fe80910aa457c512ef15be60 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Tue, 18 Feb 2025 16:01:17 +0100
Subject: [PATCH 02/10] Increase minimum core requirement

---
 .../simple_oauth_static_scope.info.yml                          | 2 +-
 simple_oauth.info.yml                                           | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml b/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml
index 4442743..53f8553 100644
--- a/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml
+++ b/modules/simple_oauth_static_scope/simple_oauth_static_scope.info.yml
@@ -1,7 +1,7 @@
 name: Simple OAuth static scope
 type: module
 description: 'Makes static (YAML) defined scopes available.'
-core_version_requirement: ^10.2 || ^11
+core_version_requirement: ^10.3 || ^11
 package: Authentication
 dependencies:
   - simple_oauth:simple_oauth
diff --git a/simple_oauth.info.yml b/simple_oauth.info.yml
index 346c0f0..4527812 100644
--- a/simple_oauth.info.yml
+++ b/simple_oauth.info.yml
@@ -1,7 +1,7 @@
 name: Simple OAuth & OpenID Connect
 type: module
 description: 'The OAuth 2.0 Authorization Framework'
-core_version_requirement: ^10.2 || ^11
+core_version_requirement: ^10.3 || ^11
 package: Authentication
 configure: oauth2_token.settings
 dependencies:
-- 
GitLab


From 87c94ae4fc44b8d37b79ea073990a33b33bca7c4 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Wed, 19 Feb 2025 10:21:54 +0100
Subject: [PATCH 03/10] Update static scope + granularity test plugin

---
 .../simple_oauth_static_scope/src/Plugin/Oauth2Scope.php | 9 +++++++++
 .../src/Plugin/ScopeGranularity/TestGranularity.php      | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php b/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php
index 1424a88..608ec31 100644
--- a/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php
+++ b/modules/simple_oauth_static_scope/src/Plugin/Oauth2Scope.php
@@ -108,4 +108,13 @@ class Oauth2Scope extends PluginBase implements Oauth2ScopePluginInterface, Cont
     );
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPermissions(): array {
+    $granularity = $this->getGranularity();
+    assert($granularity instanceof ScopeGranularityInterface);
+    return $granularity->getPermissions();
+  }
+
 }
diff --git a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
index 619c0d9..f6843ef 100644
--- a/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
+++ b/tests/modules/simple_oauth_test/src/Plugin/ScopeGranularity/TestGranularity.php
@@ -62,7 +62,7 @@ class TestGranularity extends ScopeGranularityBase {
    * {@inheritdoc}
    */
   public function getPermissions(): array {
-
+    return [];
   }
 
   /**
-- 
GitLab


From 63feb7c27b3553e35d60bcc019ff1468176bcee8 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Thu, 12 Jun 2025 11:33:58 +0200
Subject: [PATCH 04/10] Move all hasPermission logic to the access policy

---
 simple_oauth.services.yml                     |  2 +-
 src/Authentication/TokenAuthUser.php          | 15 ++++++------
 src/Authentication/TokenAuthUserInterface.php |  8 +++++++
 src/Oauth2AccessPolicy.php                    | 24 +++++++++++++++++--
 4 files changed, 39 insertions(+), 10 deletions(-)

diff --git a/simple_oauth.services.yml b/simple_oauth.services.yml
index f568b8b..f0eb95b 100644
--- a/simple_oauth.services.yml
+++ b/simple_oauth.services.yml
@@ -47,7 +47,7 @@ services:
       - { name: cache.context }
   access_policy.simple_oauth:
     class: Drupal\simple_oauth\Oauth2AccessPolicy
-    arguments: [ '@simple_oauth.oauth2_scope.provider' ]
+    arguments: [ '@simple_oauth.oauth2_scope.provider', '@entity_type.manager' ]
     tags:
       - { name: access_policy }
   simple_oauth.normalizer.oauth2_token:
diff --git a/src/Authentication/TokenAuthUser.php b/src/Authentication/TokenAuthUser.php
index f93f3a7..c52d25c 100644
--- a/src/Authentication/TokenAuthUser.php
+++ b/src/Authentication/TokenAuthUser.php
@@ -82,6 +82,13 @@ class TokenAuthUser implements TokenAuthUserInterface {
     return $this->consumer;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getSubject(): UserInterface {
+    return $this->subject;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -90,14 +97,8 @@ class TokenAuthUser implements TokenAuthUserInterface {
       @trigger_error('Calling ' . __METHOD__ . '() with a $permission parameter of type other than string is deprecated in drupal:10.3.0 and will cause an error in drupal:11.0.0. See https://www.drupal.org/node/3411485', E_USER_DEPRECATED);
       return FALSE;
     }
-    // When the 'auth_user_id' isn't available on the token (which can happen
-    // with the 'client credentials' grant type):
-    // has permission checks are then only performed on the scopes.
-    if ($this->token->get('auth_user_id')->isEmpty()) {
-      return $this->permissionChecker->hasPermission($permission, $this);
-    }
 
-    return $this->permissionChecker->hasPermission($permission, $this) && $this->subject->hasPermission($permission);
+    return $this->permissionChecker->hasPermission($permission, $this);
   }
 
   /* ---------------------------------------------------------------------------
diff --git a/src/Authentication/TokenAuthUserInterface.php b/src/Authentication/TokenAuthUserInterface.php
index 315210f..cf13584 100644
--- a/src/Authentication/TokenAuthUserInterface.php
+++ b/src/Authentication/TokenAuthUserInterface.php
@@ -29,4 +29,12 @@ interface TokenAuthUserInterface extends \IteratorAggregate, UserInterface {
    */
   public function getConsumer(): Consumer;
 
+  /**
+   * Get the decorated subject.
+   *
+   * @return \Drupal\user\UserInterface
+   *   The original user object.
+   */
+  public function getSubject(): UserInterface;
+
 }
diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php
index ec29edf..2d4b56e 100644
--- a/src/Oauth2AccessPolicy.php
+++ b/src/Oauth2AccessPolicy.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\simple_oauth;
 
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Session\AccessPolicyBase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Session\CalculatedPermissionsItem;
@@ -15,7 +16,7 @@ use Drupal\simple_oauth\Authentication\TokenAuthUserInterface;
  */
 final class Oauth2AccessPolicy extends AccessPolicyBase {
 
-  public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider) {}
+  public function __construct(protected Oauth2ScopeProviderInterface $scopeProvider, protected EntityTypeManagerInterface $entityTypeManager) {}
 
   /**
    * {@inheritdoc}
@@ -28,9 +29,28 @@ final class Oauth2AccessPolicy extends AccessPolicyBase {
     }
 
     $token = $account->getToken();
+    $user_permissions = [];
+
+    // When the 'auth_user_id' isn't available on the token (which can happen
+    // with the 'client credentials' grant type):
+    // has permission checks are then only performed on the scopes.
+    if (!$token->get('auth_user_id')->isEmpty()) {
+      /** @var \Drupal\user\RoleInterface[] $user_roles */
+      $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($account->getSubject()->getRoles());
+      foreach ($user_roles as $user_role) {
+        $user_permissions = array_merge($user_permissions, $user_role->getPermissions());
+      }
+    }
+
     foreach ($token->get('scopes')->getScopes() as $oauth2_scope) {
+      $permissions = $this->scopeProvider->getPermissions($oauth2_scope);
+
+      if (!empty($user_permissions)) {
+        $permissions = array_intersect($permissions, $user_permissions);
+      }
+
       $calculated_permissions
-        ->addItem(new CalculatedPermissionsItem($this->scopeProvider->getPermissions($oauth2_scope)))
+        ->addItem(new CalculatedPermissionsItem($permissions))
         ->addCacheableDependency($oauth2_scope);
     }
 
-- 
GitLab


From ae91894e5348cbde83367d1faa24c1cc102e8d13 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Thu, 12 Jun 2025 11:47:25 +0200
Subject: [PATCH 05/10] Correct vars

---
 .../Provider/SimpleOauthAuthenticationProvider.php        | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
index 87497dc..d62e278 100644
--- a/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
+++ b/src/Authentication/Provider/SimpleOauthAuthenticationProvider.php
@@ -51,9 +51,9 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa
    *   The HTTP message factory.
    * @param \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface $httpFoundationFactory
    *   The HTTP foundation factory.
-   * @param \Drupal\Core\Path\PathValidatorInterface $path_validator
+   * @param \Drupal\Core\Path\PathValidatorInterface $pathValidator
    *   The path validator service.
-   * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
+   * @param \Drupal\Core\Routing\RouteProviderInterface $routeProvider
    *   The route provider service.
    * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
    *   The request stack.
@@ -66,8 +66,8 @@ class SimpleOauthAuthenticationProvider implements AuthenticationProviderInterfa
     protected readonly SimpleOauthRequestPolicyInterface $oauthPageCacheRequestPolicy,
     protected readonly HttpMessageFactoryInterface $httpMessageFactory,
     protected readonly HttpFoundationFactoryInterface $httpFoundationFactory,
-    protected readonly PathValidatorInterface $path_validator,
-    protected readonly RouteProviderInterface $route_provider,
+    protected readonly PathValidatorInterface $pathValidator,
+    protected readonly RouteProviderInterface $routeProvider,
     protected readonly RequestStack $requestStack,
     protected readonly PermissionCheckerInterface $permissionChecker,
   ) {}
-- 
GitLab


From 89e3c4066afd67b835e26d4ee488885d51b0a768 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Thu, 12 Jun 2025 11:52:00 +0200
Subject: [PATCH 06/10] Fix whitespace

---
 .../Authentication/Provider/SimpleOauthAuthenticationTest.php   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
index 98c30dd..d38c985 100644
--- a/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
+++ b/tests/src/Unit/Authentication/Provider/SimpleOauthAuthenticationTest.php
@@ -91,7 +91,7 @@ class SimpleOauthAuthenticationTest extends UnitTestCase {
     $this->routeProvider = $this->routeProviderMock->reveal();
     $request_stack = $this->prophesize(RequestStack::class);
     $permission_checker = $this->prophesize(PermissionCheckerInterface::class);
-  
+
     $this->provider = new SimpleOauthAuthenticationProvider(
       $resource_server_factory->reveal(),
       $entity_type_manager->reveal(),
-- 
GitLab


From 23e42b94a46c607c8aa556d8e87c097bbca275df Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Tue, 17 Jun 2025 15:44:21 +0200
Subject: [PATCH 07/10] Added support for admin roles and test coverage

---
 src/Oauth2AccessPolicy.php                |  15 +-
 src/Oauth2ScopeCacheContext.php           |   2 +-
 tests/src/Unit/Oauth2AccessPolicyTest.php | 269 ++++++++++++++++++++++
 3 files changed, 283 insertions(+), 3 deletions(-)
 create mode 100644 tests/src/Unit/Oauth2AccessPolicyTest.php

diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php
index 2d4b56e..0a7b1c3 100644
--- a/src/Oauth2AccessPolicy.php
+++ b/src/Oauth2AccessPolicy.php
@@ -29,20 +29,31 @@ final class Oauth2AccessPolicy extends AccessPolicyBase {
     }
 
     $token = $account->getToken();
+    $user_roles = [];
     $user_permissions = [];
 
     // When the 'auth_user_id' isn't available on the token (which can happen
     // with the 'client credentials' grant type):
     // has permission checks are then only performed on the scopes.
     if (!$token->get('auth_user_id')->isEmpty()) {
+      $user_role_ids = $account->getSubject()->getRoles();
       /** @var \Drupal\user\RoleInterface[] $user_roles */
-      $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($account->getSubject()->getRoles());
+      $user_roles = $this->entityTypeManager->getStorage('user_role')->loadMultiple($user_role_ids);
       foreach ($user_roles as $user_role) {
         $user_permissions = array_merge($user_permissions, $user_role->getPermissions());
       }
     }
 
+    /** @var \Drupal\simple_oauth\Oauth2ScopeInterface $oauth2_scope */
     foreach ($token->get('scopes')->getScopes() as $oauth2_scope) {
+      $is_admin = FALSE;
+      if ($oauth2_scope->getGranularity()->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) {
+        $user_role = $user_roles[$oauth2_scope->getGranularity()->getConfiguration()['role']] ?? NULL;
+        if ($user_role) {
+          $is_admin = $user_role->isAdmin();
+        }
+      }
+
       $permissions = $this->scopeProvider->getPermissions($oauth2_scope);
 
       if (!empty($user_permissions)) {
@@ -50,7 +61,7 @@ final class Oauth2AccessPolicy extends AccessPolicyBase {
       }
 
       $calculated_permissions
-        ->addItem(new CalculatedPermissionsItem($permissions))
+        ->addItem(new CalculatedPermissionsItem($permissions, $is_admin))
         ->addCacheableDependency($oauth2_scope);
     }
 
diff --git a/src/Oauth2ScopeCacheContext.php b/src/Oauth2ScopeCacheContext.php
index d89ca2e..a443b7e 100644
--- a/src/Oauth2ScopeCacheContext.php
+++ b/src/Oauth2ScopeCacheContext.php
@@ -50,7 +50,7 @@ class Oauth2ScopeCacheContext implements CalculatedCacheContextInterface {
   /**
    * {@inheritdoc}
    */
-  public function getCacheableMetadata($scope = NULL) {
+  public function getCacheableMetadata($oauth2_scope = NULL) {
     return (new CacheableMetadata())->setCacheTags(['user:' . $this->account->id()]);
   }
 
diff --git a/tests/src/Unit/Oauth2AccessPolicyTest.php b/tests/src/Unit/Oauth2AccessPolicyTest.php
new file mode 100644
index 0000000..34b03e8
--- /dev/null
+++ b/tests/src/Unit/Oauth2AccessPolicyTest.php
@@ -0,0 +1,269 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\simple_oauth\Unit;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Session\AccessPolicyInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Session\CalculatedPermissionsItem;
+use Drupal\Core\Session\RefinableCalculatedPermissions;
+use Drupal\simple_oauth\Authentication\TokenAuthUserInterface;
+use Drupal\simple_oauth\Entity\Oauth2Scope;
+use Drupal\simple_oauth\Entity\Oauth2TokenInterface;
+use Drupal\simple_oauth\Oauth2AccessPolicy;
+use Drupal\simple_oauth\Oauth2ScopeInterface;
+use Drupal\simple_oauth\Oauth2ScopeProviderInterface;
+use Drupal\simple_oauth\Plugin\Field\FieldType\Oauth2ScopeReferenceItemListInterface;
+use Drupal\simple_oauth\Plugin\ScopeGranularityInterface;
+use Drupal\Tests\UnitTestCase;
+use Drupal\user\RoleInterface;
+use Drupal\user\RoleStorageInterface;
+use Drupal\user\UserInterface;
+use Prophecy\Argument;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\simple_oauth\Oauth2AccessPolicy
+ * @group simple_oauth
+ */
+class Oauth2AccessPolicyTest extends UnitTestCase {
+
+  /**
+   * The mocked scope provider service.
+   *
+   * @var \Drupal\simple_oauth\Oauth2ScopeProviderInterface|\Prophecy\Prophecy\ObjectProphecy
+   */
+  protected $scopeProvider;
+
+  /**
+   * The access policy to test.
+   *
+   * @var \Drupal\simple_oauth\Oauth2AccessPolicy
+   */
+  protected $accessPolicy;
+
+  /**
+   * The mocked entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface|\Prophecy\Prophecy\ObjectProphecy
+   */
+  protected $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->scopeProvider = $this->prophesize(Oauth2ScopeProviderInterface::class);
+    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
+    $this->accessPolicy = new Oauth2AccessPolicy($this->scopeProvider->reveal(), $this->entityTypeManager->reveal());
+
+    $cache_context_manager = $this->prophesize(CacheContextsManager::class);
+    $cache_context_manager->assertValidTokens(Argument::any())->willReturn(TRUE);
+
+    $container = $this->prophesize(ContainerInterface::class);
+    $container->get('cache_contexts_manager')->willReturn($cache_context_manager->reveal());
+    \Drupal::setContainer($container->reveal());
+  }
+
+  /**
+   * @covers ::applies
+   */
+  public function testApplies(): void {
+    $this->assertTrue($this->accessPolicy->applies(AccessPolicyInterface::SCOPE_DRUPAL));
+    $this->assertFalse($this->accessPolicy->applies('another scope'));
+    $this->assertFalse($this->accessPolicy->applies($this->randomString()));
+  }
+
+  /**
+   * Tests the calculatePermissions method.
+   *
+   * @param array $roles
+   *   The available roles.
+   * @param bool $expect_admin_rights
+   *   Whether to expect admin rights to be granted.
+   * @param array $scopes
+   *   The scopes to grant the account.
+   *
+   * @covers ::calculatePermissions
+   * @dataProvider calculatePermissionsProvider
+   */
+  public function testCalculatePermissions(array $roles, bool $expect_admin_rights, array $scopes): void {
+    $account = $this->prophesize(TokenAuthUserInterface::class);
+    $user = $this->prophesize(UserInterface::class);
+    $user->getRoles()->willReturn(array_keys($roles));
+    $account->getSubject()->willReturn($user->reveal());
+    $token = $this->prophesize(Oauth2TokenInterface::class);
+    $auth_id_field = $this->prophesize(FieldItemListInterface::class);
+    $auth_id_field->isEmpty()->willReturn(FALSE);
+    $token->get('auth_user_id')->willReturn($auth_id_field->reveal());
+    $scopes_field = $this->prophesize(Oauth2ScopeReferenceItemListInterface::class);
+
+    $total_permissions = $cache_tags = $mocked_scopes = $mocked_roles = [];
+    foreach ($scopes as $scope_id => $scope) {
+      $scope_permissions = [];
+      if ($scope['granularity'] === Oauth2ScopeInterface::GRANULARITY_PERMISSION) {
+        $scope_permissions = [$scope['granularity_configuration']['permission']];
+        $total_permissions = array_merge($total_permissions, $scope_permissions);
+      }
+      elseif ($scope['granularity'] === Oauth2ScopeInterface::GRANULARITY_ROLE) {
+        $role = $scope['granularity_configuration']['role'];
+        $scope_permissions = $roles[$role]['permissions'];
+        $total_permissions = array_merge($total_permissions, $scope_permissions);
+      }
+
+      $cache_tags[] = "oauth2_scopes.$scope_id";
+
+      $mocked_scope = $this->prophesize(Oauth2Scope::class);
+      $scope_granularity = $this->prophesize(ScopeGranularityInterface::class);
+      $scope_granularity->getPluginId()->willReturn($scope['granularity']);
+      $scope_granularity->getConfiguration()->willReturn($scope['granularity_configuration']);
+      $mocked_scope->getGranularity()->willReturn($scope_granularity->reveal());
+      $mocked_scope->getCacheTags()->willReturn(["oauth2_scopes.$scope_id"]);
+      $mocked_scope->getCacheContexts()->willReturn([]);
+      $mocked_scope->getCacheMaxAge()->willReturn(Cache::PERMANENT);
+      $mocked_scopes[$scope_id] = $mocked_scope->reveal();
+      $this->scopeProvider->getPermissions($mocked_scope->reveal())->willReturn($scope_permissions);
+    }
+
+    $scopes_field->getScopes()->willReturn($mocked_scopes);
+    $token->get('scopes')->willReturn($scopes_field->reveal());
+    $account->getToken()->willReturn($token->reveal());
+
+    foreach ($roles as $role_id => $role) {
+      $mocked_role = $this->prophesize(RoleInterface::class);
+      $mocked_role->getPermissions()->willReturn($role['permissions']);
+      $mocked_role->isAdmin()->willReturn($role['is_admin']);
+      $mocked_roles[$role_id] = $mocked_role->reveal();
+    }
+
+    $role_storage = $this->prophesize(RoleStorageInterface::class);
+    $role_storage->loadMultiple(array_keys($roles))->willReturn($mocked_roles);
+    $this->entityTypeManager->getStorage('user_role')->willReturn($role_storage->reveal());
+
+    $calculated_permissions = $this->accessPolicy->calculatePermissions($account->reveal(), AccessPolicyInterface::SCOPE_DRUPAL);
+
+    if (!empty($roles)) {
+      $this->assertCount(1, $calculated_permissions->getItems(), 'Only one calculated permissions item was added.');
+      $item = $calculated_permissions->getItem();
+
+      if ($expect_admin_rights) {
+        $this->assertSame([], $item->getPermissions());
+        $this->assertTrue($item->isAdmin());
+      }
+      else {
+        $this->assertSame($total_permissions, $item->getPermissions());
+        $this->assertFalse($item->isAdmin());
+      }
+    }
+
+    $this->assertSame($cache_tags, $calculated_permissions->getCacheTags());
+    $this->assertSame(['oauth2_scopes'], $calculated_permissions->getCacheContexts());
+    $this->assertSame(Cache::PERMANENT, $calculated_permissions->getCacheMaxAge());
+  }
+
+  /**
+   * Data provider for testCalculatePermissions.
+   *
+   * @return array
+   *   A list of test scenarios.
+   */
+  public static function calculatePermissionsProvider(): array {
+    $cases['permission-scope'] = [
+      'roles' => [],
+      'expect_admin_rights' => FALSE,
+      'scopes' => [
+        'scope_bar' => [
+          'granularity' => Oauth2ScopeInterface::GRANULARITY_PERMISSION,
+          'granularity_configuration' => [
+            'permission' => 'bar',
+          ],
+        ],
+      ],
+    ];
+    $cases['role-and-permission-scope'] = [
+      'roles' => [
+        'role_foo' => [
+          'permissions' => ['foo'],
+          'is_admin' => FALSE,
+        ],
+        'role_bar_baz' => [
+          'permissions' => ['bar', 'baz'],
+          'is_admin' => FALSE,
+        ],
+      ],
+      'expect_admin_rights' => FALSE,
+      'scopes' => [
+        'scope_bar_baz' => [
+          'granularity' => Oauth2ScopeInterface::GRANULARITY_ROLE,
+          'granularity_configuration' => [
+            'role' => 'role_bar_baz',
+          ],
+        ],
+        'scope_foo' => [
+          'granularity' => Oauth2ScopeInterface::GRANULARITY_PERMISSION,
+          'granularity_configuration' => [
+            'permission' => 'foo',
+          ],
+        ],
+      ],
+    ];
+    $cases['admin-role-scope'] = [
+      'roles' => [
+        'role_foo' => [
+          'permissions' => ['foo'],
+          'is_admin' => FALSE,
+        ],
+        'role_bar' => [
+          'permissions' => ['bar'],
+          'is_admin' => TRUE,
+        ],
+      ],
+      'expect_admin_rights' => TRUE,
+      'scopes' => [
+        'scope_bar' => [
+          'granularity' => Oauth2ScopeInterface::GRANULARITY_ROLE,
+          'granularity_configuration' => [
+            'role' => 'role_bar',
+          ],
+        ],
+      ],
+    ];
+    return $cases;
+  }
+
+  /**
+   * Tests the alterPermissions method.
+   *
+   * @covers ::alterPermissions
+   */
+  public function testAlterPermissions(): void {
+    $account = $this->prophesize(AccountInterface::class);
+
+    $calculated_permissions = new RefinableCalculatedPermissions();
+    $calculated_permissions->addItem(new CalculatedPermissionsItem(['foo']));
+    $calculated_permissions->addCacheTags(['bar']);
+    $calculated_permissions->addCacheContexts(['baz']);
+
+    $this->accessPolicy->alterPermissions($account->reveal(), AccessPolicyInterface::SCOPE_DRUPAL, $calculated_permissions);
+    $this->assertSame(['foo'], $calculated_permissions->getItem()->getPermissions());
+    $this->assertSame(['bar'], $calculated_permissions->getCacheTags());
+    $this->assertSame(['baz'], $calculated_permissions->getCacheContexts());
+  }
+
+  /**
+   * Tests the getPersistentCacheContexts method.
+   *
+   * @covers ::getPersistentCacheContexts
+   */
+  public function testGetPersistentCacheContexts(): void {
+    $this->assertSame(['oauth2_scopes'], $this->accessPolicy->getPersistentCacheContexts(AccessPolicyInterface::SCOPE_DRUPAL));
+  }
+
+}
-- 
GitLab


From efb3621ef7bbdda318bc88682606bf5ea70980fa Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Wed, 18 Jun 2025 07:26:00 +0200
Subject: [PATCH 08/10] Add cache context test coverage

---
 .../src/Unit/Oauth2ScopeCacheContextTest.php  | 50 +++++++++++++++++++
 1 file changed, 50 insertions(+)
 create mode 100644 tests/src/Unit/Oauth2ScopeCacheContextTest.php

diff --git a/tests/src/Unit/Oauth2ScopeCacheContextTest.php b/tests/src/Unit/Oauth2ScopeCacheContextTest.php
new file mode 100644
index 0000000..35a1919
--- /dev/null
+++ b/tests/src/Unit/Oauth2ScopeCacheContextTest.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\simple_oauth\Unit;
+
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\simple_oauth\Authentication\TokenAuthUserInterface;
+use Drupal\simple_oauth\Entity\Oauth2TokenInterface;
+use Drupal\simple_oauth\Oauth2ScopeCacheContext;
+use Drupal\simple_oauth\Oauth2ScopeInterface;
+use Drupal\simple_oauth\Plugin\Field\FieldType\Oauth2ScopeReferenceItemListInterface;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\simple_oauth\Oauth2ScopeCacheContext
+ * @group Cache
+ */
+class Oauth2ScopeCacheContextTest extends UnitTestCase {
+
+  /**
+   * @covers ::getContext
+   */
+  public function testCalculatedScope(): void {
+    $account = $this->prophesize(AccountProxyInterface::class);
+    $token_auth_user = $this->prophesize(TokenAuthUserInterface::class);
+    $token = $this->prophesize(Oauth2TokenInterface::class);
+    $scopes_field = $this->prophesize(Oauth2ScopeReferenceItemListInterface::class);
+
+    $scopes = [];
+    foreach (['scope1', 'scope2'] as $scope_name) {
+      $scope = $this->prophesize(Oauth2ScopeInterface::class);
+      $scope->getName()->willReturn($scope_name);
+      $scopes[] = $scope->reveal();
+    }
+
+    $scopes_field->getScopes()->willReturn($scopes);
+    $token->get('scopes')->willReturn($scopes_field->reveal());
+    $token_auth_user->getToken()->willReturn($token->reveal());
+    $account->getAccount()->willReturn($token_auth_user->reveal());
+
+    $account->id()->willReturn(2);
+    $cache_context = new Oauth2ScopeCacheContext($account->reveal());
+    $this->assertSame('true', $cache_context->getContext('scope1'));
+    $this->assertSame('true', $cache_context->getContext('scope2'));
+    $this->assertSame('false', $cache_context->getContext('scope3'));
+    $this->assertSame('scope1,scope2', $cache_context->getContext());
+  }
+
+}
-- 
GitLab


From c19b0744b3f4750935c8927f6d9a8fa92b612507 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Mon, 23 Jun 2025 11:52:04 +0200
Subject: [PATCH 09/10] Add Kernel test for the SuperUser case

---
 tests/src/Kernel/SuperUserTest.php | 112 +++++++++++++++++++++++++++++
 1 file changed, 112 insertions(+)
 create mode 100644 tests/src/Kernel/SuperUserTest.php

diff --git a/tests/src/Kernel/SuperUserTest.php b/tests/src/Kernel/SuperUserTest.php
new file mode 100644
index 0000000..11675a7
--- /dev/null
+++ b/tests/src/Kernel/SuperUserTest.php
@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\simple_oauth\Kernel;
+
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\Psr7\Query;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Test case for getting all permissions as a super user with auth code.
+ *
+ * @group simple_oauth
+ */
+class SuperUserTest extends AuthorizedRequestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected bool $usesSuperUserAccessPolicy = TRUE;
+
+  /**
+   * The test entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), [
+      'grant simple_oauth codes',
+    ]);
+
+    $this->client->set('automatic_authorization', TRUE);
+    $this->client->save();
+    $current_user = $this->container->get('current_user');
+    $current_user->setAccount($this->user);
+
+    $this->entity = EntityTest::create();
+    $this->entity->save();
+  }
+
+  /**
+   * Tests the super user access policy grants all permissions.
+   */
+  public function testSuperUser(): void {
+    // Check if we are dealing with the super user.
+    $this->assertEquals('1', $this->user->id());
+
+    $response = $this->getAuthenticatedEntityResponse();
+    $this->assertEquals(200, $response->getStatusCode());
+
+    // Turn off the super user access policy and try again.
+    $this->usesSuperUserAccessPolicy = FALSE;
+    $this->bootKernel();
+    $this->setUp();
+
+    $response = $this->getAuthenticatedEntityResponse();
+    $this->assertEquals(403, $response->getStatusCode());
+  }
+
+  /**
+   * Get the authenticated entity request response.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   Returns the response.
+   *
+   * @throws \Drupal\Core\Entity\EntityMalformedException
+   */
+  private function getAuthenticatedEntityResponse(): Response {
+    $parameters = [
+      'response_type' => 'code',
+      'client_id' => $this->client->getClientId(),
+      'client_secret' => $this->clientSecret,
+      'scope' => $this->scope,
+      'redirect_uri' => $this->redirectUri,
+    ];
+    $authorize_url = Url::fromRoute('oauth2_token.authorize')->toString();
+    $request = Request::create($authorize_url, 'GET', $parameters);
+    $response = $this->httpKernel->handle($request);
+    $parsed_url = parse_url($response->headers->get('location'));
+    $parsed_query = Query::parse($parsed_url['query']);
+    $code = $parsed_query['code'];
+    $parameters = [
+      'grant_type' => 'authorization_code',
+      'client_id' => $this->client->getClientId(),
+      'client_secret' => $this->clientSecret,
+      'code' => $code,
+      'scope' => $this->scope,
+      'redirect_uri' => $this->redirectUri,
+    ];
+    $request = Request::create($this->url->toString(), 'POST', $parameters);
+    $response = $this->httpKernel->handle($request);
+    $parsed_response = $this->assertValidTokenResponse($response, TRUE);
+    $access_token = $parsed_response['access_token'];
+    $request = Request::create($this->entity->toUrl()->toString());
+    $request->headers->add(['Authorization' => "Bearer {$access_token}"]);
+
+    return $this->httpKernel->handle($request);
+  }
+
+}
-- 
GitLab


From 96edf3a7075a4aeccb3eee4570ea5d3d96c48967 Mon Sep 17 00:00:00 2001
From: Bojan Bogdanovic <info@bojanbogdanovic.nl>
Date: Mon, 30 Jun 2025 09:16:06 +0200
Subject: [PATCH 10/10] Granularity can be null, correct statement

---
 src/Oauth2AccessPolicy.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Oauth2AccessPolicy.php b/src/Oauth2AccessPolicy.php
index 0a7b1c3..fecc476 100644
--- a/src/Oauth2AccessPolicy.php
+++ b/src/Oauth2AccessPolicy.php
@@ -47,7 +47,7 @@ final class Oauth2AccessPolicy extends AccessPolicyBase {
     /** @var \Drupal\simple_oauth\Oauth2ScopeInterface $oauth2_scope */
     foreach ($token->get('scopes')->getScopes() as $oauth2_scope) {
       $is_admin = FALSE;
-      if ($oauth2_scope->getGranularity()->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) {
+      if ($oauth2_scope->getGranularity()?->getPluginId() === Oauth2ScopeInterface::GRANULARITY_ROLE) {
         $user_role = $user_roles[$oauth2_scope->getGranularity()->getConfiguration()['role']] ?? NULL;
         if ($user_role) {
           $is_admin = $user_role->isAdmin();
-- 
GitLab