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 + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy