From 0b8fef234724cb4cb3a4ab37466efe193a6c7708 Mon Sep 17 00:00:00 2001 From: Johannes Schmitt Date: Fri, 4 Feb 2011 14:37:01 +0100 Subject: [PATCH] [Security/DependencyInjection] adds support for merging security configurations The merging is done in three steps: 1. Normalization: ================= All passed config arrays will be transformed into the same structure regardless of what format they come from. 2. Merging: =========== This is the step when the actual merging is performed. Starting at the root the configs will be passed along the tree until a node has no children, or the merging of sub-paths of the current node has been specifically disabled. Left-Side Right-Side Merge Result ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -nothing- array Right-Side will be taken. scalar scalar Right-Side will be taken. array false Right-Side will be taken if ->canBeUnset() was called on the array node. false array Right-Side will be taken. array array Each value in the array will be passed to the specific child node, or the prototype node (whatever is present). 3. Finalization: ================ The normalized, and merged config will be passed through the config tree to perform final validation on the submitted values, and set default values where this has been requested. You can influence this process in various ways, here is a list with some examples. All of these methods must be called on the node on which they should be applied. * isRequired(): Node must be present in at least one config file. * requiresAtLeastOneElement(): PrototypeNode must have at least one element. * treatNullLike($value): Replaces null with $value during normalization. * treatTrueLike($value): Same as above just for true * treatFalseLike($value): Same as above just for false * defaultValue($value): Sets a default value for this node (only for scalars) * addDefaultsIfNotSet(): Whether to add default values of an array which has not been defined in any configuration file. * disallowNewKeysInSubsequentConfigs(): All keys for this array must be defined in one configuration file, subsequent configurations may only overwrite these. * fixXmlConfig($key, $plural = null): Transforms XML config into same structure as YAML, and PHP configurations. * useAttributeAsKey($name): Defines which XML attribute to use as array key. * cannotBeOverwritten(): Declares a certain sub-path as non-overwritable. All configuration for this path must be defined in the same configuration file. * cannotBeEmpty(): If value is set, it must be non-empty. * canBeUnset(): If array values should be unset if false is specified. Architecture: ============= The configuration consists basically out of two different sets of classes. 1. Builder classes: These classes provide the fluent interface and are used to construct the config tree. 2. Node classes: These classes contain the actual logic for normalization, merging, and finalizing configurations. After you have added all the metadata to your builders, the call to ->buildTree() will convert this metadata to actual node classes. Most of the time, you will not have to interact with the config nodes directly, but will delegate this to the Processor class which will call the respective methods on the config node classes. --- .../DependencyInjection/Configuration.php | 234 ++++++++++ .../Security/Factory/AbstractFactory.php | 44 +- .../Security/Factory/FormLoginFactory.php | 7 +- .../Security/Factory/HttpBasicFactory.php | 9 + .../Security/Factory/HttpDigestFactory.php | 9 + .../Security/Factory/RememberMeFactory.php | 54 ++- .../Factory/SecurityFactoryInterface.php | 3 + .../Security/Factory/X509Factory.php | 9 + .../DependencyInjection/SecurityExtension.php | 441 ++++++------------ .../Resources/config/security_listeners.xml | 12 +- .../Fixtures/php/merge.php | 15 + .../Fixtures/php/merge_import.php | 15 + .../Fixtures/xml/access.xml | 11 +- .../Fixtures/xml/hierarchy.xml | 8 +- .../Fixtures/xml/merge.xml | 20 + .../Fixtures/xml/merge_import.xml | 17 + .../Fixtures/yml/merge.yml | 11 + .../Fixtures/yml/merge_import.yml | 9 + .../Security/Factory/AbstractFactoryTest.php | 9 +- .../SecurityExtensionTest.php | 16 +- .../Configuration/ArrayNode.php | 271 ++++++++++- .../Configuration/BaseNode.php | 113 ++++- .../Configuration/BooleanNode.php | 21 + .../Configuration/Builder/ExprBuilder.php | 36 +- .../Configuration/Builder/MergeBuilder.php | 41 ++ .../Configuration/Builder/NodeBuilder.php | 214 +++++++-- .../Builder/NormalizationBuilder.php | 48 ++ .../Configuration/Builder/TreeBuilder.php | 79 +++- .../Exception/DuplicateKeyException.php | 13 + .../Configuration/Exception/Exception.php | 5 + .../Exception/ForbiddenOverwriteException.php | 13 + .../InvalidConfigurationException.php | 13 + .../Exception/InvalidTypeException.php | 4 +- .../Exception/UnsetKeyException.php | 13 + .../Configuration/NodeInterface.php | 12 + .../Configuration/Processor.php | 26 ++ .../Configuration/ScalarNode.php | 51 +- .../Configuration/ArrayNodeTest.php | 17 + .../Configuration/FinalizationTest.php | 59 +++ .../Configuration/MergeTest.php | 148 ++++++ .../Configuration/NormalizationTest.php | 8 +- 41 files changed, 1721 insertions(+), 437 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Configuration.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge_import.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge_import.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge_import.yml create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/BooleanNode.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Builder/MergeBuilder.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Builder/NormalizationBuilder.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Exception/DuplicateKeyException.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Exception/ForbiddenOverwriteException.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidConfigurationException.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Exception/UnsetKeyException.php create mode 100644 src/Symfony/Component/DependencyInjection/Configuration/Processor.php create mode 100644 tests/Symfony/Tests/Component/DependencyInjection/Configuration/ArrayNodeTest.php create mode 100644 tests/Symfony/Tests/Component/DependencyInjection/Configuration/FinalizationTest.php create mode 100644 tests/Symfony/Tests/Component/DependencyInjection/Configuration/MergeTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Configuration.php new file mode 100644 index 0000000000..5f48157cb3 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Configuration.php @@ -0,0 +1,234 @@ + + */ +class Configuration +{ + public function getAclConfigTree() + { + $tb = new TreeBuilder(); + + return $tb + ->root('security:acl', 'array') + ->scalarNode('connection')->end() + ->scalarNode('cache')->end() + ->end() + ->buildTree(); + } + + public function getFactoryConfigTree() + { + $tb = new TreeBuilder(); + + return $tb + ->root('security:config', 'array') + ->fixXmlConfig('factory', 'factories') + ->arrayNode('factories') + ->prototype('scalar')->end() + ->end() + ->end() + ->buildTree(); + } + + public function getMainConfigTree(array $factories) + { + $tb = new TreeBuilder(); + $rootNode = $tb->root('security:config', 'array'); + + $rootNode + ->scalarNode('access_denied_url')->end() + ->scalarNode('session_fixation_strategy')->cannotBeEmpty()->defaultValue('migrate')->end() + ; + + $this->addEncodersSection($rootNode); + $this->addProvidersSection($rootNode); + $this->addFirewallsSection($rootNode, $factories); + $this->addAccessControlSection($rootNode); + $this->addRoleHierarchySection($rootNode); + + return $tb->buildTree(); + } + + protected function addRoleHierarchySection($rootNode) + { + $rootNode + ->fixXmlConfig('role', 'role_hierarchy') + ->arrayNode('role_hierarchy') + ->containsNameValuePairsWithKeyAttribute('id') + ->prototype('array') + ->beforeNormalization()->ifString()->then(function($v) { return array('value' => $v); })->end() + ->beforeNormalization() + ->ifTrue(function($v) { return is_array($v) && isset($v['value']); }) + ->then(function($v) { return preg_split('/\s*,\s*/', $v['value']); }) + ->end() + ->prototype('scalar')->end() + ->end() + ->end() + ; + } + + protected function addAccessControlSection($rootNode) + { + $rootNode + ->fixXmlConfig('rule', 'access_control') + ->arrayNode('access_control') + ->cannotBeOverwritten() + ->prototype('array') + ->scalarNode('requires_channel')->defaultNull()->end() + ->scalarNode('path')->defaultNull()->end() + ->scalarNode('host')->defaultNull()->end() + ->scalarNode('ip')->defaultNull()->end() + ->arrayNode('methods') + ->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end() + ->prototype('scalar')->end() + ->end() + ->fixXmlConfig('role') + ->arrayNode('roles') + ->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end() + ->prototype('scalar')->end() + ->end() + ->fixXmlConfig('attribute') + ->arrayNode('attributes') + ->containsNameValuePairsWithKeyAttribute('key') + ->prototype('scalar') + ->beforeNormalization() + ->ifTrue(function($v) { return is_array($v) && isset($v['pattern']); }) + ->then(function($v) { return $v['pattern']; }) + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + protected function addFirewallsSection($rootNode, array $factories) + { + $firewallNodeBuilder = + $rootNode + ->fixXmlConfig('firewall') + ->arrayNode('firewalls') + ->disallowNewKeysInSubsequentConfigs() + ->useAttributeAsKey('name') + ->prototype('array') + ->scalarNode('pattern')->end() + ->booleanNode('security')->defaultTrue()->end() + ->scalarNode('request_matcher')->end() + ->scalarNode('access_denied_url')->end() + ->scalarNode('access_denied_handler')->end() + ->scalarNode('entry_point')->end() + ->scalarNode('provider')->end() + ->booleanNode('stateless')->defaultFalse()->end() + ->scalarNode('context')->cannotBeEmpty()->end() + ->arrayNode('logout') + ->treatTrueLike(array()) + ->canBeUnset() + ->scalarNode('path')->defaultValue('/logout')->end() + ->scalarNode('target')->defaultValue('/')->end() + ->booleanNode('invalidate_session')->defaultTrue()->end() + ->fixXmlConfig('delete_cookie') + ->arrayNode('delete_cookies') + ->beforeNormalization() + ->ifTrue(function($v) { return is_array($v) && is_int(key($v)); }) + ->then(function($v) { return array_map(function($v) { return array('name' => $v); }, $v); }) + ->end() + ->useAttributeAsKey('name') + ->prototype('array') + ->scalarNode('path')->defaultNull()->end() + ->scalarNode('domain')->defaultNull()->end() + ->end() + ->end() + ->fixXmlConfig('handler') + ->arrayNode('handlers') + ->prototype('scalar')->end() + ->end() + ->end() + ->booleanNode('anonymous')->end() + ->arrayNode('switch_user') + ->scalarNode('provider')->end() + ->scalarNode('parameter')->defaultValue('_switch_user')->end() + ->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() + ->end() + ; + + foreach ($factories as $factoriesAtPosition) { + foreach ($factoriesAtPosition as $factory) { + $factoryNode = + $firewallNodeBuilder->arrayNode(str_replace('-', '_', $factory->getKey())) + ->canBeUnset() + ; + + $factory->addConfiguration($factoryNode); + } + } + } + + protected function addProvidersSection($rootNode) + { + $rootNode + ->fixXmlConfig('provider') + ->arrayNode('providers') + ->disallowNewKeysInSubsequentConfigs() + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->prototype('array') + ->scalarNode('id')->end() + ->fixXmlConfig('provider') + ->arrayNode('providers') + ->prototype('scalar')->end() + ->end() + ->fixXmlConfig('user') + ->arrayNode('users') + ->useAttributeAsKey('name') + ->prototype('array') + ->scalarNode('password')->defaultValue(uniqid())->end() + ->arrayNode('roles') + ->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end() + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->arrayNode('entity') + ->scalarNode('class')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('property')->defaultNull()->end() + ->end() + ->arrayNode('document') + ->scalarNode('class')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('property')->defaultNull()->end() + ->end() + ->end() + ->end() + ; + } + + protected function addEncodersSection($rootNode) + { + $rootNode + ->fixXmlConfig('encoder') + ->arrayNode('encoders') + ->useAttributeAsKey('class') + ->prototype('array') + ->beforeNormalization()->ifString()->then(function($v) { return array('algorithm' => $v); })->end() + ->scalarNode('algorithm')->isRequired()->cannotBeEmpty()->end() + ->booleanNode('ignore_case')->end() + ->booleanNode('encode_as_base64')->end() + ->scalarNode('iterations')->end() + ->scalarNode('id')->end() + ->end() + ->end() + ; + } +} \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php index 860ba9e145..88b481e3b1 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; + use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -38,10 +40,6 @@ abstract class AbstractFactory implements SecurityFactoryInterface public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId) { - if (!is_array($config)) { - $config = array(); - } - // authentication provider $authProviderId = $this->createAuthProvider($container, $id, $config, $userProviderId); $container @@ -66,6 +64,24 @@ abstract class AbstractFactory implements SecurityFactoryInterface return array($authProviderId, $listenerId, $entryPointId); } + public function addConfiguration(NodeBuilder $node) + { + $node + ->scalarNode('provider')->end() + ->booleanNode('remember_me')->defaultTrue()->end() + ->scalarNode('success_handler')->end() + ->scalarNode('failure_handler')->end() + ; + + foreach ($this->options as $name => $default) { + if (is_bool($default)) { + $node->booleanNode($name)->defaultValue($default); + } else { + $node->scalarNode($name)->defaultValue($default); + } + } + } + public final function addOption($name, $default = null) { $this->options[$name] = $default; @@ -127,18 +143,15 @@ abstract class AbstractFactory implements SecurityFactoryInterface */ protected function isRememberMeAware($config) { - return !isset($config['remember_me']) || (Boolean) $config['remember_me']; + return $config['remember_me']; } protected function createListener($container, $id, $config, $userProvider) { - // merge set options with default options - $options = $this->getOptionsFromConfig($config); - $listenerId = $this->getListenerId(); $listener = new DefinitionDecorator($listenerId); $listener->setArgument(3, $id); - $listener->setArgument(4, $options); + $listener->setArgument(4, array_intersect_key($config, $this->options)); // success handler if (isset($config['success_handler'])) { @@ -155,17 +168,4 @@ abstract class AbstractFactory implements SecurityFactoryInterface return $listenerId; } - - protected final function getOptionsFromConfig($config) - { - $options = $this->options; - - foreach (array_keys($options) as $key) { - if (array_key_exists($key, $config)) { - $options[$key] = $config[$key]; - } - } - - return $options; - } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php index 6149361fb8..eef9aecdc4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php @@ -59,14 +59,11 @@ class FormLoginFactory extends AbstractFactory protected function createEntryPoint($container, $id, $config, $defaultEntryPoint) { - // merge set options with default options - $options = $this->getOptionsFromConfig($config); - $entryPointId = 'security.authentication.form_entry_point.'.$id; $container ->setDefinition($entryPointId, new DefinitionDecorator('security.authentication.form_entry_point')) - ->addArgument($options['login_path']) - ->addArgument($options['use_forward']) + ->addArgument($config['login_path']) + ->addArgument($config['use_forward']) ; return $entryPointId; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php index 3686a1e06f..9b2b7a870a 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; + use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -53,4 +55,11 @@ class HttpBasicFactory implements SecurityFactoryInterface { return 'http-basic'; } + + public function addConfiguration(NodeBuilder $builder) + { + $builder + ->scalarNode('provider')->end() + ; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php index d837032e41..49cf748cf4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; + use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -53,4 +55,11 @@ class HttpDigestFactory implements SecurityFactoryInterface { return 'http-digest'; } + + public function addConfiguration(NodeBuilder $builder) + { + $builder + ->scalarNode('provider')->end() + ; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php index e8646e90d0..c4727429ab 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php @@ -2,6 +2,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; + use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\Reference; @@ -10,16 +12,19 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; class RememberMeFactory implements SecurityFactoryInterface { + protected $options = array( + 'name' => 'REMEMBERME', + 'lifetime' => 31536000, + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'httponly' => true, + 'always_remember_me' => false, + 'remember_me_parameter' => '_remember_me', + ); + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) { - if (!isset($config['key']) || empty($config['key'])) { - throw new \RuntimeException('A "key" must be defined for each remember-me section.'); - } - - if (isset($config['provider'])) { - throw new \RuntimeException('You must not set a user provider for remember-me.'); - } - // authentication provider $authProviderId = 'security.authentication.provider.rememberme.'.$id; $container @@ -60,22 +65,7 @@ class RememberMeFactory implements SecurityFactoryInterface } // remember-me options - $options = array( - 'name' => 'REMEMBERME', - 'lifetime' => 31536000, - 'path' => '/', - 'domain' => null, - 'secure' => false, - 'httponly' => true, - 'always_remember_me' => false, - 'remember_me_parameter' => '_remember_me', - ); - foreach ($options as $name => $option) { - if (array_key_exists($name, $config)) { - $options[$name] = $config[$name]; - } - } - $rememberMeServices->setArgument(3, $options); + $rememberMeServices->setArgument(3, array_intersect_key($config, $this->options)); // attach to remember-me aware listeners $userProviders = array(); @@ -118,4 +108,20 @@ class RememberMeFactory implements SecurityFactoryInterface { return 'remember-me'; } + + public function addConfiguration(NodeBuilder $node) + { + $node + ->scalarNode('key')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('token_provider')->end() + ; + + foreach ($this->options as $name => $value) { + if (is_bool($value)) { + $node->booleanNode($name)->defaultValue($value); + } else { + $node->scalarNode($name)->defaultValue($value); + } + } + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php index a2ec07df05..05dcc74f8a 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder; /** @@ -25,4 +26,6 @@ interface SecurityFactoryInterface function getPosition(); function getKey(); + + function addConfiguration(NodeBuilder $builder); } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php index dbf0e35914..d53f75d978 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory; +use Symfony\Component\DependencyInjection\Configuration\Builder\NodeBuilder; + use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -50,4 +52,11 @@ class X509Factory implements SecurityFactoryInterface { return 'x509'; } + + public function addConfiguration(NodeBuilder $builder) + { + $builder + ->scalarNode('provider')->end() + ; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index edc27e69f2..ac835082bd 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -11,6 +11,8 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection; +use Symfony\Component\DependencyInjection\Configuration\Processor; +use Symfony\Component\DependencyInjection\Configuration\Builder\TreeBuilder; use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -34,19 +36,82 @@ class SecurityExtension extends Extension protected $requestMatchers = array(); protected $contextListeners = array(); protected $listenerPositions = array('pre_auth', 'form', 'http', 'remember_me'); + protected $configuration; + protected $factories; + + public function __construct() + { + $this->configuration = new Configuration(); + } public function configLoad(array $configs, ContainerBuilder $container) { - foreach ($configs as $config) { - $this->doConfigLoad($this->normalizeKeys($config), $container); + $processor = new Processor(); + + // first assemble the factories + $factories = $this->createListenerFactories($container, $processor->process($this->configuration->getFactoryConfigTree(), $configs)); + + // normalize and merge the actual configuration + $tree = $this->configuration->getMainConfigTree($factories); + $config = $processor->process($tree, $configs); + + // load services + $loader = new XmlFileLoader($container, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); + $loader->load('security.xml'); + $loader->load('security_listeners.xml'); + $loader->load('security_rememberme.xml'); + $loader->load('templating_php.xml'); + $loader->load('templating_twig.xml'); + $loader->load('collectors.xml'); + + // set some global scalars + if (isset($config['access_denied_url'])) { + $container->setParameter('security.access.denied_url', $config['access_denied_url']); } + if (isset($config['session_fixation_protection'])) { + $container->setParameter('security.authentication.session_strategy.strategy', $config['session_fixation_protection']); + } + + $this->createFirewalls($config, $container); + $this->createAuthorization($config, $container); + $this->createRoleHierarchy($config, $container); } public function aclLoad(array $configs, ContainerBuilder $container) { - foreach ($configs as $config) { - $this->doAclLoad($this->normalizeKeys($config), $container); + $processor = new Processor(); + $config = $processor->process($this->configuration->getAclConfigTree(), $configs); + + $loader = new XmlFileLoader($container, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); + $loader->load('security_acl.xml'); + + if (isset($config['connection'])) { + $container->setAlias('security.acl.dbal.connection', sprintf('doctrine.dbal.%s_connection', $config['connection'])); } + + if (isset($config['cache'])) { + $container->setAlias('security.acl.cache', sprintf('security.acl.cache.%s', $config['cache'])); + } + } + + /** + * Returns the base path for the XSD files. + * + * @return string The XSD base path + */ + public function getXsdValidationBasePath() + { + return __DIR__.'/../Resources/config/schema'; + } + + public function getNamespace() + { + return 'http://www.symfony-project.org/schema/dic/security'; + } + + public function getAlias() + { + return 'security'; } /** @@ -55,121 +120,50 @@ class SecurityExtension extends Extension * @param array $config An array of configuration settings * @param ContainerBuilder $container A ContainerBuilder instance */ - protected function doConfigLoad($config, ContainerBuilder $container) - { - if (!$container->hasDefinition('security.context')) { - $loader = new XmlFileLoader($container, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); - $loader->load('security.xml'); - $loader->load('security_listeners.xml'); - $loader->load('security_rememberme.xml'); - $loader->load('templating_php.xml'); - $loader->load('templating_twig.xml'); - $loader->load('collectors.xml'); - } - - if (isset($config['access_denied_url'])) { - $container->setParameter('security.access.denied_url', $config['access_denied_url']); - } - - // session fixation protection - if (isset($config['session_fixation_protection'])) { - $container->setParameter('security.authentication.session_strategy.strategy', $config['session_fixation_protection']); - } - - $this->createFirewalls($config, $container); - $this->createAuthorization($config, $container); - $this->createRoleHierarchy($config, $container); - - return $container; - } protected function createRoleHierarchy($config, ContainerBuilder $container) { - $roles = array(); - if (isset($config['role_hierarchy'])) { - $roles = $config['role_hierarchy']; + if (!isset($config['role_hierarchy'])) { + return; } - if (isset($roles['role']) && is_int(key($roles['role']))) { - $roles = $roles['role']; - } - - $hierarchy = array(); - foreach ($roles as $id => $role) { - if (is_array($role) && isset($role['id'])) { - $id = $role['id']; - } - - $value = $role; - if (is_array($role) && isset($role['value'])) { - $value = $role['value']; - } - - $hierarchy[$id] = is_array($value) ? $value : preg_split('/\s*,\s*/', $value); - } - - $container->setParameter('security.role_hierarchy.roles', $hierarchy); + $container->setParameter('security.role_hierarchy.roles', $config['role_hierarchy']); $container->remove('security.access.simple_role_voter'); $container->getDefinition('security.access.role_hierarchy_voter')->addTag('security.voter'); } protected function createAuthorization($config, ContainerBuilder $container) { - $rules = array(); - if (isset($config['access_control'])) { - $rules = $config['access_control']; + if (!isset($config['access_control'])) { + return; } - if (isset($rules['rule']) && is_array($rules['rule'])) { - $rules = $rules['rule']; - } + foreach ($config['access_control'] as $access) { + $matcher = $this->createRequestMatcher( + $container, + $access['path'], + $access['host'], + count($access['methods']) === 0 ? null : $access['methods'], + $access['ip'], + $access['attributes'] + ); - foreach ($rules as $i => $access) { - $roles = isset($access['role']) ? (is_array($access['role']) ? $access['role'] : preg_split('/\s*,\s*/', $access['role'])) : array(); - $channel = null; - if (isset($access['requires_channel'])) { - $channel = $access['requires_channel']; - } - - // matcher - $path = $host = $methods = $ip = null; - if (isset($access['path'])) { - $path = $access['path']; - } - if (isset($access['host'])) { - $host = $access['host']; - } - if (count($tMethods = $this->normalizeConfig($access, 'method')) > 0) { - $methods = $tMethods; - } - if (isset($access['ip'])) { - $ip = $access['ip']; - } - - $matchAttributes = array(); - $attributes = $this->normalizeConfig($access, 'attribute'); - foreach ($attributes as $key => $attribute) { - if (isset($attribute['key'])) { - $key = $attribute['key']; - } - $matchAttributes[$key] = $attribute['pattern']; - } - $matcher = $this->createRequestMatcher($container, $path, $host, $methods, $ip, $matchAttributes); - - $container->getDefinition('security.access_map')->addMethodCall('add', array($matcher, $roles, $channel)); + $container->getDefinition('security.access_map') + ->addMethodCall('add', array($matcher, $access['roles'], $access['requires_channel'])); } } protected function createFirewalls($config, ContainerBuilder $container) { + if (!isset($config['firewalls'])) { + return; + } + + $firewalls = $config['firewalls']; $providerIds = $this->createUserProviders($config, $container); $this->createEncoders($config, $container); - if (!$firewalls = $this->normalizeConfig($config, 'firewall')) { - return; - } - // make the ContextListener aware of the configured user providers $definition = $container->getDefinition('security.context_listener'); $arguments = $definition->getArguments(); @@ -185,16 +179,8 @@ class SecurityExtension extends Extension // load firewall map $mapDef = $container->getDefinition('security.firewall.map'); - $names = $map = array(); + $map = array(); foreach ($firewalls as $name => $firewall) { - if (isset($firewall['name'])) { - $name = $firewall['name']; - } - if (in_array($name, $names)) { - throw new \RuntimeException(sprintf('The firewall name must be unique. Duplicate found: "%s"', $name)); - } - $names[] = $name; - list($matcher, $listeners, $exceptionListener) = $this->createFirewall($container, $name, $firewall, $providerIds, $factories); $contextId = 'security.firewall.map.context.'.$name; @@ -220,7 +206,7 @@ class SecurityExtension extends Extension } // Security disabled? - if (isset($firewall['security']) && !$firewall['security']) { + if (false === $firewall['security']) { return array($matcher, array(), null); } @@ -228,9 +214,6 @@ class SecurityExtension extends Extension if (isset($firewall['provider'])) { $defaultProvider = $this->getUserProviderId($firewall['provider']); } else { - if (!$providerIds) { - throw new \InvalidArgumentException('You must provide at least one authentication provider.'); - } $defaultProvider = reset($providerIds); } @@ -242,7 +225,7 @@ class SecurityExtension extends Extension $listeners[] = new Reference('security.channel_listener'); // Context serializer listener - if (!isset($firewall['stateless']) || !$firewall['stateless']) { + if (false === $firewall['stateless']) { $contextKey = $id; if (isset($firewall['context'])) { $contextKey = $firewall['context']; @@ -252,44 +235,29 @@ class SecurityExtension extends Extension } // Logout listener - if (array_key_exists('logout', $firewall)) { + if (isset($firewall['logout'])) { $listenerId = 'security.logout_listener.'.$id; $listener = $container->setDefinition($listenerId, new DefinitionDecorator('security.logout_listener')); - + $listener->addArgument($firewall['logout']['path']); + $listener->addArgument($firewall['logout']['target']); $listeners[] = new Reference($listenerId); - if (!is_array($firewall['logout'])) { - $firewall['logout'] = array(); - } - - if (isset($firewall['logout']['path'])) { - $listener->setArgument(1, $firewall['logout']['path']); - } - - if (isset($firewall['logout']['target'])) { - $listener->setArgument(2, $firewall['logout']['target']); - } - // add session logout handler - $invalidateSession = true; - if (isset($firewall['logout']['invalidate_session'])) { - $invalidateSession = (Boolean) $firewall['logout']['invalidate_session']; - } - if (true === $invalidateSession && (!isset($firewall['stateless']) || !$firewall['stateless'])) { + if (true === $firewall['logout']['invalidate_session'] && false === $firewall['stateless']) { $listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session'))); } // add cookie logout handler - if (count($cookies = $this->normalizeConfig($firewall['logout'], 'cookie')) > 0) { + if (count($firewall['logout']['delete_cookies']) > 0) { $cookieHandlerId = 'security.logout.handler.cookie_clearing.'.$id; $cookieHandler = $container->setDefinition($cookieHandlerId, new DefinitionDecorator('security.logout.handler.cookie_clearing')); - $cookieHandler->addArgument($cookies); + $cookieHandler->addArgument($firewall['logout']['delete_cookies']); $listener->addMethodCall('addHandler', array(new Reference($cookieHandlerId))); } // add custom handlers - foreach ($this->normalizeConfig($firewall['logout'], 'handler') as $handlerId) { + foreach ($firewall['logout']['handlers'] as $handlerId) { $listener->addMethodCall('addHandler', array(new Reference($handlerId))); } } @@ -303,7 +271,7 @@ class SecurityExtension extends Extension $listeners[] = new Reference('security.access_listener'); // Switch user listener - if (array_key_exists('switch_user', $firewall)) { + if (isset($firewall['switch_user'])) { $listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider)); } @@ -340,13 +308,9 @@ class SecurityExtension extends Extension foreach ($this->listenerPositions as $position) { foreach ($factories[$position] as $factory) { - $key = $factory->getKey(); - $keybis = str_replace('-', '_', $key); + $key = str_replace('-', '_', $factory->getKey()); - if (array_key_exists($keybis, $firewall)) { - $firewall[$key] = $firewall[$keybis]; - } - if (array_key_exists($key, $firewall) && $firewall[$key] !== false) { + if (isset($firewall[$key])) { $userProvider = isset($firewall[$key]['provider']) ? $this->getUserProviderId($firewall[$key]['provider']) : $defaultProvider; list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint); @@ -359,7 +323,7 @@ class SecurityExtension extends Extension } // Anonymous - if (array_key_exists('anonymous', $firewall)) { + if (isset($firewall['anonymous'])) { $listeners[] = new Reference('security.authentication.listener.anonymous'); $hasListeners = true; } @@ -371,66 +335,14 @@ class SecurityExtension extends Extension return array($listeners, $providers, $defaultEntryPoint); } - // Parses user providers and returns an array of their ids - protected function createUserProviders($config, ContainerBuilder $container) - { - $providers = $this->normalizeConfig($config, 'provider'); - if (!$providers) { - return array(); - } - - $providerIds = array(); - foreach ($providers as $name => $provider) { - $id = $this->createUserDaoProvider($name, $provider, $container); - - if (in_array($id, $providerIds, true)) { - throw new \RuntimeException(sprintf('Provider names must be unique. Duplicate entry for %s.', $id)); - } - - $providerIds[] = $id; - } - - return $providerIds; - } - - protected function createListenerFactories(ContainerBuilder $container, $config) - { - // load service templates - $c = new ContainerBuilder(); - $parameterBag = $container->getParameterBag(); - $loader = new XmlFileLoader($c, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); - $loader->load('security_factories.xml'); - - // load user-created listener factories - foreach ($this->normalizeConfig($config, 'factory', 'factories') as $factory) { - $loader->load($parameterBag->resolveValue($factory)); - } - - $tags = $c->findTaggedServiceIds('security.listener.factory'); - - $factories = array(); - foreach ($this->listenerPositions as $position) { - $factories[$position] = array(); - } - - foreach (array_keys($tags) as $tag) { - $factory = $c->get($tag); - - $factories[$factory->getPosition()][] = $factory; - } - - return $factories; - } - protected function createEncoders($config, ContainerBuilder $container) { - $encoders = $this->normalizeConfig($config, 'encoder'); - if (!$encoders) { - return array(); + if (!isset($config['encoders'])) { + return; } $encoderMap = array(); - foreach ($encoders as $class => $encoder) { + foreach ($config['encoders'] as $class => $encoder) { $encoderMap = $this->createEncoder($encoderMap, $class, $encoder, $container); } @@ -442,21 +354,6 @@ class SecurityExtension extends Extension protected function createEncoder(array $encoderMap, $accountClass, $config, ContainerBuilder $container) { - if (is_array($config) && isset($config['class'])) { - $accountClass = $config['class']; - } - - if (empty($accountClass)) { - throw new \RuntimeException('Each encoder needs an account class.'); - } - - // a minimal message digest, or plaintext encoder - if (is_string($config)) { - $config = array( - 'algorithm' => $config, - ); - } - // a custom encoder service if (isset($config['id'])) { $container @@ -467,17 +364,12 @@ class SecurityExtension extends Extension return $encoderMap; } - // a lazy loaded, message digest or plaintext encoder - if (!isset($config['algorithm'])) { - throw new \RuntimeException('"algorithm" must be defined.'); - } - // plaintext encoder if ('plaintext' === $config['algorithm']) { $arguments = array(); if (isset($config['ignore_case'])) { - $arguments[0] = (Boolean) $config['ignore_case']; + $arguments[0] = $config['ignore_case']; } $encoderMap[$accountClass] = array( @@ -493,7 +385,7 @@ class SecurityExtension extends Extension // add optional arguments if (isset($config['encode_as_base64'])) { - $arguments[1] = (Boolean) $config['encode_as_base64']; + $arguments[1] = $config['encode_as_base64']; } else { $arguments[1] = false; } @@ -512,17 +404,23 @@ class SecurityExtension extends Extension return $encoderMap; } + // Parses user providers and returns an array of their ids + protected function createUserProviders($config, ContainerBuilder $container) + { + $providerIds = array(); + foreach ($config['providers'] as $name => $provider) { + $id = $this->createUserDaoProvider($name, $provider, $container); + $providerIds[] = $id; + } + + return $providerIds; + } + // Parses a tag and returns the id for the related user provider service + // FIXME: Replace register() calls in this method with DefinitionDecorator + // and move the actual definition to an xml file protected function createUserDaoProvider($name, $provider, ContainerBuilder $container, $master = true) { - if (isset($provider['name'])) { - $name = $provider['name']; - } - - if (!$name) { - throw new \RuntimeException('You must define a name for each user provider.'); - } - $name = $this->getUserProviderId(strtolower($name)); // Existing DAO service provider @@ -533,7 +431,7 @@ class SecurityExtension extends Extension } // Chain provider - if (isset($provider['provider'])) { + if (count($provider['providers']) > 0) { // FIXME throw new \RuntimeException('Not implemented yet.'); } @@ -546,8 +444,9 @@ class SecurityExtension extends Extension ->setArguments(array( new Reference('security.user.entity_manager'), $provider['entity']['class'], - isset($provider['entity']['property']) ? $provider['entity']['property'] : null, - )); + $provider['entity']['property'], + )) + ; return $name; } @@ -560,7 +459,7 @@ class SecurityExtension extends Extension ->setArguments(array( new Reference('security.user.document_manager'), $provider['document']['class'], - isset($provider['document']['property']) ? $provider['document']['property'] : null, + $provider['document']['property'], )); return $name; @@ -569,27 +468,8 @@ class SecurityExtension extends Extension // In-memory DAO provider $definition = $container->register($name, '%security.user.provider.in_memory.class%'); $definition->setPublic(false); - foreach ($this->normalizeConfig($provider, 'user') as $username => $user) { - if (isset($user['name'])) { - $username = $user['name']; - } - - if (!array_key_exists('password', $user)) { - // if no password is provided explicitly, it means that - // the user will be used with OpenID, X.509 certificates, ... - // Let's generate a random password just to be sure this - // won't be used accidentally with other authentication schemes. - // If you want an empty password, just say so explicitly - $user['password'] = uniqid(); - } - - if (!isset($user['roles'])) { - $user['roles'] = array(); - } else { - $user['roles'] = is_array($user['roles']) ? $user['roles'] : preg_split('/\s*,\s*/', $user['roles']); - } - - $userId = $name.'_'.md5(serialize(array($username, $user['password'], $user['roles']))); + foreach ($provider['users'] as $username => $user) { + $userId = $name.'_'.md5(json_encode(array($username, $user['password'], $user['roles']))); $container ->register($userId, 'Symfony\Component\Security\Core\User\User') @@ -632,14 +512,8 @@ class SecurityExtension extends Extension $listener = $container->setDefinition($switchUserListenerId, new DefinitionDecorator('security.authentication.switchuser_listener')); $listener->setArgument(1, new Reference($userProvider)); $listener->setArgument(3, $id); - - if (isset($config['parameter'])) { - $listener->setArgument(5, $config['parameter']); - } - - if (isset($config['role'])) { - $listener->setArgument(6, $config['role']); - } + $listener->addArgument($config['parameter']); + $listener->addArgument($config['role']); return $switchUserListenerId; } @@ -668,42 +542,35 @@ class SecurityExtension extends Extension return $this->requestMatchers[$id] = new Reference($id); } - protected function doAclLoad(array $config, ContainerBuilder $container) + protected function createListenerFactories(ContainerBuilder $container, $config) { - if (!$container->hasDefinition('security.acl')) { - $loader = new XmlFileLoader($container, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); - $loader->load('security_acl.xml'); + if (null !== $this->factories) { + return $this->factories; } - if (isset($config['connection'])) { - $container->setAlias('security.acl.dbal.connection', sprintf('doctrine.dbal.%s_connection', $config['connection'])); + // load service templates + $c = new ContainerBuilder(); + $parameterBag = $container->getParameterBag(); + $loader = new XmlFileLoader($c, array(__DIR__.'/../Resources/config', __DIR__.'/Resources/config')); + $loader->load('security_factories.xml'); + + // load user-created listener factories + foreach ($config['factories'] as $factory) { + $loader->load($parameterBag->resolveValue($factory)); } - if (isset($config['cache'])) { - $container->setAlias('security.acl.cache', sprintf('security.acl.cache.%s', $config['cache'])); - } else { - $container->remove('security.acl.cache.doctrine'); - $container->removeAlias('security.acl.cache.doctrine.cache_impl'); + $tags = $c->findTaggedServiceIds('security.listener.factory'); + + $factories = array(); + foreach ($this->listenerPositions as $position) { + $factories[$position] = array(); } - } - /** - * Returns the base path for the XSD files. - * - * @return string The XSD base path - */ - public function getXsdValidationBasePath() - { - return __DIR__.'/../Resources/config/schema'; - } + foreach (array_keys($tags) as $tag) { + $factory = $c->get($tag); + $factories[$factory->getPosition()][] = $factory; + } - public function getNamespace() - { - return 'http://www.symfony-project.org/schema/dic/security'; - } - - public function getAlias() - { - return 'security'; + return $this->factories = $factories; } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 3245168813..f36a6c461a 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -28,12 +28,8 @@ Symfony\Component\Security\Http\Firewall\AnonymousAuthenticationListener Symfony\Component\Security\Http\Firewall\SwitchUserListener - ROLE_ALLOWED_TO_SWITCH - _switch_user Symfony\Component\Security\Http\Firewall\LogoutListener - /logout - / Symfony\Component\Security\Http\Logout\SessionLogoutHandler Symfony\Component\Security\Http\Logout\CookieClearingLogoutHandler @@ -83,14 +79,12 @@ - + - %security.logout.path% - %security.logout.target_path% @@ -162,13 +156,11 @@ - + - %security.authentication.switchuser.parameter% - %security.authentication.switchuser.role% diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge.php new file mode 100644 index 0000000000..988640fb28 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge.php @@ -0,0 +1,15 @@ +load('merge_import.php', $container); + +$container->loadFromExtension('security', 'config', array( + 'firewalls' => array( + 'main' => array( + 'form_login' => false, + 'http_basic' => null, + ), + ), + 'role_hierarchy' => array( + 'FOO' => array('MOO'), + ) +)); \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge_import.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge_import.php new file mode 100644 index 0000000000..2b9be399d7 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/merge_import.php @@ -0,0 +1,15 @@ +loadFromExtension('security', 'config', array( + 'firewalls' => array( + 'main' => array( + 'form_login' => array( + 'login_path' => '/login', + ) + ) + ), + 'role_hierarchy' => array( + 'FOO' => 'BAR', + 'ADMIN' => 'USER', + ), +)); \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/access.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/access.xml index c01acd6101..2ca9f5b9d0 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/access.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/access.xml @@ -6,12 +6,9 @@ xsi:schemaLocation="http://www.symfony-project.org/schema/dic/services http://www.symfony-project.org/schema/dic/services/services-1.0.xsd"> - - - - - - - + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/hierarchy.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/hierarchy.xml index 795105230c..4c8985a791 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/hierarchy.xml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/hierarchy.xml @@ -6,10 +6,8 @@ xsi:schemaLocation="http://www.symfony-project.org/schema/dic/services http://www.symfony-project.org/schema/dic/services/services-1.0.xsd"> - - ROLE_USER - ROLE_USER,ROLE_ADMIN,ROLE_ALLOWED_TO_SWITCH - ROLE_USER,ROLE_ADMIN - + ROLE_USER + ROLE_USER,ROLE_ADMIN,ROLE_ALLOWED_TO_SWITCH + ROLE_USER,ROLE_ADMIN diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge.xml new file mode 100644 index 0000000000..36f7b4de72 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge_import.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge_import.xml new file mode 100644 index 0000000000..806719ab5f --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/merge_import.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge.yml new file mode 100644 index 0000000000..a42fc99fab --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge.yml @@ -0,0 +1,11 @@ +imports: + - { resource: merge_import.yml } + +security.config: + firewalls: + main: + form_login: false + http_basic: ~ + + role_hierarchy: + FOO: [MOO] \ No newline at end of file diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge_import.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge_import.yml new file mode 100644 index 0000000000..497fb398c7 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/merge_import.yml @@ -0,0 +1,9 @@ +security.config: + firewalls: + main: + form_login: + login_path: /login + + role_hierarchy: + FOO: BAR + ADMIN: USER diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AbstractFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AbstractFactoryTest.php index 9d6d1390ee..c412ce0e30 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AbstractFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AbstractFactoryTest.php @@ -28,7 +28,7 @@ class AbstractFactoryTest extends \PHPUnit_Framework_TestCase list($authProviderId, $listenerId, $entryPointId - ) = $factory->create($container, 'foo', array('use_forward' => true, 'failure_path' => '/foo', 'success_handler' => 'foo'), 'user_provider', 'entry_point'); + ) = $factory->create($container, 'foo', array('use_forward' => true, 'failure_path' => '/foo', 'success_handler' => 'foo', 'remember_me' => true), 'user_provider', 'entry_point'); // auth provider $this->assertEquals('auth_provider', $authProviderId); @@ -41,15 +41,8 @@ class AbstractFactoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(array( 'index_3' => 'foo', 'index_4' => array( - 'check_path' => '/login_check', - 'login_path' => '/login', 'use_forward' => true, - 'always_use_default_target_path' => false, - 'default_target_path' => '/', - 'target_path_parameter' => '_target_path', - 'use_referer' => false, 'failure_path' => '/foo', - 'failure_forward' => false, ), 'index_5' => new Reference('foo'), ), $definition->getArguments()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php index d76f505d93..73df009691 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/SecurityExtensionTest.php @@ -36,10 +36,10 @@ abstract class SecurityExtensionTest extends \PHPUnit_Framework_TestCase $expectedProviders = array( 'security.authentication.provider.digest', - 'security.authentication.provider.digest_0ff1b54f2a4b7f71b2b9d6604fcca4b8', + 'security.authentication.provider.digest_23374fce51fe846516ff85bfa9add8fe', 'security.authentication.provider.basic', - 'security.authentication.provider.basic_b7f0cf21802ffc8b22cadbb255f07213', - 'security.authentication.provider.basic_98e44377704554700e68c22094b51ca4', + 'security.authentication.provider.basic_745e8583f784c83c4b4208fd281001f3', + 'security.authentication.provider.basic_af4bcce7246fb064b8e219034043d88a', 'security.authentication.provider.doctrine', 'security.authentication.provider.service', 'security.authentication.provider.anonymous', @@ -109,6 +109,16 @@ abstract class SecurityExtensionTest extends \PHPUnit_Framework_TestCase } } + public function testMerge() + { + $container = $this->getContainer('merge'); + + $this->assertEquals(array( + 'FOO' => array('MOO'), + 'ADMIN' => array('USER'), + ), $container->getParameter('security.role_hierarchy.roles')); + } + protected function getContainer($file) { $container = new ContainerBuilder(); diff --git a/src/Symfony/Component/DependencyInjection/Configuration/ArrayNode.php b/src/Symfony/Component/DependencyInjection/Configuration/ArrayNode.php index 9a9fc165f8..1b9cbdfdc1 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/ArrayNode.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/ArrayNode.php @@ -2,23 +2,117 @@ namespace Symfony\Component\DependencyInjection\Configuration; -use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Configuration\Exception\InvalidConfigurationException; +use Symfony\Component\DependencyInjection\Configuration\Exception\DuplicateKeyException; use Symfony\Component\DependencyInjection\Configuration\Exception\InvalidTypeException; +use Symfony\Component\DependencyInjection\Configuration\Exception\UnsetKeyException; +use Symfony\Component\DependencyInjection\Extension\Extension; +/** + * Represents an ARRAY node in the config tree. + * + * @author Johannes M. Schmitt + */ class ArrayNode extends BaseNode implements PrototypeNodeInterface { - protected $normalizeTransformations; + protected $xmlRemappings; protected $children; protected $prototype; protected $keyAttribute; + protected $allowFalse; + protected $allowNewKeys; + protected $addIfNotSet; + protected $minNumberOfElements; + protected $performDeepMerging; - public function __construct($name, NodeInterface $parent = null, array $beforeTransformations = array(), array $afterTransformations = array(), array $normalizeTransformations = array(), $keyAttribute = null) + public function __construct($name, NodeInterface $parent = null) { - parent::__construct($name, $parent, $beforeTransformations, $afterTransformations); + parent::__construct($name, $parent); $this->children = array(); - $this->normalizeTransformations = $normalizeTransformations; - $this->keyAttribute = $keyAttribute; + $this->xmlRemappings = array(); + $this->allowFalse = false; + $this->addIfNotSet = false; + $this->allowNewKeys = true; + $this->performDeepMerging = true; + $this->minNumberOfElements = 0; + } + + /** + * Sets the xml remappings that should be performed. + * + * @param array $remappings an array of the form array(array(string, string)) + * @return void + */ + public function setXmlRemappings(array $remappings) + { + $this->xmlRemappings = $remappings; + } + + /** + * Sets the minimum number of elements that a prototype based node must + * contain. By default this is zero, meaning no elements. + * + * @param integer $number + * @return void + */ + public function setMinNumberOfElements($number) + { + $this->minNumberOfElements = $number; + } + + /** + * The name of the attribute that should be used as key. + * + * This is only relevant for XML configurations, and only in combination + * with a prototype based node. + * + * @param string $attribute + * @return void + */ + public function setKeyAttribute($attribute) + { + $this->keyAttribute = $attribute; + } + + /** + * Sets whether to add default values for this array if it has not been + * defined in any of the configuration files. + * + * @param Boolean $boolean + * @return void + */ + public function setAddIfNotSet($boolean) + { + $this->addIfNotSet = (Boolean) $boolean; + } + + /** + * Sets whether false is allowed as value indicating that the array should + * be unset. + * + * @param Boolean $allow + * @return void + */ + public function setAllowFalse($allow) + { + $this->allowFalse = (Boolean) $allow; + } + + /** + * Sets whether new keys can be defined in subsequent configurations. + * + * @param Boolean $allow + * @return void + */ + public function setAllowNewKeys($allow) + { + $this->allowNewKeys = (Boolean) $allow; + } + + public function setPerformDeepMerging($boolean) + { + $this->performDeepMerging = (Boolean) $boolean; } public function setName($name) @@ -26,10 +120,41 @@ class ArrayNode extends BaseNode implements PrototypeNodeInterface $this->name = $name; } + public function hasDefaultValue() + { + if (null !== $this->prototype) { + return true; + } + + return $this->addIfNotSet; + } + + public function getDefaultValue() + { + if (!$this->hasDefaultValue()) { + throw new \RuntimeException(sprintf('The node at path "%s" has no default value.', $this->getPath())); + } + + if (null !== $this->prototype) { + return array(); + } + + $defaults = array(); + foreach ($this->children as $name => $child) { + if (!$child->hasDefaultValue()) { + continue; + } + + $defaults[$name] = $child->getDefaultValue(); + } + + return $defaults; + } + public function setPrototype(PrototypeNodeInterface $node) { if (count($this->children) > 0) { - throw new \RuntimeException('An ARRAY node must either have concrete children, or a prototype node.'); + throw new \RuntimeException($this->getPath().': An ARRAY node must either have concrete children, or a prototype node.'); } $this->prototype = $node; @@ -51,9 +176,65 @@ class ArrayNode extends BaseNode implements PrototypeNodeInterface $this->children[$name] = $node; } + protected function finalizeValue($value) + { + if (false === $value) { + throw new UnsetKeyException(sprintf( + 'Unsetting key for path "%s", value: %s', + $this->getPath(), + json_encode($value) + )); + } + + if (null !== $this->prototype) { + foreach ($value as $k => $v) { + try { + $value[$k] = $this->prototype->finalize($v); + } catch (UnsetKeyException $unset) { + unset($value[$k]); + } + } + + if (count($value) < $this->minNumberOfElements) { + throw new InvalidConfigurationException(sprintf( + 'You must define at least %d element(s) for path "%s".', + $this->minNumberOfElements, + $this->getPath() + )); + } + + return $value; + } + + foreach ($this->children as $name => $child) { + if (!array_key_exists($name, $value)) { + if ($child->isRequired()) { + throw new InvalidConfigurationException(sprintf( + 'The node at path "%s" must be configured.', + $this->getPath() + )); + } + + if ($child->hasDefaultValue()) { + $value[$name] = $child->getDefaultValue(); + } + + continue; + } + + try { + $value[$name] = $child->finalize($value[$name]); + } catch (UnsetKeyException $unset) { + unset($value[$name]); + } + } + + return $value; + } + protected function validateType($value) { - if (!is_array($value)) { + if (!is_array($value) && (!$this->allowFalse || false !== $value)) { throw new InvalidTypeException(sprintf( 'Invalid type for path "%s". Expected array, but got %s', $this->getPath(), @@ -64,7 +245,11 @@ class ArrayNode extends BaseNode implements PrototypeNodeInterface protected function normalizeValue($value) { - foreach ($this->normalizeTransformations as $transformation) { + if (false === $value) { + return $value; + } + + foreach ($this->xmlRemappings as $transformation) { list($singular, $plural) = $transformation; if (!isset($value[$singular])) { @@ -77,8 +262,24 @@ class ArrayNode extends BaseNode implements PrototypeNodeInterface if (null !== $this->prototype) { $normalized = array(); foreach ($value as $k => $v) { - if (null !== $this->keyAttribute && is_array($v) && isset($v[$this->keyAttribute])) { - $k = $v[$this->keyAttribute]; + if (null !== $this->keyAttribute && is_array($v)) { + if (!isset($v[$this->keyAttribute]) && is_int($k)) { + throw new InvalidConfigurationException(sprintf( + 'You must set a "%s" attribute for path "%s".', + $this->keyAttribute, + $this->getPath() + )); + } else if (isset($v[$this->keyAttribute])) { + $k = $v[$this->keyAttribute]; + } + + if (array_key_exists($k, $normalized)) { + throw new DuplicateKeyException(sprintf( + 'Duplicate key "%s" for path "%s".', + $k, + $this->getPath() + )); + } } $this->prototype->setName($k); @@ -103,4 +304,50 @@ class ArrayNode extends BaseNode implements PrototypeNodeInterface return $normalized; } -} + + protected function mergeValues($leftSide, $rightSide) + { + if (false === $rightSide) { + // if this is still false after the last config has been merged the + // finalization pass will take care of removing this key entirely + return false; + } + + if (false === $leftSide || !$this->performDeepMerging) { + return $rightSide; + } + + foreach ($rightSide as $k => $v) { + // no conflict + if (!array_key_exists($k, $leftSide)) { + if (!$this->allowNewKeys) { + throw new InvalidConfigurationException(sprintf( + 'You are not allowed to define new elements for path "%s". ' + .'Please define all elements for this path in one config file.', + $this->getPath() + )); + } + + $leftSide[$k] = $v; + continue; + } + + try { + if (null !== $this->prototype) { + $this->prototype->setName($k); + $leftSide[$k] = $this->prototype->merge($leftSide[$k], $v); + } else { + if (!isset($this->children[$k])) { + throw new \RuntimeException('merge() expects a normalized config array.'); + } + + $leftSide[$k] = $this->children[$k]->merge($leftSide[$k], $v); + } + } catch (UnsetKeyException $unset) { + unset($leftSide[$k]); + } + } + + return $leftSide; + } +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/BaseNode.php b/src/Symfony/Component/DependencyInjection/Configuration/BaseNode.php index 69f5847fbc..b2a78e9e91 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/BaseNode.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/BaseNode.php @@ -2,15 +2,25 @@ namespace Symfony\Component\DependencyInjection\Configuration; +use Symfony\Component\DependencyInjection\Configuration\Exception\Exception; +use Symfony\Component\DependencyInjection\Configuration\Exception\ForbiddenOverwriteException; + +/** + * The base node class + * + * @author Johannes M. Schmitt + */ abstract class BaseNode implements NodeInterface { protected $name; protected $parent; - protected $beforeTransformations; - protected $afterTransformations; - protected $nodeFactory; + protected $normalizationClosures; + protected $finalValidationClosures; + protected $allowOverwrite; + protected $required; + protected $equivalentValues; - public function __construct($name, NodeInterface $parent = null, $beforeTransformations = array(), $afterTransformations = array()) + public function __construct($name, NodeInterface $parent = null) { if (false !== strpos($name, '.')) { throw new \InvalidArgumentException('The name must not contain ".".'); @@ -18,8 +28,41 @@ abstract class BaseNode implements NodeInterface $this->name = $name; $this->parent = $parent; - $this->beforeTransformations = $beforeTransformations; - $this->afterTransformations = $afterTransformations; + $this->normalizationClosures = array(); + $this->finalValidationClosures = array(); + $this->allowOverwrite = true; + $this->required = false; + $this->equivalentValues = array(); + } + + public function addEquivalentValue($originalValue, $equivalentValue) + { + $this->equivalentValues[] = array($originalValue, $equivalentValue); + } + + public function setRequired($boolean) + { + $this->required = (Boolean) $boolean; + } + + public function setAllowOverwrite($allow) + { + $this->allowOverwrite = (Boolean) $allow; + } + + public function setNormalizationClosures(array $closures) + { + $this->normalizationClosures = $closures; + } + + public function setFinalValidationClosures(array $closures) + { + $this->finalValidationClosures = $closures; + } + + public function isRequired() + { + return $this->required; } public function getName() @@ -38,22 +81,64 @@ abstract class BaseNode implements NodeInterface return $path; } + public final function merge($leftSide, $rightSide) + { + if (!$this->allowOverwrite) { + throw new ForbiddenOverwriteException(sprintf( + 'Configuration path "%s" cannot be overwritten. You have to ' + .'define all options for this path, and any of its sub-paths in ' + .'one configuration section.', + $this->getPath() + )); + } + + $this->validateType($leftSide); + $this->validateType($rightSide); + + return $this->mergeValues($leftSide, $rightSide); + } + public final function normalize($value) { - // run before transformations - foreach ($this->beforeTransformations as $transformation) { - $value = $transformation($value); + // run custom normalization closures + foreach ($this->normalizationClosures as $closure) { + $value = $closure($value); + } + + // replace value with their equivalent + foreach ($this->equivalentValues as $data) { + if ($data[0] === $value) { + $value = $data[1]; + } } // validate type $this->validateType($value); // normalize value - $value = $this->normalizeValue($value); + return $this->normalizeValue($value); + } - // run after transformations - foreach ($this->afterTransformations as $transformation) { - $value = $transformation($value); + public final function finalize($value) + { + $this->validateType($value); + + $value = $this->finalizeValue($value); + + // Perform validation on the final value if a closure has been set. + // The closure is also allowed to return another value. + foreach ($this->finalValidationClosures as $closure) { + try { + $value = $closure($value); + } catch (Exception $correctEx) { + throw $correctEx; + } catch (\Exception $invalid) { + throw new InvalidConfigurationException(sprintf( + 'Invalid configuration for path "%s": %s', + $this->getPath(), + $invalid->getMessage() + ), $invalid->getCode(), $invalid); + } } return $value; @@ -61,4 +146,6 @@ abstract class BaseNode implements NodeInterface abstract protected function validateType($value); abstract protected function normalizeValue($value); + abstract protected function mergeValues($leftSide, $rightSide); + abstract protected function finalizeValue($value); } \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/BooleanNode.php b/src/Symfony/Component/DependencyInjection/Configuration/BooleanNode.php new file mode 100644 index 0000000000..3960e0e756 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/BooleanNode.php @@ -0,0 +1,21 @@ +getPath(), + json_encode($value) + )); + } + } +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Builder/ExprBuilder.php b/src/Symfony/Component/DependencyInjection/Configuration/Builder/ExprBuilder.php index c8f2e0cbdd..1e29a6ed72 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/Builder/ExprBuilder.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/Builder/ExprBuilder.php @@ -2,6 +2,11 @@ namespace Symfony\Component\DependencyInjection\Configuration\Builder; +/** + * This class builds an if expression. + * + * @author Johannes M. Schmitt + */ class ExprBuilder { public $parent; @@ -13,8 +18,12 @@ class ExprBuilder $this->parent = $parent; } - public function ifTrue(\Closure $closure) + public function ifTrue(\Closure $closure = null) { + if (null === $closure) { + $closure = function($v) { return true === $v; }; + } + $this->ifPart = $closure; return $this; @@ -48,6 +57,31 @@ class ExprBuilder return $this; } + public function thenReplaceKeyWithAttribute($attribute) + { + $this->thenPart = function($v) { + $newValue = array(); + foreach ($v as $k => $oldValue) { + if (is_array($oldValue) && isset($oldValue['id'])) { + $k = $oldValue['id']; + } + + $newValue[$k] = $oldValue; + } + + return $newValue; + }; + + return $this; + } + + public function thenEmptyArray() + { + $this->thenPart = function($v) { return array(); }; + + return $this; + } + public function end() { if (null === $this->ifPart) { diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Builder/MergeBuilder.php b/src/Symfony/Component/DependencyInjection/Configuration/Builder/MergeBuilder.php new file mode 100644 index 0000000000..5ac10001db --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Builder/MergeBuilder.php @@ -0,0 +1,41 @@ + + */ +class MergeBuilder +{ + public $parent; + public $allowFalse; + public $allowOverwrite; + + public function __construct($parent) + { + $this->parent = $parent; + $this->allowFalse = false; + $this->allowOverwrite = true; + } + + public function allowUnset($allow = true) + { + $this->allowFalse = $allow; + + return $this; + } + + public function denyOverwrite($deny = true) + { + $this->allowOverwrite = !$deny; + + return $this; + } + + public function end() + { + return $this->parent; + } +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Builder/NodeBuilder.php b/src/Symfony/Component/DependencyInjection/Configuration/Builder/NodeBuilder.php index 0c63068d71..aa19de0e61 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/Builder/NodeBuilder.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/Builder/NodeBuilder.php @@ -2,6 +2,11 @@ namespace Symfony\Component\DependencyInjection\Configuration\Builder; +/** + * This class provides a fluent interface for building a config tree. + * + * @author Johannes M. Schmitt + */ class NodeBuilder { /************ @@ -13,9 +18,20 @@ class NodeBuilder public $parent; public $children; public $prototype; - public $normalizeTransformations; - public $beforeTransformations; - public $afterTransformations; + public $normalization; + public $merge; + public $finalization; + public $defaultValue; + public $default; + public $addDefaults; + public $required; + public $atLeastOne; + public $allowNewKeys; + public $allowEmptyValue; + public $nullEquivalent; + public $trueEquivalent; + public $falseEquivalent; + public $performDeepMerging; public function __construct($name, $type, $parent = null) { @@ -23,10 +39,28 @@ class NodeBuilder $this->type = $type; $this->parent = $parent; - $this->children = - $this->beforeTransformations = - $this->afterTransformations = - $this->normalizeTransformations = array(); + $this->default = false; + $this->required = false; + $this->addDefaults = false; + $this->allowNewKeys = true; + $this->atLeastOne = false; + $this->allowEmptyValue = true; + $this->children = array(); + $this->performDeepMerging = true; + + if ('boolean' === $type) { + $this->nullEquivalent = true; + } else if ('array' === $type) { + $this->nullEquivalent = array(); + } + + if ('array' === $type) { + $this->trueEquivalent = array(); + } else { + $this->trueEquivalent = true; + } + + $this->falseEquivalent = false; } /**************************** @@ -35,54 +69,178 @@ class NodeBuilder public function node($name, $type) { - $node = new NodeBuilder($name, $type, $this); + $node = new static($name, $type, $this); return $this->children[$name] = $node; } - public function normalize($key, $plural = null) + public function arrayNode($name) { - if (null === $plural) { - $plural = $key.'s'; - } + return $this->node($name, 'array'); + } - $this->normalizeTransformations[] = array($key, $plural); + public function scalarNode($name) + { + return $this->node($name, 'scalar'); + } + + public function booleanNode($name) + { + return $this->node($name, 'boolean'); + } + + public function defaultValue($value) + { + $this->default = true; + $this->defaultValue = $value; return $this; } - public function key($name) + public function isRequired() + { + $this->required = true; + + return $this; + } + + public function containsNameValuePairsWithKeyAttribute($attribute) + { + $this->beforeNormalization() + ->ifArray() + ->thenReplaceKeyWithAttribute($attribute) + ; + + $this->useAttributeAsKey($attribute); + + return $this; + } + + public function requiresAtLeastOneElement() + { + $this->atLeastOne = true; + + return $this; + } + + public function treatNullLike($value) + { + $this->nullEquivalent = $value; + + return $this; + } + + public function treatTrueLike($value) + { + $this->trueEquivalent = $value; + + return $this; + } + + public function treatFalseLike($value) + { + $this->falseEquivalent = $value; + + return $this; + } + + public function defaultNull() + { + return $this->defaultValue(null); + } + + public function defaultTrue() + { + return $this->defaultValue(true); + } + + public function defaultFalse() + { + return $this->defaultValue(false); + } + + public function addDefaultsIfNotSet() + { + $this->addDefaults = true; + + return $this; + } + + public function disallowNewKeysInSubsequentConfigs() + { + $this->allowNewKeys = false; + + return $this; + } + + protected function normalization() + { + if (null === $this->normalization) { + $this->normalization = new NormalizationBuilder($this); + } + + return $this->normalization; + } + + public function beforeNormalization() + { + return $this->normalization()->before(); + } + + public function fixXmlConfig($singular, $plural = null) + { + $this->normalization()->remap($singular, $plural); + + return $this; + } + + public function useAttributeAsKey($name) { $this->key = $name; return $this; } - public function before(\Closure $closure = null) + protected function merge() { - if (null !== $closure) { - $this->beforeTransformations[] = $closure; - - return $this; + if (null === $this->merge) { + $this->merge = new MergeBuilder($this); } - return $this->beforeTransformations[] = new ExprBuilder($this); + return $this->merge; + } + + public function cannotBeOverwritten($deny = true) + { + $this->merge()->denyOverwrite($deny); + + return $this; + } + + public function cannotBeEmpty() + { + $this->allowEmptyValue = false; + + return $this; + } + + public function canBeUnset($allow = true) + { + $this->merge()->allowUnset($allow); + + return $this; } public function prototype($type) { - return $this->prototype = new NodeBuilder(null, $type, $this); + return $this->prototype = new static(null, $type, $this); } - public function after(\Closure $closure = null) + public function performNoDeepMerging() { - if (null !== $closure) { - $this->afterTransformations[] = $closure; + $this->performDeepMerging = false; - return $this; - } - - return $this->afterTransformations[] = new ExprBuilder($this); + return $this; } public function end() diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Builder/NormalizationBuilder.php b/src/Symfony/Component/DependencyInjection/Configuration/Builder/NormalizationBuilder.php new file mode 100644 index 0000000000..96e76cb586 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Builder/NormalizationBuilder.php @@ -0,0 +1,48 @@ + + */ +class NormalizationBuilder +{ + public $parent; + public $before; + public $remappings; + + public function __construct($parent) + { + $this->parent = $parent; + + $this->keys = false; + + $this->remappings = + $this->before = + $this->after = array(); + } + + public function remap($key, $plural = null) + { + if (null === $plural) { + $plural = $key.'s'; + } + + $this->remappings[] = array($key, $plural); + + return $this; + } + + public function before(\Closure $closure = null) + { + if (null !== $closure) { + $this->before[] = $closure; + + return $this; + } + + return $this->before[] = new ExprBuilder($this->parent); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Builder/TreeBuilder.php b/src/Symfony/Component/DependencyInjection/Configuration/Builder/TreeBuilder.php index 93f48bdf19..e24a23cb4d 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/Builder/TreeBuilder.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/Builder/TreeBuilder.php @@ -2,9 +2,18 @@ namespace Symfony\Component\DependencyInjection\Configuration\Builder; +use Symfony\Component\DependencyInjection\Configuration\BaseNode; + +use Symfony\Component\DependencyInjection\Configuration\BooleanNode; + use Symfony\Component\DependencyInjection\Configuration\ArrayNode; use Symfony\Component\DependencyInjection\Configuration\ScalarNode; +/** + * This is the entry class for building your own config tree. + * + * @author Johannes M. Schmitt + */ class TreeBuilder { protected $root; @@ -32,9 +41,6 @@ class TreeBuilder protected function createConfigNode(NodeBuilder $node) { - $node->beforeTransformations = $this->buildExpressions($node->beforeTransformations); - $node->afterTransformations = $this->buildExpressions($node->afterTransformations); - $method = 'create'.$node->type.'ConfigNode'; if (!method_exists($this, $method)) { throw new \RuntimeException(sprintf('Unknown node type: "%s"', $node->type)); @@ -43,14 +49,77 @@ class TreeBuilder return $this->$method($node); } + protected function createBooleanConfigNode(NodeBuilder $node) + { + $configNode = new BooleanNode($node->name, $node->parent); + $this->configureScalarNode($configNode, $node); + + return $configNode; + } + protected function createScalarConfigNode(NodeBuilder $node) { - return new ScalarNode($node->name, $node->parent, $node->beforeTransformations, $node->afterTransformations); + $configNode = new ScalarNode($node->name, $node->parent); + $this->configureScalarNode($configNode, $node); + + return $configNode; + } + + protected function configureScalarNode(ScalarNode $configNode, NodeBuilder $node) + { + if (null !== $node->normalization) { + $configNode->setNormalizationClosures( + $this->buildExpressions($node->normalization->before) + ); + } + + if (null !== $node->merge) { + $configNode->setAllowOverwrite($node->merge->allowOverwrite); + } + + if (true === $node->default) { + $configNode->setDefaultValue($node->defaultValue); + } + + if (false === $node->allowEmptyValue) { + $configNode->setAllowEmptyValue($node->allowEmptyValue); + } + + $configNode->addEquivalentValue(null, $node->nullEquivalent); + $configNode->addEquivalentValue(true, $node->trueEquivalent); + $configNode->addEquivalentValue(false, $node->falseEquivalent); } protected function createArrayConfigNode(NodeBuilder $node) { - $configNode = new ArrayNode($node->name, $node->parent, $node->beforeTransformations, $node->afterTransformations, $node->normalizeTransformations, $node->key); + $configNode = new ArrayNode($node->name, $node->parent); + $configNode->setAddIfNotSet($node->addDefaults); + $configNode->setAllowNewKeys($node->allowNewKeys); + $configNode->addEquivalentValue(null, $node->nullEquivalent); + $configNode->addEquivalentValue(true, $node->trueEquivalent); + $configNode->addEquivalentValue(false, $node->falseEquivalent); + $configNode->setPerformDeepMerging($node->performDeepMerging); + + if (null !== $node->key) { + $configNode->setKeyAttribute($node->key); + } + + if (true === $node->atLeastOne) { + $configNode->setMinNumberOfElements(1); + } + + if (null !== $node->normalization) { + $configNode->setNormalizationClosures( + $this->buildExpressions($node->normalization->before) + ); + + $configNode->setXmlRemappings($node->normalization->remappings); + } + + if (null !== $node->merge) { + $configNode->setAllowOverwrite($node->merge->allowOverwrite); + $configNode->setAllowFalse($node->merge->allowFalse); + } foreach ($node->children as $child) { $child->parent = $configNode; diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/DuplicateKeyException.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/DuplicateKeyException.php new file mode 100644 index 0000000000..7da500ba56 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/DuplicateKeyException.php @@ -0,0 +1,13 @@ + + */ +class DuplicateKeyException extends InvalidConfigurationException +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/Exception.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/Exception.php index e5a464b276..c669089f47 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/Exception/Exception.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/Exception.php @@ -2,6 +2,11 @@ namespace Symfony\Component\DependencyInjection\Configuration\Exception; +/** + * Base exception for all configuration exceptions + * + * @author Johannes M. Schmitt + */ class Exception extends \RuntimeException { } \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/ForbiddenOverwriteException.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/ForbiddenOverwriteException.php new file mode 100644 index 0000000000..0f7537747e --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/ForbiddenOverwriteException.php @@ -0,0 +1,13 @@ + + */ +class ForbiddenOverwriteException extends InvalidConfigurationException +{ +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidConfigurationException.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidConfigurationException.php new file mode 100644 index 0000000000..71f3ffbd52 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidConfigurationException.php @@ -0,0 +1,13 @@ + + */ +class InvalidConfigurationException extends Exception +{ +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidTypeException.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidTypeException.php index 436c80fa80..3cdbc52439 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidTypeException.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/InvalidTypeException.php @@ -5,8 +5,8 @@ namespace Symfony\Component\DependencyInjection\Configuration\Exception; /** * This exception is thrown if an invalid type is encountered. * - * @author johannes + * @author Johannes M. Schmitt */ -class InvalidTypeException extends Exception +class InvalidTypeException extends InvalidConfigurationException { } \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Exception/UnsetKeyException.php b/src/Symfony/Component/DependencyInjection/Configuration/Exception/UnsetKeyException.php new file mode 100644 index 0000000000..2388b134b4 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Exception/UnsetKeyException.php @@ -0,0 +1,13 @@ + + */ +class UnsetKeyException extends Exception +{ +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/NodeInterface.php b/src/Symfony/Component/DependencyInjection/Configuration/NodeInterface.php index a5e8611c63..70271946b4 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/NodeInterface.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/NodeInterface.php @@ -2,9 +2,21 @@ namespace Symfony\Component\DependencyInjection\Configuration; +/** + * Common Interface among all nodes. + * + * In most cases, it is better to inherit from BaseNode instead of implementing + * this interface yourself. + * + * @author Johannes M. Schmitt + */ interface NodeInterface { function getName(); function getPath(); + function isRequired(); + function hasDefaultValue(); + function getDefaultValue(); function normalize($value); + function merge($leftSide, $rightSide); } \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/Processor.php b/src/Symfony/Component/DependencyInjection/Configuration/Processor.php new file mode 100644 index 0000000000..cdabd29a03 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Configuration/Processor.php @@ -0,0 +1,26 @@ + + */ +class Processor +{ + public function process(NodeInterface $configTree, array $configs) + { + $configs = Extension::normalizeKeys($configs); + + $currentConfig = array(); + foreach ($configs as $config) { + $config = $configTree->normalize($config); + $currentConfig = $configTree->merge($currentConfig, $config); + } + + return $configTree->finalize($currentConfig); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/DependencyInjection/Configuration/ScalarNode.php b/src/Symfony/Component/DependencyInjection/Configuration/ScalarNode.php index cdcebe3dac..fd871c3864 100644 --- a/src/Symfony/Component/DependencyInjection/Configuration/ScalarNode.php +++ b/src/Symfony/Component/DependencyInjection/Configuration/ScalarNode.php @@ -2,10 +2,41 @@ namespace Symfony\Component\DependencyInjection\Configuration; +use Symfony\Component\DependencyInjection\Configuration\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Configuration\Exception\InvalidTypeException; +/** + * This node represents a scalar value in the config tree. + * + * @author Johannes M. Schmitt + */ class ScalarNode extends BaseNode implements PrototypeNodeInterface { + protected $defaultValueSet = false; + protected $defaultValue; + protected $allowEmptyValue = true; + + public function setDefaultValue($value) + { + $this->defaultValueSet = true; + $this->defaultValue = $value; + } + + public function hasDefaultValue() + { + return $this->defaultValueSet; + } + + public function getDefaultValue() + { + return $this->defaultValue; + } + + public function setAllowEmptyValue($boolean) + { + $this->allowEmptyValue = (Boolean) $boolean; + } + public function setName($name) { $this->name = $name; @@ -14,7 +45,7 @@ class ScalarNode extends BaseNode implements PrototypeNodeInterface protected function validateType($value) { if (!is_scalar($value)) { - throw new \InvalidTypeException(sprintf( + throw new InvalidTypeException(sprintf( 'Invalid type for path "%s". Expected scalar, but got %s.', $this->getPath(), json_encode($value) @@ -22,8 +53,26 @@ class ScalarNode extends BaseNode implements PrototypeNodeInterface } } + protected function finalizeValue($value) + { + if (!$this->allowEmptyValue && empty($value)) { + throw new InvalidConfigurationException(sprintf( + 'The path "%s" cannot contain an empty value, but got %s.', + $this->getPath(), + json_encode($value) + )); + } + + return $value; + } + protected function normalizeValue($value) { return $value; } + + protected function mergeValues($leftSide, $rightSide) + { + return $rightSide; + } } \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/DependencyInjection/Configuration/ArrayNodeTest.php b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/ArrayNodeTest.php new file mode 100644 index 0000000000..da3155493e --- /dev/null +++ b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/ArrayNodeTest.php @@ -0,0 +1,17 @@ +normalize(false); + } +} \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/DependencyInjection/Configuration/FinalizationTest.php b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/FinalizationTest.php new file mode 100644 index 0000000000..ab3f5af12b --- /dev/null +++ b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/FinalizationTest.php @@ -0,0 +1,59 @@ +root('config', 'array') + ->node('level1', 'array') + ->canBeUnset() + ->node('level2', 'array') + ->canBeUnset() + ->node('somevalue', 'scalar')->end() + ->node('anothervalue', 'scalar')->end() + ->end() + ->node('level1_scalar', 'scalar')->end() + ->end() + ->end() + ->buildTree() + ; + + $a = array( + 'level1' => array( + 'level2' => array( + 'somevalue' => 'foo', + 'anothervalue' => 'bar', + ), + 'level1_scalar' => 'foo', + ), + ); + + $b = array( + 'level1' => array( + 'level2' => false, + ), + ); + + $this->assertEquals(array( + 'level1' => array( + 'level1_scalar' => 'foo', + ), + ), $this->process($tree, array($a, $b))); + } + + protected function process(NodeInterface $tree, array $configs) + { + $processor = new Processor(); + + return $processor->process($tree, $configs); + } +} \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/DependencyInjection/Configuration/MergeTest.php b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/MergeTest.php new file mode 100644 index 0000000000..0fab77cc11 --- /dev/null +++ b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/MergeTest.php @@ -0,0 +1,148 @@ +root('root', 'array') + ->node('foo', 'scalar') + ->merge() + ->denyOverwrite() + ->end() + ->end() + ->end() + ->buildTree() + ; + + $a = array( + 'foo' => 'bar', + ); + + $b = array( + 'foo' => 'moo', + ); + + $tree->merge($a, $b); + } + + public function testUnsetKey() + { + $tb = new TreeBuilder(); + $tree = $tb + ->root('root', 'array') + ->node('foo', 'scalar')->end() + ->node('bar', 'scalar')->end() + ->node('unsettable', 'array') + ->merge()->allowUnset()->end() + ->node('foo', 'scalar')->end() + ->node('bar', 'scalar')->end() + ->end() + ->node('unsetted', 'array') + ->merge()->allowUnset()->end() + ->prototype('scalar')->end() + ->end() + ->end() + ->buildTree() + ; + + $a = array( + 'foo' => 'bar', + 'unsettable' => array( + 'foo' => 'a', + 'bar' => 'b', + ), + 'unsetted' => false, + ); + + $b = array( + 'foo' => 'moo', + 'bar' => 'b', + 'unsettable' => false, + 'unsetted' => array('a', 'b'), + ); + + $this->assertEquals(array( + 'foo' => 'moo', + 'bar' => 'b', + 'unsettable' => false, + 'unsetted' => array('a', 'b'), + ), $tree->merge($a, $b)); + } + + /** + * @expectedException Symfony\Component\DependencyInjection\Configuration\Exception\InvalidConfigurationException + */ + public function testDoesNotAllowNewKeysInSubsequentConfigs() + { + $tb = new TreeBuilder(); + $tree = $tb + ->root('config', 'array') + ->node('test', 'array') + ->disallowNewKeysInSubsequentConfigs() + ->useAttributeAsKey('key') + ->prototype('array') + ->node('value', 'scalar')->end() + ->end() + ->end() + ->end() + ->buildTree(); + + $a = array( + 'test' => array( + 'a' => array('value' => 'foo') + ) + ); + + $b = array( + 'test' => array( + 'b' => array('value' => 'foo') + ) + ); + + $tree->merge($a, $b); + } + + public function testPerformsNoDeepMerging() + { + $tb = new TreeBuilder(); + + $tree = $tb + ->root('config', 'array') + ->node('no_deep_merging', 'array') + ->performNoDeepMerging() + ->node('foo', 'scalar')->end() + ->node('bar', 'scalar')->end() + ->end() + ->end() + ->buildTree() + ; + + $a = array( + 'no_deep_merging' => array( + 'foo' => 'a', + 'bar' => 'b', + ), + ); + + $b = array( + 'no_deep_merging' => array( + 'c' => 'd', + ) + ); + + $this->assertEquals(array( + 'no_deep_merging' => array( + 'c' => 'd', + ) + ), $tree->merge($a, $b)); + } +} \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/DependencyInjection/Configuration/NormalizationTest.php b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/NormalizationTest.php index 4afbdb9eee..9254700eb6 100644 --- a/tests/Symfony/Tests/Component/DependencyInjection/Configuration/NormalizationTest.php +++ b/tests/Symfony/Tests/Component/DependencyInjection/Configuration/NormalizationTest.php @@ -15,11 +15,11 @@ class NormalizerTest extends \PHPUnit_Framework_TestCase $tb = new TreeBuilder(); $tree = $tb ->root('root_name', 'array') - ->normalize('encoder') + ->fixXmlConfig('encoder') ->node('encoders', 'array') - ->key('class') + ->useAttributeAsKey('class') ->prototype('array') - ->before()->ifString()->then(function($v) { return array('algorithm' => $v); })->end() + ->beforeNormalization()->ifString()->then(function($v) { return array('algorithm' => $v); })->end() ->node('algorithm', 'scalar')->end() ->end() ->end() @@ -87,7 +87,7 @@ class NormalizerTest extends \PHPUnit_Framework_TestCase $tree = $tb ->root('root', 'array') ->node('logout', 'array') - ->normalize('handler') + ->fixXmlConfig('handler') ->node('handlers', 'array') ->prototype('scalar')->end() ->end()