Skip to content

Commit 85db83b

Browse files
Merge branch '7.4' into 8.0
* 7.4: [SecurityBundle] Fix tests on Windows use the empty string instead of null as an array offset pass the empty string instead of null as key to array_key_exists() fix test setup [Validator] Review Turkish translations [Validator] Review Croatian translations [Console] Add #[Input] attribute to support DTOs in commands [Security][SecurityBundle] Dump role hierarchy as mermaid chart [DependencyInjection] Allow `Class::function(...)` and `global_function(...)` closures in PHP DSL for factories [VarExporter] Add support for exporting named closures [Validator] Review translations for Polish (pl) use the empty string instead of null as an array offset Review translations for Chinese (zh_TW) [Serializer] Adjust ObjectNormalizerTest for the accessor method changes from #61097 fix merge [Security] Fix `HttpUtils::createRequest()` when the base request is forwarded map legacy options to the "sentinel" key when parsing DSNs fix setup to actually run Redis Sentinel/Cluster integration tests [Routing] Don't rebuild cache when controller action body changes
2 parents ef4fa0c + cee83b8 commit 85db83b

File tree

68 files changed

+1724
-222
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+1724
-222
lines changed

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ jobs:
261261
REDIS_SENTINEL_SERVICE: redis_sentinel
262262
REDIS_REPLICATION_HOSTS: 'localhost:16382 localhost:16381'
263263
MESSENGER_REDIS_DSN: redis://127.0.0.1:7006/messages
264+
MESSENGER_REDIS_SENTINEL_MASTER: redis_sentinel
264265
MESSENGER_AMQP_DSN: amqp://localhost/%2f/messages
265266
MESSENGER_SQS_DSN: "sqs://localhost:4566/messages?sslmode=disable&poll_timeout=0.01"
266267
MESSENGER_SQS_FIFO_QUEUE_DSN: "sqs://localhost:4566/messages.fifo?sslmode=disable&poll_timeout=0.01"

src/Symfony/Bundle/SecurityBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CHANGELOG
1313
7.4
1414
---
1515

16+
* Add `debug:security:role-hierarchy` command to dump role hierarchy graphs in the Mermaid.js flowchart format
1617
* Add `Security::getAccessDecision()` and `getAccessDecisionForUser()` helpers
1718
* Add options to configure a cache pool and storage service for login throttling rate limiters
1819
* Register alias for argument for password hasher when its key is not a class name:
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
use Symfony\Component\Console\Style\SymfonyStyle;
20+
use Symfony\Component\Security\Core\Dumper\MermaidDirectionEnum;
21+
use Symfony\Component\Security\Core\Dumper\MermaidDumper;
22+
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
23+
24+
/**
25+
* Command to dump the role hierarchy as a Mermaid flowchart.
26+
*
27+
* @author Damien Fernandes <damien.fernandes24@gmail.com>
28+
*/
29+
#[AsCommand(name: 'debug:security:role-hierarchy', description: 'Dump the role hierarchy as a Mermaid flowchart')]
30+
class SecurityRoleHierarchyDumpCommand extends Command
31+
{
32+
public function __construct(
33+
private readonly RoleHierarchyInterface $roleHierarchy,
34+
) {
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this
41+
->setDefinition([
42+
new InputOption(
43+
'direction',
44+
'd',
45+
InputOption::VALUE_REQUIRED,
46+
'The direction of the flowchart ['.implode('|', $this->getAvailableDirections()).']',
47+
MermaidDirectionEnum::TOP_TO_BOTTOM->value,
48+
$this->getAvailableDirections()
49+
),
50+
])
51+
->setHelp(<<<'USAGE'
52+
The <info>%command.name%</info> command dumps the role hierarchy in Mermaid format.
53+
54+
<info>Mermaid</info>: %command.full_name% > roles.mmd
55+
<info>Mermaid with direction</info>: %command.full_name% --direction=BT > roles.mmd
56+
USAGE
57+
)
58+
;
59+
}
60+
61+
protected function execute(InputInterface $input, OutputInterface $output): int
62+
{
63+
$io = new SymfonyStyle($input, $output);
64+
65+
if (null === $this->roleHierarchy) {
66+
$io->getErrorStyle()->writeln('<comment>No role hierarchy is configured.</comment>');
67+
68+
return Command::SUCCESS;
69+
}
70+
71+
$direction = $input->getOption('direction');
72+
73+
if (!MermaidDirectionEnum::tryFrom($direction)) {
74+
$io->getErrorStyle()->writeln(\sprintf('<error>Invalid direction, available options are "%s"</error>', implode('"', $this->getAvailableDirections())));
75+
76+
return Command::FAILURE;
77+
}
78+
79+
$dumper = new MermaidDumper();
80+
$mermaidOutput = $dumper->dump($this->roleHierarchy, MermaidDirectionEnum::from($direction));
81+
82+
$output->writeln($mermaidOutput, OutputInterface::OUTPUT_RAW);
83+
84+
return Command::SUCCESS;
85+
}
86+
87+
/**
88+
* @return string[]
89+
*/
90+
private function getAvailableDirections(): array
91+
{
92+
return array_map(fn ($case) => $case->value, MermaidDirectionEnum::cases());
93+
}
94+
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ public function load(array $configs, ContainerBuilder $container): void
182182
$container->getDefinition('security.command.user_password_hash')->replaceArgument(1, array_keys($config['password_hashers']));
183183
}
184184

185+
if ($container->hasDefinition('security.role_hierarchy')) {
186+
$loader->load('security_role_hierarchy_dump_command.php');
187+
}
188+
185189
$container->registerForAutoconfiguration(VoterInterface::class)
186190
->addTag('security.voter');
187191
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand;
15+
16+
return static function (ContainerConfigurator $container): void {
17+
$container->services()
18+
->set('security.command.role_hierarchy_dump', SecurityRoleHierarchyDumpCommand::class)
19+
->args([
20+
service('security.role_hierarchy'),
21+
])
22+
->tag('console.command')
23+
;
24+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Tests\Command;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bundle\SecurityBundle\Command\SecurityRoleHierarchyDumpCommand;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Tester\CommandTester;
18+
use Symfony\Component\Security\Core\Role\RoleHierarchy;
19+
20+
class SecurityRoleHierarchyDumpCommandTest extends TestCase
21+
{
22+
public function testExecuteWithRoleHierarchy()
23+
{
24+
$hierarchy = [
25+
'ROLE_ADMIN' => ['ROLE_USER'],
26+
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_USER'],
27+
];
28+
29+
$roleHierarchy = new RoleHierarchy($hierarchy);
30+
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
31+
$commandTester = new CommandTester($command);
32+
33+
$exitCode = $commandTester->execute([]);
34+
35+
$this->assertSame(Command::SUCCESS, $exitCode);
36+
$output = $commandTester->getDisplay();
37+
$expectedOutput = str_replace("\n", \PHP_EOL, <<<EXPECTED
38+
graph TB
39+
ROLE_ADMIN
40+
ROLE_USER
41+
ROLE_SUPER_ADMIN
42+
ROLE_ADMIN --> ROLE_USER
43+
ROLE_SUPER_ADMIN --> ROLE_ADMIN
44+
ROLE_SUPER_ADMIN --> ROLE_USER
45+
46+
EXPECTED);
47+
48+
$this->assertSame($expectedOutput, $output);
49+
}
50+
51+
public function testExecuteWithCustomDirection()
52+
{
53+
$hierarchy = [
54+
'ROLE_ADMIN' => ['ROLE_USER'],
55+
];
56+
57+
$roleHierarchy = new RoleHierarchy($hierarchy);
58+
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
59+
$commandTester = new CommandTester($command);
60+
61+
$exitCode = $commandTester->execute(['--direction' => 'BT']);
62+
63+
$this->assertSame(Command::SUCCESS, $exitCode);
64+
$output = $commandTester->getDisplay();
65+
$this->assertStringContainsString('graph BT', $output);
66+
}
67+
68+
public function testExecuteWithInvalidDirection()
69+
{
70+
$hierarchy = [
71+
'ROLE_ADMIN' => ['ROLE_USER'],
72+
];
73+
74+
$roleHierarchy = new RoleHierarchy($hierarchy);
75+
$command = new SecurityRoleHierarchyDumpCommand($roleHierarchy);
76+
$commandTester = new CommandTester($command);
77+
78+
$exitCode = $commandTester->execute(['--direction' => 'INVALID']);
79+
80+
$this->assertSame(Command::FAILURE, $exitCode);
81+
$this->assertStringContainsString('Invalid direction', $commandTester->getDisplay());
82+
}
83+
}

src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,24 @@ public function testSwitchUserNotStatelessOnStatelessFirewall()
141141
$this->assertTrue($container->getDefinition('security.authentication.switchuser_listener.some_firewall')->getArgument(9));
142142
}
143143

