Loading core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php→core/lib/Drupal/Core/Recipe/AllowListConfigStorage.php +131 −0 Original line number Diff line number Diff line Loading @@ -4,118 +4,104 @@ namespace Drupal\Core\Recipe; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\StorageInterface; /** * Allows the recipe to select configuration from the module. * A read-only storage wrapper that only allows access to certain config names. * * @internal * This API is experimental. */ final class RecipeExtensionConfigStorage implements StorageInterface { protected readonly StorageInterface $storage; final class AllowListConfigStorage implements StorageInterface { /** * @param string $extensionPath * The path extension to read configuration from * @param array $configNames * The list of config to read from the extension. An empty array means all * configuration. * @param string $collection * (optional) The collection to store configuration in. Defaults to the * default collection. * @param \Drupal\Core\Config\StorageInterface $decorated * A config storage backend to wrap around. * @param string[] $allowList * A list of config names. Only these names will be visible, or readable, * by this storage. Cannot be empty. */ public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { $this->storage = new RecipeConfigStorageWrapper( new FileStorage($this->extensionPath . '/config/install', $this->collection), new FileStorage($this->extensionPath . '/config/optional', $this->collection), $collection ); public function __construct( private readonly StorageInterface $decorated, private readonly array $allowList, ) { if (empty($allowList)) { throw new \LogicException('AllowListConfigStorage cannot be constructed with an empty allow list.'); } } /** * {@inheritdoc} */ public function exists($name): bool { if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { return FALSE; if (in_array($name, $this->allowList, TRUE)) { return $this->decorated->exists($name); } return $this->storage->exists($name); return FALSE; } /** * {@inheritdoc} */ public function read($name): array|bool { if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { return FALSE; } return $this->storage->read($name); public function read($name): array|false { return $this->exists($name) ? $this->decorated->read($name) : FALSE; } /** * {@inheritdoc} */ public function readMultiple(array $names): array { if (!empty($this->configNames)) { $names = array_intersect($this->configNames, $names); } return $this->storage->readMultiple($names); $names = array_intersect($names, $this->allowList); return $this->decorated->readMultiple($names); } /** * {@inheritdoc} */ public function write($name, array $data): bool { throw new \BadMethodCallException(); public function write($name, array $data): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function delete($name): bool { throw new \BadMethodCallException(); public function delete($name): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function rename($name, $new_name): bool { throw new \BadMethodCallException(); public function rename($name, $new_name): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function encode($data): string { throw new \BadMethodCallException(); return $this->decorated->encode($data); } /** * {@inheritdoc} */ public function decode($raw): array { throw new \BadMethodCallException(); return $this->decorated->decode($raw); } /** * {@inheritdoc} */ public function listAll($prefix = ''): array { $names = $this->storage->listAll($prefix); if (!empty($this->configNames)) { $names = array_intersect($this->configNames, $names); } return $names; return array_intersect($this->decorated->listAll($prefix), $this->allowList); } /** * {@inheritdoc} */ public function deleteAll($prefix = ''): bool { throw new \BadMethodCallException(); public function deleteAll($prefix = ''): never { throw new \BadMethodCallException('This storage is read-only.'); } /** Loading @@ -123,9 +109,8 @@ public function deleteAll($prefix = ''): bool { */ public function createCollection($collection): static { return new static( $this->extensionPath, $this->configNames, $collection $this->decorated->createCollection($collection), $this->allowList, ); } Loading @@ -133,14 +118,14 @@ public function createCollection($collection): static { * {@inheritdoc} */ public function getAllCollectionNames(): array { return $this->storage->getAllCollectionNames(); return $this->decorated->getAllCollectionNames(); } /** * {@inheritdoc} */ public function getCollectionName(): string { return $this->collection; return $this->decorated->getCollectionName(); } } core/lib/Drupal/Core/Recipe/ConfigConfigurator.php +45 −6 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ namespace Drupal\Core\Recipe; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\NullStorage; use Drupal\Core\Config\StorageInterface; /** Loading @@ -15,6 +16,8 @@ final class ConfigConfigurator { public readonly ?string $recipeConfigDirectory; private readonly bool|array $strict; /** * @param array $config * Config options for a recipe. Loading @@ -25,8 +28,20 @@ final class ConfigConfigurator { */ public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) { $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL; // @todo Consider defaulting this to FALSE in https://drupal.org/i/3478669. $this->strict = $config['strict'] ?? TRUE; $recipe_storage = $this->getConfigStorage(); foreach ($recipe_storage->listAll() as $config_name) { if ($this->strict === TRUE) { $strict_list = $recipe_storage->listAll(); } else { $strict_list = $this->strict ?: []; } // Everything in the strict list needs to be identical in the recipe and // active storage. foreach ($strict_list as $config_name) { if ($active_data = $active_configuration->read($config_name)) { // @todo https://www.drupal.org/i/3439714 Investigate if there is any // generic code in core for this. Loading Loading @@ -90,10 +105,10 @@ public function getConfigStorage(): StorageInterface { $module_list = \Drupal::service('extension.list.module'); /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */ $theme_list = \Drupal::service('extension.list.theme'); foreach ($this->config['import'] as $extension => $config) { foreach ($this->config['import'] as $extension => $names) { // If the recipe explicitly does not want to import any config from this // extension, skip it. if ($config === NULL) { if ($names === NULL) { continue; } $path = match (TRUE) { Loading @@ -101,12 +116,36 @@ public function getConfigStorage(): StorageInterface { $theme_list->exists($extension) => $theme_list->getPath($extension), default => throw new \RuntimeException("$extension is not a theme or module") }; $config = $config === '*' ? [] : $config; $storages[] = new RecipeExtensionConfigStorage($path, $config); $storage = new RecipeConfigStorageWrapper( new FileStorage($path . '/config/install'), new FileStorage($path . '/config/optional'), ); // If we get here, $names is either '*', or a list of config names // provided by the current extension. In the latter case, we only want // to import the config that is in the list, so use an // AllowListConfigStorage to filter out the extension's other config. if ($names && is_array($names)) { $storage = new AllowListConfigStorage($storage, $names); } $storages[] = $storage; } } $storage = RecipeConfigStorageWrapper::createStorageFromArray($storages); if ($this->strict) { return $storage; } // If we're not in strict mode, we only want to import config that doesn't // exist yet in active storage. return RecipeConfigStorageWrapper::createStorageFromArray($storages); $names = array_diff( $storage->listAll(), \Drupal::service('config.storage')->listAll(), ); return $names ? new AllowListConfigStorage($storage, $names) : new NullStorage(); } /** Loading core/lib/Drupal/Core/Recipe/Recipe.php +10 −0 Original line number Diff line number Diff line Loading @@ -257,6 +257,16 @@ private static function parse(string $file): array { ]), ]), ]), 'strict' => new Optional([ new AtLeastOneOf([ new Type('boolean'), new All([ new Type('string'), new NotBlank(), new Regex('/^.+\./'), ]), ], message: 'This value must be a boolean, or a list of config names.', includeInternalMessages: FALSE), ]), 'actions' => new Optional([ new All([ new Type('array'), Loading core/recipes/administrator_role/config/user.role.administrator.yml 0 → 100644 +8 −0 Original line number Diff line number Diff line langcode: en status: true dependencies: { } id: administrator label: Administrator weight: 3 is_admin: true permissions: { } core/recipes/administrator_role/recipe.yml +2 −8 Original line number Diff line number Diff line Loading @@ -2,11 +2,5 @@ name: 'Administrator role' description: 'Provides the Administrator role.' type: 'User role' config: actions: user.role.administrator: # If this role already exists, then this action has no effect. If it doesn't exist, we'll create it with the following values. createIfNotExists: id: administrator label: Administrator weight: 3 is_admin: true # If the administrator role already exists, we don't really care what it looks like. strict: false Loading
core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php→core/lib/Drupal/Core/Recipe/AllowListConfigStorage.php +131 −0 Original line number Diff line number Diff line Loading @@ -4,118 +4,104 @@ namespace Drupal\Core\Recipe; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\StorageInterface; /** * Allows the recipe to select configuration from the module. * A read-only storage wrapper that only allows access to certain config names. * * @internal * This API is experimental. */ final class RecipeExtensionConfigStorage implements StorageInterface { protected readonly StorageInterface $storage; final class AllowListConfigStorage implements StorageInterface { /** * @param string $extensionPath * The path extension to read configuration from * @param array $configNames * The list of config to read from the extension. An empty array means all * configuration. * @param string $collection * (optional) The collection to store configuration in. Defaults to the * default collection. * @param \Drupal\Core\Config\StorageInterface $decorated * A config storage backend to wrap around. * @param string[] $allowList * A list of config names. Only these names will be visible, or readable, * by this storage. Cannot be empty. */ public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) { $this->storage = new RecipeConfigStorageWrapper( new FileStorage($this->extensionPath . '/config/install', $this->collection), new FileStorage($this->extensionPath . '/config/optional', $this->collection), $collection ); public function __construct( private readonly StorageInterface $decorated, private readonly array $allowList, ) { if (empty($allowList)) { throw new \LogicException('AllowListConfigStorage cannot be constructed with an empty allow list.'); } } /** * {@inheritdoc} */ public function exists($name): bool { if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { return FALSE; if (in_array($name, $this->allowList, TRUE)) { return $this->decorated->exists($name); } return $this->storage->exists($name); return FALSE; } /** * {@inheritdoc} */ public function read($name): array|bool { if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) { return FALSE; } return $this->storage->read($name); public function read($name): array|false { return $this->exists($name) ? $this->decorated->read($name) : FALSE; } /** * {@inheritdoc} */ public function readMultiple(array $names): array { if (!empty($this->configNames)) { $names = array_intersect($this->configNames, $names); } return $this->storage->readMultiple($names); $names = array_intersect($names, $this->allowList); return $this->decorated->readMultiple($names); } /** * {@inheritdoc} */ public function write($name, array $data): bool { throw new \BadMethodCallException(); public function write($name, array $data): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function delete($name): bool { throw new \BadMethodCallException(); public function delete($name): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function rename($name, $new_name): bool { throw new \BadMethodCallException(); public function rename($name, $new_name): never { throw new \BadMethodCallException('This storage is read-only.'); } /** * {@inheritdoc} */ public function encode($data): string { throw new \BadMethodCallException(); return $this->decorated->encode($data); } /** * {@inheritdoc} */ public function decode($raw): array { throw new \BadMethodCallException(); return $this->decorated->decode($raw); } /** * {@inheritdoc} */ public function listAll($prefix = ''): array { $names = $this->storage->listAll($prefix); if (!empty($this->configNames)) { $names = array_intersect($this->configNames, $names); } return $names; return array_intersect($this->decorated->listAll($prefix), $this->allowList); } /** * {@inheritdoc} */ public function deleteAll($prefix = ''): bool { throw new \BadMethodCallException(); public function deleteAll($prefix = ''): never { throw new \BadMethodCallException('This storage is read-only.'); } /** Loading @@ -123,9 +109,8 @@ public function deleteAll($prefix = ''): bool { */ public function createCollection($collection): static { return new static( $this->extensionPath, $this->configNames, $collection $this->decorated->createCollection($collection), $this->allowList, ); } Loading @@ -133,14 +118,14 @@ public function createCollection($collection): static { * {@inheritdoc} */ public function getAllCollectionNames(): array { return $this->storage->getAllCollectionNames(); return $this->decorated->getAllCollectionNames(); } /** * {@inheritdoc} */ public function getCollectionName(): string { return $this->collection; return $this->decorated->getCollectionName(); } }
core/lib/Drupal/Core/Recipe/ConfigConfigurator.php +45 −6 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ namespace Drupal\Core\Recipe; use Drupal\Core\Config\FileStorage; use Drupal\Core\Config\NullStorage; use Drupal\Core\Config\StorageInterface; /** Loading @@ -15,6 +16,8 @@ final class ConfigConfigurator { public readonly ?string $recipeConfigDirectory; private readonly bool|array $strict; /** * @param array $config * Config options for a recipe. Loading @@ -25,8 +28,20 @@ final class ConfigConfigurator { */ public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) { $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL; // @todo Consider defaulting this to FALSE in https://drupal.org/i/3478669. $this->strict = $config['strict'] ?? TRUE; $recipe_storage = $this->getConfigStorage(); foreach ($recipe_storage->listAll() as $config_name) { if ($this->strict === TRUE) { $strict_list = $recipe_storage->listAll(); } else { $strict_list = $this->strict ?: []; } // Everything in the strict list needs to be identical in the recipe and // active storage. foreach ($strict_list as $config_name) { if ($active_data = $active_configuration->read($config_name)) { // @todo https://www.drupal.org/i/3439714 Investigate if there is any // generic code in core for this. Loading Loading @@ -90,10 +105,10 @@ public function getConfigStorage(): StorageInterface { $module_list = \Drupal::service('extension.list.module'); /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */ $theme_list = \Drupal::service('extension.list.theme'); foreach ($this->config['import'] as $extension => $config) { foreach ($this->config['import'] as $extension => $names) { // If the recipe explicitly does not want to import any config from this // extension, skip it. if ($config === NULL) { if ($names === NULL) { continue; } $path = match (TRUE) { Loading @@ -101,12 +116,36 @@ public function getConfigStorage(): StorageInterface { $theme_list->exists($extension) => $theme_list->getPath($extension), default => throw new \RuntimeException("$extension is not a theme or module") }; $config = $config === '*' ? [] : $config; $storages[] = new RecipeExtensionConfigStorage($path, $config); $storage = new RecipeConfigStorageWrapper( new FileStorage($path . '/config/install'), new FileStorage($path . '/config/optional'), ); // If we get here, $names is either '*', or a list of config names // provided by the current extension. In the latter case, we only want // to import the config that is in the list, so use an // AllowListConfigStorage to filter out the extension's other config. if ($names && is_array($names)) { $storage = new AllowListConfigStorage($storage, $names); } $storages[] = $storage; } } $storage = RecipeConfigStorageWrapper::createStorageFromArray($storages); if ($this->strict) { return $storage; } // If we're not in strict mode, we only want to import config that doesn't // exist yet in active storage. return RecipeConfigStorageWrapper::createStorageFromArray($storages); $names = array_diff( $storage->listAll(), \Drupal::service('config.storage')->listAll(), ); return $names ? new AllowListConfigStorage($storage, $names) : new NullStorage(); } /** Loading
core/lib/Drupal/Core/Recipe/Recipe.php +10 −0 Original line number Diff line number Diff line Loading @@ -257,6 +257,16 @@ private static function parse(string $file): array { ]), ]), ]), 'strict' => new Optional([ new AtLeastOneOf([ new Type('boolean'), new All([ new Type('string'), new NotBlank(), new Regex('/^.+\./'), ]), ], message: 'This value must be a boolean, or a list of config names.', includeInternalMessages: FALSE), ]), 'actions' => new Optional([ new All([ new Type('array'), Loading
core/recipes/administrator_role/config/user.role.administrator.yml 0 → 100644 +8 −0 Original line number Diff line number Diff line langcode: en status: true dependencies: { } id: administrator label: Administrator weight: 3 is_admin: true permissions: { }
core/recipes/administrator_role/recipe.yml +2 −8 Original line number Diff line number Diff line Loading @@ -2,11 +2,5 @@ name: 'Administrator role' description: 'Provides the Administrator role.' type: 'User role' config: actions: user.role.administrator: # If this role already exists, then this action has no effect. If it doesn't exist, we'll create it with the following values. createIfNotExists: id: administrator label: Administrator weight: 3 is_admin: true # If the administrator role already exists, we don't really care what it looks like. strict: false