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 ¶
- Variables
- func FormatObject(objectType, objectID string) (string, error)
- func FormatSubject(principalType, principalID string) string
- func HidesExistenceOnDeny(fullMethod string, entry RPCEntry, objectType string) bool
- func OwnerNotFoundFormat(objectType string) (string, bool)
- func TenantSubject(principalType, principalID string) (string, bool)
- type Cache
- func (c *Cache) Get(subjectID, relation, objectType, objectID string) (allowed bool, ok bool)
- func (c *Cache) InvalidateAll()
- func (c *Cache) InvalidateBySubject(subjectID string)
- func (c *Cache) SetAllowed(subjectID, relation, objectType, objectID string)
- func (c *Cache) SetNowFunc(now func() time.Time)
- func (c *Cache) Size() (subjects int, entries int)
- func (c *Cache) Stats() CacheStats
- func (c *Cache) TTL() time.Duration
- type CacheStats
- type CheckClient
- type CheckClientFrom
- type CheckClientFunc
- type Decision
- type Interceptor
- type InterceptorOptions
- type ListenInvalidator
- type Metrics
- type ObjectExtractor
- type RPCEntry
- type RPCMap
- type RevocationWindowPolicy
- type SubjectNaming
Constants ¶
This section is empty.
Variables ¶
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, только сам сверив наличие объекта в своей БД.
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). Другие клиенты могут игнорировать.
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.
ErrUnavailable — FGA / kaname.Check недоступны. fail-closed default.
var ErrUnmapped = errors.New("authz: RPC not mapped in PermissionMap")
ErrUnmapped — RPC не покрыт RPCMap. Должен мапиться в `PermissionDenied` (fail-closed). Метрика `kacho_authz_unmapped_total{rpc=...}` инкрементируется.
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 ¶
FormatObject форматирует FGA-object string: "<type>:<id>". Возвращает err если type/id содержат FGA-разделители (':', '#', '@', whitespace) — симметрично subject-пути (validSubjectID), чтобы attacker-controlled resource id не мог сдвинуть границу type:id или образовать userset-ссылку.
func FormatSubject ¶
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 ¶
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:
- explicit RPCEntry.HideExistence — for a MUTATION the edge marks the same way (registry Update/Delete carry `hide_existence` in the catalog);
- 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 ¶
OwnerNotFoundFormat отдаёт формат промаха владельца для типа объекта модели — ровно тот, которым звено решения о доступе ответит на скрывающем отказе.
Существует ради ОДНОГО читателя: носитель контура (`pkg/servicehost`) сверяет с ним форму, объявленную сервисом в своём дескрипторе. Пока сверки не было, про один и тот же текст утверждали в двух местах — здесь и в дескрипторе, — и расхождение между ними осталось бы незамеченным ровно потому, что отвечает всегда ЭТА таблица: дескриптор объявил бы одно, вызывающий увидел бы другое, и «объявлено» перестало бы что-либо значить.
ok ложен, если у типа нет текста владельца: тогда отказ отвечает нейтральным «not found», отличимым от настоящего промаха, — и это находка на стороне носителя, а не умолчание здесь.
func TenantSubject ¶
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 ¶
NewCache создает кеш с указанным TTL. ttl ≤ 0 → defaults to 5*time.Second. Число entry ограничено defaultMaxEntries (см. NewCacheWithLimit).
func NewCacheWithLimit ¶
NewCacheWithLimit создает кеш с указанным TTL и жёстким потолком числа entry. ttl ≤ 0 → 5s; maxEntries ≤ 0 → defaultMaxEntries. При достижении потолка insert нового ключа сперва вычищает просроченные записи, а если и после этого кеш полон — эвиктит произвольные entry до low-water (см. evictLocked). Cache-miss всегда безопасен (fallback на авторитетный Check), поэтому произвольная эвикция не влияет на корректность авторизации — только на hit-rate.
func (*Cache) Get ¶
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 ¶
InvalidateBySubject удаляет ВСЕ записи для subjectID.
Вызывается:
- из listen_invalidate.go при NOTIFY `kacho_iam_subjects` (push-invalidate).
- может вызываться вручную (например в тесте).
Idempotent.
func (*Cache) SetAllowed ¶
SetAllowed — кеширует positive result (TTL).
Set negative — не делается; если allowed=false, вызывающий не должен звать SetAllowed.
func (*Cache) SetNowFunc ¶
SetNowFunc — для тестов: подмена time.Now.
func (*Cache) TTL ¶
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 ¶
CheckClientFunc — adapter, который позволяет использовать функцию как CheckClient.
Использование в тестах:
stub := authz.CheckClientFunc(func(ctx context.Context, s, r, o string) (bool, error) {
return s == "user:usr_alice" && r == "viewer", nil
})
type Decision ¶
type Decision int
Decision — что interceptor решил сделать с RPC.
const ( // DecisionAllowed — разрешено (Check вернул allowed=true или break-glass). DecisionAllowed Decision = iota // DecisionDenied — отказано (Check вернул allowed=false). DecisionDenied 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 )
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 ¶
func (i *Interceptor) Stream() grpc.StreamServerInterceptor
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 ¶
func (i *Interceptor) Unary() grpc.UnaryServerInterceptor
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
Breakglass uint64
Unmapped uint64
RateLimited uint64
// Cache — величины окна вердиктов, прочитанные у САМОГО окна.
//
// Вложенным полем, а не парой плоских счётчиков рядом: у окна пять величин, и
// доля попаданий без промахов не считается (нет знаменателя), а без размера и
// причин вытеснения не объясняется. Из решений они не выводятся ни одна:
// `Allowed` считает и попадание, и пропуск «нет пути», а `Denied` — ещё и
// отказы, случившиеся ДО обращения к окну (разбор объекта, форматирование),
// то есть вопросы, которых окну не задавали вовсе.
Cache CacheStats
}
Metrics — снимок величин звена решения о доступе.
Читается коллектором `pkg/authz/authzmetrics`, зарегистрированным композиционным корнем сервиса на его диагностической поверхности. Прежде этот снимок объявлял себя «счётчиками для Prometheus», не имея в прод-коде НИ ОДНОГО читателя: величины росли и никуда не выходили, а «отказов не было» и «звено не спрашивали» снаружи выглядели одинаково.
type ObjectExtractor ¶
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 ¶
RPCMap — карта `<FullMethod>` → RPCEntry. Передается в Interceptor.
FullMethod (grpc-go convention): "/<package>.<service>/<method>", напр. "/kacho.cloud.vpc.v1.NetworkService/Get".
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
Source Files
¶
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. |