Help us shape the future of Go! Take the 2026 Go Developer Survey

listnarrow

package
v1.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: Apache-2.0 Imports: 12 Imported by: 1

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

View Source
const DefaultParallelism = 5

DefaultParallelism — сколько партий ОДНОГО отношения летят в kaname одновременно (ограниченный пул исполнителей, не «горутина на партию»).

5 выбрано из арифметики контракта, а не на глаз: оно РОВНО делит максимум партий на предельной странице (validate.MaxPageSize / MaxBatchSize = 10), поэтому каждое отношение — ровно две полные волны без рваного хвоста. Глубина worst-case падает с 10 последовательных до 2, и это ОСВОБОЖДАЕТ бюджет под реалистичный срок одного вызова: пока партии обходились по одной, срок приходилось пилить под число последовательных хопов, и первый же перешагнувший его хоп ронял весь ПОЛОЖИТЕЛЬНЫЙ список в отказ доступности.

Всплеск на приёмной стороне ограничен 5 одновременными запросами (≤500 вопросов в полёте) на запрос — предсказуемый малый множитель.

View Source
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 — вход для списков, сужающих ГОЛЫЕ идентификаторы страницы.

Порядок ветвлений здесь и есть предмет: он одинаков у всех вызывающих, потому что живёт в одном месте.

  1. ЛИЧНОСТЬ — безусловно и ПЕРВОЙ. Ответ безымянному не зависит от того, что оператор прописал в конфигурации: положение вызывающего в обоих случаях одно и то же — его никто не назвал. Пока отсечка стояла за веткой посадки, посадка без модели отдавала всю страницу кому угодно;
  2. ПОСАДКА — модели нет ⇒ отказ; аварийный режим ⇒ проход, но посчитанный и названный;
  3. пустая страница ⇒ ничего, без обращения к соседу;
  4. сужение.

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

func Precheck(ctx context.Context, n *Narrower) error

Precheck — предусловие списочного чтения: вызывающий НАЗВАН и посадка позволяет спросить модель. Возвращает ту же ошибку, что вернул бы IDs/Page, потому что исполняет ТУ ЖЕ функцию — второго кодека здесь не заводится, и разойтись им негде.

Зачем отдельная дверь, если IDs проверяет то же самое. Затем, что между решением и сужением стоит ЧТЕНИЕ СТРАНИЦЫ ИЗ БД. Без предусловия безымянный запрос оплачивал бы курсорный запрос к своей базе, чтобы получить отказ на прочитанном, — работа, исход которой известен заранее. Порядок в вызывающем обязан быть:

формат пагинации → Precheck → чтение страницы → Page/IDs

Формат ПЕРВЫМ и здесь не переставляется: ответ на некорректный ввод не должен зависеть от того, что вызывающему выдано, иначе один и тот же мусорный курсор получает разный ответ у разных субъектов.

Аварийный режим Precheck НЕ считает: он ещё ничего не пропустил — страница не прочитана. Считает и называет тот проход, который действительно произошёл (IDs).

func SubjectFromContext

func SubjectFromContext(ctx context.Context) (string, error)

SubjectFromContext — субъект модели прав («user:usr_x» / «service_account:sva_x») вызывающего. Субъект не подставляется и не выводится: он либо назван, либо запроса нет.

Пять случаев «никого», и все обязаны отвергаться одинаково:

  • контекст не нёс принципала вовсе (нет удостоверения, нет доверенного отправителя). Брать здесь безусловный извлекатель нельзя: он отдаёт запасное значение уровня начальной загрузки, которому на кластере разрешено всё, — то есть безымянный запрос спрашивал бы права от имени этой учётки;
  • принципал несёт зарезервированное слово анонимности (край пометил им запрос без удостоверения). Именованная анонимность личностью не является, и проверять надо само слово: тип объявляет отправитель заголовков;
  • тип принципала не называет тенантного субъекта (служебный, неизвестный);
  • идентификатор пуст;
  • идентификатор содержит разделители модели прав: `usr_a#member` стал бы ссылкой на набор, `usr_a:usr_b` сдвинул бы границу «тип:идентификатор».

Последние три судит authz.TenantSubject — тот же кодек, которым субъекта называет всякий, кто по его имени что-то находит. Своей проверки здесь нет намеренно: она была бы вторым словарём об одном предмете.

Types

type AuthorizeClient

type AuthorizeClient interface {
	BatchCheck(ctx context.Context, checks []Check) ([]bool, error)
}

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) Budget

func (n *Narrower) Budget() time.Duration

Budget — фактический бюджет одной операции.

func (*Narrower) CacheSize

func (n *Narrower) CacheSize() int

CacheSize — текущий размер окна вердиктов.

func (*Narrower) CacheStats

func (n *Narrower) CacheStats() authz.CacheStats

CacheStats — величины окна вердиктов сужателя (#768).

Зачем они здесь, если размер уже был

Размер объясняет, ПОЧЕМУ доля попаданий такая, но самой доли не даёт. А доля — та величина, которая решает, сколько вопросов доезжает до владельца модели под списочной нагрузкой: звено решения задаёт ОДИН вопрос на вызов, а сужатель — по вопросу на КАЖДЫЙ элемент страницы, а страница контрактно бывает до тысячи. То есть здесь через окно проходит больше вопросов, чем через окно звена, и до сих пор их не считал никто.

Форма — ТА ЖЕ, что у окна звена решения

Возвращается `authz.CacheStats`, а не свой тип: коллектор (`pkg/authz/authzmetrics`) принимает читателя именно этой формы, и второй тип потребовал бы переходника, который разъехался бы с первым молча. Имена серий от этого тоже остаются едиными.

`Invalidated` здесь ВСЕГДА ноль, и это факт, а не пропуск

Проактивного снятия у окна сужателя НЕТ: кешируются только положительные вердикты и только на своё время жизни, поэтому окно отзыва целиком определяется истечением. Ноль на этой полосе — утверждение об устройстве, и оно верно; сделать его отсутствующим значило бы скрыть эту разницу с окном звена, у которого снятие по субъекту есть.

func (*Narrower) Counts

func (n *Narrower) Counts() Counts

Counts — прочитанные величины (см. Counts).

func (*Narrower) Narrows

func (n *Narrower) Narrows() bool

Narrows сообщает, СУЖАЕТ ли сужатель на самом деле: собеседник есть, аварийный режим не взведён, мягкий проход не превращает отказ в сквозной пропуск. Читается загрузочным стражем — «поле есть» и «значение меняет исход» разные вещи.

func (*Narrower) Relations

func (n *Narrower) Relations(resourceType string) ([]string, bool)

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

func (n *Narrower) WithClock(clock func() time.Time) *Narrower

WithClock подменяет часы окна вердиктов. Нужен там, где проба утверждает ИСТЕЧЕНИЕ: ожидание настоящего срока сделало бы её медленной и недетерминированной.

func (*Narrower) WithLogger

func (n *Narrower) WithLogger(l *slog.Logger) *Narrower

WithLogger подменяет приёмник записей о проходах (композиционный корень / тесты).

func (*Narrower) WorstCaseDepth

func (n *Narrower) WorstCaseDepth() int

WorstCaseDepth — worst-case число ПОСЛЕДОВАТЕЛЬНЫХ волн на предельной странице при текущем веере.

Directories

Path Synopsis
Package narrowmetrics — ЕДИНСТВЕННЫЙ коллектор величин сужателя списков.
Package narrowmetrics — ЕДИНСТВЕННЫЙ коллектор величин сужателя списков.
Package narrowtest — дублёры сужателя списков для проб сервисов.
Package narrowtest — дублёры сужателя списков для проб сервисов.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL