(dev/core#2258) Add System.rotateKey API
authorTim Otten <totten@civicrm.org>
Mon, 21 Dec 2020 08:20:07 +0000 (00:20 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 30 Dec 2020 21:39:27 +0000 (13:39 -0800)
CRM/Utils/Hook.php
Civi/Api4/Action/System/RotateKey.php [new file with mode: 0644]
Civi/Api4/System.php
tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php [new file with mode: 0644]

index 920cdf74e145ecfb5c432c4ed35bbc0ee7a43cf4..e5f576e408d5daaa8ce5ae707d965438d72e507e 100644 (file)
@@ -2083,6 +2083,47 @@ abstract class CRM_Utils_Hook {
     );
   }
 
+  /**
+   * Rotate the cryptographic key used in the database.
+   *
+   * The purpose of this hook is to visit any encrypted values in the database
+   * and re-encrypt the content.
+   *
+   * For values encoded via `CryptoToken`, you can use `CryptoToken::rekey($oldToken, $tag)`
+   *
+   * @param string $tag
+   *   The type of crypto-key that is currently being rotated.
+   *   The hook-implementer should use this to decide which (if any) fields to visit.
+   *   Ex: 'CRED'
+   * @param \Psr\Log\LoggerInterface $log
+   *   List of messages about re-keyed values.
+   *
+   * @code
+   * function example_civicrm_rekey($tag, &$log) {
+   *   if ($tag !== 'CRED') return;
+   *
+   *   $cryptoToken = Civi::service('crypto.token');
+   *   $rows = sql('SELECT id, secret_column FROM some_table');
+   *   foreach ($rows as $row) {
+   *     $new = $cryptoToken->rekey($row['secret_column']);
+   *     if ($new !== NULL) {
+   *       sql('UPDATE some_table SET secret_column = %1 WHERE id = %2',
+   *         $new, $row['id']);
+   *     }
+   *   }
+   * }
+   * @endCode
+   *
+   * @return null
+   *   The return value is ignored
+   */
+  public static function cryptoRotateKey($tag, $log) {
+    return self::singleton()->invoke(['tag', 'log'], $tag, $log, self::$_nullObject,
+      self::$_nullObject, self::$_nullObject, self::$_nullObject,
+      'civicrm_cryptoRotateKey'
+    );
+  }
+
   /**
    * @param CRM_Core_Exception $exception
    * @param mixed $request
diff --git a/Civi/Api4/Action/System/RotateKey.php b/Civi/Api4/Action/System/RotateKey.php
new file mode 100644 (file)
index 0000000..c904a2c
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Action\System;
+
+use Civi\Api4\Generic\AbstractAction;
+use Civi\Api4\Generic\Result;
+
+/**
+ * Rotate the keys used for encrypted database content.
+ *
+ * Crypto keys are loaded from the CryptoRegistry based on tag name. Each tag will
+ * have one preferred key and 0+ legacy keys. They rekey operation finds any
+ * old content (based on legacy keys) and rewrites it (using the preferred key).
+ *
+ * @method string getTag()
+ * @method $this setTag(string $tag)
+ */
+class RotateKey extends AbstractAction {
+
+  /**
+   * Tag name (e.g. "CRED")
+   *
+   * @var string
+   */
+  protected $tag;
+
+  /**
+   * @param \Civi\Api4\Generic\Result $result
+   *
+   * @throws \API_Exception
+   * @throws \Civi\Crypto\Exception\CryptoException
+   */
+  public function _run(Result $result) {
+    if (empty($this->tag)) {
+      throw new \API_Exception("Missing required argument: tag");
+    }
+
+    // Track log of changes in memory.
+    $logger = new class() extends \Psr\Log\AbstractLogger {
+
+      /**
+       * @var array
+       */
+      public $log = [];
+
+      /**
+       * Logs with an arbitrary level.
+       *
+       * @param mixed $level
+       * @param string $message
+       * @param array $context
+       */
+      public function log($level, $message, array $context = []) {
+        $evalVar = function($m) use ($context) {
+          return $context[$m[1]] ?? '';
+        };
+
+        $this->log[] = [
+          'level' => $level,
+          'message' => preg_replace_callback('/\{([a-zA-Z0-9\.]+)\}/', $evalVar, $message),
+        ];
+      }
+
+    };
+
+    \CRM_Utils_Hook::cryptoRotateKey($this->tag, $logger);
+
+    $result->exchangeArray($logger->log);
+  }
+
+}
index da4ea2bb75549fbc7ea8be522abed96faf89935f..0afefe11a1a51233c257ba54fac40cf72d6fc703 100644 (file)
@@ -43,6 +43,16 @@ class System extends Generic\AbstractEntity {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @param bool $checkPermissions
+   *
+   * @return Action\System\RotateKey
+   */
+  public static function rotateKey($checkPermissions = TRUE) {
+    return (new Action\System\RotateKey(__CLASS__, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
   /**
    * @param bool $checkPermissions
    * @return Generic\BasicGetFieldsAction
diff --git a/tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php b/tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php
new file mode 100644 (file)
index 0000000..5244fbd
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace api\v4\Entity;
+
+use api\v4\UnitTestCase;
+use Civi\Crypto\CryptoTestTrait;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @group headless
+ */
+class RotateKeyTest extends UnitTestCase {
+
+  use CryptoTestTrait;
+
+  /**
+   * Set up baseline for testing
+   */
+  public function setUp() {
+    parent::setUp();
+    \CRM_Utils_Hook::singleton()->setHook('civicrm_crypto', [$this, 'registerExampleKeys']);
+    \CRM_Utils_Hook::singleton()->setHook('civicrm_cryptoRotateKey', [$this, 'onRotateKey']);
+  }
+
+  public function testRekey() {
+    $result = \Civi\Api4\System::rotateKey(0)->setTag('UNIT-TEST')->execute();
+    $this->assertEquals(2, count($result));
+    $this->assertEquals('Updated field A using UNIT-TEST.', $result[0]['message']);
+    $this->assertEquals('info', $result[0]['level']);
+    $this->assertEquals('Updated field B using UNIT-TEST.', $result[1]['message']);
+    $this->assertEquals('info', $result[1]['level']);
+  }
+
+  public function onRotateKey(string $tag, LoggerInterface $log) {
+    $this->assertEquals('UNIT-TEST', $tag);
+    $log->info('Updated field A using {tag}.', [
+      'tag' => $tag,
+    ]);
+    $log->info('Updated field B using {tag}.', [
+      'tag' => $tag,
+    ]);
+  }
+
+}