diff --git a/composer.json b/composer.json index b6099e895494..2e6528a13c36 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "symfony/polyfill-uuid": "^1.15" }, "replace": { + "symfony/access-control": "self.version", "symfony/asset": "self.version", "symfony/asset-mapper": "self.version", "symfony/browser-kit": "self.version", diff --git a/src/Symfony/Component/AccessControl/.gitattributes b/src/Symfony/Component/AccessControl/.gitattributes new file mode 100644 index 000000000000..14c3c3594042 --- /dev/null +++ b/src/Symfony/Component/AccessControl/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/AccessControl/.gitignore b/src/Symfony/Component/AccessControl/.gitignore new file mode 100644 index 000000000000..c7f1fdae683c --- /dev/null +++ b/src/Symfony/Component/AccessControl/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +Tests/Fixtures/var/ diff --git a/src/Symfony/Component/AccessControl/AccessControlManager.php b/src/Symfony/Component/AccessControl/AccessControlManager.php new file mode 100644 index 000000000000..71d4fdca2099 --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessControlManager.php @@ -0,0 +1,112 @@ + + */ + private readonly array $strategies; + + /** + * @var array> + */ + private array $votersCacheAttributes = []; + /** + * @var array> + */ + private mixed $votersCacheSubject = []; + + /** + * @param iterable $strategies + * @param iterable $voters + */ + public function __construct( + iterable $strategies, + private readonly iterable $voters, + ?string $defaultStrategy = null, + private readonly ?EventDispatcherInterface $dispatcher = null + ) { + $namedStrategies = []; + foreach ($strategies as $strategy) { + $namedStrategies[$strategy->getName()] = $strategy; + } + if (\count($namedStrategies) === 0) { + $namedStrategies['affirmative'] = new AffirmativeStrategy(); + $defaultStrategy = 'affirmative'; + } + if ($defaultStrategy === null) { + $defaultStrategy = array_key_first($namedStrategies); + assert($defaultStrategy !== null, 'The default strategy cannot be null.'); + } + $this->defaultStrategy = $defaultStrategy; + $this->strategies = $namedStrategies; + } + + public function decide(AccessRequest $accessRequest, ?string $strategy = null): AccessDecision + { + $strategy = $strategy ?? $this->defaultStrategy; + if (!isset($this->strategies[$strategy])) { + throw new InvalidStrategyException(sprintf('Strategy "%s" is not registered. Valid strategies are: %s', $strategy, implode(', ', array_keys($this->strategies)))); + } + $votes = []; + foreach ($this->getVoters($accessRequest) as $voter) { + $vote = $voter->vote($accessRequest); + $votes[] = $vote; + $this->dispatcher?->dispatch(new VoteEvent($voter, $accessRequest, $vote)); + } + + $accessDecision = $this->strategies[$strategy]->evaluate($accessRequest, $votes); + if ($accessDecision->decision !== DecisionVote::ACCESS_ABSTAIN) { + $this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision)); + return $accessDecision; + } + + $accessDecision = AccessDecision::deny($accessRequest, $votes, $accessDecision->reason); + if ($accessRequest->allowIfAllAbstainOrTie) { + $accessDecision = AccessDecision::grant($accessRequest, $votes, $accessDecision->reason); + } + + $this->dispatcher?->dispatch(new AccessDecisionEvent($accessRequest, $accessDecision)); + + return $accessDecision; + } + + /** + * @return iterable + */ + private function getVoters(AccessRequest $accessRequest): iterable + { + $keyAttribute = \is_object($accessRequest->attribute) ? $accessRequest->attribute::class : get_debug_type($accessRequest->attribute); + $keySubject = \is_object($accessRequest->subject) ? $accessRequest->subject::class : get_debug_type($accessRequest->subject); + foreach ($this->voters as $key => $voter) { + if (!isset($this->votersCacheAttributes[$keyAttribute][$key])) { + $this->votersCacheAttributes[$keyAttribute][$key] = $voter->supportsAttribute($accessRequest->attribute); + } + if (!$this->votersCacheAttributes[$keyAttribute][$key]) { + continue; + } + + if (!isset($this->votersCacheSubject[$keySubject][$key])) { + $this->votersCacheSubject[$keySubject][$key] = $voter->supportsSubject($accessRequest->subject); + } + if (!$this->votersCacheSubject[$keySubject][$key]) { + continue; + } + yield $voter; + } + } +} diff --git a/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php b/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php new file mode 100644 index 000000000000..35080429769f --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessControlManagerInterface.php @@ -0,0 +1,11 @@ + $votes + */ + public function __construct( + public AccessRequest $accessRequest, + public DecisionVote $decision, + public iterable $votes, + public ?string $reason = null, + ) { + } + + /** + * @param iterable $votes + */ + public static function grant(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_GRANTED, $votes, $reason); + } + + /** + * @param iterable $votes + */ + public static function deny(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_DENIED, $votes, $reason); + } + + public static function abstain(AccessRequest $accessRequest, iterable $votes, ?string $reason = null): self + { + return new self($accessRequest, DecisionVote::ACCESS_ABSTAIN, $votes, $reason); + } +} diff --git a/src/Symfony/Component/AccessControl/AccessRequest.php b/src/Symfony/Component/AccessControl/AccessRequest.php new file mode 100644 index 000000000000..bc24191b884f --- /dev/null +++ b/src/Symfony/Component/AccessControl/AccessRequest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +readonly class AccessPolicy +{ + /** + * @param array $metadata + */ + public function __construct( + public mixed $attribute, + public mixed $subject = null, + public ?string $strategy = null, + public array $metadata = [], + public bool $allowIfAllAbstain = false, + public string $message = 'Access Denied.', + public ?int $statusCode = null, + public ?int $exceptionCode = null, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/Attribute/All.php b/src/Symfony/Component/AccessControl/Attribute/All.php new file mode 100644 index 000000000000..abc2f9ea3a50 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Attribute/All.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final readonly class All +{ + /** + * @param list $accessPolicies + */ + public function __construct( + public array $accessPolicies, + public string $message = 'Access Denied.', + public ?int $statusCode = null, + public ?int $exceptionCode = null, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php new file mode 100644 index 000000000000..2de03bd28484 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Attribute/AtLeastOneOf.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Attribute; + +/** + * @experimental + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final readonly class AtLeastOneOf +{ + /** + * @param list $accessPolicies + */ + public function __construct( + public array $accessPolicies, + ) { + } +} diff --git a/src/Symfony/Component/AccessControl/CHANGELOG.md b/src/Symfony/Component/AccessControl/CHANGELOG.md new file mode 100644 index 000000000000..0f29770616c5 --- /dev/null +++ b/src/Symfony/Component/AccessControl/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/AccessControl/DecisionVote.php b/src/Symfony/Component/AccessControl/DecisionVote.php new file mode 100644 index 000000000000..df9640d13661 --- /dev/null +++ b/src/Symfony/Component/AccessControl/DecisionVote.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage; + +if (!class_exists(BaseExpressionLanguage::class)) { + throw new \LogicException(\sprintf('The "%s" class requires the "ExpressionLanguage" component. Try running "composer require symfony/expression-language".', ExpressionLanguage::class)); +} + +// Help opcache.preload discover always-needed symbols +class_exists(ExpressionLanguageProvider::class); + + +/** + * @experimental + */ +class ExpressionLanguage extends BaseExpressionLanguage +{ + public function __construct(?CacheItemPoolInterface $cache = null, array $providers = []) + { + // prepend the default provider to let users override it easily + array_unshift($providers, new ExpressionLanguageProvider()); + + parent::__construct($cache, $providers); + } +} diff --git a/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php b/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php new file mode 100644 index 000000000000..e5c0e8c7d479 --- /dev/null +++ b/src/Symfony/Component/AccessControl/ExpressionLanguageProvider.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +use Symfony\Component\ExpressionLanguage\ExpressionFunction; +use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface; + + +/** + * @experimental + */ +class ExpressionLanguageProvider implements ExpressionFunctionProviderInterface +{ + public function getFunctions(): array + { + return [ + new ExpressionFunction('is_authenticated', static fn () => '$token && $trust_resolver->isAuthenticated($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isAuthenticated($variables['token'])), + new ExpressionFunction('is_fully_authenticated', static fn () => '$token && $trust_resolver->isFullFledged($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isFullFledged($variables['token'])), + new ExpressionFunction('is_remember_me', static fn () => '$token && $trust_resolver->isRememberMe($token)', static fn (array $variables) => $variables['token'] && $variables['trust_resolver']->isRememberMe($variables['token'])), + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/LICENSE b/src/Symfony/Component/AccessControl/LICENSE new file mode 100644 index 000000000000..3ed9f412ce53 --- /dev/null +++ b/src/Symfony/Component/AccessControl/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php new file mode 100644 index 000000000000..a0df3025ab44 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AccessPolicyListener.php @@ -0,0 +1,102 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(AccessPolicy::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var AccessPolicy[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[AccessPolicy::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(AccessPolicy $attribute, array $metadata): void + { + $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), + $attribute->attribute, + $attribute->subject, + new MetadataBag([ + ...$attribute->metadata, + ...$metadata, + ]), + $attribute->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $attribute->strategy); + + if ($accessDecision->decision !== DecisionVote::ACCESS_GRANTED) { + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } + } +} diff --git a/src/Symfony/Component/AccessControl/Listener/AllListener.php b/src/Symfony/Component/AccessControl/Listener/AllListener.php new file mode 100644 index 000000000000..481556293791 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AllListener.php @@ -0,0 +1,104 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(All::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var All[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[All::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(All $attribute, array $metadata): void + { + foreach ($attribute->accessPolicies as $accessPolicy) { + $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), + $accessPolicy->attribute, + $accessPolicy->subject, + new MetadataBag([ + ...$accessPolicy->metadata, + ...$metadata, + ]), + $accessPolicy->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); + + if ($accessDecision->decision !== DecisionVote::ACCESS_GRANTED) { + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } + } + } +} diff --git a/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php new file mode 100644 index 000000000000..80071b26feb9 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Listener/AtLeastOneOfListener.php @@ -0,0 +1,106 @@ + ['onKernelControllerArguments', 20], + ConsoleEvents::COMMAND => ['onConsoleCommand', 20], + ]; + } + + public function onConsoleCommand(ConsoleCommandEvent $event): void + { + $command = $event->getCommand(); + if ($command === null) { + return; + } + $reflectionClass = new \ReflectionClass($command); + $attributes = $reflectionClass->getAttributes(All::class); + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute->newInstance(), + [ + 'input' => $event->getInput(), + 'output' => $event->getOutput() + ] + ); + } + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var All[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[All::class] ?? null)) { + return; + } + + foreach ($attributes as $attribute) { + $this->processAttribute( + $attribute, + [ + 'request' => $event->getRequest(), + 'args' => $event->getArguments(), + ] + ); + } + } + + private function processAttribute(All $attribute, array $metadata): void + { + foreach ($attribute->accessPolicies as $accessPolicy) { + $accessRequest = new AccessRequest( + $this->tokenStorage->getToken(), + $accessPolicy->attribute, + $accessPolicy->subject, + new MetadataBag([ + ...$accessPolicy->metadata, + ...$metadata, + ]), + $accessPolicy->allowIfAllAbstain + ); + $accessDecision = $this->accessControlManager->decide($accessRequest, $accessPolicy->strategy); + + if ($accessDecision->decision === DecisionVote::ACCESS_GRANTED) { + return; + } + } + + if ($statusCode = $attribute->statusCode) { + throw new HttpException($statusCode, $attribute->message, code: $attribute->exceptionCode ?? 0); + } + + throw new AccessDeniedException( + $attribute->message, + null, + $attribute->exceptionCode ?? 403 + ); + } +} diff --git a/src/Symfony/Component/AccessControl/MetadataBag.php b/src/Symfony/Component/AccessControl/MetadataBag.php new file mode 100644 index 000000000000..530a2f6279e3 --- /dev/null +++ b/src/Symfony/Component/AccessControl/MetadataBag.php @@ -0,0 +1,56 @@ + $parameters + */ + public function __construct( + private array $parameters = [], + ) { + } + + /** + * Returns the parameter keys. + */ + public function keys(): array + { + return array_keys($this->parameters); + } + + public function get(string $key, mixed $default = null): mixed + { + return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default; + } + + /** + * Returns true if the parameter is defined. + */ + public function has(string $key): bool + { + return \array_key_exists($key, $this->parameters); + } + + /** + * Returns an iterator for parameters. + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->parameters); + } + + /** + * Returns the number of parameters. + */ + public function count(): int + { + return \count($this->parameters); + } +} diff --git a/src/Symfony/Component/AccessControl/README.md b/src/Symfony/Component/AccessControl/README.md new file mode 100644 index 000000000000..21118b83c556 --- /dev/null +++ b/src/Symfony/Component/AccessControl/README.md @@ -0,0 +1,16 @@ +AssetMapper Component +===================== + +The AssetMapper component allows you to expose directories of assets that are +then moved to a public directory with digested (i.e. versioned) filenames. It +also allows you to dump an [importmap](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) +to allow writing modern JavaScript without a build system. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php b/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php new file mode 100644 index 000000000000..905bf82c4b03 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/AffirmativeStrategy.php @@ -0,0 +1,48 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $deny = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + return AccessDecision::grant($accessRequest, $votes, $vote->reason); + } + + if ($vote->decision === DecisionVote::ACCESS_DENIED) { + ++$deny; + } + } + + if ($deny > 0) { + return AccessDecision::deny($accessRequest, $votes, 'At least one voter denied access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, 'All voters abstained from voting.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php b/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php new file mode 100644 index 000000000000..6847c45bcfaf --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/ConsensusStrategy.php @@ -0,0 +1,51 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $grantCount = 0; + $denyCount = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + $grantCount += $vote->weight; + } elseif ($vote->decision === DecisionVote::ACCESS_DENIED) { + $denyCount += $vote->weight; + } + } + + if ($denyCount > $grantCount) { + return AccessDecision::deny($accessRequest, $votes, 'A majority of voters denied access.'); + } + + if ($grantCount > $denyCount) { + return AccessDecision::grant($accessRequest, $votes, 'A majority of voters granted access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, $grantCount === 0 ? 'All voters abstained from voting.' : 'There is a tie.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php b/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php new file mode 100644 index 000000000000..91dfbffe717a --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/StrategyInterface.php @@ -0,0 +1,20 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision; +} diff --git a/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php b/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php new file mode 100644 index 000000000000..f61d43ee0a22 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Strategy/UnanimousStrategy.php @@ -0,0 +1,48 @@ + $votes + */ + public function evaluate(AccessRequest $accessRequest, iterable $votes): AccessDecision + { + $grant = 0; + + foreach ($votes as $vote) { + if ($vote->decision === DecisionVote::ACCESS_DENIED) { + return AccessDecision::deny($accessRequest, $votes, $vote->reason); + } + + if ($vote->decision === DecisionVote::ACCESS_GRANTED) { + ++$grant; + } + } + + if ($grant > 0) { + return AccessDecision::grant($accessRequest, $votes, 'All non-abstaining voters granted access.'); + } + + return AccessDecision::abstain($accessRequest, $votes, 'All voters abstained from voting.'); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php new file mode 100644 index 000000000000..6cbf01f18122 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/AffirmativeStrategyTest.php @@ -0,0 +1,101 @@ +getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'affirmative'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + yield 'affirmative strategy and deny on abstain' => [ + new AccessRequest(new NullToken(),'read', 'article'), + DecisionVote::ACCESS_DENIED, + 'All voters abstained from voting.', + ]; + + yield 'affirmative strategy and grant on abstain' => [ + new AccessRequest(new NullToken(),'read', 'article', allowIfAllAbstainOrTie: true), + DecisionVote::ACCESS_GRANTED, + 'All voters abstained from voting.', + ]; + yield 'affirmative strategy and deny on unauthenticated user' => [ + new AccessRequest(new NullToken(),'ROLE_USER'), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUser); + yield 'affirmative strategy and grant on authenticated user (classic interface)' => [ + new AccessRequest($userToken,'ROLE_USER'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole); + yield 'affirmative strategy and grant on authenticated user (new interface)' => [ + new AccessRequest($userToken,'ROLE_USER'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'affirmative strategy and grant on authenticated user (inherited role)' => [ + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_GRANTED, + 'The user has the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + yield 'affirmative strategy and deny on authenticated user (inherited role)' => [ + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + $expression = new Expression('"ROLE_ADMIN" in role_names and is_authenticated()'); + yield 'affirmative strategy and grant on expression' => [ + new AccessRequest($userToken,$expression), + DecisionVote::ACCESS_GRANTED, + 'Access granted by expression', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + $expression = new Expression('"ROLE_SUPER_ADMIN" in role_names and is_fully_authenticated()'); + yield 'affirmative strategy and denied on expression' => [ + new AccessRequest($userToken,$expression), + DecisionVote::ACCESS_DENIED, + 'At least one voter denied access.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php new file mode 100644 index 000000000000..c636a515a0ab --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/ConsensusStrategyTest.php @@ -0,0 +1,48 @@ +getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'consensus'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'consensus strategy and deny on authenticated user' => [ + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'There is a tie.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'consensus strategy and grant on authenticated user' => [ + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH', allowIfAllAbstainOrTie: true), + DecisionVote::ACCESS_GRANTED, + 'There is a tie.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/EventsTest.php b/src/Symfony/Component/AccessControl/Tests/EventsTest.php new file mode 100644 index 000000000000..5da9bef57dd2 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/EventsTest.php @@ -0,0 +1,26 @@ +getAccessControlManager()->decide($accessRequest); + + // Assert + $events = $this->getEventDispatcher()->events; + $this->assertCount(2, $events); + $this->assertInstanceOf(VoteEvent::class, $events[0]); + $this->assertInstanceOf(AccessDecisionEvent::class, $events[1]); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php b/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php new file mode 100644 index 000000000000..5a6e573e4af4 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeEventDispatcher.php @@ -0,0 +1,16 @@ +events[] = $event; + + return $event; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php b/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php new file mode 100644 index 000000000000..ffe79d1dc919 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeTokenStorage.php @@ -0,0 +1,21 @@ +token; + } + + public function setToken(?TokenInterface $token): void + { + $this->token = $token; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeUser.php b/src/Symfony/Component/AccessControl/Tests/FakeUser.php new file mode 100644 index 000000000000..040357909c20 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeUser.php @@ -0,0 +1,30 @@ +username; + } + + public function getRoles(): array + { + return $this->roles; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php b/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php new file mode 100644 index 000000000000..530497614a23 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/FakeUserWithRole.php @@ -0,0 +1,30 @@ +username; + } + + public function getRoles(): array + { + return $this->roles; + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php new file mode 100644 index 000000000000..a0f26dc2cc3f --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/StrategyTestCase.php @@ -0,0 +1,80 @@ +tokenStorage === null) { + $this->tokenStorage = new FakeTokenStorage(); + } + + return $this->tokenStorage; + } + + protected function getAccessControlManager(): AccessControlManager + { + if ($this->accessControlManager === null) { + $this->accessControlManager = new AccessControlManager( + [ + new AffirmativeStrategy(), + new ConsensusStrategy(), + new UnanimousStrategy(), + ], + [ + new ExpressionVoter( + new ExpressionLanguage(), + new AuthenticationTrustResolver(), + $this->getRoleHierarchy(), + ), + new RoleVoter(), + new RoleHierarchyVoter($this->getRoleHierarchy()), + new AuthenticatedVoter( + new AuthenticationTrustResolver(), + $this->getTokenStorage() + ), + ], + dispatcher: $this->getEventDispatcher(), + ); + } + + return $this->accessControlManager; + } + + protected function getEventDispatcher(): FakeEventDispatcher + { + if ($this->eventDispatcher === null) { + $this->eventDispatcher = new FakeEventDispatcher(); + } + + return $this->eventDispatcher; + } + + protected function getRoleHierarchy(): RoleHierarchyInterface + { + return new RoleHierarchy([ + 'ROLE_ADMIN' => ['ROLE_USER'], + 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'], + ]); + } +} diff --git a/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php new file mode 100644 index 000000000000..821f0e77cdf9 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Tests/UnanimousStrategyTest.php @@ -0,0 +1,50 @@ +getAccessControlManager(); + + // Act + $decision = $accessControlManger->decide($accessRequest, 'unanimous'); + + // Assert + $this->assertEquals($expectedDecision, $decision->decision); + $this->assertEquals($reason, $decision->reason); + } + + /** + * @return iterable{0: string, 1: AccessRequest, 2: DecisionVote, 3: string} + */ + public function provideScenarios(): iterable + { + // In this scenario, both RoleVoter and RoleHierarchyVoter will vote. The former will deny access. + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_SUPER_ADMIN'])); + yield 'unanimous strategy and deny on authenticated user' => [ + new AccessRequest($userToken,'ROLE_ALLOWED_TO_SWITCH'), + DecisionVote::ACCESS_DENIED, + 'The user does not have the required role.', + ]; + + $userToken = $this->createMock(TokenInterface::class); + $userToken->method('getUser')->willReturn(new FakeUserWithRole(roles: ['ROLE_ADMIN'])); + yield 'unanimous strategy and grant on authenticated user' => [ + new AccessRequest($userToken,'ROLE_ADMIN'), + DecisionVote::ACCESS_GRANTED, + 'All non-abstaining voters granted access.', + ]; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php new file mode 100644 index 000000000000..75312ba9e6c1 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticatedVoter.php @@ -0,0 +1,77 @@ +attribute); + if (!$accessRequest->requester instanceof TokenInterface) { + return VoterOutcome::deny('The token is not an instance of TokenInterface.'); + } + + if ($attribute === null) { + return VoterOutcome::abstain('The attribute is not an authentication state.'); + } + if ($attribute === AuthenticationState::PUBLIC_ACCESS) { + return VoterOutcome::grant('Access granted to public access'); + } + + if ($accessRequest->requester instanceof OfflineTokenInterface) { + throw new InvalidArgumentException('Cannot decide on authentication attributes when an offline token is used.'); + } + + if (AuthenticationState::IS_AUTHENTICATED_FULLY === $attribute + && $this->authenticationTrustResolver->isFullFledged($accessRequest->requester)) { + return VoterOutcome::grant('Access granted by fully authenticated user.'); + } + + if (AuthenticationState::IS_AUTHENTICATED_REMEMBERED === $attribute + && ($this->authenticationTrustResolver->isRememberMe($accessRequest->requester) + || $this->authenticationTrustResolver->isFullFledged($accessRequest->requester))) { + return VoterOutcome::grant('Access granted by remembered user.'); + } + + if (AuthenticationState::IS_AUTHENTICATED === $attribute && $this->authenticationTrustResolver->isAuthenticated($accessRequest->requester)) { + return VoterOutcome::grant('Access granted by authenticated user.'); + } + + if (AuthenticationState::IS_REMEMBERED === $attribute && $this->authenticationTrustResolver->isRememberMe($accessRequest->requester)) { + return VoterOutcome::grant('Access granted by remembered user.'); + } + + if (AuthenticationState::IS_IMPERSONATOR === $attribute && $accessRequest->requester instanceof SwitchUserToken) { + return VoterOutcome::grant('Access granted by impersonator.'); + } + + return VoterOutcome::deny('The user does not have the required authentication state.'); + } + + public function supportsAttribute(mixed $attribute): bool + { + return \is_string($attribute) && \in_array($attribute, AuthenticationState::caseNames(), true); + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } +} + diff --git a/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php new file mode 100644 index 000000000000..8e83ad8989eb --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/ABAC/AuthenticationState.php @@ -0,0 +1,38 @@ + + */ + public static function caseNames(): array + { + return array_map(static fn (AuthenticationState $state):string => $state->value, self::cases()); + } + + public static function fromValue(string $state): ?AuthenticationState + { + return match ($state) { + 'IS_AUTHENTICATED_FULLY' => self::IS_AUTHENTICATED_FULLY, + 'IS_AUTHENTICATED_REMEMBERED' => self::IS_AUTHENTICATED_REMEMBERED, + 'IS_AUTHENTICATED' => self::IS_AUTHENTICATED, + 'IS_IMPERSONATOR' => self::IS_IMPERSONATOR, + 'IS_REMEMBERED' => self::IS_REMEMBERED, + 'PUBLIC_ACCESS' => self::PUBLIC_ACCESS, + default => null, + }; + } +} + diff --git a/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php new file mode 100644 index 000000000000..e53201d37f7d --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/Expression/ExpressionVoter.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\Expression; + +use Symfony\Component\AccessControl\AccessRequest; +use Symfony\Component\AccessControl\Voter\RBAC\UserWithRoleInterface; +use Symfony\Component\AccessControl\VoterInterface; +use Symfony\Component\AccessControl\VoterOutcome; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +final readonly class ExpressionVoter implements VoterInterface +{ + public function __construct( + private ExpressionLanguage $expressionLanguage, + private AuthenticationTrustResolverInterface $trustResolver, + private ?RoleHierarchyInterface $roleHierarchy = null, + ) { + } + + public function supportsAttribute(mixed $attribute): bool + { + return $attribute instanceof Expression; + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } + + public function vote(AccessRequest $accessRequest): VoterOutcome + { + $variables = $this->getVariables($accessRequest); + if ($this->expressionLanguage->evaluate($accessRequest->attribute, $variables)) { + return VoterOutcome::grant('Access granted by expression'); + } + + return VoterOutcome::deny('Access denied by expression'); + } + + /** + * @return array{token: TokenInterface, user: UserInterface|null, object: mixed, subject: mixed, roles: array, role_names: array, trust_resolver: AuthenticationTrustResolverInterface, auth_checker: AuthorizationCheckerInterface, request: Request|null} + */ + private function getVariables(AccessRequest $accessRequest): array + { + $user = $accessRequest->requester?->getUser(); + $roleNames = []; + if ($user !== null && ($user instanceof UserWithRoleInterface || method_exists($user, 'getRoles'))) { + $roleNames = $user->getRoles(); + } + + if ($this->roleHierarchy !== null) { + $roleNames = $this->roleHierarchy->getReachableRoleNames($roleNames); + } + + $variables = [ + 'token' => $accessRequest->requester, + 'user' => $user, + 'object' => $accessRequest->subject, + 'subject' => $accessRequest->subject, + 'role_names' => $roleNames, + 'trust_resolver' => $this->trustResolver, + 'metadata' => $accessRequest->metadata, + ]; + + if ($accessRequest->subject instanceof Request) { + $variables['request'] = $accessRequest->subject; + } + + return $variables; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php new file mode 100644 index 000000000000..6be992c31aa1 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleHierarchyVoter.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; + +/** + * @experimental + */ +readonly class RoleHierarchyVoter extends RoleVoter +{ + public function __construct( + private RoleHierarchyInterface $roleHierarchy, + string $prefix = 'ROLE_', + ){ + parent::__construct($prefix); + } + + protected function extractRoles(?TokenInterface $requester): array + { + $roles = parent::extractRoles($requester); + + return $this->roleHierarchy->getReachableRoleNames($roles); + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php new file mode 100644 index 000000000000..078e6e982c05 --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/RoleVoter.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\AccessControl\AccessRequest; +use Symfony\Component\AccessControl\VoterInterface; +use Symfony\Component\AccessControl\VoterOutcome; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +readonly class RoleVoter implements VoterInterface +{ + public function __construct( + private string $prefix = 'ROLE_', + ){} + + public function vote(AccessRequest $accessRequest): VoterOutcome + { + $roles = $this->extractRoles($accessRequest->requester); + + if (!\is_string($accessRequest->attribute) || !str_starts_with($accessRequest->attribute, $this->prefix)) { + return VoterOutcome::abstain('The attribute is not a role.'); + } + + if (\in_array($accessRequest->attribute, $roles, true)) { + return VoterOutcome::grant('The user has the required role.'); + } + + return VoterOutcome::deny('The user does not have the required role.'); + } + + public function supportsAttribute(mixed $attribute): bool + { + return \is_string($attribute) && str_starts_with($attribute, $this->prefix); + } + + public function supportsSubject(mixed $subject): bool + { + return true; + } + + protected function extractRoles(?TokenInterface $requester): array + { + $user = $requester?->getUser(); + if (!$user instanceof UserInterface && !$user instanceof UserWithRoleInterface) { + return []; + } + + if ($user instanceof UserWithRoleInterface || method_exists($user, 'getRoles')) { + return $user->getRoles(); + } + + return []; + } +} diff --git a/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php b/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php new file mode 100644 index 000000000000..8f6573c48a2a --- /dev/null +++ b/src/Symfony/Component/AccessControl/Voter/RBAC/UserWithRoleInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl\Voter\RBAC; + +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * @experimental + */ +interface UserWithRoleInterface extends UserInterface +{ + /** + * Returns the roles granted to the user. + * + * @return string[] + */ + public function getRoles(): array; +} diff --git a/src/Symfony/Component/AccessControl/VoterInterface.php b/src/Symfony/Component/AccessControl/VoterInterface.php new file mode 100644 index 000000000000..416e39cbcc3d --- /dev/null +++ b/src/Symfony/Component/AccessControl/VoterInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AccessControl; + +/** + * @experimental + */ +interface VoterInterface +{ + public function vote(AccessRequest $accessRequest): VoterOutcome; + + public function supportsAttribute(mixed $attribute): bool; + + public function supportsSubject(mixed $subject): bool; +} diff --git a/src/Symfony/Component/AccessControl/VoterOutcome.php b/src/Symfony/Component/AccessControl/VoterOutcome.php new file mode 100644 index 000000000000..3d25de26684e --- /dev/null +++ b/src/Symfony/Component/AccessControl/VoterOutcome.php @@ -0,0 +1,31 @@ +=8.2", + "composer/semver": "^3.0" + }, + "require-dev": { + }, + "autoload": { + "psr-4": { "Symfony\\Component\\AccessControl\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/AccessControl/phpunit.xml.dist b/src/Symfony/Component/AccessControl/phpunit.xml.dist new file mode 100644 index 000000000000..ee540691a49f --- /dev/null +++ b/src/Symfony/Component/AccessControl/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + +