listnarrow

package
v1.4.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

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