authz

package
v1.3.1 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: 13 Imported by: 0

Documentation

Overview

Package authz реализует REBAC-based authorization для backend-сервисов Kachō.

Потребителей ШЕСТЬ — vpc, nlb, compute, storage, geo, registry, — и каждый получает звено через носитель контура (`pkg/servicehost`). Здесь стоял перечень из четырёх имён, включавший **iam**: он неверен и был неверен в ту сторону, в которую ошибаться дороже всего — читатель искал бы у владельца модели кеш вердиктов, которого там нет. Предикат: `git grep -l "kacho/pkg/authz\"" -- services/iam` → пусто; iam решает у себя и этот пакет не импортирует.

Архитектура

┌──────────────┐     unary/stream     ┌──────────────────────────┐
│   client     │ ───── gRPC ─────────►│  authz.Interceptor       │
└──────────────┘                       │  (per-service)           │
                                       │                          │
                                       │  1. lookup PermissionMap │
                                       │     RPC → {object_type,  │
                                       │            relation,     │
                                       │            extractor}    │
                                       │  2. cache.Get (≤0.5ms)   │
                                       │  3. (cache miss) call    │
                                       │     CheckClient.Check    │
                                       │  4. cache.Set positive   │
                                       │  5. allow / DENY         │
                                       └──────────────────────────┘
                                                  │
                                                  ▼ Check(subj, rel, obj)
                                       ┌──────────────────────────┐
                                       │  kaname :9091         │
                                       │  InternalIAMService.Check│
                                       └──────────────────────────┘

Дальше сети НЕТ: вердикт складывает реляционная форма в собственной базе iam. Здесь стоял пятый ярус — внешний движок отношений, которому iam пересылал вопрос. Его сняли, и для потребителя это значит ровно одно: сосед в пути решения один, поэтому и отказ ниже назван один.

Окно отзыва (объявлено политикой — см. revocation_policy.go)

  • Кешируются только положительные вердикты, отрицательные — никогда. Поэтому ВЫДАЧА видна сразу, а ОТЗЫВ ждёт истечения записи.
  • Срок жизни записи И ЕСТЬ окно отзыва: иного пути снять её у backend-сервиса нет. Число и его обоснование — в `RevocationPolicy` (умолчание 5s, потолок 10s), перепись по сервисам — там же.
  • Здесь стояло «push-invalidation через pg_notify('kacho_iam_subjects') в каждом backend» и складывался бюджет «TTL=5s + NOTIFY≤1s + outbox-drain≤2s = ≤10s». Слагаемого NOTIFY не существует: у канала нет отправителя, а при database-per-service backend-сервис к БД iam и не подключён. Итог ≤10s остался верным, но по другой причине — он теперь объявленный ПОТОЛОК, а не сумма с несуществующим членом.
  • Отзыв УЧЁТНЫХ ДАННЫХ (токен, ключ, уволенный сотрудник) по этому окну НЕ ездит и остаётся немедленным: он снимается на краю (чтение краем журнала `subject_change_outbox` владельца прав, дренаж ≤1s), и запрос с отозванным токеном до backend-сервиса не доходит.

Как звучит отказ

Отказ на ПООБЪЕКТНОМ чтении (`/Get` на глагольном `v_get`) и на мутации, объявленной скрывающей (`RPCEntry.HideExistence`), приходит как `NOT_FOUND` текстом ВЛАДЕЛЬЦА — тем же, что даёт настоящий промах (см. hide_existence.go). Иначе вызывающий отличал бы «есть, но не твоё» от «нет такого» по одному лишь сообщению, а край на том же запросе уже отвечает промахом. Handler не вызывается ни в одной из веток: меняется звучание, не решение. Остальные отказы — `PermissionDenied`, включая случаи, где скрывать нечего: тип объекта без текста владельца, вызов без конкретного id, неназвавшийся вызывающий.

Fail modes

  • kaname.Check unavailable → fail-closed `PermissionDenied`.
  • `KACHO_<SVC>_AUTHZ__BREAKGLASS=true` env (dev/break-glass) → bypass Check
  • WARN log (rate-limited) + Prometheus alert.

Фундамент не зависит от контракта службы доступа

Пакет НЕ импортирует стабы контракта. Вместо этого он определяет узкий port-интерфейс CheckClient:

type CheckClient interface {
    Check(ctx context.Context, subjectID, relation, object string) (allowed bool, err error)
}

Это не украшение слоёв: после разъезда на три модуля такая зависимость дала бы ЦИКЛ — фундамент потребовал бы службу доступа, которая уже требует фундамент, — и Go такой граф не собирает.

Реализация (gRPC-клиент к `InternalIAMService.Check`) живёт в адаптере `pkg/authz/authziam`: он импортирует стабы контракта и реализует authz.CheckClient. Каталог объявлен классом `kaname` в карте расщеплений гейта границы фундамента (`internal/repohygiene/foundationboundary.go`) — контракт остаётся у того, кто его реализует.

Здесь дважды стояла координата в дереве ОТДЕЛЬНОГО сервиса (`…/internal/clients/iam_authz_client.go`) и имя прежнего репозитория контрактов. Ни того, ни другого в дереве нет: адаптер был один и уехал в носитель, а оттуда — в каталог выше. Правку 534996d979 откатил массовый переезд контракта d46aaa7280, сделанный на отставшей копии; поэтому вместе с текстом заведена проверка, которая назовёт следующий такой откат сама — `internal/repohygiene` `TestFoundationProseNamesNoPolyrepoCoordinate`.

Файлы пакета

  • types.go — RPCMap / Decision / типы
  • cache.go — TTL=5s positive-only кэш + LISTEN-invalidate hook
  • interceptor.go — gRPC unary/stream interceptor
  • check_client.go — port-интерфейс CheckClient, CheckClientFunc и CheckClientFrom (сборщик решателя из соединения; его приносит сервис полем дескриптора, потому что перевод в чужой контракт фундаменту не принадлежит)
  • authziam/ — единственный адаптер порта к контракту владельца
  • rate_limiter.go — token-bucket per-Principal на denied-storm
  • listen_invalidate.go — pgx LISTEN-loop, инвалидирующий cache на NOTIFY
  • authzmetrics/ — коллектор величин звена и его окна вердиктов

Наблюдаемость

`Interceptor.Metrics` отдаёт снимок величин звена ВМЕСТЕ с величинами окна вердиктов (`Metrics.Cache`), а `authzmetrics` превращает их в серии `kacho_<сервис>_authz_cache_total{lane,result}`, `kacho_<сервис>_authz_cache_entries{lane}`, `kacho_<сервис>_authz_cache_evictions_total{lane,reason}` и `kacho_<сервис>_authz_check_decisions_total{decision}` — однородные с краем.

Величины ОКНА считает само окно (`Cache.Stats`), а не звено: у звена нет ни истечения записи, ни давления потолка, ни снятия, поэтому второй счётчик попаданий рядом со звеном разошёлся бы с первым молча.