144+
public function testRoleHierarchyDumpCommandIsRegisteredWithRoleHierarchy()
145+
{
146+
$container = $this->getRawContainer();
147+
$container->loadFromExtension('security', [
148+
'role_hierarchy' => [
149+
'ROLE_ADMIN' => ['ROLE_USER'],
150+
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'],
151+
],
152+
'firewalls' => [
153+
'some_firewall' => [
154+
],
155+
],
156+
]);
157+
$container->compile();
158+
159+
$this->assertTrue($container->hasDefinition('security.command.role_hierarchy_dump'));
160+
}
161+
144162
public function testPerListenerProvider()
145163
{
146164
$container = $this->getRawContainer();

src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ private function getTagVersions(array $tagsByKey, bool $persistTags): array
378378
(self::$saveTags)($this->tags, $newTags);
379379
}
380380

381-
while ($now > ($this->knownTagVersions[$tag = array_key_first($this->knownTagVersions)][0] ?? \INF)) {
381+
while ($now > ($this->knownTagVersions[$tag = array_key_first($this->knownTagVersions) ?? ''][0] ?? \INF)) {
382382
unset($this->knownTagVersions[$tag]);
383383
}
384384

src/Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ public function normalizeKeys(bool $bool): static
345345

346346
public function append(NodeDefinition $node): static
347347
{
348-
$this->children[$node->name] = $node->setParent($this);
348+
$this->children[$node->name ?? ''] = $node->setParent($this);
349349

350350
return $this;
351351
}

src/Symfony/Component/Console/Attribute/Argument.php

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Console\Attribute;
1313

14+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1415
use Symfony\Component\Console\Completion\CompletionInput;
1516
use Symfony\Component\Console\Completion\Suggestion;
1617
use Symfony\Component\Console\Exception\InvalidArgumentException;
@@ -19,15 +20,17 @@
1920
use Symfony\Component\Console\Input\InputInterface;
2021
use Symfony\Component\String\UnicodeString;
2122

22-
#[\Attribute(\Attribute::TARGET_PARAMETER)]
23+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
2324
class Argument
2425
{
2526
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
2627

2728
private string|bool|int|float|array|null $default = null;
2829
private array|\Closure $suggestedValues;
2930
private ?int $mode = null;
30-
private string $function = '';
31+
/**
32+
* @var string|class-string<\BackedEnum>
33+
*/
3134
private string $typeName = '';
3235

3336
/**
@@ -48,52 +51,45 @@ public function __construct(
4851
/**
4952
* @internal
5053
*/
51-
public static function tryFrom(\ReflectionParameter $parameter): ?self
54+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
5255
{
53-
/** @var self $self */
54-
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
55-
return null;
56-
}
56+
$reflection = new ReflectionMember($member);
5757

58-
if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
59-
$self->function = $function->class.'::'.$function->name;
60-
} else {
61-
$self->function = $function->name;
58+
if (!$self = $reflection->getAttribute(self::class)) {
59+
return null;
6260
}
6361

64-
$type = $parameter->getType();
65-
$name = $parameter->getName();
62+
$type = $reflection->getType();
63+
$name = $reflection->getName();
6664

6765
if (!$type instanceof \ReflectionNamedType) {
68-
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name, $self->function));
66+
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
6967
}
7068

7169
$self->typeName = $type->getName();
7270
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
7371

7472
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
75-
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
73+
throw new LogicException(\sprintf('The type "%s" on %s "$%s" of "%s" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $reflection->getMemberName(), $name, $reflection->getSourceName(), implode('", "', self::ALLOWED_TYPES)));
7674
}
7775

7876
if (!$self->name) {
7977
$self->name = (new UnicodeString($name))->kebab();
8078
}
8179

82-
if ($parameter->isDefaultValueAvailable()) {
83-
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
84-
}
80+
$self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
8581

86-
$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
82+
$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
8783
if ('array' === $self->typeName) {
8884
$self->mode |= InputArgument::IS_ARRAY;
8985
}
9086

91-
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
87+
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
9288
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
9389
}
9490

9591
if ($isBackedEnum && !$self->suggestedValues) {
96-
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
92+
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
9793
}
9894

9995
return $self;
@@ -117,7 +113,7 @@ public function resolveValue(InputInterface $input): mixed
117113
$value = $input->getArgument($this->name);
118114

119115
if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) {
120-
return ($this->typeName)::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
116+
return $this->typeName::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
121117
}
122118

123119
return $value;

0 commit comments

Comments
 (0)