grpcsrv

package
v1.4.0 Latest Latest
Warning

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

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

Documentation

Overview

Package grpcsrv — acr.go: THE step-up (ACR / MFA-freshness) rule. Single implementation, two callers.

The platform enforces the per-RPC catalog `required_acr_min` at TWO points:

  • the public front door — api-gateway `middleware.StepUpGate.Check` (RFC 9470 `401` + `WWW-Authenticate: acr_values`);
  • the cluster-internal listener — kaname `authzguard.ACRFloor` (:9091, gateway-fronted internal RPCs → `PERMISSION_DENIED` + step-up detail), because the gateway re-dials :9091 and "internal = trusted" is a forbidden assumption.

Both points call EvaluateStepUp below. They do NOT re-derive the rule: neither keeps its own ranking table and neither keeps its own machine-principal exemption. Divergence is prevented BY CONSTRUCTION (there is one function), not by agreement between two copies — a re-introduced local override is caught by the verdict-parity guards on both sides (gateway stepup_verdict_parity_test.go, iam acr_floor_stepup_parity_test.go), which drive each REAL enforcement entrypoint — including the machine branch — against this function.

WHY HERE: `gateway/internal/...` and `services/iam/internal/...` cannot import each other (Go internal-package rule), so the shared rule has to live under pkg/. It belongs in grpcsrv specifically because grpcsrv already owns every INPUT of the decision — the ACR ranking (ACRRank/ACRSatisfies), the trusted carriers the iam side reads (TrustedACRFromContext / TrustedPrincipalFromContext) and the metadata key contract (MDKeyTokenACR / MDKeyPrincipalType). Putting the rule anywhere else would split the decision from its own inputs, i.e. re-create the very second home this consolidation removes.

ACR ordering (normative):

"" / "0" (anonymous)  <  "1" (password-only, AAL1)  <
"2" (phishing-resistant / MFA, AAL2)  <  "3" (hardware-bound UV passkey, AAL3)

An unknown value ranks 0 (fail-closed). `required==""` (or "0") means NO requirement.

Package grpcsrv — cert_identity.go: client-cert identity extractor + the principal⟺mTLS trust invariant.

Two orthogonal server-side identities coexist on a cluster-internal listener and are BOTH made available downstream (for audit):

  • cert-identity (the *module*): an unmodified, opaque SPIFFE-like SAN string extracted from the verified client-cert presented over mTLS. This layer only extracts the string; it does NOT parse the sva-id, validate it against IAM, or resolve it to a ServiceAccount.
  • principal (the *user*): carried in x-kacho-principal-* metadata, set by the api-gateway auth-interceptor after JWT validation (see principal_extract.go).

Trust invariant: on an mTLS listener, incoming principal-metadata is trusted ⟺ the peer passed mTLS client-cert verification from the internal CA. With no verified client-cert, principal-metadata from that peer is NOT trusted (and must be dropped by the authz layer). On an insecure listener (enable=false, dev-mode) the invariant is inapplicable — there is no client-cert at all and principal-metadata is accepted as today (backward-compat). The invariant activates only under mTLS.

The SAN format is the SPIRE-compatible internal trust-domain form spiffe://<trust-domain>/ns/<ns>/sa/kacho-<svc>; cert-manager issues string-SANs in this exact shape. The trust domain is NAMED BY THE INSTALLATION (TrustDomain), not compiled in: only URIs under the declared domain are accepted, and a foreign spiffe trust-domain yields empty (no foreign-field leak). An installation that did not name its domain recognizes nobody — see TrustDomain on why the zero value is the strictest reading available.

Package grpcsrv — identity_arrival.go: отказ при ОБЪЯВЛЕННОЙ и не приехавшей личности (приёмка KAN-WIRE-1, сценарии KAN-W2-02…KAN-W2-04, предмет `ПР-1`).

Предмет: рассинхрон даёт ПОТЕРЮ личности, а не отказ

Пространство имён личности несёт край, а читает слушатель. Пока приёмник умел спросить только про СВОЮ приставку, о чужой он не узнавал НИЧЕГО: пересланная личность для него не приезжала вовсе, и он читал это как «личности нет». Отказом это не кончалось — отсутствие личности в этом тракте отказом не является намеренно: фоновые пути ходят без неё, и запасное значение существует по решению. Значит переход обязан принести СВОЙ отказ.

Различает ОДИН факт, и он положительный

Отвергается запрос, в котором личность БЫЛА ОБЪЯВЛЕНА и не приехала:

  • пир прислал ключ ФОРМЫ личности под пространством имён, которого эта сборка не читает — то есть назвал кого-то именем, которого здесь нет;
  • либо прислал часть НАШЕГО подсемейства личности, не назвав ни типа, ни идентификатора.

Запрос, не приславший ничего похожего на личность, не отвергается. Это и есть законная безымянность, и на ней стоят фоновые согласователи: признак положительный, поэтому ложных отказов на них нет BY CONSTRUCTION — им нечего прислать, чтобы под него попасть.

Почему не «доверенный отправитель без личности — отказ»

Такое правило выглядит проще и НЕВЕРНО: круг доверенных отправителей перечисляет не только край. Служба, законно передающая личность инициатора на одном пути, на другом звонит соседу ЗА СЕБЯ — и делает это тем же сертификатом. Отказ по членству в круге остановил бы её вторую полосу, а заодно проверенную пробу, утверждающую, что доверенный отправитель без пересланной личности личности НЕ ПОЛУЧАЕТ.

Почему отказ только у пира, чью пересылку мы почитаем

«Личность объявлена и не приехала» — утверждение о пересылке, которую этот слушатель принял бы. У пира вне круга пересланная личность снимается и так, поэтому его рассинхрон ничего не теряет: терять нечего.

Package grpcsrv — principal_extract.go.

PrincipalExtractInterceptor читает три metadata-header'а, которые api-gateway auth-interceptor выставляет после успешной JWT-валидации:

x-kacho-principal-type         "user" | "service_account" | "system"
x-kacho-principal-id           "usr-..." | "sva-..." | "anonymous"
x-kacho-principal-display-name "alice@example.com" | "" | ...

и кладет `operations.Principal` в ctx через `operations.WithPrincipal`. Backend use-case'ы вызывают `operations.PrincipalFromContext(ctx)` → `Repo.CreateWithPrincipal(ctx, op, p)` — реальный principal попадает в `operations.principal_*` колонки.

Если headers отсутствуют (legacy-call'ы, прямой gRPC без api-gateway) — fallback на `SystemPrincipal()` (идентично `PrincipalFromContext` поведению без auth).

Package grpcsrv — tls.go: opt-in mTLS server-credentials helper.

TLSServerCreds is the single source of truth for assembling server-side TLS transport credentials for inter-service gRPC. It is a server-option builder by analogy with the keepalive-helper: NewServer takes the returned grpc.ServerOption.

Behavior contract:

  • enable=false → insecure server-credentials (current plaintext behavior, dev backward-compat); cert files are NOT read.
  • enable=true → mTLS: presents server-cert (cert_file/key_file), verifies client-certs against client_ca_files with ClientAuth = RequireAndVerifyClientCert (server-cert + client-CA).
  • enable=true + unreadable/garbage cert / empty client-CA → error (fail-closed; never a silent insecure fallback).

Cert files are read once at startup; rotation = pod restart (hot-reload deliberately out of scope).

Index

Constants

View Source
const (
	// MsgReadRateExceeded — исчерпан темп ЧТЕНИЙ.
	MsgReadRateExceeded = "Rate limit exceeded for read requests"
	// MsgMutationRateExceeded — исчерпан темп МУТАЦИЙ.
	MsgMutationRateExceeded = "Rate limit exceeded for mutating requests"
	// MsgInFlightExceeded — исчерпан предел ОДНОВРЕМЕННЫХ запросов.
	MsgInFlightExceeded = "Too many concurrent requests"
)

Тексты отказов — ЧАСТЬ КОНТРАКТА, ровно как тон остальных отказов продукта. Меняются осознанно, не по ходу правки.

Личность вызывающего в тексте НЕ называется: сообщение уезжает через край наружу, и подставленный туда идентификатор превратил бы отказ в справочник чужих личностей. Предмет назван — что именно исчерпано, — а «кто» и так знает тот, кто получает ответ.

View Source
const (
	// FloorPublicReadPerSec — устойчивый темп чтений на арендатора.
	FloorPublicReadPerSec = 100
	// FloorPublicMutationPerSec — устойчивый темп мутаций на арендатора.
	FloorPublicMutationPerSec = 20
	// FloorBurstFactor — кратность всплеска, общая обоим листенерам: всплеск
	// покрывает нормальную пачку клиента, не давая ей занять секунду целиком.
	FloorBurstFactor = 5
	// FloorPublicInFlight — одновременных запросов на арендатора.
	FloorPublicInFlight = 16
)

Пол публичного листенера — на ЛИЧНОСТЬ КОНЕЧНОГО ПОЛЬЗОВАТЕЛЯ.

Числа не выведены из нагрузки железа, а назначены стоимостью запроса, и она названа в шапке admission.go: чтение стоит до 1000 объектов на страницу с проверкой прав партиями, мутация — три строки в базе (ресурс, очередь намерения, операция). Отсюда темп мутаций впятеро ниже темпа чтений.

Одновременность — ОТДЕЛЬНАЯ ось, и сводить её с темпом нельзя: шестнадцать одновременных чтений по 1000 объектов укладываются в любой разумный темп и всё равно занимают базу целиком.

View Source
const (
	// FloorInternalReadPerSec — устойчивый темп чтений на модуль.
	FloorInternalReadPerSec = 1000
	// FloorInternalMutationPerSec — устойчивый темп мутаций на модуль.
	FloorInternalMutationPerSec = 500
	// FloorInternalInFlight — одновременных запросов на модуль.
	FloorInternalInFlight = 256
)

Пол внутреннего листенера — на ЛИЧНОСТЬ СЕРТИФИКАТА вызывающего модуля.

Заведомо выше публичного, и это решение, а не щедрость: запрос модуля несёт личности РАЗНЫХ арендаторов, поэтому одно ведро модуля обслуживает поток многих. Ограничитель, задушивший наш собственный поток намерения, воспроизводит заклинивание головы очереди — класс, при котором работа перестаёт доезжать БЕЗ ЕДИНОГО ВИДИМОГО СИМПТОМА, потому что «работает» и «не доехало» выглядят одинаково.

View Source
const (
	// AdmissionReportInterval — как часто в журнал уходит счёт.
	AdmissionReportInterval = time.Minute
	// AdmissionIdleWindow — за сколько простоя ведро субъекта убирается.
	//
	// Величина щедрая намеренно: убрать ведро значит вернуть субъекту полный
	// всплеск, поэтому уборка обязана отставать от окна, в котором предел ещё
	// имеет смысл.
	AdmissionIdleWindow = 10 * time.Minute
)
View Source
const (
	// DefaultConcurrentStreamsPerConnection — одновременных вызовов на ОДНО
	// соединение. Величина §8.6.
	DefaultConcurrentStreamsPerConnection uint32 = 250

	// DefaultSendMsgBytes — предельный размер ответа, 16 МиБ. Законная страница
	// из 1000 объектов (`page_size` до 1000 — часть контракта) — порядка 2 МиБ,
	// то есть запас восьмикратный, но величина КОНЕЧНА.
	DefaultSendMsgBytes int = 16 << 20

	// DefaultRecvMsgBytes — предельный размер запроса, 4 МиБ. Совпадает с
	// умолчанием библиотеки; см. §«Предел приёма ОСТАВЛЕН» выше.
	DefaultRecvMsgBytes int = 4 << 20
)
  • Почему константы, а не ручки
  • Что было до них — измерено по исходнику собираемой версии
  • Предел приёма ОСТАВЛЕН равным умолчанию, и это РЕШЕНИЕ, а не забывчивость

Пределы самого gRPC-сервера — платформенные константы, а не настройка стенда.

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

Величина, которую объявляет ПОСАДКА, обязана быть настройкой: перечень служебных диапазонов или круг доверенных отправителей у каждого стенда свой, и литерал в коде описывал бы один стенд, оставаясь ложью про остальные. Здесь наоборот: это обещание ПРОДУКТА, одинаковое на любом стенде (документ решений, §8.6), и разные стенды с разными пределами означали бы, что клиентская библиотека не может исходить ни из чего. Поэтому величины живут здесь и правятся здесь.

Что было до них — измерено по исходнику собираемой версии

Умолчания `google.golang.org/grpc v1.83.2` (прочитаны в исходнике, а не по памяти):

  • `maxConcurrentStreams` = `math.MaxUint32` (server.go:189), и сервер при этом ДАЖЕ НЕ ОБЪЯВЛЯЕТ предел клиенту: кадр настроек посылается только когда значение НЕ равно `MaxUint32` (internal/transport/http2_server.go:186). То есть «предела нет» и «предел бесконечен» на проводе неотличимы, и одно соединение открывает столько одновременных вызовов, сколько захочет, — каждый из них идёт в базу. Это не теоретический риск, а способ занять процесс, обслуживающий всех арендаторов, одним соединением;
  • `maxSendMessageSize` = `math.MaxInt32` (server.go:62) — не ограничен;
  • `maxReceiveMessageSize` = 4 МиБ (server.go:61) — ограничен умолчанием.

Предел приёма ОСТАВЛЕН равным умолчанию, и это РЕШЕНИЕ, а не забывчивость

Он объявляется здесь явно ровно затем, чтобы молчание о нём не читалось как пропуск: следующий читатель, не найдя третьей величины рядом с двумя, решит, что о ней забыли, — и либо заведёт вторую, расходящуюся с этой, либо снимет первые две как неполные. Явная константа плюс проба на проводе делают будущее ослабление видимым.

View Source
const (
	MDKeyPrincipalType    = principalwire.MetaPrincipalType
	MDKeyPrincipalID      = principalwire.MetaPrincipalID
	MDKeyPrincipalDisplay = principalwire.MetaPrincipalDisplay

	// MDKeyPrincipalDisplayBin — ДВОИЧНАЯ форма того же значения: значение
	// обычного ключа метаданных ограничено печатаемой латиницей, и имя,
	// записанное кириллицей, роняет ВЕСЬ вызов, не дойдя до обработчика.
	// Разбор — у объявления, `principalwire.MetaPrincipalDisplayBin`.
	MDKeyPrincipalDisplayBin = principalwire.MetaPrincipalDisplayBin
)

Ключи ЛИЧНОСТИ на проводе объявлены ОДИН раз — в `pkg/principalwire`, откуда их берёт и край. Здесь они только переименованы под привычные вызывающему имена: имя ключа, написанное тут своей рукой, было бы вторым объявлением одного предмета, а расходятся такие два МОЛЧА — переименование одной стороны собирается чисто и кончается потерей личности, а не отказом. Единственность объявления держит гейт дерева `internal/repohygiene` `TestIdentityWireNamespaceIsDeclaredOnce`.

View Source
const MDKeyTokenACR = principalwire.MetaTokenACR

MDKeyTokenACR is the trusted metadata key carrying the validated JWT `acr` claim, forwarded by the api-gateway on the mTLS-verified gateway→iam re-dial (alongside x-kacho-principal-*). It is read ONLY under the trust invariant (see UnaryTrustedPrincipalExtract) — on an unverified peer it is dropped with the principal (anti-spoof). Имя ключа объявлено ОДИН раз — в `pkg/principalwire`; здесь оно только переименовано под привычное вызывающему имя (см. разбор у MDKeyPrincipalType).

View Source
const PrincipalTypeServiceAccount = "service_account"

PrincipalTypeServiceAccount is the `kaname_principal_type` value identifying a MACHINE principal — the claim value stamped by the iam token-hook on a client_credentials service-account token, and the MDKeyPrincipalType metadata value the api-gateway forwards for it.

It is the ONLY value that lifts the interactive-authentication floor (see EvaluateStepUp). `user`, `system`, an empty/absent type and any unknown value are NOT exempt (fail-closed).

View Source
const SANShape = spiffeScheme + TrustDomainPlaceholder + "/ns/<пространство>/sa/<учётка>"

SANShape — ФОРМА личности сертификата: то, что оператор пишет в круге отправителей. Домена не несёт намеренно — его называет установка.

Живёт у владельца разбора, потому что именно он решает, что считать личностью: образец, написанный рукой вызывающего, объявлял бы форму, которую разбор не обещал.

View Source
const TrustDomainPlaceholder = "<домен-доверия>"

TrustDomainPlaceholder — ЗАПОЛНИТЕЛЬ домена в образцах, справке и текстах отказа.

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

View Source
const UnattributedSubject = "<unattributed>"

UnattributedSubject — ключ ведра для запроса, у которого личности нет.

Такой запрос НЕ освобождается от предела: освобождение сделало бы обход тривиальным (не присылай личность — не плати), а «личности нет» на боевой посадке означает, что до обработчика он вообще не дошёл бы — решение о доступе стоит раньше. Все безымянные делят ОДНО ведро: их поток ограничен вместе, и число вёдер от них не растёт.

Variables

This section is empty.

Functions

func ACRRank

func ACRRank(acr string) int

ACRRank maps an ACR string to a comparable integer. Unknown / malformed values resolve to 0 (anonymous) — fail-closed when policy expects ≥ 1.

This is the single ranking table for the whole platform: both enforcement points reach it through EvaluateStepUp.

func ACRSatisfies

func ACRSatisfies(presented, required string) bool

ACRSatisfies reports whether a presented acr meets a required floor.

  • required == "" or "0" → no requirement → always true (no-op floor).
  • otherwise → ACRRank(presented) >= ACRRank(required).

An absent / unknown presented acr ranks 0, so it fails any positive floor (fail-closed).

This is the ACR arm ONLY. Enforcement points must call EvaluateStepUp, which additionally applies the machine-principal exemption and the MFA-freshness arm; calling ACRSatisfies directly from an enforcement path re-creates half of the rule and is what the parity guards exist to catch.

func CertIdentityFromContext

func CertIdentityFromContext(ctx context.Context) (id string, verified bool)

CertIdentityFromContext returns the extracted module identity and whether the peer was mTLS-verified. A ctx that never carried a cert-identity (no mTLS peer) reports ("", false) — i.e. NOT mTLS-verified (default-deny of trust).

func CertIdentitySubject

func CertIdentitySubject(ctx context.Context) string

CertIdentitySubject — ключ ВНУТРЕННЕГО листенера: личность сертификата (SPIFFE-SAN проверенного пира), а НЕ личность конечного пользователя.

Разница не косметическая. Внутренний листенер зовут наши же модули, и запрос одного модуля несёт личности РАЗНЫХ арендаторов — ключ по арендатору дробил бы бюджет соседа на тысячу вёдер и душил бы его на ровном месте. Модуль же — один, известный и конечный, поэтому ключ по нему называет ровно того, чей поток мы ограничиваем.

func DefaultKeepaliveEnforcement

func DefaultKeepaliveEnforcement() keepalive.EnforcementPolicy

DefaultKeepaliveEnforcement — server-side EnforcementPolicy, допускающая частые idle keepalive-пинги client'ов.

gRPC-дефолт (MinTime: 5m, PermitWithoutStream: false) забанил бы клиента с PermitWithoutStream-пингами каждые 10s → GOAWAY too_many_pings. Чтобы idle-prone conn'ы (compute→iam-internal authz, iam subject-drainer→api-gateway) могли держать conn теплым, сервер обязан разрешать такие пинги: MinTime <= клиентский Time (10s) и PermitWithoutStream=true.

func LogAdmissionStats

func LogAdmissionStats(log *slog.Logger, msg string, limiters ...*Admission)

LogAdmissionStats печатает снимок счётчиков — по строке на листенер.

Отдельная от ReportAdmission функция затем, чтобы «напечатать счёт» было доступно и вне цикла (итог при остановке, отладочный снимок), и чтобы форма строки была ОДНА: две формы одного факта читаются как два разных факта.

func NewServer

func NewServer(opts ...grpc.ServerOption) *grpc.Server

NewServer создает gRPC-сервер с зарегистрированными Health-сервисом в состоянии SERVING и server-reflection (для grpcurl, debug, dev-tooling).

DefaultKeepaliveEnforcement() и DefaultServerLimits() ставятся ПЕРВЫМИ в opts, чтобы caller-opts могли их переопределить.

Почему пределы стоят ЗДЕСЬ, а не у каждого вызывающего

Умолчания библиотеки оставляют сервер без предела одновременных вызовов и без предела размера ответа (числа и координаты — в limits.go). Пока пределы выставлял бы каждый сервис у себя, «не выставил» было бы неотличимо от «выставил такие же»: опция не обязательна, её отсутствие ничего не печатает, и слушатель поднимается молча. Здесь у величин ОДИН источник, и ни один из слушателей платформы не может подняться без них — не потому, что это правило, а потому что другого конструктора сервера в дереве нет.

Следствие для гейта: «сервер без объявленного предела одновременных вызовов» перестал быть находкой, которую надо искать по дереву, — такого состояния не существует по построению. Утверждается это не прочтением, а на проводе: [TestServerAdvertisesItsConcurrentStreamLimit] читает кадр настроек соединения.

func PrincipalExtractStream

func PrincipalExtractStream(d TrustDomain, f TrustedForwarders,
	opts ...TrustedPrincipalOption,
) []grpc.StreamServerInterceptor

PrincipalExtractStream — то же для stream RPC (тот же инвариант порядка).

func PrincipalExtractUnary

func PrincipalExtractUnary(d TrustDomain, f TrustedForwarders,
	opts ...TrustedPrincipalOption,
) []grpc.UnaryServerInterceptor

PrincipalExtractUnary — пара звеньев, отвечающая на вопрос «чью личность несёт этот запрос», для unary RPC. ОДИН И ТОТ ЖЕ набор на ОБОИХ листенерах: «internal = доверенный» — запрещённое допущение.

Почему пара, а не одиночное звено: сертификат доказывает, ЧЕЙ это пир, и ничего не говорит о праве представляться другим. Первое звено достаёт личность сертификата, второе решает по ней, принимать ли переданные заголовки. Порядок обязателен — переставленные звенья означают решение о доверии по ещё не извлечённой личности.

Конструктор существует затем, чтобы пару нельзя было ни разорвать, ни переставить в композиционном корне: до него семь сервисов собирали её вручную четырнадцатью литералами.

Домен доверия стоит ПЕРВЫМ аргументом по той же причине, по какой первым стоит звено извлечения личности сертификата: сперва решается, чей это предъявитель, и только потом — вправе ли он представляться другим. Дополнительные опции ВАРИАДИЧНЫ намеренно: пара уже собирается семью композиционными корнями и двумя сборками, и расширение подписи развело бы их во времени — половина деревьев перестала бы собираться до сдвига пина. Сегодня отсюда приезжает счётчик исходов личности (WithIdentityArrival).

func PrincipalSubject

func PrincipalSubject(ctx context.Context) string

PrincipalSubject — ключ ПУБЛИЧНОГО листенера: личность конечного пользователя, установленная парой звеньев `PrincipalExtract*` (личность сертификата → круг доверенных отправителей). Читается из того же носителя, что и всё остальное в процессе, поэтому ограничитель и аудит говорят об одном и том же субъекте.

Безымянная пара ключом не становится: `operations.Principal.IsAnonymous` покрывает и пустое значение, и зарезервированное слово, которым край помечает запрос без credential'а.

func ReportAdmission

func ReportAdmission(ctx context.Context, log *slog.Logger, prefix string, limiters ...*Admission)

ReportAdmission — фоновая задача процесса: периодически печатает счётчики каждого ограничителя и убирает вёдра простаивающих субъектов. Возвращает управление по отмене контекста, напечатав итог.

Ограничители подаются перечнем, а не парой: у края и у сервиса их по два, но перечень не заставляет вызывающего изобретать пустышку там, где слушатель один. `nil` в перечне пропускается — это объявленное изъятие, а не ошибка; а перечень БЕЗ единого живого ограничителя означает, что считать нечего, и задача завершается сразу, не занимая горутину до конца процесса.

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

РЕПЛИКИ: на-реплику — печатает счётчики СВОЕГО процесса и вычищает его же память. Общего состояния не трогает, к соседям не ходит; предел допуска здесь и публикуется полом — «не менее», что остаётся истинным при любом числе реплик.

func SetPrincipalDisplayMD

func SetPrincipalDisplayMD(md metadata.MD, displayName string)

SetPrincipalDisplayMD кладёт отображаемое имя в метаданные ЕДИНСТВЕННЫМ правильным способом — двоичным ключом.

Функция существует, чтобы у производителей не было выбора: имя, положенное обычным ключом, роняет вызов на первом же не-латинском символе, и падает он НЕ там, где имя записали, а на любом последующем запросе. Один вход в тракт делает этот класс невоспроизводимым по построению.

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

func StreamCertIdentityExtract

func StreamCertIdentityExtract(d TrustDomain) grpc.StreamServerInterceptor

StreamCertIdentityExtract is the stream analogue of UnaryCertIdentityExtract.

func StreamPanicRecovery

func StreamPanicRecovery(logger *slog.Logger) grpc.StreamServerInterceptor

StreamPanicRecovery — stream-звено восстановления паники. Stream-листенер не освобождён: паника stream-обработчика завершает процесс тем же способом.

func StreamPrincipalExtract

func StreamPrincipalExtract(opts ...PrincipalExtractOption) grpc.StreamServerInterceptor

StreamPrincipalExtract — то же для stream RPC.

func StreamTrustedPrincipalExtract

func StreamTrustedPrincipalExtract(opts ...TrustedPrincipalOption) grpc.StreamServerInterceptor

StreamTrustedPrincipalExtract is the stream analogue.

func TLSServerCreds

func TLSServerCreds(cfg TLSServer) (grpc.ServerOption, error)

TLSServerCreds returns the grpc.ServerOption carrying the transport credentials for this config. See package doc for the behavior contract.

Prefer TLSServerTransportCreds where the credentials THEMSELVES are the value being carried — a service descriptor, for one. Wrapping them into a server option too early loses the one question worth asking about them: what the transport says it IS. An option is opaque; `TransportCredentials.Info()` is not, and a start-time refusal reads that rather than trusting a knob.

func TLSServerTransportCreds

func TLSServerTransportCreds(cfg TLSServer) (credentials.TransportCredentials, error)

TLSServerTransportCreds returns the transport credentials themselves.

Same contract as TLSServerCreds, one layer lower: `Enable=false` yields INSECURE credentials without an error, exactly as before. That is deliberate, and it is precisely why the value is worth carrying instead of the option: a caller inspecting only its own knob cannot tell a configured-but-degraded edge from a verified one, whereas `creds.Info().SecurityProtocol` answers for the transport itself.

func TrustedACRFromContext

func TrustedACRFromContext(ctx context.Context) (acr string, trusted bool)

TrustedACRFromContext returns the forwarded JWT `acr` and whether it is trusted under the trust invariant. trusted=false means the acr came from an unverified peer on an mTLS listener (or no acr was carried) and an acr-floor must treat it as absent (rank 0, fail-closed). On the insecure dev listener the acr is accepted as today (back-compat), consistent with the principal.

func TrustedPrincipalFromContext

func TrustedPrincipalFromContext(ctx context.Context) (operations.Principal, bool)

TrustedPrincipalFromContext returns the principal and whether it is trusted under the trust invariant. trusted=false means the principal-metadata came from an unverified peer on an mTLS listener and the authz layer must ignore it.

The returned principal is the ZERO Principal when the peer forwarded none, and likewise when ctx never carried the trust decision at all — trusted=true then says "this forwarder is recognised", not "someone was named". Callers must treat an empty Type/ID as "no principal" and never as an identity to compare against (operations.Principal.IsAnonymous is the shared predicate). The absent-carrier case deliberately does NOT fall back to the system principal: pairing a real-looking value with trusted=false only protects callers who read the flag, and the value would be the very identity the operation-ownership predicate honours everywhere.

func UnaryCertIdentityExtract

func UnaryCertIdentityExtract(d TrustDomain) grpc.UnaryServerInterceptor

UnaryCertIdentityExtract is a server interceptor that classifies the peer's transport security and, for an mTLS-verified peer, extracts its module identity into ctx. It MUST run before UnaryTrustedPrincipalExtract.

  • mTLS-verified peer → WithCertIdentity(ctx, CertIdentity(leaf), true).
  • TLS peer without a verified client-cert → WithCertIdentity(ctx, "", false) (defense-in-depth: marks the peer not-verified so principal is dropped).
  • insecure (plaintext) peer → ctx untouched (no cert-identity ever set); CertIdentityFromContext then reports ("", false) and the principal layer treats the insecure listener as dev backward-compat.

func UnaryPanicRecovery

func UnaryPanicRecovery(logger *slog.Logger) grpc.UnaryServerInterceptor

UnaryPanicRecovery — unary-звено восстановления паники.

logger == nil допустим и НЕ приводит к отказу: звено переходит на slog.Default(). Разыменовать здесь отсутствующий журнал значило бы уронить процесс внутри обработки паники, то есть отменить собственный предмет; а промолчать значило бы погасить падение невидимо, и «ноль паник за всю жизнь сервиса» стало бы неотличимо от «паники не записываются».

func UnaryPrincipalExtract

func UnaryPrincipalExtract(opts ...PrincipalExtractOption) grpc.UnaryServerInterceptor

UnaryPrincipalExtract — gRPC unary interceptor для backend-сервисов. Должен стоять РАНЬШЕ бизнес-handler'ов в цепочке interceptor'ов.

Отказа при объявленной и не приехавшей личности здесь НЕТ, и это решение

Отказ (identity_arrival.go) утверждает: пир, чью пересылку слушатель ПОЧИТАЕТ, назвал личность именем, которого эта сборка не читает. У безусловного извлекателя решения о доверии нет вовсе — он читает заголовки, не глядя ни на транспорт, ни на личность сертификата, — поэтому его посылка здесь НЕВЫРАЗИМА, а не опущена.

Провязок у него в дереве ноль (предикат: обращения к `grpcsrv.Unary/StreamPrincipalExtract` вне проб и вне этого файла), и у службы личности их отсутствие держит своя проба. Появится провязка — вместе с ней придётся завести и решение о доверии, то есть перейти на trust-aware пару, которая отказ несёт.

ВНИМАНИЕ (trust): этот extractor читает x-kacho-principal-* БЕЗУСЛОВНО, без проверки транспорта/cert-identity форвардера — он доверяет, что заголовки проставил только api-gateway. Монтировать ТОЛЬКО на listener'е, куда не может дозвониться неконтролируемый peer. Для cluster-internal mTLS-листенеров предпочтительна trust-aware связка UnaryCertIdentityExtract + UnaryTrustedPrincipalExtract(WithTrustedForwarders(<gateway-SAN>)): она снимает principal на недоверенном/не-форвардер peer'е (защита от confused-deputy). Для новых mTLS-листенеров предпочитайте именно trust-aware связку. При построении этот extractor выводит один startup-WARN об этом свойстве.

func UnaryTrustedPrincipalExtract

func UnaryTrustedPrincipalExtract(opts ...TrustedPrincipalOption) grpc.UnaryServerInterceptor

UnaryTrustedPrincipalExtract reads x-kacho-principal-* metadata and exposes it downstream ONLY when it is trustworthy under the trust invariant. It MUST run after UnaryCertIdentityExtract.

Trust decision:

  • mTLS-verified peer (CertIdentityFromContext verified=true) → principal trusted.
  • insecure listener (no cert-identity ever set on ctx) → principal trusted as today, dev backward-compat. Distinguished from an unverified TLS peer by peerTLSState: insecure ⇒ no TLS transport at all.
  • TLS peer without a verified client-cert → principal NOT trusted; dropped (defense-in-depth).

Trust answers "may this peer speak for a user", not "did this request name one". A trusted peer that forwarded NO principal-metadata leaves the request principal-less: the standard carrier reports no principal, and no identity is invented for it. Anything else would hand the system identity — the one the ownership predicate matches on every system-written operation — to a request that presented no credential at all.

The decision is recorded via withTrustedPrincipal so TrustedPrincipalFromContext returns (principal, trusted). cert-identity and principal are orthogonal and both remain available downstream for audit — neither substitutes the other.

func WithCertIdentityIn

func WithCertIdentityIn(ctx context.Context, d TrustDomain, id string, verified bool) context.Context

WithCertIdentityIn stores the extracted cert-identity, the domain it was recognized under, and the mTLS-verified flag in ctx. Exposed so the principal-aware layer (and tests) can assert the invariant deterministically without a live TLS peer.

Почему домен здесь ОБЯЗАТЕЛЕН, а формы без него не существует

Личность существует ОТНОСИТЕЛЬНО домена, и читатели ниже по цепочке (`authzguard.SANToServiceDomain` и соседи) разбирают эту же строку, спрашивая домен у контекста. Контекст, собранный без домена, отдал бы им нулевое значение — и они fail-closed отвергли бы личность, которую сами же признали нашей. Отказ при этом выглядел бы как отсутствие прав у законного модуля.

Форма с двумя аргументами существовала и была снята: она собиралась молча и давала ровно такой контекст. Запретить её значением было нечем — запретили подписью.

func WithTrustedACR

func WithTrustedACR(ctx context.Context, acr string, trusted bool) context.Context

WithTrustedACR stores a forwarded JWT `acr` and the trust flag directly in ctx (bypassing the metadata extract). Exposed so the iam acr-floor and tests can assert the floor deterministically without a live mTLS peer — the mirror of WithCertIdentity for the principal/acr layer. Note: this overwrites any existing trusted-principal carrier's acr/trusted with the given values while keeping the principal as previously recorded — and leaving it EMPTY when none was: an acr says how strongly someone authenticated, never who they are, so this constructor has no business naming anyone.

func WithTrustedPrincipal

func WithTrustedPrincipal(ctx context.Context, p operations.Principal, trusted bool) context.Context

WithTrustedPrincipal stores a forwarded principal and the trust flag directly in ctx (bypassing the metadata extract) — the principal-side mirror of WithTrustedACR, exposed for the same reason: so the iam floors and their tests can drive a machine-vs-user caller deterministically without a live mTLS peer. It keeps any acr already recorded on the carrier and overwrites the principal and the trust flag.

Note the asymmetry it preserves from the real extract path: on an untrusted peer the forwarded PRINCIPAL is still recorded (only `trusted` goes false), whereas the acr is scrubbed. Consumers must therefore consult the trusted flag before believing the principal type — a forged `service_account` from an unverified peer must never buy the step-up exemption.

Types

type Admission

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

Admission — ограничитель допуска ОДНОГО листенера.

func NewAdmission

func NewAdmission(listener string, limits AdmissionLimits, subject SubjectFunc, opts ...AdmissionOption) (*Admission, error)

NewAdmission собирает ограничитель одного листенера.

Величины обязаны быть объявлены полностью: конструктор, молча принимающий неполный набор, отдал бы вызывающему объект, который выглядит ограничителем и не ограничивает. Ключ обязан быть назван вызывающим — умолчания у него нет, потому что «на кого считаем» и есть решение, ради которого этот тип заведён.

func (*Admission) Admit

func (a *Admission) Admit(ctx context.Context, fullMethod string) (release func(), err error)

Admit решает, пропустить ли вызов, и отдаёт функцию освобождения слота одновременности. Ошибка — уже готовый ответ вызывающему.

Возвращаемая функция обязана быть вызвана ровно один раз на КАЖДОМ пути возврата обработчика — иначе слоты одновременности утекают, и предел превращается в счётчик прожитых запросов.

func (*Admission) EvictIdle

func (a *Admission) EvictIdle(maxAge time.Duration) int

EvictIdle убирает вёдра, которых не касались дольше maxAge и у которых нет запросов в полёте. Возвращает число убранных.

func (*Admission) Limits

func (a *Admission) Limits() AdmissionLimits

Limits — объявленные величины (копия значения).

func (*Admission) Listener

func (a *Admission) Listener() string

Listener — имя листенера, за который отвечает ограничитель.

func (*Admission) Registrar

Registrar оборачивает регистратор так, что КАЖДЫЙ метод КАЖДОЙ службы, зарегистрированной через него, проходит допуск.

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

func (*Admission) Stats

func (a *Admission) Stats() AdmissionStats

Stats — снимок счётчиков.

func (*Admission) StreamInterceptor

func (a *Admission) StreamInterceptor() grpc.StreamServerInterceptor

StreamInterceptor — ВТОРАЯ форма того же допуска: звено потоковой цепочки.

Зачем вторая форма, если есть [Admission.Registrar]

Обёртка регистратора видит дескриптор службы целиком и потому покрывает всё, что через неё зарегистрировано. Есть ровно один слушатель платформы, чей основной поток НЕ проходит ни через один дескриптор, — КРАЙ: чужие методы он пересылает обработчиком неизвестной службы (`grpc.UnknownServiceHandler`), а тот вызывается вне какой бы то ни было службы. Обёртка регистратора покрыла бы там только собственную поверхность края (здоровье, опрос операций) и промолчала бы на всём проксируемом — то есть на всём, ради чего край существует. Ограничитель, покрывающий одну сотую потока, — форма без содержания, а не половина защиты.

Библиотека диспетчеризует обработчик неизвестной службы КАК ПОТОК и проводит его через цепочку потоковых звеньев, подставляя в `info.FullMethod` НАСТОЯЩЕЕ имя метода (а не имя обработчика). Поэтому потоковое звено покрывает проксируемый поток целиком, а классификатор чтений и мутаций видит ровно то же имя, что увидел бы дескриптор.

Два ограничения, и оба несущие

  1. МЕСТО. Звено обязано стоять ПОСЛЕ того, которое устанавливает личность: ключом служит она, и ограничитель, ключующийся раньше этого решения, снимается подстановкой чужого заголовка — то есть ограничивает только того, кто не пытается его обойти. Проверить это за вызывающего звено не может: ему виден лишь контекст, который ему дали.
  2. НЕ СОВМЕЩАТЬ с Admission.Registrar на ОДНОЙ И ТОЙ ЖЕ службе. Потоковый метод, зарегистрированный через обёртку И прошедший это звено, платит ДВАЖДЫ: обёртка и звено спрашивают допуск независимо. Формы дополняют друг друга — звено для потока без дескриптора, обёртка для зарегистрированных служб, — а не накладываются.

type AdmissionKnobs

type AdmissionKnobs struct {
	// ReadPerSec — устойчивый темп ЧТЕНИЙ на вызывающего, запросов в секунду.
	ReadPerSec float64 `mapstructure:"read-per-sec" envconfig:"READ_PER_SEC"`
	// MutationPerSec — устойчивый темп МУТАЦИЙ на вызывающего.
	MutationPerSec float64 `mapstructure:"mutation-per-sec" envconfig:"MUTATION_PER_SEC"`
	// BurstFactor — во сколько раз всплеск превышает устойчивый темп.
	BurstFactor float64 `mapstructure:"burst-factor" envconfig:"BURST_FACTOR"`
	// InFlight — предел ОДНОВРЕМЕННЫХ запросов на вызывающего.
	InFlight int `mapstructure:"in-flight" envconfig:"IN_FLIGHT"`
}

AdmissionKnobs — четыре оси допуска ОДНОГО листенера в том виде, в каком их объявляет посадка.

Структура несёт ОБА набора тегов и НЕ несёт абсолютных имён — ровно как [TLSClient] рядом. Сервис на viper подставляет её под своим ключом (`api-server.rate-limit.public`), сервис на envconfig — под своей группой (`ADMISSION_PUBLIC` → `KACHO_<СЕРВИС>_ADMISSION_PUBLIC_READ_PER_SEC`). Одна структура на три семейства настроек: три копии тегов разъехались бы на первой же новой оси, и разъехались бы молча — незнакомый ключ viper игнорирует.

Умолчаний в тегах НЕТ намеренно. Их пришлось бы написать дважды — для публичного и внутреннего листенера, у которых полы разные, — то есть завести вторую пропись чисел рядом с первой. Молчание посадки разрешается в AdmissionKnobs.Resolve, где пол приходит ПАРАМЕТРОМ и остаётся в одном месте.

func (AdmissionKnobs) IsSilent

func (k AdmissionKnobs) IsSilent() bool

IsSilent — посадка не сказала НИЧЕГО.

Отличается от «сказала негодное»: у молчания есть законное прочтение («беру пол платформы»), у частичного набора — нет. Предикат тот же, которым механизм отличает канонический ноль (AdmissionLimits.IsBlank).

func (AdmissionKnobs) Resolve

Resolve — величины листенера: пол, если посадка молчит; её собственные, если она назвала ВЕСЬ набор; отказ, если назвала часть либо назвала негодное.

Отказ, а не дополнение полом: оператор, задавший темп и забывший одновременность, получил бы наполовину свои, наполовину чужие величины и считал бы предел выставленным. Отказ называет ось, потому что искать её он пойдёт в файл, где, по его мнению, всё написано верно.

type AdmissionLimits

type AdmissionLimits struct {
	// ReadPerSec — устойчивый темп ЧТЕНИЙ на субъекта, запросов в секунду.
	ReadPerSec float64
	// MutationPerSec — устойчивый темп МУТАЦИЙ на субъекта, запросов в секунду.
	MutationPerSec float64
	// BurstFactor — во сколько раз всплеск превышает устойчивый темп. Значение
	// меньше единицы означало бы всплеск НИЖЕ устойчивого темпа — набор, в
	// котором ведро не наполняется до одного токена, отвергает даже законный
	// поток.
	BurstFactor float64
	// InFlight — предел ОДНОВРЕМЕННЫХ запросов на субъекта.
	InFlight int
}

AdmissionLimits — объявленные величины ОДНОГО листенера.

Все четыре оси объявляются вместе или не объявляются вовсе: частичное объявление — самопротиворечие, а не «часть защиты». Оператор, задавший темп и забывший одновременность, считает предел выставленным, а стоимость одного мгновения остаётся неограниченной.

func PlatformInternalAdmission

func PlatformInternalAdmission() AdmissionLimits

PlatformInternalAdmission — пол внутреннего листенера.

func PlatformPublicAdmission

func PlatformPublicAdmission() AdmissionLimits

PlatformPublicAdmission — пол публичного листенера.

Функция, а не переменная: значение обязано быть неизменяемым для вызывающего, а пакетная переменная позволила бы одному процессу подвинуть пол другому.

func (AdmissionLimits) IsBlank

func (l AdmissionLimits) IsBlank() bool

IsBlank — канонический ноль: не объявлено НИЧЕГО. Отличается от негодного объявления, у которого часть осей заполнена.

func (AdmissionLimits) IsDeclared

func (l AdmissionLimits) IsDeclared() bool

IsDeclared — ЕДИНСТВЕННЫЙ предикат «величины объявлены».

Его спрашивают страж старта, самоотчёт о посадке и композиционный корень, поэтому «страж пропустил» ⟺ «ограничитель действительно навешен» — по построению, а не по совпадению трёх одинаково написанных условий.

func (AdmissionLimits) String

func (l AdmissionLimits) String() string

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

func (AdmissionLimits) Unusable

func (l AdmissionLimits) Unusable() []string

Unusable — причины, по которым НЕПУСТОЕ объявление исполнить нельзя.

Отделено от [IsDeclared] намеренно, ровно как у перечня служебных диапазонов: «посадка не объявила» — вопрос режима, а «объявление противоречит себе» — негодность сама по себе, и она отвергается в любом режиме. Пустой набор причинами не является: у него законное прочтение «не объявлено».

type AdmissionOption

type AdmissionOption func(*Admission)

AdmissionOption — функциональная опция NewAdmission.

func WithAdmissionClock

func WithAdmissionClock(now func() time.Time) AdmissionOption

WithAdmissionClock подменяет часы. Нужен пробам: ограничитель, чья проба ждёт настоящую секунду, либо медленный, либо недетерминированный.

func WithAdmissionSubjectCap

func WithAdmissionSubjectCap(n int) AdmissionOption

WithAdmissionSubjectCap задаёт потолок числа вёдер.

func WithCallClassifier

func WithCallClassifier(c CallClassifier) AdmissionOption

WithCallClassifier подменяет классификатор вызова.

type AdmissionStats

type AdmissionStats struct {
	// Admitted — допущено запросов.
	Admitted uint64
	// RejectedRate — отвергнуто по темпу.
	RejectedRate uint64
	// RejectedInFlight — отвергнуто по одновременности.
	RejectedInFlight uint64
	// Subjects — субъектов под наблюдением в момент снимка.
	Subjects int
}

AdmissionStats — счётчики ограничителя.

«Ноль отказов за всю жизнь контроля» обязано быть ЗАМЕТНО, иначе мёртвый ограничитель невидим: он навешен, исполняется на каждом запросе и не отверг ни разу — ровно как и ограничитель, чей ключ всегда пуст. Поэтому счётчиков три, и допущенные считаются наравне с отвергнутыми: ноль отвергнутых при нуле допущенных означает «никто не звал», а при миллионе допущенных — «предел ни разу не достигнут», и это разные факты.

type CallClass

type CallClass uint8

CallClass — класс вызова: у чтений и мутаций РАЗНАЯ стоимость и разные объявленные величины.

const (
	// ClassRead — синхронное чтение (`Get`/`List` по конвенции продукта).
	ClassRead CallClass = iota
	// ClassMutation — всё остальное: мутация, действие-глагол, подписка.
	ClassMutation
)

func ClassifyByKachoConvention

func ClassifyByKachoConvention(fullMethod string) CallClass

ClassifyByKachoConvention — классификатор по конвенции продукта.

Конвенция называет чтения прямо: `Get`/`List` синхронны, мутации возвращают `Operation`, дополнительные действия оформляются глаголом. Поэтому чтение распознаётся по префиксу имени метода, а ВСЁ ОСТАЛЬНОЕ считается мутацией.

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

Что из этого ДЕРЖИТСЯ проверкой и что не держится — названо порознь, иначе комментарий обещал бы больше кода:

  • ОПАСНОЕ направление (мутация, названная по-читательски, покупает впятеро более щедрый бюджет при втрое большей стоимости запроса) держит гейт ДЕРЕВА `TestNoMutationBuysTheReadBudget` в `internal/repohygiene`. Он обходит дескрипторы ВСЕХ объявленных контрактом пакетов и опирается на машинный признак мутации — асинхронный ответ `Operation` (правило #9). Прежде он стоял рядом с композиционным корнем vpc и наблюдал один бинарь: потолок провязан у семи сервисов, а страж был один, то есть свойство держалось не переписью дерева, а тем, у кого случайно оказался страж (#799);
  • обратное направление (настоящее чтение, названное не по конвенции, получает бюджет мутации) НЕ держится ничем и держаться не может: машинного признака «это чтение» у контракта нет. Цена названа честно — опубликованный пол чтений на таком методе не выполнится. Дыры это не открывает: полярность сужает, а не расширяет, и конвенция такие имена запрещает.

func (CallClass) String

func (c CallClass) String() string

type CallClassifier

type CallClassifier func(fullMethod string) CallClass

CallClassifier — как листенер относит вызов к классу.

type ForwarderGate

type ForwarderGate struct {
	// Production — боевой режим (production ИЛИ production-strict). В нём круг
	// обязан быть сужен, и опт-ин ниже НЕ действует.
	Production bool
	// DevTrustAny — явный dev-опт-ин «не сужаем». Действует только вне боевого
	// режима и только там, где посадка заведомо локальная (in-process фикстуры).
	DevTrustAny bool
	// SANsKnob — имя ручки, которой круг задаётся (для текста отказа).
	SANsKnob string
	// TrustAnyKnob — имя ручки опт-ина (для текста отказа).
	TrustAnyKnob string
}

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

Ручки называются ЯВНО, потому что текст отказа читает оператор: сообщение, не называющее ручку, оставляет стенд неподнятым и непонятным. Это одно из трёх мест, выведенных из-под запрета на подробности в публичных артефактах, — рантайм-диагностика, а не рассказ о том, где было открыто.

type IdentityArrival

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

IdentityArrival — счётчик того, ЧТО запрос сказал о личности.

Зачем отдельная серия, а не код ответа

«Личность объявлена и не приехала» и «вызов законно пришёл без личности» — разные события с одинаковым видом снаружи: в обоих случаях у обработчика личности нет. Пока их не различает наблюдение, рассинхрон написания выглядит ростом безымянных вызовов, то есть не выглядит ничем. Код ответа этого не закрывает: отказ виден только у первого, а второй отказом не является и обязан не являться.

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

func NewIdentityArrival

func NewIdentityArrival(reg prometheus.Registerer) (*IdentityArrival, error)

NewIdentityArrival заводит счётчик и регистрирует его в переданном реестре.

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

type Listener

type Listener string

latencyBuckets — границы, выбранные под ЭТУ платформу, а не умолчание библиотеки.

Умолчание (`prometheus.DefBuckets`) начинается с 5 мс и кончается 10 с. Обе границы здесь неверны: чтения из своей базы укладываются в единицы миллисекунд, и умолчание сваливает их все в первую корзину — то есть p50 и p90 становятся неразличимы ровно там, где живёт большинство запросов. С другой стороны, мутация с обращением к соседу и материализацией прав переваливает за секунду законно, и потолок в десять секунд лишает хвост разрешения.

Сетка ниже покрывает четыре порядка — от четверти миллисекунды до тридцати секунд — и сгущается там, где принимаются решения: между миллисекундой и сотней миллисекунд. Listener — ПОЛОСА, на которой обслужен вызов.

Зачем метка, если метод уже назван

Один и тот же метод служится ОБОИМИ слушателями: `OperationService` в этом дереве регистрируется и на публичном, и на внутреннем. Публичный вызов приходит от арендатора через край и тащит за собой выяснение личности и вопрос о правах; внутренний приходит от соседнего модуля по mTLS. Это разные величины, и слитый ряд — среднее двух разных, то есть число, неверное про обе полосы сразу и молча.

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

Метка обязана оставаться ОГРАНИЧЕННОЙ по числу значений: свободная строка — способ уронить хранилище рядами, которых никто не заказывал. Значение вне словаря схлопывается в `unknown` (см. [Listener.label]), а не заводит ряд.

const (
	// ListenerPublic — слушатель, досягаемый арендатором через край.
	ListenerPublic Listener = "public"
	// ListenerInternal — слушатель, досягаемый только внутри кластера.
	ListenerInternal Listener = "internal"
)

type PrincipalExtractOption

type PrincipalExtractOption func(*principalExtractConfig)

PrincipalExtractOption — функциональная опция UnaryPrincipalExtract / StreamPrincipalExtract.

func WithPrincipalDebug

func WithPrincipalDebug(enabled bool) PrincipalExtractOption

WithPrincipalDebug включает/выключает debug-логирование extract-решений и dump'а incoming metadata (по умолчанию — из env KACHO_DEBUG_PRINCIPAL). Composition root решает, а не package-init.

func WithPrincipalDebugLogger

func WithPrincipalDebugLogger(l *slog.Logger) PrincipalExtractOption

WithPrincipalDebugLogger направляет debug-вывод в указанный логгер (вместо slog.Default()). nil игнорируется.

type ServerLatency

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

ServerLatency — измеритель задержки обслуженных вызовов.

Заводится один раз на процесс и регистрируется в реестре сервиса. Повторная регистрация того же реестра — ошибка вызывающего, а не причина ронять процесс: NewServerLatency возвращает ошибку, а не паникует.

func NewServerLatency

func NewServerLatency(reg prometheus.Registerer) (*ServerLatency, error)

NewServerLatency заводит измеритель и регистрирует его в переданном реестре.

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

func (*ServerLatency) StreamServerInterceptor

func (l *ServerLatency) StreamServerInterceptor(on Listener) grpc.StreamServerInterceptor

StreamServerInterceptor наблюдает СРОК ЖИЗНИ серверного стрима и его исход.

Длительность уходит в свою серию ([streamBuckets]), а счётчик обслуженных — общий с одиночными вызовами: подписка тоже обслуженный вызов, и её отсутствие в счётчике означало бы, что оборванные подписки не видны нигде.

Нулевой измеритель прозрачен ровно на тех же условиях, что и у одиночного вызова.

func (*ServerLatency) UnaryServerInterceptor

func (l *ServerLatency) UnaryServerInterceptor(on Listener) grpc.UnaryServerInterceptor

UnaryServerInterceptor возвращает интерсептор, наблюдающий длительность и исход каждого обслуженного вызова.

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

type ServerLimits

type ServerLimits struct {
	// ConcurrentStreamsPerConnection — одновременных вызовов на соединение.
	// Ноль библиотека молча трактует как «не ограничено», поэтому [Validate]
	// отвергает его: настройка, которую молча переворачивают в свою
	// противоположность, хуже отсутствующей.
	ConcurrentStreamsPerConnection uint32

	// SendMsgBytes — предельный размер одного ответа, байт.
	SendMsgBytes int

	// RecvMsgBytes — предельный размер одного запроса, байт.
	RecvMsgBytes int
}

ServerLimits — пределы одного gRPC-сервера, собранные в одно значение.

Тип заведён, чтобы у величин был ОДИН источник на процесс: их читает и конструктор сервера, и проба, утверждающая наблюдаемое на проводе. Три разрозненных литерала разъехались бы молча — и разъехались бы именно там, где расхождение не видно, потому что предел, который никто не превысил, ведёт себя неотличимо от отсутствующего.

func DefaultServerLimits

func DefaultServerLimits() ServerLimits

DefaultServerLimits — пределы, с которыми поднимается КАЖДЫЙ слушатель платформы (оба слушателя каждого сервиса — см. NewServer).

func (ServerLimits) ServerOptions

func (l ServerLimits) ServerOptions() []grpc.ServerOption

ServerOptions отдаёт набор опций, которыми пределы доезжают до сервера.

func (ServerLimits) Validate

func (l ServerLimits) Validate() error

Validate отвергает набор, который библиотека исполнить не сможет.

Отдельный метод, а не проверка внутри [ServerOptions], потому что у неё нет исхода: опции возвращаются вызывающему, и отказ пришлось бы либо проглотить, либо уронить процесс из библиотечной функции. Здесь исход есть, и его читает тот, кто собирает свой набор.

type StepUpInput

type StepUpInput struct {
	// PrincipalType — the caller's `kaname_principal_type`
	// ("user" | "service_account" | "system"). MUST already be trust-filtered by
	// the caller: pass "" whenever the type came from an unverified peer, so a
	// forged `service_account` can never buy the exemption (anti-spoof).
	PrincipalType string
	// PresentedACR — the `acr` the caller actually authenticated with. Absent /
	// unknown ranks 0 (fail-closed). Like PrincipalType it must be trust-filtered.
	PresentedACR string
	// AuthTime — the token's `auth_time`. Consulted only when MFAMaxAge > 0.
	AuthTime time.Time
	// RequiredACR — the catalog `required_acr_min` for the RPC being called.
	// "" / "0" → no step-up requirement.
	RequiredACR string
	// MFAMaxAge — sliding freshness window on AuthTime. 0 → no freshness
	// requirement.
	MFAMaxAge time.Duration
	// Now — the evaluation instant. Required when MFAMaxAge > 0; a zero value
	// there falls back to time.Now() rather than computing a negative age that
	// would pass the window open (fail-closed defaulting).
	Now time.Time
}

StepUpInput — every input of the step-up decision. Both enforcement points build one of these and read nothing else.

The caller supplies raw values from its own transport (a verified JWT's claims at the gateway; the trust-filtered ctx carriers at iam) — extracting them is transport plumbing and stays with the caller; DECIDING on them is this package's job.

type StepUpVerdict

type StepUpVerdict uint8

StepUpVerdict — the outcome of EvaluateStepUp. Deny reasons are distinguished so each enforcement point can emit its own protocol-appropriate error (RFC 6750 challenge at the gateway, gRPC status detail at iam) WITHOUT re-deciding anything.

const (
	// StepUpAllow — the call may proceed past the step-up floor. Grants no
	// permission: the authorization Check runs independently and is unaffected.
	StepUpAllow StepUpVerdict = iota
	// StepUpDenyACR — presented acr ranks below the required floor.
	StepUpDenyACR
	// StepUpDenyAuthTimeMissing — a freshness window is required but the token
	// carries no auth_time.
	StepUpDenyAuthTimeMissing
	// StepUpDenyMFAStale — auth_time is older than the freshness window.
	StepUpDenyMFAStale
)

func EvaluateStepUp

func EvaluateStepUp(in StepUpInput) StepUpVerdict

EvaluateStepUp is THE step-up rule. Both enforcement points call it and neither may re-implement any arm of it.

Arms, in order:

  1. MACHINE-PRINCIPAL EXEMPTION. A service-account principal is exempt from BOTH the acr floor and the MFA-freshness window. This is not a courtesy: a machine has no interactive authentication ceremony and can NEVER present acr ≥ 1 or a fresh auth_time, so gating it on assurance level does not protect the RPC — it makes the RPC permanently unreachable for machines (including the bootstrap-admin service account on the acr-gated credential/grant RPCs). Expressing "machines must not call X" belongs in the authorization MODEL as a relation, not in an assurance floor that no machine can satisfy.

    The exemption lifts ONLY the assurance floor. It grants no permission whatsoever: the per-RPC authorization Check (FGA/ReBAC) runs independently and is untouched, and the machine path carries its own controls (credential lifetime, sender-constrained binding, narrow grant).

    It is NARROW: exactly PrincipalTypeServiceAccount exempts. A `user`, a `system` principal, an empty/absent type (which is also what a caller must pass for an untrusted peer) and any unknown value are NOT exempt.

  2. ACR FLOOR — ACRSatisfies(PresentedACR, RequiredACR).

  3. MFA FRESHNESS — when MFAMaxAge > 0, AuthTime must exist and be within the window.

type SubjectFunc

type SubjectFunc func(ctx context.Context) string

SubjectFunc — чем ключуется листенер.

Пустая строка означает «личности нет» и приводит к UnattributedSubject. Тип существует затем, чтобы у публичного и внутреннего листенеров был РАЗНЫЙ ключ и это было видно в композиционном корне, а не спрятано в общем коде.

type TLSServer

type TLSServer struct {
	// Enable toggles mTLS for this server. Zero-value false ⇒ insecure.
	Enable bool
	// CertFile is the PEM server-certificate presented to clients.
	CertFile string
	// KeyFile is the PEM private key for CertFile.
	KeyFile string
	// ClientCAFiles are PEM CA bundles used to verify presented client-certs.
	ClientCAFiles []string
}

TLSServer is a HORIZONTAL, per-edge server-side TLS value-struct. It is a plain value struct with no process-wide TLS singleton: every grpcsrv.NewServer receives its own TLSServerCreds argument (no global singletons outside cmd/).

It carries NO absolute envconfig tags ON PURPOSE. This struct is embedded by every service under its own per-edge config field; an absolute tag (e.g. KACHO_VPC_TLS_SERVER_ENABLE) would collapse every embedding onto the same env names and break per-edge independence. Instead the env name is derived from the hierarchy of field names: the SERVICE owns the edge name by choosing the parent field and loading with config.LoadPrefixed("KACHO_<DOMAIN>", &cfg).

Example — a service with a public and an admin server-edge:

type Config struct {
	Public grpcsrv.TLSServer // → KACHO_VPC_PUBLIC_ENABLE, ..._CERTFILE, ...
	Admin  grpcsrv.TLSServer // → KACHO_VPC_ADMIN_ENABLE,  ..._CERTFILE, ...
}
_ = config.LoadPrefixed("KACHO_VPC", &cfg) // each edge resolves independently

This yields the KACHO_<DOMAIN>_<EDGE>_<NAME> convention with true per-edge prefixing: distinct edges in one process resolve to distinct env blocks (one process may run a TLS server and an insecure client simultaneously).

type TrustDomain

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

TrustDomain — домен доверия установки: то, чьи сертификаты она признаёт своими.

Зачем это тип, а не строка

Величину читают трое, и ровно так же, как круг отправителей (TrustedForwarders): транспорт (сверяет ей личность предъявленного сертификата), стража старта (решает, поднимать ли процесс) и самоотчёт о посадке (докладывает, под каким доменом процесс работает). Пока это была СКОМПИЛИРОВАННАЯ константа, каждый из троих отвечал на вопрос «наш ли это предъявитель» своим литералом, и литералов в дереве накопилось четыре штуки в четырёх файлах — в фундаменте, на крае и дважды в службе.

Половина посадки при этом настраивалась честно: сертификаты выпускались под доменом из величины профиля. Значит правка величины давала сертификаты нового домена, а принимающая сторона оставалась прежней — и расходились они МОЛЧА: законный отправитель переставал опознаваться, а отказ выглядел как отсутствие личности, то есть как законный вызов без пользователя.

Нулевое значение — «домен НЕ объявлен», и это самое строгое прочтение

`var d TrustDomain` соберётся всегда: запретить нулевое значение Go не даёт. Значит распорядиться можно только тем, ЧТО ОНО ЗНАЧИТ. Оно значит «установка домена не назвала», и личность по нему не опознаётся ни одна: [URIPrefix] пустого домена не собирается вовсе, а не вырождается в «принимаем любой». Пропущенная величина упирается в отказ старта ([Require]) — до транспорта она не доходит.

Почему умолчания нет

Умолчание здесь всегда непусто, поэтому контроль выглядел бы включённым и вёл бы в чужой домен: установка, забывшая назвать свой, молча принимала бы сертификаты нашего. Это тот же класс, что адрес зависимости, выведенный из чужого адреса (`security.md` §«Адрес зависимости… НЕ выводится из чужого»).

Значение неизменяемо после сборки: поле неэкспортируемое, а всё, что тип отдаёт, — производные строки.

func CertIdentityDomainFromContext

func CertIdentityDomainFromContext(ctx context.Context) TrustDomain

CertIdentityDomainFromContext returns the trust domain the cert-identity on ctx was recognized under.

Существует затем, чтобы читатель личности НИЖЕ по цепочке разбирал её относительно ТОГО ЖЕ домена, который её впустил. Домен, взятый им из своей настройки, был бы вторым местом об одном предмете: две величины совпадают сегодня и разъезжаются молча — а расхождение здесь означает либо отказ законному модулю, либо признание чужого.

Нулевой домен у контекста, никогда не проходившего извлекатель, — фейл-клоуз: по необъявленному домену не опознаётся никто.

func NewTrustDomain

func NewTrustDomain(raw string) TrustDomain

NewTrustDomain собирает домен из того, что объявила установка.

Пробелы по краям срезаются по той же причине, по какой их срезает круг отправителей: оператор, написавший величину с пробелом, получил бы не отказ старта, а молчаливый отказ в обслуживании законному отправителю — префикс " spiffe://…" не совпал бы ни с одним сертификатом.

Схема, если оператор её написал, снимается: величина `spiffe://kaname.local` и величина `kaname.local` означают один и тот же домен, и различать их значило бы завести два написания одного предмета. Косые черты по краям снимаются по той же причине.

Пустая строка даёт КАНОНИЧЕСКИЙ НОЛЬ — нулевое значение типа: у «не объявлен» обязано быть одно представление, иначе сравнение по одному из них однажды разойдётся с другим.

func (TrustDomain) CertIdentity

func (d TrustDomain) CertIdentity(cert *x509.Certificate) string

CertIdentity extracts the module identity from a (verified) client-cert as the unmodified, opaque SPIFFE-like SAN string. Selection rule (part of the extractor contract): the FIRST URI-SAN belonging to THIS trust domain is returned exactly as it appears in the cert; other URI-SANs are ignored and the result is stable across calls.

Метод, а не функция пакета, намеренно: личность существует ОТНОСИТЕЛЬНО домена, и вопрос «наш ли этот предъявитель» без домена не задаётся. Пока это была функция, домен приезжал скомпилированной константой, то есть ответ на него был один на все установки.

Returns "" deterministically when cert is nil, has no URI-SANs, or has no URI-SAN under the declared trust domain — and ALWAYS when the domain itself is not declared (TrustDomain.Matches recognizes nobody then). It never parses or resolves the identity and never panics.

func (TrustDomain) IsDeclared

func (d TrustDomain) IsDeclared() bool

IsDeclared — ЕДИНСТВЕННЫЙ предикат «назвала ли установка свой домен». Его спрашивают стража старта, самоотчёт о посадке и всякий, кому нужно решить, объявлена ли посадка. Транспорт принимает то же значение целиком, поэтому «стража пропустила» ⟺ «домен реально объявлен» — по построению.

func (TrustDomain) Matches

func (d TrustDomain) Matches(uri string) bool

Matches — принадлежит ли предъявленный идентификатор этому домену.

Единственный предикат сверки на всё дерево. Необъявленный домен не признаёт своим НИКОГО — включая пустой идентификатор: «домен не назвали» не означает «принимаем всех».

func (TrustDomain) Name

func (d TrustDomain) Name() string

Name — власть без схемы. Для диагностики и для тех, кто собирает адрес сам; решение принимается по TrustDomain.IsDeclared, а не сравнением с пустой строкой.

func (TrustDomain) NamespacePrefix

func (d TrustDomain) NamespacePrefix() string

NamespacePrefix — начало идентификатора рабочей нагрузки: `spiffe://<домен>/ns/`. Пусто у необъявленного домена по той же причине, что и TrustDomain.URIPrefix.

func (TrustDomain) Require

func (d TrustDomain) Require(g TrustDomainGate) error

Require — стража домена доверия, ОДНА на все службы.

Почему она срабатывает на ЛЮБОМ старте, а не только в боевом режиме

У необъявленного домена нет режима, в котором он работает: личность сертификата по нему не опознаётся ни одна, поэтому процесс, поднявшийся без него, отвергает КАЖДОГО отправителя — и отвергает молча, отказом, неотличимым от вызова без личности. Опт-ина «поднимусь без домена» нет и быть не может: он означал бы согласие не работать.

func (TrustDomain) String

func (d TrustDomain) String() string

String — читаемое представление для журнала. Домен не секрет (он написан в каждом выпущенном сертификате), поэтому печатается как есть: скрывать его значило бы делать неотлаживаемым отказ законному отправителю.

func (TrustDomain) URIPrefix

func (d TrustDomain) URIPrefix() string

URIPrefix — начало всякого идентификатора этого домена: `spiffe://<домен>/`. У необъявленного домена префикса НЕТ (пустая строка), и это не забывчивость: префикс `spiffe://` совпал бы с идентификатором ЛЮБОГО домена, то есть признал бы своим чужого предъявителя.

type TrustDomainGate

type TrustDomainGate struct {
	// Knob — имя ручки, которой домен задаётся (для текста отказа).
	Knob string
}

TrustDomainGate — вход стражи домена: то, что стража обязана знать о посадке, чтобы решить, поднимать ли процесс.

Ручка называется ЯВНО, потому что текст отказа читает оператор: сообщение, не называющее ручку, оставляет стенд неподнятым и непонятным. Это одно из трёх мест, выведенных из-под запрета на подробности в публичных артефактах, — рантайм-диагностика, а не рассказ о том, где было открыто.

type TrustedForwarders

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

TrustedForwarders — круг личностей клиентского сертификата, которым разрешено ПЕРЕДАВАТЬ личность конечного пользователя (`x-kacho-principal-*`).

Зачем это тип, а не срез строк

Величину читают трое: транспорт (собирает опцию извлечения), стража старта (решает, поднимать ли процесс) и самоотчёт о посадке (докладывает, сужен ли круг). Пока это был срез, каждый из троих отвечал на вопрос «сужено ли» СВОИМ предикатом, и предикатов в дереве накопилось четыре штуки в трёх сервисах — у одного из них стража и отчёт звали РАЗНЫЕ функции в РАЗНЫХ пакетах. Согласие держалось тем, что три автора написали одинаковое тело. Теперь предикат один — метод TrustedForwarders.IsNarrowed, и разойтись нечему.

Нулевое значение — самое строгое прочтение, какое доступно предикату

`var f TrustedForwarders` соберётся всегда: запретить нулевое значение Go не даёт. Значит распорядиться можно только тем, ЧТО ОНО ЗНАЧИТ, — и оно значит «круг НЕ сужен». Поэтому пропущенное поле упирается в отказ старта у каждого, кто сужает, а не в тихое «доверяем любому предъявившему сертификат».

Семантика транспорта этим типом НЕ меняется

WithTrustedForwarders по-прежнему сужает круг ТОЛЬКО на непустом множестве; на пустом переданная личность принимается от любого пира, прошедшего проверку сертификата (см. principalIsTrusted). Это действующее, намеренное поведение общей библиотеки, и тип его не переопределяет. Защиту от «забыл заполнить» даёт отказ старта, а не смена значения пустого множества: сменить его значило бы поменять смысл величины у всех, кто её уже читает, включая тех, кто про этот тип ничего не знает.

Значение неизменяемо после сборки: TrustedForwarders.SANs отдаёт копию, поэтому «один объект у троих» остаётся одним и тем же значением на всё время жизни процесса.

func NewTrustedForwarders

func NewTrustedForwarders(raw ...string) TrustedForwarders

NewTrustedForwarders собирает круг из того, что написал оператор.

Отбрасывает пустые записи, потому что их отбрасывает и транспорт (WithTrustedForwarders принимает только s != ""): список из одних пустых строк (`SANS=","`) вырождается там в пустое множество, то есть снова «доверяем любому». Считать такую строку заполненной значило бы пропустить дыру через стражу старта.

Пробелы по краям срезаются — и это ОСОЗНАННОЕ расхождение с транспортом, а не его зеркало: транспорт сравнивает личность сертификата побайтово (CertIdentity отдаёт SAN как есть), поэтому запись " spiffe://…" не совпала бы там ни с одним сертификатом. Без среза оператор, написавший список через «запятая-пробел», получил бы не отказ старта, а молчаливый отказ в обслуживании законному отправителю. Круг от этого не расширяется: в него попадают ровно те строки, которые оператор перечислил.

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

func (TrustedForwarders) IsNarrowed

func (f TrustedForwarders) IsNarrowed() bool

IsNarrowed — ЕДИНСТВЕННЫЙ предикат «сужен ли круг». Его спрашивают стража старта, самоотчёт о посадке и всякий, кому нужно решить, объявлять ли посадку суженной. Транспорт принимает то же значение целиком, поэтому «стража пропустила» ⟺ «круг реально сужен» — по построению, а не по совпадению.

func (TrustedForwarders) Len

func (f TrustedForwarders) Len() int

Len — размер круга. Для диагностики и отчётов; решение принимается по TrustedForwarders.IsNarrowed, а не по сравнению этого числа с нулём.

func (TrustedForwarders) Require

func (f TrustedForwarders) Require(g ForwarderGate) error

Require — стража круга отправителей, ОДНА на все семь сервисов.

Почему она срабатывает на ЛЮБОМ старте, а не только в боевом режиме

Развёрнутый стенд обязан работать в боевой посадке независимо от того, dev он называется или нет. Стража, молчащая вне боевого режима, — контроль, чья ветка на локальном стенде не исполняется ни разу, поэтому «забыл выставить круг» обнаруживается только на боевом профиле, где цена ошибки максимальна. Вне боевого режима пустой круг остаётся возможным, но становится ЯВНЫМ опт-ином: его надо попросить, а не получить умолчанием.

Почему опт-ин не действует в боевом режиме

Иначе он был бы ручкой, снимающей защиту на боевом стенде, — то есть ровно тем именованным обходом, которого у нас быть не должно.

func (TrustedForwarders) SANs

func (f TrustedForwarders) SANs() []string

SANs отдаёт КОПИЮ круга в виде среза — форма, которую принимает транспорт. Копия, а не внутренний срез: вызывающий не должен уметь изменить круг после того, как стража его одобрила.

func (TrustedForwarders) String

func (f TrustedForwarders) String() string

String — читаемое представление для журнала. Личности сертификатов не секрет (они публикуются в самих сертификатах), поэтому круг печатается как есть: скрывать его значило бы делать неотлаживаемым отказ законному отправителю.

type TrustedPrincipalOption

type TrustedPrincipalOption func(*trustedPrincipalConfig)

TrustedPrincipalOption — функциональная опция UnaryTrustedPrincipalExtract.

func WithIdentityArrival

func WithIdentityArrival(a *IdentityArrival) TrustedPrincipalOption

WithIdentityArrival отдаёт звену извлечения личности счётчик исходов.

func WithTrustedForwarders

func WithTrustedForwarders(f TrustedForwarders) TrustedPrincipalOption

WithTrustedForwarders ограничивает форвард end-user principal'а КРУГОМ доверенных отправителей (api-gateway и те, кто законно говорит за инициатора). Когда круг сужен, principal форвардится ТОЛЬКО если cert-identity peer'а ∈ круг — иначе principal снимается (defense-in-depth против confused-deputy: внутренний сервис со своим валидным mTLS-cert'ом не может выдать себя за пользователя). Несуженный круг (нулевое значение TrustedForwarders) сохраняет прежнее поведение «любой verified peer доверен».

Эта семантика НЕ изменилась вместе с вводом типа и меняться не должна: её уже читают все, включая тех, кто про тип ничего не знает. От «забыл заполнить» защищает отказ старта у каждого, кто сужает, — а не переопределение смысла пустого множества здесь.

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

Jump to

Keyboard shortcuts

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