Бортовой журнал Ктулху

Yii2: правильная дешифровка данных на уровне DataProvider

Если вы храните чувствительные данные в базе в зашифрованном виде, рано или поздно возникает вопрос: где в Yii2 правильно делать дешифровку?

Почти все начинают одинаково:

- сначала — afterFind() в модели,

- потом — дешифровка прямо в GridView::value,

- кто-то идёт дальше и вешает Behavior.

Я прошёл все эти этапы.

Все эти подходы работают.

Но с ростом проекта начинают проявляться архитектурные и производственные проблемы.

И в итоге пришёл к выводу, что все они решают проблему не на том уровне.

В этой статье я хочу показать архитектурно корректный подход: централизованную дешифровку данных на уровне ActiveDataProvider.

Типовые решения и их ограничения

afterFind() в модели

Самый распространённый вариант — выполнять дешифровку сразу после выборки:

- просто реализовать;

- не требует изменений в UI;

- кажется “логичным”.

Но у этого подхода есть серьёзные минусы: afterFind() вызывается всегда, независимо от контекста:

- веб-интерфейс;

- API;

- консоль;

- фоновые задачи.

- модель теряет исходные (зашифрованные) данные;

- невозможно контролировать, где дешифровка действительно нужна;

- при массовых выборках дешифровка происходит даже там, где она не используется.

В результате модель начинает выполнять инфраструктурную работу, не относящуюся к бизнес-логике.

Дешифровка в GridView

Другой популярный вариант — выполнять дешифровку при рендере:

[
 'attribute' => 'sensitiveField',
 'value' => function ($model) {
 return /* дешифровка */;
 },
]

Этот подход переносит логику из модели в представление, но создаёт другую проблему.

Если таблица содержит:

- десятки строк,

- несколько колонок с чувствительными данными,

- то дешифровка будет выполняться многократно — по сути, в каждой ячейке.

Недостатки:

- большое количество повторных операций;

- логика размазана по UI;

- сложнее централизовать обработку ошибок;

- представление начинает знать о криптографии.

 

Behavior на модели

Использование Behavior технически возможно, но архитектурно это всё ещё уровень модели.

Минусы:

- неявное поведение;

- сложность отладки;

- отсутствие контроля контекста;

- та же проблема “всегда и везде”.

Во всех этих подходах проблема одна и та же: дешифровка размещена не на том уровне абстракции.

 

Где должна жить дешифровка

Дешифровка данных:

- не является бизнес-логикой;

- не относится к представлению;

- не должна быть обязанностью модели.

Это инфраструктурная задача. В Yii2 есть слой, который идеально для неё подходит — DataProvider.

Почему именно он:

- данные уже выбраны из базы;

- известен объём данных (например, текущая страница);

- есть единая точка подготовки данных;

- UI получает уже готовый результат.

Идея простая:

Модель отвечает за данные.

DataProvider — за их подготовку к отображению.

class DecryptingActiveDataProvider extends ActiveDataProvider

Его ответственность:

- определить, какие поля требуют дешифровки;

- собрать значения один раз;

- выполнить пакетную дешифровку;

- аккуратно подставить результат обратно в модели.

При этом:

- модель не знает о дешифровке;

- GridView не содержит никакой логики;

- криптография полностью изолирована.

 

Основные моменты реализации

Метод prepareModels() — это оптимальная точка для обработки данных. В этом месте данные уже выбраны и подготовлены моделью для рендеринга, что уже позволяет работать постранично, собрать значения для дешифровки, централизованно дешифровать, не вмешиваясь в бизнес-логику.

Если в модели остается старая логика дешифровки через afterFind(), мы ее отключаем, что бы избежать двойной дешифровки. Это позволяет сохранить совместимость в других местах, поведение кода останется предсказуемым.

 

Дешифровка производится пакетным способом, что позволяет сократить количество задержек.

$map = $decryptor->decryptBatch($values);

Сервис возвращает ассоциативный массив вида [encrypted=>decrypted], который провайдер раскладывает обратно в модель.

Интеграция провайдера выполняется так же как и в исходном виде:

$dataProvider = new DecryptingActiveDataProvider([
 'query' => SecureEntity::find(),
 'decryptAttributes' => ['fieldA', 'fieldB'],
 'decryptionEnabled' => true,
]);

 

После этого:

1). модели остаются “чистыми” и не содержат криптологики;

2). GridView отображает уже подготовленные значения;

3). разработки в интерфейсе не требуют дополнительных обработчиков и callback’ов.

 

<?php
namespace app\components;
use yii\data\ActiveDataProvider;
/**
 * DecryptingActiveDataProvider
 *
 * Централизованная дешифровка данных на уровне DataProvider:
 * - избегает дешифровки в afterFind()
 * - выполняет пакетную обработку
 * - не вмешивает криптографию в модели и UI
 */
class DecryptingActiveDataProvider extends ActiveDataProvider
{
 /** @var string[] атрибуты модели, которые нужно дешифровать */
 public $decryptAttributes = [];
/** @var bool включить/выключить дешифровку */
 public $decryptionEnabled = true;
/** @var object сервис дешифровки (должен иметь метод decryptBatch()) */
 public $decryptor;
/** @var int минимальная длина строки-кандидата */
 public $minCandidateLength = 16;
/** @var bool если true — при ошибке выбрасывается исключение */
 public $failHard = false;
protected function prepareModels()
 {
 $models = parent::prepareModels();
if (
 !$this->decryptionEnabled ||
 empty($this->decryptAttributes) ||
 empty($models)
 ) {
 return $models;
 }
// 1. Собираем все значения для дешифровки
 $values = [];
foreach ($models as $model) {
 foreach ($this->decryptAttributes as $attr) {
 $value = $model->$attr ?? null;
 if (!$this->isCandidate($value)) {
 continue;
 }
 $values[] = $value;
 }
 }
$values = array_values(array_unique($values));
if (empty($values)) {
 return $models;
 }
// 2. Пакетная дешифровка
 try {
 $map = $this->decryptor->decryptBatch($values); // [encrypted => decrypted]
 } catch (\Throwable $e) {
 if ($this->failHard) {
 throw $e;
 }
 return $models;
 }
if (empty($map)) {
 return $models;
 }
// 3. Раскладываем результат обратно в модели
 foreach ($models as $model) {
 foreach ($this->decryptAttributes as $attr) {
 $value = $model->$attr;
if (
 is_string($value) &&
 isset($map[$value]) &&
 $map[$value] !== null &&
 $map[$value] !== false
 ) {
 $model->$attr = $map[$value];
 }
 }
 }
return $models;
 }
/**
 * Проверка, что значение подходит под "кандидат на дешифровку".
 */
 private function isCandidate($value): bool
 {
 if (!is_string($value)) {
 return false;
 }
$value = trim($value);
if ($value === '' || strlen($value) < $this->minCandidateLength) {
 return false;
 }
// Здесь можно использовать любую мягкую эвристику
 // Например, заглушку "похожести на кодированные данные"
 return (bool) preg_match('/^[A-Za-z0-9+\/=]+$/', $value);
 }
}