Провязку держат два места, и оба обязательны: поле `AuthzObserve` дескриптора (без него носитель отказывает в старте) и обход дерева `internal/repohygiene.TestEveryCarrierServiceExportsItsVerdictCacheHitRate` (сервис у носителя обязан строить коллектор). Первое ловит незаполненное поле, второе — заполненное заглушкой.

Index

Constants

This section is empty.

Variables

View Source
var ErrHideExistence = errors.New("authz: hide existence (deny on existing object)")

ErrHideExistence — CheckClient.Check() sentinel: object-scoped deny на ресурс, который СУЩЕСТВУЕТ в БД сервиса, но caller не вправе его видеть. В отличие от ErrNoPath (passthrough → handler сам отдаст NOT_FOUND для отсутствующего), здесь объект есть — passthrough слил бы его. Interceptor БЛОКИРУЕТ handler и возвращает NOT_FOUND (existence-hiding): «есть-но-не-твой» неотличимо от «нет». Клиент возвращает этот sentinel, только сам сверив наличие объекта в своей БД.

View Source
var ErrNoPath = errors.New("authz: no FGA path to resource")

ErrNoPath — CheckClient.Check() sentinel: FGA вернул allowed=false с причиной "no path" (нет hierarchy-tuple для объекта). Означает: ресурс либо не существует, либо tuple еще не записан. Interceptor интерпретирует это как DecisionNoPath и пропускает RPC к handler'у, который вернет NOT_FOUND из DB.

Используется только клиентами, которые имеют доступ к полю `reason` в CheckResponse (kacho-compute, kacho-vpc). Другие клиенты могут игнорировать.

View Source
var ErrPermissionDenied = errors.New("authz: permission denied")

ErrPermissionDenied — FGA / kaname отвергли запрос (gRPC PermissionDenied). Семантически отличается от ErrUnavailable: это легитимный denial subject'а, а НЕ инфраструктурная недоступность. Caller должен мапить на gRPC PermissionDenied (HTTP 403), а не Unavailable (HTTP 503) — иначе клиент (UI / SDK) не отличит "у тебя нет прав" от "сервис не работает", и retry-логика сделает хуже.

Ранее listobjects.go оборачивал PermissionDenied в ErrUnavailable через `fmt.Errorf("%w: %v", ErrUnavailable, err)` — gRPC-код терялся в `%v`-formatting, caller'ы вынужденно возвращали 503.

View Source
var ErrUnavailable = errors.New("authz: check service unavailable")

ErrUnavailable — FGA / kaname.Check недоступны. fail-closed default.

View Source
var ErrUnmapped = errors.New("authz: RPC not mapped in PermissionMap")

ErrUnmapped — RPC не покрыт RPCMap. Должен мапиться в `PermissionDenied` (fail-closed). Метрика `kacho_authz_unmapped_total{rpc=...}` инкрементируется.

View Source
var RevocationPolicy = RevocationWindowPolicy{
	Default: 5 * time.Second,
	Ceiling: 10 * time.Second,

	DeliveryCeiling:        30 * time.Second,
	MaterializationCeiling: 30 * time.Second,

	Windows: map[string]time.Duration{
		"vpc authz.cache-ttl":                            5 * time.Second,
		"vpc authz.list-filter.cache-ttl":                5 * time.Second,
		"nlb authz.cache.ttl":                            5 * time.Second,
		"nlb authz.list-filter.cache-ttl":                5 * time.Second,
		"registry KACHO_REGISTRY_AUTHZ_CACHE_TTL":        2 * time.Second,
		"compute KACHO_COMPUTE_LIST_FILTER_CACHE_TTL_MS": 5 * time.Second,
		"storage KACHO_STORAGE_LIST_FILTER_CACHE_TTL_MS": 5 * time.Second,

		"compute KACHO_COMPUTE_AUTHZ_CACHE_TTL": 5 * time.Second,
		"storage KACHO_STORAGE_AUTHZ_CACHE_TTL": 5 * time.Second,
		"geo KACHO_GEO_AUTHZ_CACHE_TTL":         5 * time.Second,

		"api-gateway KACHO_API_GATEWAY_AUTHZ_CACHE_TTL_SECONDS": 5 * time.Second,

		"iam authz.cache-ttl": 5 * time.Second,
	},
}

RevocationPolicy — действующая политика.

Обоснование числа

Потолок 10s выбран как сумма трёх слагаемых, каждое из которых наблюдаемо: срок жизни записи (≤5s) + дренаж очереди отзыва до хранилища прав (≤2s) + запас на переспрос под нагрузкой. Меньше 5s означало бы ходить к iam почти на каждый RPC — та самая нагрузка, ради амортизации которой кеш и заведён; больше 10s означало бы, что снятие права заметно человеку, который его снял.

Число НЕ выведено из механизма проактивной инвалидации: такого механизма у backend-сервисов нет (см. ниже). Прежняя запись в doc.go складывала окно с членом «NOTIFY≤1s» от пути, у которого в этом репозитории нет ни одного отправителя, и потому обещала распространение, которого не происходит.

Functions

func FormatObject

func FormatObject(objectType, objectID string) (string, error)

FormatObject форматирует FGA-object string: "<type>:<id>". Возвращает err если type/id содержат FGA-разделители (':', '#', '@', whitespace) — симметрично subject-пути (validSubjectID), чтобы attacker-controlled resource id не мог сдвинуть границу type:id или образовать userset-ссылку.

func FormatSubject

func FormatSubject(principalType, principalID string) string

FormatSubject форматирует FGA-subject string из Principal'а.

Mapping:

  • Principal{Type:"user", ID:"usr_xxx"} → "user:usr_xxx"
  • Principal{Type:"service_account", ID:"sva_xxx"} → "service_account:sva_xxx"
  • Principal{Type:"system", ID:"bootstrap"} → "user:bootstrap" (для аудита; обычно system-principal обходит interceptor через internal-path).

Group-principal'ы в этом mapping'е не появляются: для group-binding'ов FGA разрешает access через `group:<id>#member` tuple — но subject в Check всегда конкретный user / SA (resolved от Principal в auth-interceptor'е).

func HidesExistenceOnDeny

func HidesExistenceOnDeny(fullMethod string, entry RPCEntry, objectType string) bool

HidesExistenceOnDeny reports whether a deny on this RPC must be answered with the owning service's NotFound rather than PermissionDenied.

It is the SERVICE side of the same rule the api-gateway applies one hop earlier (CatalogEntry.HidesExistenceOnDeny), and it is deliberately written to the same shape, because the two answers are compared by whoever called: an edge that hides while the owner refuses is exactly the oracle hiding exists to close. The two deciders disagree in practice — each keeps its own positive-verdict cache with its own window — so a call the edge lets through can still be refused here.

Resolution order:

  1. explicit RPCEntry.HideExistence — for a MUTATION the edge marks the same way (registry Update/Delete carry `hide_existence` in the catalog);
  2. otherwise the shape of a per-object read: a unary `/Get` gated on the verb-bearing `v_get`.

Both are additionally gated on there being an owner text to speak with: an object type absent from hideExistenceNotFoundFormats keeps its PermissionDenied, since a neutral "not found" would be as distinguishable as the 403 it replaced. The repo-wide gate TestServiceGateHidesExistenceWhereTheEdgeDoes walks every service map against the catalog and fails when the two sides stop agreeing.

func OwnerNotFoundFormat

func OwnerNotFoundFormat(objectType string) (string, bool)

OwnerNotFoundFormat отдаёт формат промаха владельца для типа объекта модели — ровно тот, которым звено решения о доступе ответит на скрывающем отказе.

Существует ради ОДНОГО читателя: носитель контура (`pkg/servicehost`) сверяет с ним форму, объявленную сервисом в своём дескрипторе. Пока сверки не было, про один и тот же текст утверждали в двух местах — здесь и в дескрипторе, — и расхождение между ними осталось бы незамеченным ровно потому, что отвечает всегда ЭТА таблица: дескриптор объявил бы одно, вызывающий увидел бы другое, и «объявлено» перестало бы что-либо значить.

ok ложен, если у типа нет текста владельца: тогда отказ отвечает нейтральным «not found», отличимым от настоящего промаха, — и это находка на стороне носителя, а не умолчание здесь.

func TenantSubject

func TenantSubject(principalType, principalID string) (string, bool)

TenantSubject — субъект модели прав для НАЗВАННОЙ пары «тип, идентификатор», либо отказ. Строгая дверь рядом с FormatSubject.

Чем отличается от FormatSubject и почему нужны обе

FormatSubject обязан вернуть строку ВСЕГДА: он зовётся на пути аудита, где «никакого субъекта» — не исход, и потому сводит неизвестный тип к `user:`. Для решения о доступе такое сведение недопустимо: оно ПРИДУМЫВАЕТ субъекта, которым вызывающий не является.

Здесь исход второй — «названным субъектом это не является», — и он нужен всякому, кто по имени субъекта ЧТО-ТО НАХОДИТ: спрашивает право у модели, сбрасывает записи кэша, закрывает открытые потоки по отзыву (kacho#1022). Ключи всех троих обязаны совпадать ПО ПОСТРОЕНИЮ, поэтому кодек один: две похожие сборки строки разошлись бы молча — обе непусты, обе выглядят субъектом, а найти по второй нельзя ничего.

Словарь ЗАКРЫТ и псевдонимов не признаёт (`usr`, `sva`, `serviceaccount`): расширять его переписыванием входа значит заводить второй словарь.

Types

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache хранит positive Check-results; срок жизни записи объявлен политикой (RevocationPolicy, revocation_policy.go), умолчание — 5s.

Семантика:

  • Кешируются ТОЛЬКО `allowed=true` (positive results).
  • Negative (deny) НЕ кешируются — иначе grant binding'а не проявится до истечения TTL → расходится с UX «дал права — почему не работает?».
  • Отсюда асимметрия: ВЫДАЧА видна сразу, ОТЗЫВ ждёт истечения записи. Срок жизни записи и есть окно отзыва — см. RevocationPolicy, где оно объявлено числом с обоснованием, и гейт `internal/repohygiene.TestRevocationWindowIsDeclaredPolicy`, который держит объявление и дерево в согласии.
  • Проактивного снятия записи у backend-сервиса НЕТ. Здесь стояло утверждение, что отзыв прилетает по `pg_notify('kacho_iam_subjects')` в `InvalidateBySubject`; в этом репозитории у канала нет НИ ОДНОГО отправителя (при database-per-service его и не может быть — сигнал шёл бы из БД iam, к которой у backend-сервиса нет доступа). Механизм `ListenInvalidator` остаётся пригодным, но пока по каналу никто не пишет, единственный путь снятия записи — истечение срока.

Thread-safe: используется из нескольких gRPC-handler goroutines одновременно.

func NewCache

func NewCache(ttl time.Duration) *Cache

NewCache создает кеш с указанным TTL. ttl ≤ 0 → defaults to 5*time.Second. Число entry ограничено defaultMaxEntries (см. NewCacheWithLimit).

func NewCacheWithLimit

func NewCacheWithLimit(ttl time.Duration, maxEntries int) *Cache

NewCacheWithLimit создает кеш с указанным TTL и жёстким потолком числа entry. ttl ≤ 0 → 5s; maxEntries ≤ 0 → defaultMaxEntries. При достижении потолка insert нового ключа сперва вычищает просроченные записи, а если и после этого кеш полон — эвиктит произвольные entry до low-water (см. evictLocked). Cache-miss всегда безопасен (fallback на авторитетный Check), поэтому произвольная эвикция не влияет на корректность авторизации — только на hit-rate.

func (*Cache) Get

func (c *Cache) Get(subjectID, relation, objectType, objectID string) (allowed bool, ok bool)

Get возвращает (true, true) если есть валидная positive-запись. Возвращает (false, false) в остальных случаях (miss / expired).

На expiry — синхронно удаляет stale-entry (lazy eviction).

func (*Cache) InvalidateAll

func (c *Cache) InvalidateAll()

InvalidateAll удаляет весь кеш. Используется:

  • в periodic full-cache-clear (см. KACHO_<SVC>_AUTHZ__FULL_CACHE_CLEAR_INTERVAL).
  • в LISTEN-loop reconnect (conservative — иначе риск пропустить NOTIFY во время disconnect).

func (*Cache) InvalidateBySubject

func (c *Cache) InvalidateBySubject(subjectID string)

InvalidateBySubject удаляет ВСЕ записи для subjectID.

Вызывается:

  • из listen_invalidate.go при NOTIFY `kacho_iam_subjects` (push-invalidate).
  • может вызываться вручную (например в тесте).

Idempotent.

func (*Cache) SetAllowed

func (c *Cache) SetAllowed(subjectID, relation, objectType, objectID string)

SetAllowed — кеширует positive result (TTL).

Set negative — не делается; если allowed=false, вызывающий не должен звать SetAllowed.

func (*Cache) SetNowFunc

func (c *Cache) SetNowFunc(now func() time.Time)

SetNowFunc — для тестов: подмена time.Now.

func (*Cache) Size

func (c *Cache) Size() (subjects int, entries int)

Size возвращает (subjectsCount, entriesCount). Используется в метриках.

func (*Cache) Stats

func (c *Cache) Stats() CacheStats

Stats — снимок величин окна вердиктов.

func (*Cache) TTL

func (c *Cache) TTL() time.Duration

TTL — срок жизни положительной записи, то есть окно отзыва этого кеша.

Экспортирован ради гейта политики окна отзыва: без него «какое окно у кеша, построенного вот так» нельзя ни спросить, ни утверждать — можно только пересказать литерал из конструктора, а пересказ переживает правку.

type CacheStats

type CacheStats struct {
	// Hits / Misses — исходы обращений к окну. Их сумма и есть число заданных
	// окну вопросов; доля попаданий считается ПОТРЕБИТЕЛЕМ, а не здесь: доля,
	// посчитанная в процессе за всё время жизни, не дифференцируется по времени и
	// не складывается по репликам.
	Hits   uint64
	Misses uint64

	// Subjects / Entries — текущий размер: субъектов и записей.
	Subjects int
	Entries  int

	// EvictedExpired — записи, снятые ПО ИСТЕЧЕНИИ окна (лениво на чтении и
	// подметанием перед вставкой). Штатная работа.
	EvictedExpired uint64
	// EvictedCapacity — записи, снятые ДАВЛЕНИЕМ ПОТОЛКА, то есть ещё живые.
	// Каждая такая — попадание, которого не будет: ненулевое значение означает,
	// что потолок мал для нагрузки, и доля попаданий упирается в него, а не в
	// окно.
	EvictedCapacity uint64
	// Invalidated — записи, снятые ЯВНО (по субъекту либо целиком). Единственный
	// проактивный путь снятия; ноль здесь означает, что окно отзыва целиком
	// определяется истечением.
	Invalidated uint64
}

CacheStats — ПРОЧИТАННЫЕ величины окна вердиктов.

Именно прочитанные: по отсутствию строки на поверхности «события не было» и «счётчика нет» неразличимы, а прочитанный ноль их различает.

Зачем каждая, и почему трёх из пяти не хватает

`Hits` без `Misses` не даёт доли: у неё нет знаменателя, и «попаданий много» одинаково верно при кеше, поглощающем весь поток, и при кеше, мимо которого идёт вдесятеро больше. `Entries` объясняет, ПОЧЕМУ доля такая, а три причины вытеснения объясняют, почему она упала, — и сводить их в одну нельзя: истечение окна есть штатная работа, давление потолка есть сигнал, что кеша не хватает на нагрузку, а снятие есть единственный проактивный путь. Сложенные, они объявили бы исчерпание потолка нормой.

type CheckClient

type CheckClient interface {
	// Check возвращает (allowed, err).
	//
	//   - subjectID: "user:usr_xxx" | "service_account:sva_xxx" | "group:grp_xxx#member"
	//   - relation:  "viewer" | "editor" | "admin" | "use" | ...
	//   - object:    "project:prj_xxx" | "vpc_network:enp_xxx" | ...
	//
	// Error semantics:
	//   - returned err = nil + allowed=true  → пропустить RPC
	//   - returned err = nil + allowed=false → DENY (PermissionDenied)
	//   - returned err != nil                → considered Unavailable
	//     → fail-closed PermissionDenied (если не выставлен break-glass)
	Check(ctx context.Context, subjectID, relation, object string) (allowed bool, err error)
}

CheckClient — port-интерфейс (DIP). Реализация — адаптер `pkg/authz/authziam`: он импортирует стабы контракта и зовёт `InternalIAMService.Check`.

Decoupling: фундамент НЕ зависит от контракта службы доступа. Это не украшение слоёв: после разъезда на три модуля такая зависимость дала бы цикл, потому что служба уже требует фундамент.

type CheckClientFrom

type CheckClientFrom func(conn grpc.ClientConnInterface) CheckClient

CheckClientFrom — СБОРЩИК решателя из соединения с владельцем модели.

Именованный тип живёт здесь, у порта, а не у дескриптора носителя, и это не стиль. Дескриптор (`pkg/servicecontract`) обязан оставаться пакетом, которому звено цепочки выразить НЕЧЕМ: его гейт (`internal/repohygiene` `TestDescriptorCarriesNoChainLink`) держит это тем, что из grpc там доступны только креденшелы. Поле типа `func(grpc.ClientConnInterface) CheckClient` втащило бы туда grpc целиком и обезвредило предпосылку гейта — он это и сказал, когда поле объявили там.

Соединение по-прежнему набирает НОСИТЕЛЬ по объявленному ребру; наружу вынесен ровно перевод вопроса в контракт владельца, потому что контракт принадлежит службе доступа, а не фундаменту (приёмка K3-1 §7.2, задача #2131). Боевая реализация — `pkg/authz/authziam`.

type CheckClientFunc

type CheckClientFunc func(ctx context.Context, subjectID, relation, object string) (bool, error)

CheckClientFunc — adapter, который позволяет использовать функцию как CheckClient.

Использование в тестах:

stub := authz.CheckClientFunc(func(ctx context.Context, s, r, o string) (bool, error) {
    return s == "user:usr_alice" && r == "viewer", nil
})

func (CheckClientFunc) Check

func (f CheckClientFunc) Check(ctx context.Context, subjectID, relation, object string) (bool, error)

Check satisfies CheckClient.

type Decision

type Decision int

Decision — что interceptor решил сделать с RPC.

const (
	// DecisionAllowed — разрешено (Check вернул allowed=true или break-glass).
	DecisionAllowed Decision = iota
	// DecisionDenied — отказано (Check вернул allowed=false).
	DecisionDenied
	// DecisionUnavailable — FGA / kaname недоступны (fail-closed).
	DecisionUnavailable
	// DecisionUnmapped — RPC не в RPCMap (fail-closed по умолчанию).
	DecisionUnmapped
	// DecisionInternal — RPC помечен Public=false / internal — пропуск.
	DecisionInternal
	// DecisionRateLimited — превышен per-Principal rate limit on denied storm.
	DecisionRateLimited
	// DecisionNoPath — FGA нет пути к ресурсу: ресурс, скорее всего, не существует
	// (нет hierarchy-tuple). Interceptor пропускает вызов к handler'у, который
	// вернет NOT_FOUND из DB. Инициируется, когда CheckClient.Check() возвращает
	// ErrNoPath.
	DecisionNoPath
	// DecisionHideExistence — object-scoped deny на СУЩЕСТВУЮЩИЙ ресурс, который
	// caller не вправе видеть. Interceptor БЛОКИРУЕТ handler (в отличие от
	// DecisionNoPath) и возвращает NOT_FOUND, скрывая факт существования
	// (existence-hiding): tenant без доступа не должен отличить «есть-но-не-твой»
	// от «нет такого». Инициируется, когда CheckClient.Check() возвращает
	// ErrHideExistence (клиент сам сверил наличие объекта в своей БД).
	DecisionHideExistence
)

func (Decision) String

func (d Decision) String() string

String — human-readable representation, используется в метриках / логах.

type Interceptor

type Interceptor struct {
	// contains filtered or unexported fields
}

Interceptor реализует gRPC unary + stream interceptor'ы.

Собирает его НОСИТЕЛЬ (`pkg/servicehost/serve.go`), а не композиционный корень сервиса: сервис объявляет источник решения полем дескриптора, и носитель по нему либо берёт клиента у владельца модели, либо набирает соседа по объявленному ребру. Здесь стояла координата в дереве отдельного сервиса — такого файла нет, и ручной сборки цепочки в сервисах тоже нет:

authzIntr := authz.NewInterceptor(authz.InterceptorOptions{...})
grpc.NewServer(
    grpc.ChainUnaryInterceptor(... , authzIntr.Unary()),
    grpc.ChainStreamInterceptor(... , authzIntr.Stream()),
)

func NewInterceptor

func NewInterceptor(opts InterceptorOptions) *Interceptor

NewInterceptor конструктор. Panics при invalid options.

func (*Interceptor) CacheStats

func (i *Interceptor) CacheStats() CacheStats

CacheStats — величины окна вердиктов ЭТОГО звена.

Отдельный метод, а не поле снимка решений: окно наблюдают собиратели метрик, и им нужны размер и причины вытеснения, которых у решений нет. Метод отдаёт величины ТОГО кеша, который звено и спрашивает, — второго экземпляра у него нет by construction (кеш обязателен полем опций, см. NewInterceptor).

func (*Interceptor) EvictInactiveSubjects

func (i *Interceptor) EvictInactiveSubjects(maxAge time.Duration) int

EvictInactiveSubjects — для periodic background job; удаляет rate-limiter buckets, у которых lastSeen старше maxAge. Вернет кол-во удаленных.

func (*Interceptor) Metrics

func (i *Interceptor) Metrics() Metrics

Metrics возвращает snapshot счетчиков.

func (*Interceptor) Stream

Stream возвращает grpc.StreamServerInterceptor.

На stream-RPC interceptor извлекает Authorization decision до открытия stream'а. Дальше идет обычный wrapping.

NOTE: для stream-RPC `req` недоступен в interceptor'е до первого Recv() — поэтому StaticExtractor должен либо использовать пустой request (если RPCEntry knows fixed object_id вне request'а), либо stream-RPC не покрыт этим interceptor'ом (пометка Public=true в RPCMap для известных stream'ов типа `InternalResourceLifecycleService.Subscribe`).

На MVP — все public stream-RPC должны помечать ObjectExtractor как возвращающий статичный object (e.g. project-scope из SubscribeRequest).

func (*Interceptor) Unary

Unary возвращает grpc.UnaryServerInterceptor.

type InterceptorOptions

type InterceptorOptions struct {
	// ServiceName — имя сервиса для метрик / логов: "kacho-vpc" / "kacho-compute" / etc.
	ServiceName string

	// Map — RPCMap.
	Map RPCMap

	// Client — implements CheckClient (gRPC client к InternalIAMService.Check).
	Client CheckClient

	// Cache — кеш положительных вердиктов. ОБЯЗАТЕЛЕН: nil → конструктор
	// отказывает (см. NewInterceptor). Тот же экземпляр передаётся в
	// listen_invalidate.go.
	//
	// Почему обязателен, а не «nil → заведём сами». Кешируется только
	// «разрешено», поэтому срок жизни записи И ЕСТЬ окно отзыва — время, в
	// течение которого субъект с уже отобранным правом продолжает проходить.
	// Это параметр безопасности; `RevocationPolicy` объявляет, каким ему быть
	// позволено, а перепись накрывает ровно те площадки, которые кеш НАЗЫВАЮТ.
	// Пока конструктор заводил кеш за молчащего вызывающего, существовал второй
	// путь получить окно — не попав ни в перепись, ни под потолок, и не оставив
	// в composition root ни одной строки, которую кто-нибудь прочтёт.
	//
	// Своего числа не имеешь — передай `NewCache(0)`: это ЯВНОЕ «беру
	// умолчание политики», и гейт учитывает такую площадку как унаследованную.
	Cache *Cache

	// Logger — slog logger.
	Logger *slog.Logger

	// Breakglass — если true, interceptor пропускает все RPC без Check + WARN.
	// Source: env `KACHO_<SVC>_AUTHZ__BREAKGLASS=true` (читать в composition root).
	Breakglass bool

	// DenyRateLimitPerSec — token-bucket per-Principal на denied storm.
	// 0 / negative → **disabled**.
	//
	// Умолчания у этого поля НЕТ — ни здесь, ни в конструкторе. Не заполнил
	// вызывающий, значит бюджет выключен, и никакой темп проверок не
	// ограничивается. Прежняя редакция этой строки писала «default 100/s»: сотня
	// действительно стоит у vpc (`authz.deny-rate-limit-per-sec`) и у nlb
	// (литерал в composition root), но это ИХ выбор, а не поведение corelib.
	// Формулировка читалась как «не заполнишь — получишь сотню», то есть
	// описывала защиту там, где её нет, и ровно в ту сторону, в которую ошибаться
	// нельзя. Заполняй явно тот, кому бюджет нужен.
	//
	// Бюджет тратит РОВНО ОДИН класс исходов: те, которые кэш не поглощает, —
	// отказ, отказ с сокрытием существования, промах «нет пути» и недоступность
	// модели. Все они уходят в модель прав на КАЖДОМ повторе (кэшируются только
	// положительные ответы) и потому образуют шторм, который больше нечем
	// ограничить.
	//
	// Разрешение бюджет НЕ тратит — единственное исключение, и оно обосновано:
	// положительный ответ кэшируется, то есть уже самоограничен. Пока он платил,
	// аутентифицированный вызывающий, обходящий много разных объектов на холодном
	// кэше, получал ResourceExhausted на запросах, которые ему разрешены.
	//
	// Недоступность модели ПЛАТИТ намеренно: сбрасывать нагрузку с падающего
	// kaname особенно важно, а CheckTimeout ограничивает лишь длительность
	// одного вызова, не их темп. Из-за этого текст отказа не называет отказы (см.
	// decisionError) — иначе он описывал бы перебой как шторм отказов вызывающего.
	DenyRateLimitPerSec float64

	// CheckTimeout — таймаут на один Check-call.
	// Default 2*time.Second (если ≤0).
	CheckTimeout time.Duration

	// SubjectExtractor — функция, извлекающая (subject string, ok bool) из
	// ctx. По умолчанию — `defaultSubjectExtractor` использует
	// `operations.PrincipalFromContext(ctx)`. Можно переопределить
	// для тестов.
	SubjectExtractor func(ctx context.Context) (subjectFGA string, principalID string, ok bool)

	// AllowSystemPrincipal — если true, system-principal (Type="system",
	// ID="bootstrap") пропускается без Check. Используется для bootstrap'а /
	// миграции / фоновых job'ов, которым нет смысла Check'аться. Default false.
	AllowSystemPrincipal bool
}

InterceptorOptions — конфигурация gRPC interceptor'а.

type ListenInvalidator

type ListenInvalidator struct {
	// ConnString — pgx connection string на kaname Postgres.
	// Пример: "postgres://kaname_listener:pwd@host:5432/kaname?sslmode=disable".
	ConnString string

	// Channel — обычно "kacho_iam_subjects".
	Channel string

	// Cache — Check-cache, на котором будем invalidate (опционально).
	Cache *Cache

	// Logger.
	Logger *slog.Logger

	// FullCacheClearInterval — periodic full-clear как defensive measure.
	// 0 = disabled. Default 60s через env `KACHO_<SVC>_AUTHZ__FULL_CACHE_CLEAR_INTERVAL=60s`.
	FullCacheClearInterval time.Duration
}

ListenInvalidator подключается к kaname Postgres через dedicated pgx-conn (НЕ из пула — dedicated conn required для LISTEN) и слушает channel `kacho_iam_subjects`. На каждый NOTIFY → `cache.InvalidateBySubject(payload)`.

