Unverified Commit f0fda63b authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3478332 by phenaproxima, nicxvan, thejimbirch, alexpott: Add a way to...

Issue #3478332 by phenaproxima, nicxvan, thejimbirch, alexpott: Add a way to prevent recipes' imported config from being compared too strictly to active config
parent dd72f018
Loading
Loading
Loading
Loading
Loading
+131 −0
Original line number Diff line number Diff line
@@ -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.');
  }

  /**
@@ -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,
    );
  }

@@ -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();
  }

}
+45 −6
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@
namespace Drupal\Core\Recipe;

use Drupal\Core\Config\FileStorage;
use Drupal\Core\Config\NullStorage;
use Drupal\Core\Config\StorageInterface;

/**
@@ -15,6 +16,8 @@ final class ConfigConfigurator {

  public readonly ?string $recipeConfigDirectory;

  private readonly bool|array $strict;

  /**
   * @param array $config
   *   Config options for a recipe.
@@ -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.
@@ -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) {
@@ -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();
  }

  /**
+10 −0
Original line number Diff line number Diff line
@@ -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'),
+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: {  }
+2 −8
Original line number Diff line number Diff line
@@ -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