Skip to content

Commit

Permalink
imp: Cache authorizations in the AppVoter
Browse files Browse the repository at this point in the history
This was something that bothered me for a long time. The authorizations
were reloaded from the database for each `is_granted` call. On a basic
ticket page (just created), there were 23 database calls. Now, it's 10
calls.
  • Loading branch information
marien-probesys committed Mar 12, 2024
1 parent 14e96b1 commit beccdd0
Show file tree
Hide file tree
Showing 2 changed files with 63 additions and 6 deletions.
1 change: 1 addition & 0 deletions docs/developers/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Documentation: [symfony.com](https://symfony.com/doc/current/security/voters.htm

Bileto has a unique Voter to check the permission of a user: [`AppVoter`](/src/Security/AppVoter.php).
It loads the applicable user authorizations and related roles, then it checks that at least one role includes the current checked permission.
To improve the performance, the authorizations are cached to avoid too many database calls.

#### How to check the permissions

Expand Down
68 changes: 62 additions & 6 deletions src/Security/AppVoter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@

namespace App\Security;

use App\Entity\Authorization;
use App\Entity\Organization;
use App\Entity\User;
use App\Repository\AuthorizationRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
* @extends Voter<string, 'any'|Organization|null>
* @phpstan-type AuthorizationType 'orga'|'admin'
* @phpstan-type Scope 'any'|Organization
*
* @extends Voter<string, ?Scope>
*/
class AppVoter extends Voter
{
private AuthorizationRepository $authorizationRepo;

/** @var array<string, Authorization[]> */
private array $cacheAuthorizations = [];

public function __construct(AuthorizationRepository $authorizationRepo)
{
$this->authorizationRepo = $authorizationRepo;
Expand All @@ -35,7 +42,7 @@ protected function supports(string $attribute, mixed $subject): bool
);
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $scope, TokenInterface $token): bool
{
$user = $token->getUser();

Expand All @@ -44,14 +51,16 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter
return false;
}

if (str_starts_with($attribute, 'orga:') && $subject !== null) {
$authorizations = $this->authorizationRepo->getOrgaAuthorizationsFor($user, $subject);
if (str_starts_with($attribute, 'orga:')) {
$authorizationType = 'orga';
} elseif (str_starts_with($attribute, 'admin:')) {
$authorizations = $this->authorizationRepo->getAdminAuthorizationsFor($user);
$authorizationType = 'admin';
} else {
$authorizations = [];
throw new \DomainException("Permission must start by 'orga:' or 'admin:' (got {$attribute})");
}

$authorizations = $this->getAuthorizations($authorizationType, $user, $scope);

foreach ($authorizations as $authorization) {
if ($authorization->getRole()->hasPermission($attribute)) {
return true;
Expand All @@ -60,4 +69,51 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter

return false;
}

/**
* @param AuthorizationType $authorizationType
* @param ?Scope $scope
*
* @return Authorization[]
*/
private function getAuthorizations(string $authorizationType, User $user, mixed $scope): array
{
$cacheKey = self::getCacheKey($authorizationType, $user, $scope);
if (isset($this->cacheAuthorizations[$cacheKey])) {
return $this->cacheAuthorizations[$cacheKey];
}

if ($authorizationType === 'orga' && $scope !== null) {
$authorizations = $this->authorizationRepo->getOrgaAuthorizationsFor($user, $scope);
$this->cacheAuthorizations[$cacheKey] = $authorizations;
} elseif ($authorizationType === 'admin' && $scope === null) {
$authorizations = $this->authorizationRepo->getAdminAuthorizationsFor($user);
$this->cacheAuthorizations[$cacheKey] = $authorizations;
} else {
throw new \DomainException('Given authorization type and scope are not supported together');
}

return $authorizations;
}

/**
* @param AuthorizationType $authorizationType
* @param ?Scope $scope
*/
private static function getCacheKey(string $authorizationType, User $user, mixed $scope): string
{
$baseKey = "{$authorizationType}.{$user->getId()}";

if ($scope === 'any') {
$baseKey .= '.any';
} elseif ($scope instanceof Organization) {
$baseKey .= ".{$scope->getId()}";
} elseif ($scope === null) {
$baseKey .= '.null';
} else {
throw new \DomainException('The given scope is not supported');
}

return hash('sha256', $baseKey);
}
}

0 comments on commit beccdd0

Please sign in to comment.