From 9ea361d199fcf67683c215e86bfd1e55cf52aaa6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 25 Aug 2026 17:13:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(dds):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: - Показать слой DDS как договор данных, которому не нужна физическая копия. - Сохранить простой учебный стенд без отдельного маршрута ради узких прав. Что: - Добавлено кластерное представление dds.event_v с явными 44 колонками. - Роли bi_reader разрешено чтение ODS, DDS и DM при договоре чтения через _v. - Согласованы мастер-спека, ADR, справочники архитектуры и живые тикеты этапа. - Зафиксировано исследование поведения DEFINER поверх Distributed. Проверка: - make config-test - make smoke - make check-clickhouse - Приёмочные запросы: равенство ODS/DDS, состав колонок, коды устройств и права bi. --- README.md | 9 +- docs/adr/0007-clickhouse-access.md | 53 ++++--- docs/architecture/dds/README.md | 5 +- docs/architecture/dds/event.md | 8 +- docs/architecture/dm.md | 4 +- docs/architecture/storage.md | 70 +++++----- ...25-clickhouse-distributed-view-security.md | 132 ++++++++++++++++++ docs/specs/2026-07-30-stand-v2-realism.md | 15 +- infra/clickhouse/users.d/access.xml | 1 + sql/ddl/00-databases.sql | 3 +- sql/ddl/50-dds-views.sql | 64 +++++++++ 11 files changed, 288 insertions(+), 76 deletions(-) create mode 100644 docs/research/2026-08-25-clickhouse-distributed-view-security.md create mode 100644 sql/ddl/50-dds-views.sql diff --git a/README.md b/README.md index 3bd7a3c..7a3e676 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ Prometheus, Grafana, экспортёр метрик Kafka и общая баз целиком со стороны клиента: у него есть контракт схемы события, из которого собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до -типизированного `ods.event`, а `make up` наполняет стенд стартовым миром — -первой неделей модельного времени. +типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели +без копии данных. `make up` наполняет стенд стартовым миром — первой неделей +модельного времени. ## Быстрый старт @@ -302,8 +303,8 @@ uv run --project generator python -m clickstream_generator batch \ хранилища; - `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль `lifecycle_owner` не даёт эти административные права рабочим дагам; -- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие - слои DDS и DM; +- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники + `dic`, а запросы Superset обращаются к публичным `_v`; - `analyst` предназначен для подключения человека и читает все слои и справочники; - `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои, diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index a9a2043..61f9e32 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -1,11 +1,12 @@ # ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader` -и пользователь `grafana` добавлены 21 августа 2026 года (#114). +и пользователь `grafana` добавлены 21 августа 2026 года (#114), права +`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150). ## Решение -У кластера шесть пользователей и пять ролей. +У кластера семь пользователей и шесть ролей. - `default` — с паролем, только служебный: проверки здоровья нод и работа изнутри контейнеров. Приложения им не ходят. @@ -14,8 +15,9 @@ - `lifecycle` с ролью `lifecycle_owner` — создание и удаление только прикладных баз и объектов. Им даги жизненного цикла применяют DDL и пересоздают мир ([ADR 0013](0013-world-lifecycle.md)). -- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`. - Им ходит Superset. +- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников + `dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права + шире. - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им человек подключается снаружи, из своего клиента. - `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и @@ -41,24 +43,26 @@ Сетевых ограничений на `default` нет: порты стенда и так открыты только на `127.0.0.1`, а внутри сети Compose дверь заперта паролем. -Устройство модели — кто каким пользователем ходит и где живут пароли — -опишет раздел README «Состав и доступ»; здесь только решение и доводы. -Отдельного справочника по доступу в `docs/architecture/` не заводим: своего -содержания сверх этих двух документов у него сегодня нет, а дом ему -понадобится, когда появятся слои DDS и DM и гранты размножатся. Абзац README -«У локального учебного кластера нет пароля» этим решением отменяется. +Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где +живут пароли; здесь остаются решение и доводы. Отдельного справочника по +доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция +чтения через `_v` описана в доке хранилища. ## Почему Стенд учит не администрированию доступа, а чтению отказа в правах: человек должен узнать в бою картину, которую видел здесь, и понимать, чего просить. Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из -описания. Поэтому пользователей мало, имена у них говорящие, а границы -проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой -DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины -со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники -видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её -содержимым владеет файл репозитория, а не слой. +описания. Поэтому пользователей мало, имена у них говорящие, а `bi` отделён от +STG и от записи. Один пользователь может сверить всю цепочку DM → DDS → ODS, +не получая доступа к сырью и изменению данных. Справочники видны всем читателям +и никому на запись: зона входит в хранилище сбоку, и её содержимым владеет файл +репозитория, а не слой. + +Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным +маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока. +Публичное представление остаётся договором чтения; границу прав оно не задаёт +([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)). **Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно — @@ -124,11 +128,9 @@ DDS под ними, потому что так чаще всего и быва ## Следствия -До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно. -Дашборды рисуются поверх модели данных, а не поверх типизированных событий, -поэтому доступ Superset к ODS не планировался и правами не выдаётся. -Единственное, что ему доступно сегодня, — справочники: с ними зона `dic` -пришла раньше своих потребителей. +Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`. +Явный договор `_v` защищает потребителя от устройства хранения и случайных +новых колонок. DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и @@ -194,3 +196,12 @@ ClickHouse через MCP Context7 (#114). - Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право `bi` на чтение после появления базы. + +Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150): + +- `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM; +- `bi` прочитал одинаковые 401185 строк из `ods.event_v` и `dds.event_v`; +- чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`; +- представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде + сохранило исходного пользователя `bi`. Причина и отклонённые варианты + разобраны в [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). diff --git a/docs/architecture/dds/README.md b/docs/architecture/dds/README.md index b447f43..0386fa9 100644 --- a/docs/architecture/dds/README.md +++ b/docs/architecture/dds/README.md @@ -45,9 +45,8 @@ - DDS обслуживает человека с дашбордом: время лежит в поясе счётчика, пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md), «Часовые пояса»). -- Публичный договор каждой сущности — `_v`; роли объектов и проверяемая - граница доступа заданы [конвенциями хранилища](../storage.md), «Имена - объектов». +- Публичный договор каждой сущности — `_v`; роли объектов и конвенция чтения + заданы [докой хранилища](../storage.md), «Имена объектов». - Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и цепочка расследования — [модель заказа](order.md). diff --git a/docs/architecture/dds/event.md b/docs/architecture/dds/event.md index 42f07d6..4c4cf7a 100644 --- a/docs/architecture/dds/event.md +++ b/docs/architecture/dds/event.md @@ -22,7 +22,7 @@ автоматический `PREWHERE` — свойство ODS, представление её не усугубляет. Как публичный договор DDS, представление перечисляет колонки явно и следует -общей [границе доступа](../storage.md), «Имена объектов». +общей [конвенции чтения](../storage.md), «Имена объектов». ## Язык модели @@ -71,8 +71,8 @@ issue #143. - **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы `purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS. -- **`_load_id`, `_load_ts`** — у представления нет собственной загрузки, а - чужие координаты слой не переносит (правило слоя — [модель +- **`_load_ts`** — у представления нет собственной загрузки, а чужие + координаты слой не переносит (правило слоя — [модель заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по ключу события. @@ -96,8 +96,6 @@ issue #143. ## Не входит -- DDL представления и его раскатка на кластер — исполнение этапа 4, - нарезка #136. - Форма витрин DM — #135. - Лаба со справочником регионов — issue #143, проектирование лаб. diff --git a/docs/architecture/dm.md b/docs/architecture/dm.md index 1e96218..fff03cf 100644 --- a/docs/architecture/dm.md +++ b/docs/architecture/dm.md @@ -153,7 +153,7 @@ Airflow. Партиция — день данных; прогон звена DQ состав колонок витрин и классы сверки. - [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона, замена партиций на кластере. -- [Дока хранилища](storage.md) — имена объектов, граница доступа через - `_v`, партиция-донор. +- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`, + партиция-донор. - [Карта модели DDS](dds/README.md) — источники витрин. - Резолюция — тикет #135 карты #127. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 5c7e6ca..e3d9bc4 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -6,7 +6,7 @@ раздел «Что проверено» — чему в этом тексте верить и на каком основании. **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, -keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка +keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка `Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное событие с таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой @@ -14,7 +14,8 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап принёс первый справочник — словарь товаров из общего с генератором CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а -подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). +подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат +публичным договором события `dds.event_v` над точным состоянием ODS. Дальше по тексту устройство описано так, как оно проектируется; построенное от заложенного отличает карта таблиц в конце. @@ -64,21 +65,22 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d проходит по STG, и имена её показывают. В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы -и витрины называют только представление; `_rep` и `_dist` остаются ролям -загрузки, диагностики и обслуживания. Представление перечисляет колонки явно, -даже если его запрос — простая проекция над `_dist`: договор не выдаёт -устройство кластера и случайно добавленные физической таблице колонки. У -события физической копии в DDS нет, но правило то же: `dds.event_v` — его -публичный договор. +и витрины называют только представление; `_rep` и `_dist` служат загрузке, +диагностике и обслуживанию. Представление перечисляет колонки явно, даже если +его запрос — простая проекция над `_dist`: договор не выдаёт устройство +кластера и случайно добавленные в физическую таблицу колонки. У события +физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный +договор. -Граница доступа — часть договора, а не следствие имени. Обычное представление -создаётся с явными `DEFINER = CURRENT_USER SQL SECURITY DEFINER`: определивший -его пользователь читает источники, потребительская роль получает `SELECT` на -`_v`, физические объекты — роль загрузки или диагностики. При стандартном -`INVOKER` потребителю нужны были бы права и на источник ([документация -ClickHouse](https://clickhouse.com/docs/sql-reference/statements/create/view#sql-security)). -Значит реализация публичной поверхности закончена, когда запрос -потребительской ролью проходит через `_v` и отклоняется для `_rep` и `_dist`. +Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать +любые объекты ODS, DDS и DM, но физические объекты от этого не становятся +публичной поверхностью. Конвенцию держат явный список колонок, документация и +ревью, а не запрет на физические таблицы. + +Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая +граница поверх `Distributed` потребовала бы отдельной механики. Механизм +ClickHouse и отклонённые варианты записаны в +[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). ## Раскладка по шардам @@ -204,9 +206,9 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш `ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное -состояние читают через него, а не повторяют `FINAL` в каждом запросе. Будущее -`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том числе -snake_case-имена и расшифровку кодов. +состояние читают через него, а не повторяют `FINAL` в каждом запросе. +`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том +числе snake_case-имена и расшифровку кодов. ## Версии заказов @@ -495,8 +497,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр ### DDL -Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные -объекты, потом матвью — тогда к моменту создания матвью её цель уже существует. +Файлы лежат в `sql/ddl/` и применяются по порядку имён: зависимости создаются +раньше потребителей. | Файл | Что в нём | |---|---| @@ -506,19 +508,20 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | | `40-stg-views.sql` | матвью приёма: чтец в сырьё | +| `50-dds-views.sql` | публичный договор события на языке модели DDS | Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и правила порядка слоёв к ней не применяются. Порядок остальных задают два правила. Первое: матвью принадлежит слою своей -цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что -наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому -нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему -привязывают первую матвью; создай её раньше разбора — и всё, что доедет в -зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На -пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся -он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной -отладке. +цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому +что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся +матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает +читать топик, когда к нему привязывают первую матвью; создай её раньше +разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS +никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, +поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse +снесены, а данные Kafka целы, — то есть на обычной отладке. Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью `lifecycle`: один файл — одна последовательная задача @@ -546,7 +549,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр ## Карта таблиц -Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`. +Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в +`sql/ddl/`. | База | Объект | Что это | |---|---|---| @@ -562,6 +566,7 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр | `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа | | `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | +| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products` | словарь товаров поверх подложки | @@ -569,8 +574,9 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр `INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`. -Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом -7 мастер-спеки и переносится сюда по мере постройки. +Слой DDS начат представлением `dds.event_v`; остальные его сущности и слой DM +появятся в следующих тикетах этапа. Их состав задан разделом 7 мастер-спеки и +переносится сюда по мере постройки. ## Что проверено diff --git a/docs/research/2026-08-25-clickhouse-distributed-view-security.md b/docs/research/2026-08-25-clickhouse-distributed-view-security.md new file mode 100644 index 0000000..78a7bda --- /dev/null +++ b/docs/research/2026-08-25-clickhouse-distributed-view-security.md @@ -0,0 +1,132 @@ +# Права DEFINER у представления над Distributed + +Дата исследования: 2026-08-25. Тикет #150. ClickHouse 26.3.17.56. + +## Вопрос + +Почему `dds.event_v` с `SQL SECURITY DEFINER`, созданное пользователем +`lifecycle`, всё равно читает удалённый `ods.event_rep` как `bi`? Можно ли +оставить `bi` только с правом на публичное представление DDS, не заводя отдельный +маршрут или пользователя и не копируя данные? + +## Короткий вывод + +Простого поддерживаемого способа в ClickHouse 26.3 нет. `DEFINER` меняет +пользователя, чьи права проверяет локальный запрос представления, но сохраняет +в запросе имя его первоначального инициатора — `initial_user`. Движок +`Distributed` передаёт это имя на шарды, а межсерверный секрет делает его +пользователем удалённой сессии. Поэтому на координаторе источники читает +`lifecycle`, а на соседнем шарде физическую таблицу читает `bi`. + +Это не заявленная семантика `SQL SECURITY DEFINER`: документация обещает, что +право на источник требуется определившему представление пользователю, а +вызывающему достаточно `SELECT` на представление. Поведение следует из +сочетания двух реализаций и выглядит как недокументированное ограничение или +дефект распределённого исполнения. + +## Где меняется пользователь + +`dds.event_v` разворачивается в запрос к `ods.event_v`, затем к +`ods.event_dist FINAL`. Для внутреннего запроса представления ClickHouse +создаёт новый контекст в +[`getSQLSecurityOverriddenContext`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/StorageInMemoryMetadata.cpp#L135-L175): + +1. копирует исходный `ClientInfo` целиком, включая `initial_user = bi`; +2. затем отдельно вызывает `setUser` для определившего представление + пользователя `lifecycle`. + +В контексте остаются два имени с разными задачами: `lifecycle` отвечает за +локальную проверку прав, а `bi` остаётся первоначальным пользователем запроса. +Второй `SQL SECURITY DEFINER` на `ods.event_v` повторяет те же два шага и +поэтому ничего не меняет. + +## Что происходит на границе шарда + +`Distributed` копирует `ClientInfo` из контекста и отправляет его на удалённую +ноду без замены `initial_user` +([`RemoteQueryExecutor.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L415-L444)). +При межсерверном секрете имя входит в подписываемые данные запроса +([`Connection.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L968-L994)). +Принимающая нода проверяет подпись и открывает сессию с +`AlwaysAllowCredentials{client_info.initial_user}` +([`TCPHandler.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Server/TCPHandler.cpp#L2303-L2356)). + +Секрет здесь выполняет штатную функцию. Это не случайный побочный эффект. +Документация `Distributed` прямо говорит: если задан ``, `initial_user` +становится текущим пользователем удалённого запроса +([описание кластера](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/engines/table-engines/special/distributed.md#L105-L118)). +Так права, роли и ограничения исходного пользователя одинаково действуют на +всех шардах: координатор подтверждает личность пользователя, но не превращает +его запрос в запрос своей служебной учётки. Протокол не передаёт отдельно +эффективного пользователя `DEFINER`, поэтому на стенде эта защита и +останавливает `bi` у `ods.event_rep`. + +## Рассмотренные варианты + +### Настройка или другая форма представления + +В 26.3 нет настройки, которая подменяет `initial_user` на определившего +представление пользователя только для чтения `Distributed`. +`push_external_roles_in_interserver_queries` пересылает роли именно +`initial_user`, не роли `DEFINER` +([исходник отправки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L425-L444)). +`distributed_ddl_use_initial_user_and_roles` относится только к заданиям +`ON CLUSTER`. На `SELECT` настройка не действует +([объявление настройки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Core/ServerSettings.cpp#L1002-L1006)). + +### Пользователь в существующем описании кластера + +С межсерверным секретом ClickHouse выбирает специальную межсерверную +аутентификацию и не использует пароль из описания реплики +([`Connection::sendHello`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L452-L476)). +Чтобы удалённый запрос всегда шёл как `lifecycle`, пришлось бы убрать секрет и +передавать его пароль. Тогда между нодами перестанет передаваться исходный +пользователь. Одна модель доступа сменится другой, менее прозрачной. + +### `remote()` с явными реквизитами + +Для `remote()` ClickHouse умеет заменить `initial_user` пользователем из +адреса +([`executeQuery.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Interpreters/ClusterProxy/executeQuery.cpp#L96-L120)). +Но представлению понадобятся адреса и реквизиты удалённого пользователя — в +тексте DDL либо в отдельной настройке. Это и есть отдельный маршрут доступа; +он сложнее нынешнего кластера и требует хранить ещё один секрет. + +### Промежуточное локальное представление на шарде + +Можно направить `Distributed` на локальное представление с `DEFINER`, но +удалённому пользователю всё равно нужен `SELECT` на это представление. Значит +появятся дополнительный объект и дополнительный грант, а `bi` увидит +поверхность одного шарда. Физическая таблица останется закрытой, но простота и +контракт «только публичная поверхность DDS» исчезнут. + +### Ослабление проверки + +- `SQL SECURITY NONE` снимает проверку источника, но устарело и позволяет + создателю такого представления исполнять произвольный запрос; ClickHouse + требует для этого отдельное право + ([документация](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/sql-reference/statements/create/view.md#L115-L136)). +- Право `bi` на физическую ODS-таблицу решает ошибку без новой механики, но + ослабляет исходную границу. +- Физическая или материализованная копия в DDS сохраняет границу, но добавляет + хранение и жизненный цикл данных ради переименования колонок. + +## Решение для стенда + +25 августа 2026 года выбрана простота: роль `bi_reader` читает ODS, DDS и DM +целиком. Потребительские запросы продолжают обращаться к `_v`. Этот договор +держат документация и ревью; границы прав задают гранты. + +Отдельный межсерверный маршрут, служебная учётка и физическая поверхность DDS +отклонены: каждый вариант добавляет механику только ради защиты, которой стенд +не учит. Готовой настройки, которая сохранила бы и узкую границу, и простое +устройство, в закреплённой версии нет. + +## Граница уверенности + +Механизм подтверждён исходным кодом точной версии 26.3.17.56. Простую настройку +искали в настройках сервера и в путях формирования удалённого запроса этой же +версии. Уверенность в обоих выводах высокая. Официального +текста, который называет сочетание `DEFINER + Distributed + secret` +поддерживаемым ограничением или зарегистрированным дефектом, не найдено; +классификация как дефекта остаётся выводом из расхождения документации и кода. diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index e9e2fdf..59303c2 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -431,7 +431,7 @@ README. | ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние | | ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние | | DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс | -| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | +| DDS | `dds.event_v` | представление над `ods.event_v`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс | | DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс | | DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | @@ -440,10 +440,11 @@ README. У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — см. [доку хранилища](../architecture/storage.md). -Потребительские роли и витрины читают сущности DDS через `_v` с явным списком -колонок; прав на `_rep` и `_dist` у них нет. Физические объекты остаются ролям -загрузки, диагностики и обслуживания. Механизм и проверяемая граница доступа — -в [доке хранилища](../architecture/storage.md), «Имена объектов». +Потребительские запросы и витрины читают сущности DDS через `_v` с явным +списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию. +Договор держится конвенцией, а не правами: модель доступа описана в +[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в +[доке хранилища](../architecture/storage.md), «Имена объектов». Заказы принимаются **пакетным забором** ([ADR 0008](../adr/0008-order-ingestion.md), [ADR @@ -470,8 +471,8 @@ README. Kafka день переигрывается генератором заново: снимок — кэш чистой функции, см. [спеку генератора](2026-08-01-generator.md). -`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и возвращает -актуальную версию события. Будущее `dds.event_v` — другой контракт: оно +`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и +возвращает актуальную версию события. `dds.event_v` — другой контракт: оно переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и расшифровывает коды. Это первый на стенде пример правила «слой — это контракт, а не обязательно копия данных». diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml index d28b995..04cc4a0 100644 --- a/infra/clickhouse/users.d/access.xml +++ b/infra/clickhouse/users.d/access.xml @@ -44,6 +44,7 @@ + GRANT SELECT ON ods.* GRANT SELECT ON dds.* GRANT SELECT ON dm.* GRANT SELECT, dictGet ON dic.* diff --git a/sql/ddl/00-databases.sql b/sql/ddl/00-databases.sql index 8be3528..1433fc6 100644 --- a/sql/ddl/00-databases.sql +++ b/sql/ddl/00-databases.sql @@ -14,8 +14,7 @@ CREATE DATABASE IF NOT EXISTS stg ON CLUSTER clickstream_cluster; -- порядок файлов от этого не зависит. CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster; --- Объекты DDS появятся на следующем этапе; база заводится заранее по той же --- причине, что и ODS. +-- База DDS заводится здесь же по той же причине, что и ODS. CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster; -- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не diff --git a/sql/ddl/50-dds-views.sql b/sql/ddl/50-dds-views.sql new file mode 100644 index 0000000..4033c6d --- /dev/null +++ b/sql/ddl/50-dds-views.sql @@ -0,0 +1,64 @@ +-- DDS: публичный договор события на языке бизнес-модели. +-- +-- Событие не копируется в DDS: обычное представление вторым уровнем читает +-- точное состояние из ods.event_v. Явный список колонок не пропускает в +-- договор служебные и сырые поля ODS при будущих изменениях источника. +-- +-- Представление — договор чтения, не граница прав: роль bi_reader читает его +-- источник по решению docs/adr/0007-clickhouse-access.md. +-- +-- event_date берётся напрямую из EventDate. Пересчёт из event_time помешал бы +-- первичному индексу отбирать день до чтения. +CREATE VIEW IF NOT EXISTS dds.event_v ON CLUSTER clickstream_cluster +AS +SELECT + WatchID AS event_id, + ClientID AS client_id, + CounterID AS counter_id, + EventDate AS event_date, + toTimeZone(UTCEventTime, 'Europe/Samara') AS event_time, + ClientTimeZone AS client_timezone, + EventType AS event_type, + URL AS url, + Referer AS referer, + Title AS title, + UTMSource AS utm_source, + UTMMedium AS utm_medium, + UTMCampaign AS utm_campaign, + UTMContent AS utm_content, + UTMTerm AS utm_term, + LastTrafficSource AS last_traffic_source, + HasGCLID AS has_gclid, + YCLID AS yclid, + Browser AS browser, + BrowserMajorVersion AS browser_major_version, + BrowserLanguage AS browser_language, + OperatingSystem AS operating_system, + OperatingSystemRoot AS operating_system_root, + transform( + DeviceCategory, + [1, 2, 3, 4], + ['desktop', 'mobile', 'tablet', 'tv'], + toString(DeviceCategory) + ) AS device_category, + MobilePhoneModel AS mobile_phone_model, + ScreenWidth AS screen_width, + ScreenHeight AS screen_height, + IPAddress AS ip_address, + RegionCountry AS region_country, + RegionCity AS region_city, + RegionCountryID AS region_country_id, + RegionCityID AS region_city_id, + GoalsReached AS goals_reached, + ParsedParamsKey1 AS parsed_params_key1, + purchaseID AS purchase_id, + purchaseRevenue AS purchase_revenue, + purchaseCurrency AS purchase_currency, + purchaseCoupon AS purchase_coupon, + productID AS product_id, + productName AS product_name, + productCategory AS product_category, + productPrice AS product_price, + productQuantity AS product_quantity, + productEventType AS product_event_type +FROM ods.event_v;