feat(dds): добавлен публичный контракт события
Зачем: - Показать слой 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.
This commit is contained in:
1 parent
4ea9591a16
commit
9ea361d199
11 files changed
+288
-76
No files matched your search
@@ -14,8 +14,9 @@ Prometheus, Grafana, экспортёр метрик Kafka и общая баз
|
|||||||
целиком со стороны клиента: у него есть контракт схемы события, из которого
|
целиком со стороны клиента: у него есть контракт схемы события, из которого
|
||||||
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
||||||
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
проигрыватель, отправляющий дни в 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` применяет канонический DDL и пересоздаёт прикладные базы; роль
|
||||||
`lifecycle_owner` не даёт эти административные права рабочим дагам;
|
`lifecycle_owner` не даёт эти административные права рабочим дагам;
|
||||||
- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие
|
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
|
||||||
слои DDS и DM;
|
`dic`, а запросы Superset обращаются к публичным `_v`;
|
||||||
- `analyst` предназначен для подключения человека и читает все слои и
|
- `analyst` предназначен для подключения человека и читает все слои и
|
||||||
справочники;
|
справочники;
|
||||||
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
|
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
||||||
|
|
||||||
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
|
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
|
||||||
и пользователь `grafana` добавлены 21 августа 2026 года (#114).
|
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
|
||||||
|
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
У кластера шесть пользователей и пять ролей.
|
У кластера семь пользователей и шесть ролей.
|
||||||
|
|
||||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||||
изнутри контейнеров. Приложения им не ходят.
|
изнутри контейнеров. Приложения им не ходят.
|
||||||
@@ -14,8 +15,9 @@
|
|||||||
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
|
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
|
||||||
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
|
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
|
||||||
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
|
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
|
||||||
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников
|
||||||
Им ходит Superset.
|
`dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права
|
||||||
|
шире.
|
||||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||||
человек подключается снаружи, из своего клиента.
|
человек подключается снаружи, из своего клиента.
|
||||||
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
|
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
|
||||||
@@ -41,24 +43,26 @@
|
|||||||
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||||
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||||
|
|
||||||
Устройство модели — кто каким пользователем ходит и где живут пароли —
|
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где
|
||||||
опишет раздел README «Состав и доступ»; здесь только решение и доводы.
|
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
||||||
Отдельного справочника по доступу в `docs/architecture/` не заводим: своего
|
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
||||||
содержания сверх этих двух документов у него сегодня нет, а дом ему
|
чтения через `_v` описана в доке хранилища.
|
||||||
понадобится, когда появятся слои DDS и DM и гранты размножатся. Абзац README
|
|
||||||
«У локального учебного кластера нет пароля» этим решением отменяется.
|
|
||||||
|
|
||||||
## Почему
|
## Почему
|
||||||
|
|
||||||
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
||||||
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
||||||
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
||||||
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
описания. Поэтому пользователей мало, имена у них говорящие, а `bi` отделён от
|
||||||
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
STG и от записи. Один пользователь может сверить всю цепочку DM → DDS → ODS,
|
||||||
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
не получая доступа к сырью и изменению данных. Справочники видны всем читателям
|
||||||
со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники
|
и никому на запись: зона входит в хранилище сбоку, и её содержимым владеет файл
|
||||||
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
|
репозитория, а не слой.
|
||||||
содержимым владеет файл репозитория, а не слой.
|
|
||||||
|
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
|
||||||
|
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
|
||||||
|
Публичное представление остаётся договором чтения; границу прав оно не задаёт
|
||||||
|
([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)).
|
||||||
|
|
||||||
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
||||||
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
||||||
@@ -124,11 +128,9 @@ DDS под ними, потому что так чаще всего и быва
|
|||||||
|
|
||||||
## Следствия
|
## Следствия
|
||||||
|
|
||||||
До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно.
|
Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`.
|
||||||
Дашборды рисуются поверх модели данных, а не поверх типизированных событий,
|
Явный договор `_v` защищает потребителя от устройства хранения и случайных
|
||||||
поэтому доступ Superset к ODS не планировался и правами не выдаётся.
|
новых колонок.
|
||||||
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
|
||||||
пришла раньше своих потребителей.
|
|
||||||
|
|
||||||
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
|
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
|
||||||
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
|
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
|
||||||
@@ -194,3 +196,12 @@ ClickHouse через MCP Context7 (#114).
|
|||||||
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
||||||
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
||||||
`bi` на чтение после появления базы.
|
`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).
|
||||||
@@ -45,9 +45,8 @@
|
|||||||
- DDS обслуживает человека с дашбордом: время лежит в поясе счётчика,
|
- DDS обслуживает человека с дашбордом: время лежит в поясе счётчика,
|
||||||
пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md),
|
пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md),
|
||||||
«Часовые пояса»).
|
«Часовые пояса»).
|
||||||
- Публичный договор каждой сущности — `_v`; роли объектов и проверяемая
|
- Публичный договор каждой сущности — `_v`; роли объектов и конвенция чтения
|
||||||
граница доступа заданы [конвенциями хранилища](../storage.md), «Имена
|
заданы [докой хранилища](../storage.md), «Имена объектов».
|
||||||
объектов».
|
|
||||||
- Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не
|
- Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не
|
||||||
переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и
|
переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и
|
||||||
цепочка расследования — [модель заказа](order.md).
|
цепочка расследования — [модель заказа](order.md).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
автоматический `PREWHERE` — свойство ODS, представление её не усугубляет.
|
автоматический `PREWHERE` — свойство ODS, представление её не усугубляет.
|
||||||
|
|
||||||
Как публичный договор DDS, представление перечисляет колонки явно и следует
|
Как публичный договор DDS, представление перечисляет колонки явно и следует
|
||||||
общей [границе доступа](../storage.md), «Имена объектов».
|
общей [конвенции чтения](../storage.md), «Имена объектов».
|
||||||
|
|
||||||
## Язык модели
|
## Язык модели
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ issue #143.
|
|||||||
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
||||||
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
||||||
колонки читает ODS.
|
колонки читает ODS.
|
||||||
- **`_load_id`, `_load_ts`** — у представления нет собственной загрузки, а
|
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
||||||
чужие координаты слой не переносит (правило слоя — [модель
|
координаты слой не переносит (правило слоя — [модель
|
||||||
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
||||||
ключу события.
|
ключу события.
|
||||||
|
|
||||||
@@ -96,8 +96,6 @@ issue #143.
|
|||||||
|
|
||||||
## Не входит
|
## Не входит
|
||||||
|
|
||||||
- DDL представления и его раскатка на кластер — исполнение этапа 4,
|
|
||||||
нарезка #136.
|
|
||||||
- Форма витрин DM — #135.
|
- Форма витрин DM — #135.
|
||||||
- Лаба со справочником регионов — issue #143, проектирование лаб.
|
- Лаба со справочником регионов — issue #143, проектирование лаб.
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ Airflow. Партиция — день данных; прогон звена DQ
|
|||||||
состав колонок витрин и классы сверки.
|
состав колонок витрин и классы сверки.
|
||||||
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
|
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
|
||||||
замена партиций на кластере.
|
замена партиций на кластере.
|
||||||
- [Дока хранилища](storage.md) — имена объектов, граница доступа через
|
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
|
||||||
`_v`, партиция-донор.
|
партиция-донор.
|
||||||
- [Карта модели DDS](dds/README.md) — источники витрин.
|
- [Карта модели DDS](dds/README.md) — источники витрин.
|
||||||
- Резолюция — тикет #135 карты #127.
|
- Резолюция — тикет #135 карты #127.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
||||||
|
|
||||||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||||
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка
|
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
||||||
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
||||||
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
||||||
@@ -14,7 +14,8 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
|||||||
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
||||||
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
||||||
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
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, и имена её показывают.
|
проходит по STG, и имена её показывают.
|
||||||
|
|
||||||
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
|
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
|
||||||
и витрины называют только представление; `_rep` и `_dist` остаются ролям
|
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
|
||||||
загрузки, диагностики и обслуживания. Представление перечисляет колонки явно,
|
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
|
||||||
даже если его запрос — простая проекция над `_dist`: договор не выдаёт
|
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
|
||||||
устройство кластера и случайно добавленные физической таблице колонки. У
|
кластера и случайно добавленные в физическую таблицу колонки. У события
|
||||||
события физической копии в DDS нет, но правило то же: `dds.event_v` — его
|
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
|
||||||
публичный договор.
|
договор.
|
||||||
|
|
||||||
Граница доступа — часть договора, а не следствие имени. Обычное представление
|
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
|
||||||
создаётся с явными `DEFINER = CURRENT_USER SQL SECURITY DEFINER`: определивший
|
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
|
||||||
его пользователь читает источники, потребительская роль получает `SELECT` на
|
публичной поверхностью. Конвенцию держат явный список колонок, документация и
|
||||||
`_v`, физические объекты — роль загрузки или диагностики. При стандартном
|
ревью, а не запрет на физические таблицы.
|
||||||
`INVOKER` потребителю нужны были бы права и на источник ([документация
|
|
||||||
ClickHouse](https://clickhouse.com/docs/sql-reference/statements/create/view#sql-security)).
|
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
|
||||||
Значит реализация публичной поверхности закончена, когда запрос
|
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
|
||||||
потребительской ролью проходит через `_v` и отклоняется для `_rep` и `_dist`.
|
ClickHouse и отклонённые варианты записаны в
|
||||||
|
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
|
||||||
|
|
||||||
## Раскладка по шардам
|
## Раскладка по шардам
|
||||||
|
|
||||||
@@ -204,9 +206,9 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
|||||||
|
|
||||||
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
||||||
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
||||||
состояние читают через него, а не повторяют `FINAL` в каждом запросе. Будущее
|
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
|
||||||
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том числе
|
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
||||||
snake_case-имена и расшифровку кодов.
|
числе snake_case-имена и расшифровку кодов.
|
||||||
|
|
||||||
## Версии заказов
|
## Версии заказов
|
||||||
|
|
||||||
@@ -495,8 +497,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
|
|
||||||
### DDL
|
### DDL
|
||||||
|
|
||||||
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
|
Файлы лежат в `sql/ddl/` и применяются по порядку имён: зависимости создаются
|
||||||
объекты, потом матвью — тогда к моменту создания матвью её цель уже существует.
|
раньше потребителей.
|
||||||
|
|
||||||
| Файл | Что в нём |
|
| Файл | Что в нём |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -506,19 +508,20 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||||
|
| `50-dds-views.sql` | публичный договор события на языке модели DDS |
|
||||||
|
|
||||||
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
|
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
|
||||||
правила порядка слоёв к ней не применяются.
|
правила порядка слоёв к ней не применяются.
|
||||||
|
|
||||||
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
|
||||||
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому
|
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
|
||||||
нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему
|
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
|
||||||
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
читать топик, когда к нему привязывают первую матвью; создай её раньше
|
||||||
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
|
||||||
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся
|
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
|
||||||
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной
|
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
|
||||||
отладке.
|
снесены, а данные Kafka целы, — то есть на обычной отладке.
|
||||||
|
|
||||||
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||||
`lifecycle`: один файл — одна последовательная задача
|
`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_rep` / `_dist` | типизированные версии заказа |
|
||||||
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||||
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
||||||
|
| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии |
|
||||||
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||||
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||||
|
|
||||||
@@ -569,8 +574,9 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
`INSERT SELECT` одной задачи `parse_batch` из файла
|
`INSERT SELECT` одной задачи `parse_batch` из файла
|
||||||
`sql/ods/order_parse.sql`.
|
`sql/ods/order_parse.sql`.
|
||||||
|
|
||||||
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
|
Слой DDS начат представлением `dds.event_v`; остальные его сущности и слой DM
|
||||||
7 мастер-спеки и переносится сюда по мере постройки.
|
появятся в следующих тикетах этапа. Их состав задан разделом 7 мастер-спеки и
|
||||||
|
переносится сюда по мере постройки.
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
|||||||
@@ -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` прямо говорит: если задан `<secret>`, `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`
|
||||||
|
поддерживаемым ограничением или зарегистрированным дефектом, не найдено;
|
||||||
|
классификация как дефекта остаётся выводом из расхождения документации и кода.
|
||||||
@@ -431,7 +431,7 @@ README.
|
|||||||
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
|
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
|
||||||
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
||||||
| DDS | `dds.session_rep` / `_dist`, `dds.session_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.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс |
|
||||||
| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
|
| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
|
||||||
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
|
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
|
||||||
@@ -440,10 +440,11 @@ README.
|
|||||||
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
||||||
см. [доку хранилища](../architecture/storage.md).
|
см. [доку хранилища](../architecture/storage.md).
|
||||||
Потребительские роли и витрины читают сущности DDS через `_v` с явным списком
|
Потребительские запросы и витрины читают сущности DDS через `_v` с явным
|
||||||
колонок; прав на `_rep` и `_dist` у них нет. Физические объекты остаются ролям
|
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию.
|
||||||
загрузки, диагностики и обслуживания. Механизм и проверяемая граница доступа —
|
Договор держится конвенцией, а не правами: модель доступа описана в
|
||||||
в [доке хранилища](../architecture/storage.md), «Имена объектов».
|
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в
|
||||||
|
[доке хранилища](../architecture/storage.md), «Имена объектов».
|
||||||
|
|
||||||
Заказы принимаются **пакетным забором** ([ADR
|
Заказы принимаются **пакетным забором** ([ADR
|
||||||
0008](../adr/0008-order-ingestion.md), [ADR
|
0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
@@ -470,8 +471,8 @@ README.
|
|||||||
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
||||||
см. [спеку генератора](2026-08-01-generator.md).
|
см. [спеку генератора](2026-08-01-generator.md).
|
||||||
|
|
||||||
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и возвращает
|
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и
|
||||||
актуальную версию события. Будущее `dds.event_v` — другой контракт: оно
|
возвращает актуальную версию события. `dds.event_v` — другой контракт: оно
|
||||||
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
|
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
|
||||||
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
|
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
|
||||||
а не обязательно копия данных».
|
а не обязательно копия данных».
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
|
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
|
||||||
<bi_reader>
|
<bi_reader>
|
||||||
<grants>
|
<grants>
|
||||||
|
<query>GRANT SELECT ON ods.*</query>
|
||||||
<query>GRANT SELECT ON dds.*</query>
|
<query>GRANT SELECT ON dds.*</query>
|
||||||
<query>GRANT SELECT ON dm.*</query>
|
<query>GRANT SELECT ON dm.*</query>
|
||||||
<query>GRANT SELECT, dictGet ON dic.*</query>
|
<query>GRANT SELECT, dictGet ON dic.*</query>
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ CREATE DATABASE IF NOT EXISTS stg ON CLUSTER clickstream_cluster;
|
|||||||
-- порядок файлов от этого не зависит.
|
-- порядок файлов от этого не зависит.
|
||||||
CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
|
CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
|
||||||
|
|
||||||
-- Объекты DDS появятся на следующем этапе; база заводится заранее по той же
|
-- База DDS заводится здесь же по той же причине, что и ODS.
|
||||||
-- причине, что и ODS.
|
|
||||||
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
|
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
|
||||||
|
|
||||||
-- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не
|
-- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user