Lifecycle:

  • Run(ctx) — блокирующий loop, до cancel ctx.
  • При conn drop → reconnect (exponential backoff 1s → 2s → 4s → 8s → 30s cap).
  • После reconnect → conservative `cache.InvalidateAll()` (чтобы не пропустить NOTIFY в окне disconnect'а).

func (*ListenInvalidator) Run

func (li *ListenInvalidator) Run(ctx context.Context) error

Run блокирующий loop. Возвращается на ctx.Done() или fatal err.

РЕПЛИКИ: на-реплику — петля обслуживает кэш СВОЕГО процесса: подписка будит сброс его записей. Разведи её — и реплики без подписки продолжат отвечать по отозванному.

type Metrics

type Metrics struct {
	Allowed     uint64
	Denied      uint64
	Unavailable uint64
	Breakglass  uint64
	Unmapped    uint64
	RateLimited uint64

	// Cache — величины окна вердиктов, прочитанные у САМОГО окна.
	//
	// Вложенным полем, а не парой плоских счётчиков рядом: у окна пять величин, и
	// доля попаданий без промахов не считается (нет знаменателя), а без размера и
	// причин вытеснения не объясняется. Из решений они не выводятся ни одна:
	// `Allowed` считает и попадание, и пропуск «нет пути», а `Denied` — ещё и
	// отказы, случившиеся ДО обращения к окну (разбор объекта, форматирование),
	// то есть вопросы, которых окну не задавали вовсе.
	Cache CacheStats
}

Metrics — снимок величин звена решения о доступе.

Читается коллектором `pkg/authz/authzmetrics`, зарегистрированным композиционным корнем сервиса на его диагностической поверхности. Прежде этот снимок объявлял себя «счётчиками для Prometheus», не имея в прод-коде НИ ОДНОГО читателя: величины росли и никуда не выходили, а «отказов не было» и «звено не спрашивали» снаружи выглядели одинаково.

type ObjectExtractor

type ObjectExtractor func(req any) (objectType string, objectID string, err error)

ObjectExtractor извлекает (object_type, object_id) из request'а конкретного RPC.

Для типичных RPC (Get/Update/Delete на ресурс) — возвращает фиксированный object_type из RPCEntry и динамический object_id из request'а (например `GetNetworkRequest.network_id`).

Для scope-conditional RPC (например `iam.AccessBindingService.Upsert`, где scope в request: account / project / resource) — возвращает оба значения зависимо от полей request'а.

func StaticExtractor

func StaticExtractor(objectType string, extractID func(req any) (string, error)) ObjectExtractor

StaticExtractor — helper для типичного случая, когда object_type фиксирован в RPCEntry, а ID extract'ится из конкретного поля request'а.

Пример:

"/kacho.cloud.vpc.v1.NetworkService/Get": {
    Relation: "viewer",
    Extract:  authz.StaticExtractor("vpc_network", func(req any) (string, error) {
        return req.(*vpcv1.GetNetworkRequest).GetNetworkId(), nil
    }),
},

type RPCEntry

type RPCEntry struct {
	// Relation — FGA-relation, требуемое на object'е.
	// "viewer" | "editor" | "admin" | "use" | "start_stop" | etc.
	Relation string

	// Extract — функция, извлекающая (object_type, object_id) из request'а.
	// Возвращаемый objectType+":"+objectID составляет FGA object string.
	//
	// Для статичных object_type — используй StaticExtractor.
	// Для scope-conditional — пиши full ObjectExtractor.
	Extract ObjectExtractor

	// Public — если true, RPC освобожден от per-RPC tenant-authz Check
	// (exempt). Default false → Check энфорсится. Имя историческое: «public»
	// здесь означает «не требует tenant-authz» (напр. OperationService.Get,
	// который авторизуется на data-уровне), а не «доступен извне».
	//
	// Internal RPC, поднятый на :9091, ОБЯЗАН присутствовать в RPCMap: либо с
	// Relation (Check по своему tier), либо Public=true для явного exempt'а.
	// Не-замапленный RPC fail-closed — name-based исключений нет.
	Public bool

	// ScopeFiltered — если true, interceptor НЕ делает
	// single-object Check для этого RPC: RPC сам авторизует на data-уровне
	// (scope-filter List — handler читает страницу и проверяет права на её
	// идентификаторах через `AuthorizeService.BatchCheck`, возвращая 200 +
	// filtered, EMPTY если доступа нет). Единичный per-RPC Check здесь
	// семантически неверен — он отверг бы весь вызов `no path` 403 ДО того, как
	// scope-filter отработает.
	//
	// NB: спрашивать «перечисли ВСЕ объекты, которые subject'у можно» для этого
	// ЗАПРЕЩЕНО. Такой глагол на этой поверхности был и СНЯТ: он не был
	// постраничен by construction — продолжения у ответа не существовало, — и
	// объекты сверх потолка молча выпадали из выдачи при живых правах. Только
	// per-object проверка страницы.
	//
	// СУБЪЕКТ ПРИ ЭТОМ ОБЯЗАТЕЛЕН. Interceptor извлекает его ДО ветвления на это
	// поле и fail-close'ит запрос, который не называет никого (см. authorize()).
	// Снимается ровно единичный Check — не выяснение личности: под
	// scope-filtered RPC нет второго рубежа (per-RPC Check отсутствует по
	// построению), поэтому неназванный вызывающий плюс отсутствующий или
	// деградировавший фильтр — полный обход. Отсечка безусловна и не зависит ни
	// от режима, ни от того, подвешен ли фильтр.
	//
	// Отличие от Public: ScopeFiltered RPC требует аутентификации и авторизуется
	// на уровне данных (страница/батч → вопрос про её идентификаторы). Public —
	// «вообще вне tenant-authz», и allow отдаётся ДО чтения субъекта, поэтому
	// применим ТОЛЬКО там, где авторизация есть в другом месте (предикат
	// владельца в SQL у OperationService) либо где ответ — глобальный справочник,
	// который обязан читать каждый аутентифицированный. Mapping в
	// `DecisionInternal` (skip) — общий, но смысл разный, поэтому отдельное поле.
	ScopeFiltered bool

	// HideExistence — отказ на этом RPC обязан прийти как NOT_FOUND ТЕКСТОМ
	// владельца, а не как PermissionDenied.
	//
	// Пообъектное чтение (`/Get` на глагольном `v_get`) скрывает существование
	// БЕЗ этого поля — оно выводится из формы RPC, ровно как на крае
	// (`CatalogEntry.HidesExistenceOnDeny`). Поле нужно там, где вывести нельзя:
	// МУТАЦИЯ, которую край помечает скрывающей явно (registry Update/Delete в
	// каталоге несут `hide_existence`). Без него сервис отвечал бы 403 на тот же
	// запрос, на который край отвечает 404, и вызывающий отличал бы «нет
	// доступа» от «нет такого» по одному лишь коду.
	//
	// Поле НЕ включает скрытие само по себе: текст берётся из таблицы владельцев
	// (hide_existence.go), поэтому тип объекта без текста и вызов без
	// конкретного id остаются отказом — придумывать «not found» не из чего.
	HideExistence bool

	// Permission — строка из permission-catalog в формате
	// `<module>.<resource>.<verb>` (напр. `loadbalancer.networkLoadBalancers.getTargetStates`),
	// предназначена для будущего fine-grained Check.
	//
	// Сейчас interceptor ее НЕ читает — Check идет по полю Relation как
	// раньше. Permission заполняется параллельно в per-service
	// PermissionMap'ах, чтобы при переключении на fine-grained model
	// (relation → permission per RPC) был drop-in путь без переписывания
	// proto-stubs / extractors. Optional: zero-value ("") допустим и
	// означает «пока не каталогизировано».
	Permission string
}

RPCEntry — описание прав, требуемых на конкретный RPC.

НЕ ЗАПОЛНЯЕТСЯ РУКАМИ. Запись выводится из аннотаций метода в proto — `pkg/authz/catalogderive`, — то есть из того же источника, из которого генерируется каталог прав шлюза:

func PermissionMap() authz.RPCMap {
    return catalogderive.MustDerive("kacho.cloud.vpc.v1", "kacho.cloud.operation")
}

Литеральная карта рядом с выведенной — второе объявление одного и того же права: действующим требованием становится их пересечение, а пересечение не записано ни в одном документе, по которому выдают права. За этим следит internal/repohygiene TestNoServiceDeclaresItsPermissionsASecondTime.

type RPCMap

type RPCMap map[string]RPCEntry

RPCMap — карта `<FullMethod>` → RPCEntry. Передается в Interceptor.

FullMethod (grpc-go convention): "/<package>.<service>/<method>", напр. "/kacho.cloud.vpc.v1.NetworkService/Get".

func (RPCMap) Lookup

func (m RPCMap) Lookup(fullMethod string) (RPCEntry, bool)

Lookup возвращает RPCEntry, ok — если найден.

type RevocationWindowPolicy

type RevocationWindowPolicy struct {
	// Default — величина, которой разрешается НЕЗАДАННАЯ ручка окна. Читается
	// конструктором кеша и функцией Resolve: это единственный источник значения,
	// а не литерал, повторённый по дереву.
	//
	// Это НЕ «окно процесса без ручки»: такого процесса быть не должно (см.
	// §«Ручка есть всегда» выше). Живой потребитель — край, у которого ручка
	// есть и величину которого оператор вправе не задавать.
	Default time.Duration

	// Ceiling — потолок ТРЕТЬЕЙ ступени (видимость на крае): ни одно окно кэша
	// вердиктов не вправе быть больше. Это обещание платформы про отзыв гранта.
	Ceiling time.Duration

	// DeliveryCeiling — потолок ПЕРВОЙ ступени: доставки намерения регистрации
	// из очереди владельца РЕСУРСА до владельца прав. Строка очереди пишется
	// той же транзакцией, что и смена носителя, дренаж будится уведомлением, а
	// откат — периодический перепрос; величина отката и есть эта ступень.
	DeliveryCeiling time.Duration

	// MaterializationCeiling — потолок ВТОРОЙ ступени: пересчёта производного
	// пообъектного доступа у владельца ПРАВ. Ускоритель после коммита в пределе
	// даёт ноль, очередь сверки будится уведомлением, а внешний backstop —
	// периодический обход; величина обхода и есть эта ступень.
	MaterializationCeiling time.Duration

	// Windows — перепись объявленных окон, ключ «<сервис> <ручка>».
	// Значение обязано совпадать с тем, что реально написано в исходнике
	// сервиса; расхождение — находка гейта.
	Windows map[string]time.Duration
}

RevocationWindowPolicy — объявленная политика окна отзыва доступа.

Что такое это окно

Срок жизни записи И ЕСТЬ окно отзыва — время, в течение которого субъект, у которого право уже отобрали, продолжает проходить. Иного пути снять запись у backend-сервиса нет.

Асимметрия есть НЕ ВЕЗДЕ, и это надо знать ДО того, как менять число

Кешируются ТОЛЬКО положительные вердикты — на ВСЕХ площадках, включая край. Поэтому свежая ВЫДАЧА видна сразу (промах по кешу всегда идёт к авторитетному Check), а ждёт один лишь ОТЗЫВ. Асимметрия намеренная и правильная: администратор, выдавший доступ, не должен ждать; администратор, отобравший доступ, ждёт ограниченное и объявленное время.

Край отступал от этого правила, и цена отступления была измерена

До 2026-08-05 край (api-gateway) складывал в тот же кеш и ОТКАЗ, из-за чего то же число работало второй, необъявленной стороной — как ЗАДЕРЖКА ВЫДАЧИ. Следствие наблюдалось не в теории: запрос, проигравший гонку материализации owner-tuple, сам записывал свой отказ и держал его весь срок жизни записи уже ПОСЛЕ появления права. В прогоне e2e это дало квантованное окно: из 75 шагов, восстановившихся ограниченным клиентским ретраем, 68 сошлись ровно на десятом повторе (10×500ms = 5.0s = ровно это число), ещё 3 — на двадцатом (второе такое же окно). Разброс, свойственный настоящей задержке материализации, отсутствовал: окно производил кеш, а не материализация.

Отказ и так fail-closed, поэтому его кеширование не защищает НИЧЕГО — оно только откладывает выдачу. Край приведён к общему правилу, и нарушение сделано непредставимым по построению: у записи кеша края, как и у `Cache` ниже, нет поля «разрешено» (`gateway/internal/middleware.decisionCacheEntry`).

Почему это объявлено здесь, а не в комментарии каждого сервиса

До этой записи окно было эмерджентным. Шесть сервисов несли положительный кеш вердиктов, каждый называл своё число в своём комментарии, и ни одно место не говорило, каким числу быть позволено. Параметр безопасности, которого никто не выбирал, нельзя ни обсудить, ни отозвать, ни заметить при смене. Гейт `internal/repohygiene.TestRevocationWindowIsDeclaredPolicy` связывает эту запись с деревом: смена умолчания без правки политики роняет проверку.

Второй путь получить окно, которого перепись не видела

Перепись накрывает площадки, которые кеш НАЗЫВАЮТ. Пока `NewInterceptor` заводил кеш за вызывающего, оставившего поле пустым, существовал второй путь: сервис получал полноценное окно, ни разу его не назвав, и в исходнике не оставалось ни одной строки, по которой перепись могла бы его найти. Гейт при этом такой файл читал, засчитывал в «осмотрено» и объявлял чистым — то есть не просто не ловил, а отвечал «чисто» на вопрос, которого не задавал.

Путь снят: неназванный кеш — отказ в старте (`NewInterceptor`), плюс `internal/repohygiene.TestNoServiceTakesTheWindowImplicitly` называет файл и строку до того, как процесс поднимут.

Ручка есть всегда; незаданной бывает только ВЕЛИЧИНА

Состояний тут ДВА, и до 2026-09-08 они были склеены в одно — из-за чего платформа объявляла законным то, что сама же и запрещает:

ручка ЕСТЬ, величина не задана  → Resolve даёт Default. Законно: оператор
                                  видит ручку и вправе сузить окно на своей
                                  посадке. Так живёт край.
ручки НЕТ вовсе                 → окно принадлежит платформе, сузить его
                                  на посадке НЕЛЬЗЯ, и в конфигурации
                                  процесса о нём нет ни строки. ЗАПРЕЩЕНО.

Второе запрещено не этой строкой, а решением, уже стоящим в дереве: `pkg/servicecontract` объявляет у окна «умолчания быть не может» и ОТКАЗЫВАЕТ В СТАРТЕ процессу, не назвавшему величину; а по дереву своей ручки требует от каждого держателя гейт TestEveryVerdictCacheProcessDeclaresItsOwnKnob (`internal/repohygiene`).

Здесь стояла третья редакция того же предмета — карта `Inherited`, перечислявшая площадки «без своей ручки» как законные. Она пережила свой предмет: собственные фабрики звена сняты у всех, кеш каждому строит носитель по значению из ЕГО ручки, и карта стояла пустой. Пустой она была безвредна, а действующей стала бы неисполнимой: следующий, кто записал бы туда площадку, как велела эта строка, получил бы красное от гейта — состояние, объявленное законным и запрещённое by construction. Карта снята вместе с предметом; утверждение о площадке без своей ручки в дереве теперь ОДНО.

Что НЕ ездит по этому окну

Окно относится к отзыву ГРАНТА (AccessBinding). Оно НЕ относится к отзыву УЧЁТНЫХ ДАННЫХ — скомпрометированный токен, уволенный сотрудник, утёкший ключ. Такой отзыв обязан быть немедленным, и он немедленный не потому, что окно мало, а потому, что идёт другим путём: сессия/токен снимается на краю (`session_revocations` плюс сброс кэша вердиктов на самом крае: немедленный на обслужившей реплике, окном чтения журнала `subject_change_outbox` — на соседних), и закешированный положительный вердикт backend-сервиса ему не помогает — до backend-сервиса запрос с отозванным токеном просто не доходит.

Смешивать эти два отзыва — ошибка, которая толкает «сделать окно поменьше» вместо «снять учётные данные». Окно ниже размерено под отзыв гранта, и уменьшать его ради сценария, который им не решается, не нужно.

func (RevocationWindowPolicy) ChainCeiling

func (p RevocationWindowPolicy) ChainCeiling() time.Duration

ChainCeiling — верхняя граница ВСЕЙ цепочки отзыва гранта: доставка намерения (первая ступень) + пересчёт производного доступа (вторая) + видимость на крае (третья).

Почему сумма, а не максимум

Ступени идут ПОСЛЕДОВАТЕЛЬНО: пока намерение не доехало, пересчитывать нечего; пока пересчёт не прошёл, край спрашивает и получает прежний ответ. Худший случай — потеря сигнала на обеих первых ступенях, и тогда каждая ждёт свой откат целиком. Максимум описывал бы полосу, в которой ступени идут параллельно, а такой полосы нет.

Что эта величина НЕ покрывает

Недоступность владельца прав переводит доставку в ОТДЕЛЬНЫЙ режим повтора со своими величинами: там намерение не опаздывает, а не доезжает вовсе, и ловится это глубиной и возрастом очереди, а не границей окна. Складывать те величины с этой суммой нельзя — у них другой предмет и другой исход.

И она не относится к отзыву УЧЁТНЫХ ДАННЫХ: у него свой немедленный путь (см. §«Что НЕ ездит по этому окну» выше). Смешивать их — ошибка, толкающая сузить это окно вместо того, чтобы снять учётные данные.

func (RevocationWindowPolicy) Resolve

func (p RevocationWindowPolicy) Resolve(declared time.Duration) time.Duration

Resolve — окно, с которым процесс БУДЕТ работать, из величины, которую он объявил.

Неположительное значение означает «ручка есть, величина не задана» — и только это. Оно НЕ означает «ручки нет»: держатель окна без собственной ручки не поднимается вовсе (`pkg/servicecontract`, отказ в старте) и роняет гейт дерева (`internal/repohygiene`.TestEveryVerdictCacheProcessDeclaresItsOwnKnob). Различать эти два состояния обязательно: первое оператор вправе оставить незаданным, второе отбирает у него ручку насовсем.

Функция одна на всех: её зовёт и тот, кто строит кэш, и тот, кто судит величину при старте. Второй копии этого правила в дереве быть не должно — расходятся такие копии именно там, где расхождение не видно: обе отвечают одинаково на всяком положительном входе и по-разному на нуле, то есть ровно на той посадке, где ручку не трогали.

type SubjectNaming

type SubjectNaming uint8

SubjectNaming — ПОЧЕМУ пара «тип, идентификатор» не стала именем субъекта.

Зачем исход, если [TenantSubject] уже отвечает «нет»

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

Считая их одним числом, наблюдатель получает величину, ненулевую в штатной работе, — а на такую тревогу не вешают, и дефект остаётся в шуме нормы (kacho#1463).

Почему разделяет НЕ второе значение [TenantSubject]

Оно `false` и на группе, и на отсутствующем типе, и на типе вне словаря продукта, и на непригодном идентификаторе — один ответ на оба исхода. Разделяет САМ ТИП, известный вызывающему; здесь он сверяется со словарём продукта, а не с непустотой строки: иначе тип-мусор («nonsense») попал бы в норму, и дефект снова стал бы невидимым — только под другой корзиной.

const (
	// SubjectUnnameable — назвать НЕЛЬЗЯ: типа нет вовсе, он вне словаря
	// продукта, либо идентификатор непригоден. ДЕФЕКТ производителя.
	//
	// Ноль значения намеренно: величина, собранная без разбора, попадает в
	// ГРОМКУЮ корзину. Обратный порядок прятал бы дефект в норме — ровно то,
	// ради чего исход и разделён.
	SubjectUnnameable SubjectNaming = iota
	// SubjectNamed — имя собрано.
	SubjectNamed
	// SubjectUserset — тип назван, принадлежит словарю продукта и адресуемым
	// принципалом НЕ является. НОРМА, а не потеря.
	SubjectUserset
)

func NameTenantSubject

func NameTenantSubject(principalType, principalID string) (string, SubjectNaming)

NameTenantSubject — TenantSubject с НАЗВАННОЙ причиной отказа.

Решение об адресуемости принимает та же дверь: второго словаря здесь нет, и расширить его переписыванием входа по-прежнему нельзя.

func (SubjectNaming) String

func (n SubjectNaming) String() string

Directories

Path Synopsis
Package authzmetrics — ЕДИНСТВЕННЫЙ коллектор величин кеша положительных вердиктов.
Package authzmetrics — ЕДИНСТВЕННЫЙ коллектор величин кеша положительных вердиктов.
Package catalogderive builds a service's in-process authz.RPCMap from the per-RPC authorization annotations carried by the proto descriptors already linked into its binary.
Package catalogderive builds a service's in-process authz.RPCMap from the per-RPC authorization annotations carried by the proto descriptors already linked into its binary.
Package proxytuple holds the ONE declaration of what a resource-owning module may write into the authorization model through kaname's FGA proxy — RegisterResource / UnregisterResource.
Package proxytuple holds the ONE declaration of what a resource-owning module may write into the authorization model through kaname's FGA proxy — RegisterResource / UnregisterResource.

Jump to

Keyboard shortcuts

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