Documentation
¶
Overview ¶
Package listnarrow — реализация сужения списочной выдачи по правам для служб, чья видимость строки определяется ОДНИМ действием.
Здесь стояло «ЕДИНСТВЕННАЯ», и это было неверно (#723) ¶
На дереве реализаций ДВЕ. Вторая — `services/iam/internal/authzfilter`, и она не дубликат: у iam видимость строки определяется НАБОРОМ отношений, а не одним действием. Набор спрашивается по очереди, и каждое следующее отношение — только для тех идентификаторов, которым отказало предыдущее, поэтому порядок там решение о СТОИМОСТИ, а не о корректности.
Этот пакет такого вопроса не выражает: `IDs` принимает одно `action`. Позвать его по разу на отношение можно, но это умножает обращения к модели на размер набора — то есть меняет стоимость страницы, ради которой пакет и написан.
Решение держать две реализации — осознанное и записано с обеих сторон: `services/iam/docs/engineering/architecture/list-narrowing-has-two-implementations.md`. **Предикат его истечения:** как только этот пакет научится принимать НАБОР действий (сигнатура `IDs`/`Page` перестанет быть одиночным `action string`), причина отпадает и iam переводится сюда. Держит это гейт дерева `internal/repohygiene` `TestListNarrowSecondImplementationKeepsItsReason`.
Что остаётся общим и не расходится: форма вопроса. Обе спрашивают ПООБЪЕКТНО по странице, ни одна не перечисляет разрешённое.
Публичный List читает СТРАНИЦУ строк из своей БД курсором и затем спрашивает kaname, какие идентификаторы этой страницы видны вызывающему (`AuthorizeService.BatchCheck`, партии ≤100). Стоимость пропорциональна СТРАНИЦЕ, а не популяции типа в хранилище прав.
Где проходит шов с контрактом владельца ¶
Порт AuthorizeClient объявлен типами ЭТОГО пакета (Check и вердикты в порядке вопросов), а не сгенерированным контрактом службы доступа. Перевод в него живёт у того, кто контракт реализует, — `pkg/listnarrow/narrowiam`.
Это не косметика: пакет принадлежит фундаменту, и пока он говорил чужим контрактом, после разъезда на три модуля `corelib` потребовал бы `kaname`, который уже требует `corelib`, — цикл, который Go не собирает (приёмка K3-1 §7.2, задача #2131). Переезд всего пакета был невозможен: от Narrower зависят три прод-файла фундамента, — поэтому шов проходит СКВОЗЬ ТИП.
Почему не перечисление разрешённого ¶
Обратная форма вопроса — «перечисли ВСЕ объекты этого типа, которые субъекту можно» — имеет ЖЁСТКИЙ серверный предел и НЕ имеет продолжения: запрос вообще не несёт поля размера, поэтому просьба «дай больше» не расширяет ответ, а лишь обрезает уже усечённый. Предел действует на ТИП В ХРАНИЛИЩЕ, а не на арендатора, поэтому на долгоживущем сторе собственный ресурс арендатора выпадал за префикс и становился невидимым НАВСЕГДА при живых правах: строка есть, выдача есть, мутации работают (они задают прямой пообъектный вопрос) — а списка нет. Лечится не поднятием предела (он внешний и всё равно конечен), а формой вопроса.
Что здесь ЗАФИКСИРОВАНО, а что оставлено ПОЛЕМ ¶
Зафиксировано то, что является нормой и одинаково для всех:
- безымянный вызывающий получает ОТКАЗ — безусловно и ДО ветки «провязана ли модель». Порядок несущий: пока отсечка жила за этой веткой, посадка без модели отдавала всю страницу кому угодно, и держалось это лишь на загрузочном страже, то есть ровно до первой конфигурации, которая его не взвела;
- «модель не провязана» — ОТКАЗ, никогда не сквозной проход. Состояние посадки разрешением не бывает. За сужаемыми методами пообъектной проверки на крае нет вовсе, откатываться не на что;
- аварийный режим остаётся явным исключением, но КАЖДОЕ его срабатывание считается и называется: без прибора он становится тихим штатным, и «им пользуются» неотличимо от «им не пользуются»;
- бюджет принадлежит ЗАПРОСУ: партии идут ограниченным веером, а потолок всей операции выводится из глубины волн и срока одного вызова, а не задаётся независимой ручкой.
Оставлено полем то, что является ОСЬЮ, а не нормой:
- Config.Relations — предикат членства страницы ПО ТИПУ объекта. Сведение его к одной константе меняло бы ВИДИМОСТЬ, а это продуктовое решение по каждому ресурсу, а не выравнивание формы. Карта обязана быть ТОТАЛЬНОЙ (запись под пустым ключом — умолчание), и это не формальность: тотальность есть то, что позволяет гейту паритета прочитать объявление и сверить его с каталогом прав потипно. Предикат, спрятанный в теле функции, — предикат, которого не видит ни один гейт;
- размер партии, веер, срок вызова, окно кэша — величины посадки.
Кэш — только ПОЛОЖИТЕЛЬНЫЕ вердикты ¶
Отрицательный вердикт не кешируется никогда, иначе свежая выдача не была бы видна до истечения окна. Снятый доступ перестаёт действовать самое позднее через окно, и другого механизма отзыва здесь нет: мгновенное снятие потребовало бы канала уведомлений от владельца выдач сюда, то есть нового межсервисного ребра и отдельного решения.
Index ¶
- Constants
- func AllowedOnObject(ctx context.Context, n *Narrower, resourceType, action string, ...) (bool, error)
- func ErrModelNotWired() error
- func ErrUnnamedCaller() error
- func IDs(ctx context.Context, n *Narrower, resourceType, action string, ids []string) ([]string, error)
- func Page[T any](ctx context.Context, n *Narrower, resourceType, action string, page []T, ...) ([]T, error)
- func Precheck(ctx context.Context, n *Narrower) error
- func SubjectFromContext(ctx context.Context) (string, error)
- type AuthorizeClient
- type Check
- type Config
- type Counts
- type Narrower
- func (n *Narrower) Budget() time.Duration
- func (n *Narrower) CacheSize() int
- func (n *Narrower) CacheStats() authz.CacheStats
- func (n *Narrower) Counts() Counts
- func (n *Narrower) Narrows() bool
- func (n *Narrower) Relations(resourceType string) ([]string, bool)
- func (n *Narrower) Visible(ctx context.Context, subject, resourceType, action, relation string, ...) ([]string, error)
- func (n *Narrower) WithClock(clock func() time.Time) *Narrower
- func (n *Narrower) WithLogger(l *slog.Logger) *Narrower
- func (n *Narrower) WorstCaseDepth() int
Constants ¶
const DefaultParallelism = 5
DefaultParallelism — сколько партий ОДНОГО отношения летят в kaname одновременно (ограниченный пул исполнителей, не «горутина на партию»).
5 выбрано из арифметики контракта, а не на глаз: оно РОВНО делит максимум партий на предельной странице (validate.MaxPageSize / MaxBatchSize = 10), поэтому каждое отношение — ровно две полные волны без рваного хвоста. Глубина worst-case падает с 10 последовательных до 2, и это ОСВОБОЖДАЕТ бюджет под реалистичный срок одного вызова: пока партии обходились по одной, срок приходилось пилить под число последовательных хопов, и первый же перешагнувший его хоп ронял весь ПОЛОЖИТЕЛЬНЫЙ список в отказ доступности.
Всплеск на приёмной стороне ограничен 5 одновременными запросами (≤500 вопросов в полёте) на запрос — предсказуемый малый множитель.
const MaxBatchSize = 100
MaxBatchSize — контрактный предел `AuthorizeService.BatchCheck` приёмной стороны (>100 → InvalidArgument). Партии режутся по нему.
Variables ¶
This section is empty.
Functions ¶
func AllowedOnObject ¶
func AllowedOnObject( ctx context.Context, n *Narrower, resourceType, action string, relations []string, id string, ) (bool, error)
AllowedOnObject — ОДИНОЧНАЯ дверь того же механизма: «можно ли этому вызывающему ЭТОТ объект хотя бы по одному из названных отношений».
Живёт здесь, а не отдельным пакетом, потому что делит с сужателем ВСЁ, кроме формы вопроса: тот же клиент, тот же срок вызова, то же отображение отказа соседа, та же полярность на безымянном вызывающем и на непровязанной модели. Вынести её значило бы завести второй экземпляр этих решений — и разойтись они смогли бы молча, ровно как разошлись четыре копии сужателя.
Потребитель сегодня ОДИН (storage, привязка и отвязка тома: право видеть и менять привязки вытекает из права на МАШИНУ, а не на каждый том). Это названо прямо, чтобы следующий читатель не принял одиночность за недосмотр и не начал строить вокруг неё обобщение.
Отношения передаются ЯВНО и не берутся из предиката страницы: предмет вопроса другой — не «попадает ли строка в выдачу», а «вправе ли вызывающий распорядиться объектом», и совпадение этих наборов было бы случайным.
Fail-closed по всем линиям: нет личности → отказ по личности; нет модели → отказ по посадке; сосед не ответил → недоступность, никогда «да».
func ErrModelNotWired ¶
func ErrModelNotWired() error
ErrModelNotWired — сужателя нет либо ему не с кем говорить. Это состояние ПОСАДКИ, а не ответ модели, — поэтому отказ, а не «да».
Отдавать страницу здесь не на каком основании: сужаемые методы помечены scope-filtered, то есть пообъектной проверки на крае за них НЕ задаётся вовсе, и откатываться не на что. Пока это состояние означало сквозной проход, вся защита держалась на загрузочном страже — то есть существовала ровно до первой конфигурации, которая его не взвела.
func ErrUnnamedCaller ¶
func ErrUnnamedCaller() error
ErrUnnamedCaller — запрос не назвал никого. Отдельный, ПЕРВЫЙ исход: это не «отказ прав» и не «сосед недоступен», а отсутствие вызывающего.
Код `Unauthenticated`: ответ обязан говорить о ЛИЧНОСТИ, а не о том, что оператор прописал в конфигурации, — иначе один и тот же запрос получал бы разный ответ в зависимости от посадки.
func IDs ¶
func IDs(ctx context.Context, n *Narrower, resourceType, action string, ids []string) ([]string, error)
IDs — вход для списков, сужающих ГОЛЫЕ идентификаторы страницы.
Порядок ветвлений здесь и есть предмет: он одинаков у всех вызывающих, потому что живёт в одном месте.
- ЛИЧНОСТЬ — безусловно и ПЕРВОЙ. Ответ безымянному не зависит от того, что оператор прописал в конфигурации: положение вызывающего в обоих случаях одно и то же — его никто не назвал. Пока отсечка стояла за веткой посадки, посадка без модели отдавала всю страницу кому угодно;
- ПОСАДКА — модели нет ⇒ отказ; аварийный режим ⇒ проход, но посчитанный и названный;
- пустая страница ⇒ ничего, без обращения к соседу;
- сужение.
func Page ¶
func Page[T any]( ctx context.Context, n *Narrower, resourceType, action string, page []T, idOf func(T) string, ) ([]T, error)
Page — вход для списков, сужающих ЗАПИСИ страницы: оставляет только видимые строки, сохраняя порядок курсора.
Дженерик, потому что списочные пути отличаются ТОЛЬКО типом записи и извлечением идентификатора; альтернатива — тот же цикл, скопированный по разу на ресурс.
Второй вход в ТОТ ЖЕ контракт: он зовёт IDs, а не повторяет её ветвления, — иначе дыра просто переезжала бы в тот из двух входов, который забыли.
func Precheck ¶
Precheck — предусловие списочного чтения: вызывающий НАЗВАН и посадка позволяет спросить модель. Возвращает ту же ошибку, что вернул бы IDs/Page, потому что исполняет ТУ ЖЕ функцию — второго кодека здесь не заводится, и разойтись им негде.
Зачем отдельная дверь, если IDs проверяет то же самое. Затем, что между решением и сужением стоит ЧТЕНИЕ СТРАНИЦЫ ИЗ БД. Без предусловия безымянный запрос оплачивал бы курсорный запрос к своей базе, чтобы получить отказ на прочитанном, — работа, исход которой известен заранее. Порядок в вызывающем обязан быть:
формат пагинации → Precheck → чтение страницы → Page/IDs
Формат ПЕРВЫМ и здесь не переставляется: ответ на некорректный ввод не должен зависеть от того, что вызывающему выдано, иначе один и тот же мусорный курсор получает разный ответ у разных субъектов.
Аварийный режим Precheck НЕ считает: он ещё ничего не пропустил — страница не прочитана. Считает и называет тот проход, который действительно произошёл (IDs).
func SubjectFromContext ¶
SubjectFromContext — субъект модели прав («user:usr_x» / «service_account:sva_x») вызывающего. Субъект не подставляется и не выводится: он либо назван, либо запроса нет.
Пять случаев «никого», и все обязаны отвергаться одинаково:
- контекст не нёс принципала вовсе (нет удостоверения, нет доверенного отправителя). Брать здесь безусловный извлекатель нельзя: он отдаёт запасное значение уровня начальной загрузки, которому на кластере разрешено всё, — то есть безымянный запрос спрашивал бы права от имени этой учётки;
- принципал несёт зарезервированное слово анонимности (край пометил им запрос без удостоверения). Именованная анонимность личностью не является, и проверять надо само слово: тип объявляет отправитель заголовков;
- тип принципала не называет тенантного субъекта (служебный, неизвестный);
- идентификатор пуст;
- идентификатор содержит разделители модели прав: `usr_a#member` стал бы ссылкой на набор, `usr_a:usr_b` сдвинул бы границу «тип:идентификатор».
Последние три судит authz.TenantSubject — тот же кодек, которым субъекта называет всякий, кто по его имени что-то находит. Своей проверки здесь нет намеренно: она была бы вторым словарём об одном предмете.
Types ¶
type AuthorizeClient ¶
AuthorizeClient — узкий порт к владельцу модели.
Контракт ответа: вердикты В ПОРЯДКЕ ВОПРОСОВ и ТОЙ ЖЕ ДЛИНЫ. Иная длина — не «считаем отказом», а fail-closed ошибка у вызывающего: молчаливое смещение индексов выдало бы вердикт одного объекта за другой, и страница была бы неверна так, что вызывающий этого не обнаружит.
type Check ¶
type Check struct {
// Subject — принципал в форме владельца модели (`<тип>:<id>`).
Subject string
// ResourceType — тип объекта в словаре модели прав.
ResourceType string
// ResourceID — идентификатор объекта.
ResourceID string
// Action — метод, ради которого задан вопрос.
Action string
// RequiredRelation — отношение, которого метод требует.
RequiredRelation string
}
Check — ОДИН вопрос о доступе, объявленный типами ФУНДАМЕНТА.
Прежде порт говорил сгенерированным типом владельца модели, и сигнатура совпадала с его клиентом — «боевая реализация тонкий проброс». Цена этого удобства названа приёмкой K3-1 §7.2: фундамент импортировал контракт службы доступа, то есть после разъезда `corelib` потребовал бы `kaname`, а `kaname` уже требует `corelib`. Цикл на уровне модулей, который Go не собирает.
Поэтому шов проходит СКВОЗЬ ТИП: вопрос и вердикт объявлены здесь, а перевод в контракт владельца живёт у того, кто этот контракт реализует (`pkg/listnarrow/narrowiam`, класс `kaname`).
type Config ¶
type Config struct {
// Relations — предикат членства страницы ПО ТИПУ объекта. Обязан быть ТОТАЛЬНЫМ:
// запись под пустым ключом — умолчание для типа, не названного поимённо.
//
// Это ПОЛЕ, а не константа пакета, намеренно (решение владельца 2026-08-09):
// сведение предиката к одному значению меняло бы ВИДИМОСТЬ, а это продуктовое
// решение по каждому ресурсу. Тотальность позволяет гейту паритета прочитать
// объявление вызывающего и сверить его с каталогом прав потипно.
Relations map[string][]string
// Timeout — срок ОДНОГО запроса к приёмной стороне. НЕ бюджет операции.
Timeout time.Duration
// Parallelism — сколько партий одного отношения летят одновременно.
// 0 → DefaultParallelism.
Parallelism int
// OverallTimeout — бюджет ВСЕЙ операции. 0 → выводится из Timeout и Parallelism
// так, чтобы worst-case стена на предельной странице помещалась внутрь с
// запасом. Потолок нужен, потому что срок одного вызова ограничивает ОДИН хоп, а
// не их последовательность.
OverallTimeout time.Duration
// CacheTTL — окно жизни ОДНОГО положительного вердикта. Это ОКНО ОТЗЫВА:
// столько субъект, у которого право уже отобрали, продолжает видеть строку.
//
// НОЛЬ ЗДЕСЬ НЕ ОЗНАЧАЕТ «БЕЗ КЕША». Ноль и всякая неположительная величина
// означают умолчание `pkg/authz.RevocationPolicy.Default`; выключить окно
// этим полем НЕЛЬЗЯ, такого состояния у сужателя нет. Семантика названа
// здесь, потому что читается она ровно наоборот, и на ней уже ошиблись:
// проба, собранная с `CacheTTL: 0` ради «холодного» пути, мерила тёплый и
// зеленела бы, утверждая не то. Единственный рычаг, которым окно
// заканчивается, — время (см. Narrower.WithClock).
CacheTTL time.Duration
// CacheMaxEntries — предел размера окна. Неположительная величина означает
// умолчание `pkg/authz` (не «без предела»).
CacheMaxEntries int
// SoftPassOnPeerFailure — задокументированный мягкий проход: на отказе приёмной
// стороны страница уходит НЕсуженной. Защитим ровно пока отказ действительно
// временный, поэтому здесь он классифицируется и считается (см. Counts).
SoftPassOnPeerFailure bool
// Breakglass — аварийный режим: страница отдаётся несуженной, потому что модели
// на этой посадке нет вовсе. Остаётся явным исключением (решение владельца
// 2026-08-09), но КАЖДОЕ срабатывание считается и называется — иначе аварийный
// режим становится тихим штатным.
//
// Личности он НЕ отменяет: безымянный вызывающий отвергается и здесь.
Breakglass bool
}
Config — посадка сужателя.
type Counts ¶
type Counts struct {
// Narrowed — сколько раз страница сужена ШТАТНО: личность установлена,
// аварийного режима нет, сосед ответил, мягкий проход не понадобился.
//
// Пустая страница сюда НЕ попадает: соседа она не спрашивает и сужать в ней
// нечего, поэтому засчитывать её значило бы объявлять работу там, где её не
// делали.
Narrowed uint64
// Breakglass — сколько раз страница ушла несуженной по аварийному режиму.
Breakglass uint64
// SoftPassMisconfigured — сколько раз мягкий проход сработал на ответе,
// ДОКАЗЫВАЮЩЕМ неверную настройку (сам не пройдёт никогда).
SoftPassMisconfigured uint64
// SoftPassTransient — сколько раз мягкий проход сработал на отказе, который
// может пройти сам.
SoftPassTransient uint64
}
Counts — прочитанные величины сужателя. Именно ПРОЧИТАННЫЕ: по отсутствию строк в журнале «прохода не было» и «счётчика нет» неразличимы, а прочитанный ноль их различает.
Полос ЧЕТЫРЕ, и первая из них положительная. Трёх полос несуженного прохода недостаточно: три нуля не отличают «аварийных проходов не было» от «сужателя не звали ни разу», а это разные состояния — второе означает, что защита не исполняется вовсе. Единица счёта у всех четырёх одна: ОДНА публичная операция сужения страницы.
type Narrower ¶
type Narrower struct {
// contains filtered or unexported fields
}
Narrower — сужатель списочной страницы поверх `AuthorizeService.BatchCheck` с окном ПОЛОЖИТЕЛЬНЫХ вердиктов (TTL + вытеснение давно не использованных).
func New ¶
func New(cli AuthorizeClient, cfg Config) *Narrower
New собирает сужатель. cli == nil означает, что спросить негде: без аварийного режима такой сужатель ОТКАЗЫВАЕТ, а не пропускает.
Нормализует веер и ВЫВОДИТ бюджет операции, если тот не задан явно: бюджет — не независимая ручка, а функция контракта (максимум страницы, предел партии) и срока одного вызова. Так арифметика «сумма worst-case ожиданий помещается в бюджет» держится ПО ПОСТРОЕНИЮ, а не по надежде на когерентную настройку.
func (*Narrower) CacheStats ¶
func (n *Narrower) CacheStats() authz.CacheStats
CacheStats — величины окна вердиктов сужателя (#768).
Зачем они здесь, если размер уже был ¶
Размер объясняет, ПОЧЕМУ доля попаданий такая, но самой доли не даёт. А доля — та величина, которая решает, сколько вопросов доезжает до владельца модели под списочной нагрузкой: звено решения задаёт ОДИН вопрос на вызов, а сужатель — по вопросу на КАЖДЫЙ элемент страницы, а страница контрактно бывает до тысячи. То есть здесь через окно проходит больше вопросов, чем через окно звена, и до сих пор их не считал никто.
Форма — ТА ЖЕ, что у окна звена решения ¶
Возвращается `authz.CacheStats`, а не свой тип: коллектор (`pkg/authz/authzmetrics`) принимает читателя именно этой формы, и второй тип потребовал бы переходника, который разъехался бы с первым молча. Имена серий от этого тоже остаются едиными.
`Invalidated` здесь ВСЕГДА ноль, и это факт, а не пропуск ¶
Проактивного снятия у окна сужателя НЕТ: кешируются только положительные вердикты и только на своё время жизни, поэтому окно отзыва целиком определяется истечением. Ноль на этой полосе — утверждение об устройстве, и оно верно; сделать его отсутствующим значило бы скрыть эту разницу с окном звена, у которого снятие по субъекту есть.
func (*Narrower) Narrows ¶
Narrows сообщает, СУЖАЕТ ли сужатель на самом деле: собеседник есть, аварийный режим не взведён, мягкий проход не превращает отказ в сквозной пропуск. Читается загрузочным стражем — «поле есть» и «значение меняет исход» разные вещи.
func (*Narrower) Relations ¶
Relations — предикат страницы для одного типа объекта. Второе значение false означает, что предикат для типа НЕ ОБЪЯВЛЕН: карта не тотальна.
func (*Narrower) Visible ¶
func (n *Narrower) Visible(ctx context.Context, subject, resourceType, action, relation string, ids []string) ([]string, error)
Visible — сужение набора для УЖЕ УСТАНОВЛЕННОГО субъекта и ЯВНО названного отношения.
Нижняя дверь того же механизма, и заведена она ради вызывающего, у которого личность устанавливается СВОИМИ правилами с записанной причиной: у registry ответ безымянному приведён к тому, что даёт его же интерсептор на всех прочих RPC — иначе сам код ответа сообщал бы неаутентифицированному пробующему, какие RPC авторизуются на уровне данных, а какие интерсептором. Пропусти его через IDs — и это решение подменилось бы общим.
Отношение здесь тоже ЯВНОЕ: предикат страницы registry (`v_list` на каталоге имён) намеренно шире отношения чтения, и это записанное отступление — страница каталога отдаёт голые ИМЕНА, а не сообщение ресурса, поэтому «видно в перечне без содержимого» там реализуемо. Брать предикат из карты значило бы молча сузить чужое решение.
Всё остальное — то же: партии ≤ MaxBatchSize, ограниченный веер, бюджет операции, окно ПОЛОЖИТЕЛЬНЫХ вердиктов, fail-closed на первой ошибке.
func (*Narrower) WithClock ¶
WithClock подменяет часы окна вердиктов. Нужен там, где проба утверждает ИСТЕЧЕНИЕ: ожидание настоящего срока сделало бы её медленной и недетерминированной.
func (*Narrower) WithLogger ¶
WithLogger подменяет приёмник записей о проходах (композиционный корень / тесты).
func (*Narrower) WorstCaseDepth ¶
WorstCaseDepth — worst-case число ПОСЛЕДОВАТЕЛЬНЫХ волн на предельной странице при текущем веере.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package narrowmetrics — ЕДИНСТВЕННЫЙ коллектор величин сужателя списков.
|
Package narrowmetrics — ЕДИНСТВЕННЫЙ коллектор величин сужателя списков. |
|
Package narrowtest — дублёры сужателя списков для проб сервисов.
|
Package narrowtest — дублёры сужателя списков для проб сервисов. |