Содержание урока · 12 разделов
Маршрут урока — от «зачем» до тестов. Сначала — мотив: главный преимущество privacy layer в том, что политика объявлена один раз и исполняется всегда; ни один новый хендлер не обойдёт её, потому что обойти некуда — путь лежит через клиент, а клиент обязан спросить политику. Затем — механика включения: фича-флаг privacy и сгенерированный пакет ent/privacy, вместе с живой ошибкой забытого пустого импорта runtime. Третья часть — термины: Policy, правила, решения Allow/Deny/Skip и порядок их оценки; здесь же честный разбор «deny по умолчанию» — что ent делает сам, а что вы обязаны достроить правилом. Четвёртая — два рабочих правила: DenyIfNoViewer и AllowIfAdmin на privacy.ContextQueryMutationRule, и почему DenyIfNoViewer обязан стоять первым. Пятая — где политика сидит в конвейере: EvalQuery до интерсепторов, EvalMutation в слоте hooks[0] до всех хуков из семнадцатого урока. Шестая — сужение правил по операциям: OnMutationOperation и DenyMutationOperationRule. Финал — тесты политики: DecisionContext, errors.Is и матрица кейсов. Если вы искали «ent privacy» или «ent безопасность» — это тот урок.
Зачем privacy: политика один раз и всегда#
До этого урока доступ к данным держался на дисциплине: хендлеры сами решали, что кому показывать, фильтры сами не забывали Where, админские операции сами проверяли роль. Каждый новый эндпоинт — новая возможность забыть проверку, а рукописный обход ent-клиента — уже не защита вовсе, как мы честно считали ещё с валидаторов третьего урока. ent privacy переворачивает ответственность: политика объявляется в схеме — там же, где поля, рёбра, хуки и интерсепторы, — и исполняется на каждой операции чтения и записи этого типа, из любого места кодовой базы, у любого собранного клиента. Это ent безопасность уровня графа: нельзя создать пользователя без viewer-а нельзя, даже если завтра коллега напишет новый сервис и забудет про роли — операция упрётся в слой, который жить в схеме, а не в хендлерах. Граница ответственности с интерсептором из восемнадцатого урока строгая: интерсептор фильтрует — прячет строки из выборок; политика решает — допускает или отменяет операцию целиком. Оба инструмента исполняются до SQL, но отвечают на разные вопросы: «что вы видите» и «можно ли вам это».
Фича-флаг и пакет ent/privacy#
Privacy — фича-флаг генератора, как intercept из восемнадцатого урока. В ent/generate.go добавьте --feature privacy: go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy ./schema — и запустите go generate из седьмого урока. Генератор приносит пакет ent/privacy с готовым словарём: решения privacy.Allow, privacy.Deny и privacy.Skip — это sentinel-ошибки, то есть обычные error-значения, которые правила возвращают; хелперы Allowf, Denyf и Skipf — те же решения с форматированной причиной в тексте; AlwaysAllowRule и AlwaysDenyRule — правила-заглушки; ContextQueryMutationRule — фабрика правила из функции контекста; OnMutationOperation и DenyMutationOperationRule — сужение и запрет операций; DecisionContext — решение, вшитое в контекст. Второй обязательный шаг, за которым следит документация: если в схеме объявлен Policy(), в main нужен пустой импорт import _ «ваш-модуль/ent/runtime» — из-за возможного цикла импортов между схемой и пакетом ent рантайм обязан инициализироваться явно. Забудете — ent не уронит компиляцию, но первый же запрос честно откажет: user.Policy == nil, текст ошибки называет причину дословно — forgotten import ent/runtime.
Проверка: забытый пустой импорт
Policy() объявлен в схеме, но import _ ent/runtime в main вы забыли. Что произойдёт?
Термины: Policy, правила, решения#
ent.Policy — интерфейс из двух методов: EvalQuery(ctx, ent.Query) error — read-policy, EvalMutation(ctx, ent.Mutation) error — write-policy. В схеме вы не реализуете его руками: собираете из правил через privacy.Policy{Query: ..., Mutation: ...}, где QueryPolicy и MutationPolicy — списки правил. Правило — то же правило из мира privacy: объект с EvalQuery и/или EvalMutation, возвращающий одно из трёх решений. Решения — обычные ошибки, и семантика у них точная. privacy.Allow — «разрешить»: оценка останавливается, последующие правила не вызываются, операция допущена. privacy.Deny — «запретить»: оценка останавливается, операция отменена; любое другое возвращённое правило правило-ошибку работает как Deny. privacy.Skip — «нет мнения»: переход к следующему правилу; эквивалент возврата nil. Правила оцениваются строго в порядке объявления. И теперь честная деталь, которую надо знать про ent: если ВСЕ правила Skip — оценка завершается без ошибки, то есть операция разрешена. ent privacy не встраивает «запрещено, если не разрешено» — вы строите её сами, хвостовым правилом privacy.AlwaysDenyRule(): всё, что не пропустили предыдущие правила, отвергается. Это и есть deny по умолчанию как рабочий паттерн — и дальше в уроке он стоит в каждой политике.
Политика в схеме: deny по умолчанию
GO// в схеме User (ent/schema/user.go):
func (User) Policy() ent.Policy {
return privacy.Policy{
Query: privacy.QueryPolicy{
rule.DenyIfNoViewer(), // первым: безопасность доступа
rule.AllowIfAdmin(),
},
Mutation: privacy.MutationPolicy{
rule.DenyIfNoViewer(),
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(), // хвост deny-по-умолчанию
},
}
}privacy.Policy собирает списки правил: QueryPolicy для чтения, MutationPolicy для записи. Чтение для всех, у кого есть viewer; мутации — только админам; всё остальное — AlwaysDenyRule отвергает. Уберите хвост — и мутация от не-админа пройдёт: правила Skip-нули до конца списка, а пустая оценка = разрешено. Хвостовой AlwaysDenyRule — контракт deny по умолчанию, а не украшение.
viewer-контекст и два правила
GO// viewer/viewer.go: пользователь операции в контексте.
type UserViewer struct{ Role string }
func NewContext(parent context.Context, v *UserViewer) context.Context {
return context.WithValue(parent, viewerKey{}, v)
}
func FromContext(ctx context.Context) *UserViewer {
v, _ := ctx.Value(viewerKey{}).(*UserViewer)
return v
}
// rule/rule.go: два правила политики.
func DenyIfNoViewer() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
if viewer.FromContext(ctx) == nil {
return privacy.Denyf("ent/privacy: нет viewer в контексте")
}
return privacy.Skip
})
}
func AllowIfAdmin() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
if v := viewer.FromContext(ctx); v != nil && v.Role == "admin" {
return privacy.Allow
}
return privacy.Skip
})
}viewer — ваш пакет: ent не знает, кто такой «пользователь», он умеет читать context.Context. NewContext кладёт viewer, FromContext достаёт. ContextQueryMutationRule оборачивает функцию контекста в правило для чтения и записи сразу. DenyIfNoViewer возвращает Denyf — отказ с причиной; AllowIfAdmin — Allow или Skip. Двойная проверка v != nil в AllowIfAdmin — страховка на случай, когда правило стоит раньше DenyIfNoViewer.
Проверка: порядок правил
Почему DenyIfNoViewer обязан стоять в списке раньше AllowIfAdmin?
Мутация от не-админа
Прочитайте код и выберите, что он выведет:
// политика User: Mutation = [DenyIfNoViewer, AllowIfAdmin, AlwaysDenyRule]
ctx := context.Background() // viewer-а нет
ctxView := viewer.NewContext(ctx, viewer.UserViewer{Role: "view"})
err := client.User.Create().
SetName("борис").
SetEmail("boris@entgo.io").
Exec(ctxView)Где политика сидит в конвейере#
Две точки исполнения, обе до всего остального. Чтение: перед исполнением выборки ent вызывает Policy.EvalQuery — раньше интерсепторов из восемнадцатого урока и раньше SQL; Count, Exist, Only и обходы рёбер проходят через ту же проверку, потому что это всё — Query-пути. Отказ — операция не исполняется вовсе: ни SELECT, ни частичных данных. Запись: политика вшивается в слот hooks[0] — самый внешний слой конвейера мутаций — и EvalMutation исполняется до всех схемных и Use-хуков из семнадцатого урока; ни один ваш хук не увидит мутацию, которую политика отвергла. Отсюда два практических следствия. Первое: privacy.Deny не обрабатывается как ValidationError или ConstraintError — это собственный sentinel, ловите errors.Is(err, privacy.Deny), и он не подменяется MaskNotFound-ом. Второе: политика и транзакции из шестнадцатого урока живут дружно — проверка исполняется на границе операции, до WithTx, и отказ не оставляет после себя открытой транзакции; внутри tx-клиента политики сработают снова, потому что tx.Client() — тот же граф, те же правила.
Решения на операции: OnMutationOperation#
Privacy-список из «глобальных» правил — не единственная форма; правила можно сужать до конкретных операций мутации — знакомый приём по hook.On из семнадцатого урока. privacy.OnMutationOperation(rule, op) исполняет rule, только если m.Op().Is(op) совпал, иначе возвращает Skip — правило молчит. На нём построен готовый helper privacy.DenyMutationOperationRule(op): запрет конкретной операции с ошибкой Denyf. Вместе они закрывают классический сценарий — политику жёсткого удаления, которую четырнадцатый урок собирал из hook.Reject: там запрет жил в коде приложения и его надо было не забыть зарегистрировать, здесь — в схеме, и он работает у всех клиентов графа без исключений. Симметрия с семнадцатым уроком дословная: hook.Reject и DenyMutationOperationRule решают одну задачу на двух уровнях — рантайм и схема; если запрет обязан быть законом графа — ему место в политике. Отдельный приём для сложных правил — privacy.FilterFunc: правило получает generic-фильтр запроса и вшивает в него предикат вроде «только свой тенант», превращая доступ в ограничение выборки; это мост к мультиарендности двадцать второго урока, здесь — только знать в лицо.
Запрет жёсткого удаления политикой
GO// Group: жёсткое удаление закрыто на уровне схемы.
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
privacy.DenyMutationOperationRule(
ent.OpDelete | ent.OpDeleteOne,
),
},
}
}
// попытка из любого места кодовой базы:
err := client.Group.DeleteOneID(goNinjaID).Exec(ctx)
// errors.Is(err, privacy.Deny) == true, DELETE не исполненDenyMutationOperationRule(ent.OpDelete | ent.OpDeleteOne) — зеркало hook.Reject(ent.OpDelete, ent.OpDeleteOne) из семнадцатого урока, но в схеме: идёт в сгенерированный код и не зависит от регистраций в main. «Удаление» группы go-ninja теперь возможно только мягким путём — SetDeletedAt через Update из тринадцатого урока — а жёсткий путь возвращает Deny до всякого SQL.
Проверка: Reject против Deny
Чем privacy.DenyMutationOperationRule отличается от hook.Reject?
Каталог: API privacy#
| Инструмент | Что даёт | Нюанс |
|---|---|---|
| privacy.Allow | решение «разрешить» | останавливает оценку, операция допущена |
| privacy.Deny | решение «запретить» | sentinel-ошибка; ловится errors.Is |
| privacy.Skip | решение «нет мнения» | переход к следующему правилу; равен nil |
| Allowf/Denyf/Skipf | решения с причиной в тексте | логи читаются без трассировки |
| AlwaysAllowRule() | правило «всегда да» | открытые на чтение сущности |
| AlwaysDenyRule() | правило «всегда нет» | хвост deny-по-умолчанию: обязателен |
| ContextQueryMutationRule | правило из func(ctx) error | viewer-проверки без дублей на Query/Mutation |
| QueryRule / MutationRule | интерфейсы правил чтения и записи | QueryPolicy/MutationPolicy — их списки |
| ent.Policy | EvalQuery + EvalMutation | объявляется в схеме методом Policy() |
| OnMutationOperation | исполнение правила только на операции | аналог hook.On из семнадцатого урока |
| DenyMutationOperationRule | запрет конкретной операции | аналог hook.Reject на уровне схемы |
| DecisionContext | решение, вшитое в контекст | обход политики в тестах и внутренних запросах |
Тесты политики#
Политика, как и вся защита, требует доказательств, и ent даёт для них два инструмента. Первый — privacy.DecisionContext(ctx, privacy.Allow): вшивает решение прямо в контекст, и оценка правил не запускается вовсе — операция исполняется так, будто первое правило вернуло Allow. Это способ протестировать саму логику приложения отдельно от политик: в тестах и внутренних служебных запросах — как в примере документации, где правило тенанта делает служебную выборку через DecisionContext, чтобы не зациклиться на собственной политике. Второй — errors.Is(err, privacy.Deny): отказ политики — это sentinel-ошибка, и тест обязан проверять именно её, а не текст. Рабочая матрица кейсов для политики User: контекст без viewer — Deny на мутации; viewer с ролью view — Deny на мутации, чтение разрешено; admin — всё разрешено; DecisionContext — всё разрешено без правил. Тестовый клиент — enttest.Open с SQLite, механику которого подробно разберёт двадцать третий урок (здесь упоминаем текстом): каждый кейс — три строки. И правило гигиены: политика тестируется на каждом типе схемы отдельно — наследования политик нет, mixin-политики из документации вшиваются в каждую схему как отдельные правила, и deny-хвост одной схемы не спасает соседнюю.
Тест политики: матрица кейсов
GOfunc TestUserPolicy(t *testing.T) {
client := enttest.Open(t, dialect.SQLite,
"file:ent?mode=memory&_fk=1")
defer client.Close()
// 1. без viewer мутация запрещена:
err := client.User.Create().SetName("x").Exec(ctx)
require.True(t, errors.Is(err, privacy.Deny))
// 2. view-роль читает, но не пишет:
view := viewer.NewContext(ctx, viewer.UserViewer{Role: "view"})
require.NotZero(t, client.User.Query().CountX(view))
err = client.User.Create().SetName("x").Exec(view)
require.True(t, errors.Is(err, privacy.Deny))
// 3. админ пишет:
admin := viewer.NewContext(ctx, viewer.UserViewer{Role: "admin"})
require.NoError(t, client.User.Create().
SetName("анна").SetEmail("anna@entgo.io").Exec(admin))
// 4. DecisionContext обходит политику целиком:
free := privacy.DecisionContext(ctx, privacy.Allow)
require.NoError(t, client.User.Create().SetName("y").Exec(free))
}Четыре кейса — четыре строки: Deny без viewer, Deny на записи при view, Allow админу, обход через DecisionContext. errors.Is — обязательная форма проверки: privacy.Deny — значение-ошибка, и ==-сравнение с обёрткой от Denyf не сработает. enttest.Open поднимает SQLite в памяти — подробный разбор тестовой инфраструктуры ent оставлен двадцать третьему уроку.
Чеклист privacy#
- Политика — в схеме, один раз: func (T) Policy() ent.Policy; она исполняется на каждой операции типа у любого клиента — в этом весь смысл privacy layer.
- Deny по умолчанию достраивается руками: хвостовым privacy.AlwaysDenyRule(); все Skip подряд = операция разрешена — пустой список политики ничего не запрещает.
- DenyIfNoViewer — первым правилом: AllowIfAdmin обязан встретить viewer-а гарантированно, а не «если повезёт с порядком».
- Ловите отказы через errors.Is(err, privacy.Deny): это sentinel-ошибка, а не строка и не ValidationError.
- DenyMutationOperationRule(ent.OpDelete | ent.OpDeleteOne) — схемная версия hook.Reject: жёсткое удаление из четырнадцатого урока закрывается политикой.
- import _ ent/runtime в main — обязательная строка при Policy() в схеме; ошибка «forgotten import ent/runtime?» всплывёт на первом запросе, а не на компиляции.
- DecisionContext(ctx, privacy.Allow) — для тестов и служебных запросов; в продакшене контекст с вшитым Allow — дыра размером с приложение.
- Тестируйте каждый тип отдельно: наследования политик нет, mixin-правила дублируются в каждую схему, deny-хвост одного типа не защищает соседний.
Runner-ов в курсе ent нет, и песочница не появится и здесь: privacy требует кодогенерации и базы — правило всего курса. Включите --feature privacy, соберите политику User из урока и прогоните матрицу: без viewer — Deny; view — читает, не пишет; admin — пишет; DecisionContext — обходит. Затем снимите AlwaysDenyRule и повторите кейс view-записи: мутация пройдёт — вы своими руками увидите, почему deny по умолчанию это паттерн, а не свойство фреймворка. Наконец, добавьте DenyMutationOperationRule(ent.OpDelete|ent.OpDeleteOne) на Group и попробуйте DeleteOneID группы go-ninja: в логе не будет ни одного DELETE, а errors.Is(err, privacy.Deny) скажет true.
Соберите правило AllowIfAdmin
Впишите интерфейс правила, фабрику из контекста и решение для админа.
Жизнь мутации под политикой
Соберите путь операции от контекста до отказа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Вы умеете строить ent безопасность на уровне графа: privacy-фича-флаг и пакет ent/privacy, ent.Policy с EvalQuery и EvalMutation, три решения Allow/Deny/Skip и порядок их оценки, deny по умолчанию как сознательный паттерн с AlwaysDenyRule, viewer-контекст с DenyIfNoViewer и AllowIfAdmin, сужение правил OnMutationOperation и запрет операций DenyMutationOperationRule, тесты через DecisionContext и errors.Is. Политика отвечает «можно ли», но не «быстро ли»: как только доступ открыт, выборка исполняется со скоростью своих индексов — и WHERE от частичного фильтра из восемнадцатого урока, и уникальность email, и сортировка ленты упрутся в полнотабличный скан, если индексов нет. Дальше — индексы и аннотации двадцатого урока: index.Fields и Edges, составные уникальные, StorageKey, entsql-аннотации Desc, IndexType и IndexWhere — и обещанный четырнадцатым уроком частичный индекс на живых строках. Домашнее задание ниже заставит политику отказать в правильном месте и докажет это тестом. Полный план из 25 уроков — на странице курса, хуки и hook.Reject — в семнадцатом уроке, мягкое удаление — в четырнадцатом, валидаторы и граница с базой — в третьем, транзакции — в шестнадцатом.
Карточки: privacy
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ent privacy — слой политик в схеме: политика объявляется один раз и исполняется на каждой операции чтения и записи типа, у любого клиента; обойти её из кодовой базы нельзя — путь лежит через клиент.
- Включение: --feature privacy в generate (фича-флаг, как intercept); генератор приносит пакет ent/privacy с Allow/Deny/Skip, Allowf/Denyf/Skipf, AlwaysAllowRule/AlwaysDenyRule, ContextQueryMutationRule, OnMutationOperation, DenyMutationOperationRule, DecisionContext.
- ent.Policy — EvalQuery (read-policy) и EvalMutation (write-policy); правила оцениваются в порядке объявления: Allow или Deny останавливают оценку, Skip (равен nil) — переход к следующему.
- Deny по умолчанию — паттерн, а не свойство: все Skip = операция разрешена; хвостовое AlwaysDenyRule достраивает «всё, что не разрешено — запрещено»; пустой список ничего не запрещает.
- DenyIfNoViewer — первым правилом: гарантирует AllowIfAdmin-у наличие viewer-а; viewer — ваш пакет поверх context.Context (NewContext/FromContext).
- Исполнение: EvalQuery — до интерсепторов и SQL (включая Count/Exist); EvalMutation — в слоте hooks[0] до всех хуков из семнадцатого урока; отказ — до всякого SQL.
- OnMutationOperation сужает правило до операции, DenyMutationOperationRule(ent.OpDelete|ent.OpDeleteOne) — схемный аналог hook.Reject: жёсткое удаление закрыто у всех клиентов.
- Тесты: errors.Is(err, privacy.Deny) для отказов; privacy.DecisionContext(ctx, privacy.Allow) для обхода политики в тестах и служебных запросах; матрица кейсов — без viewer, view, admin, decision.
Домашнее задание
Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja. Первое: включите --feature privacy, запустите go generate и найдите в ent пакет privacy: запишите, какие решения и хелперы он объявляет. Второе: соберите пакеты viewer (UserViewer{Role}, NewContext, FromContext) и rule (DenyIfNoViewer, AllowIfAdmin) из урока, объявите политику User с хвостовым AlwaysDenyRule на мутациях и прогоните матрицу из четырёх кейсов — без viewer, view, admin, DecisionContext; каждый кейс оформите тестом с errors.Is(err, privacy.Deny) или require.NoError. Третье: снимите AlwaysDenyRule и повторите кейс view-записи: мутация пройдёт; верните хвост и запишите в коммент, почему deny по умолчанию — это контракт, который вы достраиваете, а не настройка ent. Четвёртое: объявите на Group политику с DenyMutationOperationRule(ent.OpDelete|ent.OpDeleteOne) и попробуйте DeleteOneID(goNinjaID) и Delete().Where() — оба отказа обязаны прийти до всякого SQL (докажите по логу драйвера отсутствием DELETE); затем сверьте с hook.Reject из семнадцатого урока: сформулируйте, когда запрету место в схеме, а когда в рантайме. Пятое: закройте мутации Post правилом «только автор может править свой пост» — правило на privacy.ContextQueryMutationRule не годится, понадобится MutationRuleFunc с проверкой m.AuthorID() из мутации; тест: борис правит свой пост — ок, чужой — Deny. Вопрос на подумать: почему проверка политики стоит до интерсепторов на чтении и до хуков на записи, и что сломалось бы, если поменять порядок?
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли privacy, если есть интерсепторы и хуки?
Да, потому что решают они разные задачи. Интерсептор фильтрует чтение: прячет строки из выборок. Хук перехватывает мутации: правит поля или отказывает. Политика — слой решений: она отвечает, допущена ли операция вообще, до SQL, до хуков и до интерсепторов, и объявлена в схеме — то есть действует для любого клиента графа. Практическая связка: политика запрещает чужие операции, интерсептор вшивает фильтры видимости, хуки — нормализация и аудит.
Privacy запрещает всё по умолчанию?
Нет — и это важно понимать честно. Если все правила вернули Skip, оценка завершается без ошибки и операция разрешается; пустой Query или Mutation-список ничего не запрещает. «Запрещено, если не разрешено» вы достраиваете сами: хвостовым privacy.AlwaysDenyRule() в списке мутаций (и чтения, если нужно). Документированные примеры ent всегда заканчивают список AlwaysDenyRule — это и есть deny по умолчанию как рабочий паттерн.
Можно ли обойти privacy-политику?
Через ent-клиент — только легальным обходом: privacy.DecisionContext(ctx, privacy.Allow) вшивает решение в контекст, и правила не оцениваются; держите этот инструмент в тестах и служебных запросах. Вне ent-клиента — как всегда с валидаторами третьего урока: рукописный SQL, другой сервис на той же базе или скрипт миграции пройдут мимо политики. Для гарантий на уровне базы существуют row-level security СУБД — это уже другая ступень ent безопасности, лежащая за пределами клиента.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker