Documentation
¶
Overview ¶
adapters.go — thin shims между port-iface'ами handler-слоя и repo adapter'ами из internal/repo/kaname/pg.
Эти shim'ы избегают cyclic dependency (handler/internal не импортирует repo/kaname/pg напрямую) и позволяют main.go подключать pg-adapters к handler ports.
Package iamhooks — HTTP webhook handlers for the kaname AuthN core (Hydra OAuth2 token hooks).
Все эти handlers слушают только на cluster-internal HTTP listener (config authn.hooks-http-endpoint, default tcp://0.0.0.0:9092). Не публикуются на external TLS endpoint (ban #6 — Internal.* не на external endpoint).
Handlers:
- TokenHookHandler POST /iam/v1/hooks/token (Hydra access_token webhook)
- RefreshHookHandler POST /iam/v1/hooks/refresh (Hydra refresh_token webhook)
- ProvisionHookHandler POST /iam/v1/hooks/provision (Kratos registration/login → UpsertFromIdentity)
Authentication: Bearer X-Kacho-Hook-Token validated против authn.hook-shared-secret (либо ENV KANAME_HOOK_TOKEN).
hook_auth.go — Bearer auth для Hydra hook endpoints.
Bearer `X-Kacho-Hook-Token` validated против authn.hook-shared-secret. Если configured secret пустой (misconfiguration) — fail-closed 500, БЕЗ auth-bypass (никакого dev-mode "accept without auth" — hook endpoints обязаны быть недоступны без валидного secret даже при пустой конфигурации).
Причин отказа ТРИ, и они различимы ТОЛЬКО в журнале (#1747) ¶
заголовка нет 401 чинится настройкой ВЫЗЫВАЮЩЕГО величина не та 401 чинится сверкой секрета у обеих сторон секрет не задан 500 чинится настройкой ПРИНИМАЮЩЕГО
Две первые дают ПОБАЙТОВО ОДИНАКОВЫЙ ответ — намеренно: различимый снаружи отказ здесь есть оракул существования. Раньше они были неразличимы и в журнале тоже, то есть не различались ниоткуда; теперь каждая называет свою причину оператору, не называя её вызывающему.
Побайтовая одинаковость держится ПОСТРОЕНИЕМ — единственным производителем отказа `writeHookAuthRefusal`, а не совпадением литералов. Обе стороны утверждения (журнал различает · ответ не различает) закреплены пробой hook_auth_reason_test.go; способность каждой половины упасть отдельно — hook_auth_reason_injection_test.go.
hook_body.go — bounded JSON body decode for the Hydra/Kratos hook endpoints.
The three hook handlers (token/refresh/provision) live on the cluster-internal :9092 mux behind a constant-time bearer gate. Bearer auth runs BEFORE this, so an unauthenticated attacker can never reach the decode — but a compromised Ory component (or an insider holding the hook shared-secret) could still POST an arbitrarily large JSON body and force unbounded heap allocation during json.Decode, repeatedly, until the pod OOM-kills (CWE-770 / OWASP A05:2021). Capping the body through pkg/httpbody bounds the post-auth allocation.
http_server.go — компоновка всех webhook handlers в один HTTP mux.
Endpoints:
POST /iam/v1/hooks/token — Hydra access_token webhook. POST /iam/v1/hooks/refresh — Hydra refresh_token webhook. POST /iam/v1/hooks/provision — Kratos registration/login user-provisioning webhook. GET /healthz — liveness probe. GET /readyz — readiness probe.
Hook-endpoints (token/refresh/provision) require Bearer X-Kacho-Hook-Token. Listener — cluster-internal-only (ban #6: Internal.* not on external endpoint).
Живость и готовность строит ОБЪЯВЛЕННЫЙ носитель, а не этот пакет (#1752) ¶
Здесь стояли СВОЙ тип именованной проверки (`{Name string; Check func(ctx) error}`) и свои обработчики `/healthz` / `/readyz`. Форма совпадала с `pkg/observability/health` дословно, а шапка того пакета объявляет его ЕДИНСТВЕННЫМ в дереве носителем разведённых живости и готовности: об одном предмете высказывались два места, и одно из них объявляло себя единственным.
Расходиться им было нечем by construction — копии не собираются вместе и друг друга не читают, — поэтому расхождение пришло бы не отказом, а тишиной. И пришло бы оно в том, что общий носитель УЖЕ решил, а своя форма не несла:
срок на чекер — зависший `Ping` держал обработчик до probe-timeout
kubelet'а, а не считался недоступной зависимостью;
«носитель не провязан» — `health.ErrDependencyNotWired`: окно старта своей
формой молча зачитывалось в готовность;
503 на гашении — `SetShuttingDown` снимает под из ротации ДО
остановки серверов; своя форма гасла молча;
зеркало в счётчик — `WithResultObserver`;
пустой набор проверок — своя форма отвечала 200 («пусто = готов»),
то есть fail-open ровно там, где ответ неизвестен.
Держит единственность гейт дерева `internal/repohygiene` `TestEveryServiceServingReadyzBuildsItWithTheDeclaredCarrier`: файл, монтирующий `/readyz`, обязан отдать туда обработчик, произведённый носителем.
lane_values.go — исход КАЖДОГО обращения поставщика личности становится величиной (задача продукта #2495).
Предмет ¶
Полоса хуков — живой путь входа человека: выдача утверждений токена, продление, заведение по первому входу, завершение восстановления доступа. Наблюдения у неё было ровно два вида — предупреждающая строка и строка доступа, — и оба видны только тому, кто читает журнал построчно.
Два состояния одного отказа давали при этом несравнимые, но одинаково ненаблюдаемые картины:
полоса отказывает растёт число строк журнала с отказом;
поставщик не зовёт хук вовсе строк журнала НЕТ. Провязку на своей стороне
делает оператор, и её отсутствие невидимо
by construction: тишина тревогой не бывает.
Почему исходов ТРИ ¶
У каждого свой владелец починки, и смешать их значило бы отдать чужую работу:
ok хук сделал работу;
refused обращение отвергнуто до работы — общий секрет, форма тела, метод.
Провязка на стороне поставщика не создана либо неверна, и чинит её
ОПЕРАТОР, а не мы;
failed ответить не смогли. Сломан продукт, и чиним мы.
Корзины «прочее» у набора нет. Ответ вне трёх диапазонов на этих маршрутах не возникает (обработчики отвечают телом JSON либо отказом), а перенаправление есть решение самого обработчика — оно попадает в `ok` вместе с остальными доведёнными ответами, а не заводит четвёртое значение, которое присутствовало бы нулём и выглядело исправным наблюдением.
Почему имена маршрутов объявлены ЗДЕСЬ ¶
Их знает мультиплексор — он один держит соответствие пути и обработчика. Второй перечень (у приёмника величин) разошёлся бы с этим молча, и разошёлся бы он в сторону «маршрут без клетки», то есть в сторону невидимости.
ports.go — port-интерфейсы handler-слоя (Clean Architecture).
Handler НЕ зависит от pgx / sqlc / grpc-stubs. Зависит только от этих abstract port'ов; реализации инжектируются из cmd/kaname/main.go.
provision_hook_handler.go — Kratos user-provisioning webhook (audit C4).
POST /iam/v1/hooks/provision
Kratos (configured selfservice.flows.{registration,login}.after.*.web_hook) вызывает этот endpoint после успешной регистрации / логина. Payload — рендер `identity-payload.jsonnet` (kacho-deploy), который маппит Kratos identity → {external_id, email, display_name}. Handler делегирует UpsertFromIdentity use-case'у (тот же, что обслуживает gRPC InternalUserService.UpsertFromIdentity на :9091) — bootstrap нового Account/ Project/AccessBinding для нового identity, либо активация PENDING-invite.
До C4 эти web_hook'и POST'или на gRPC :9091 c REST-style путем `/iam/v1/internal/users:upsertFromIdentity` — путь, которого на чистом gRPC (HTTP/2) listener не существует → каждый hook молча падал → пользователь регистрировался, но никогда не зеркалился в kaname (нет project/ namespace). C4 переводит provisioning на :9092 HTTP hooks listener (Hydra-hook'и уже там) и вызывает use-case in-process.
recovery_hook_handler.go — завершение восстановления пароля, приходящее от провайдера личности по HTTP на слушатель хуков (:9092).
Почему этот файл появился позже трёх соседних ¶
До него хук восстановления оставался нацеленным на ЛЕГАСИ gRPC-порт с REST-подобным путём — тем самым, которого на чистом gRPC не существует. Это ровно тот дефект, который уже чинили у заведения пользователя: провайдер POST'ит, получает отказ уровня транспорта, считает вызов сделанным, и до нас событие не доезжает НИКОГДА.
Отсрочка объяснялась тем, что «RPC не реализован». Утверждение пережило свой предмет: use-case существует (`internal_on_recovery.go`) и вызывается по внутреннему gRPC; не хватало ровно HTTP-маршрута к нему.
Что стоит на кону ¶
Восстановление пароля — единственный путь вернуть доступ человеку, потерявшему его. Оно обязано: снять блокировку строки пользователя и сдвинуть отсечку, по которой отбраковываются прежние сессии. Пока хук не доезжал, обе части не происходили: восстановивший доступ оставался заблокированным, а старые сессии переживали восстановление — то есть событие, ради которого механизм и нужен, не имело последствий.
refresh_hook_handler.go — Hydra refresh_token webhook.
POST /iam/v1/hooks/refresh
Hydra (configured oauth2.refresh_token_hook.url) вызывает этот endpoint каждый раз при refresh-token-rotation. Handler:
- Проверяет shared-secret.
- Читает строки субъекта КАК ЕСТЬ и выносит вердикт по состоянию (domain.InviteStatus.MayAuthenticate): ни одной строки → 403 user_disabled с причиной user_not_found; строки есть, но ни одна не вправе аутентифицироваться → 403 user_disabled с причиной user_blocked. Hydra пропагирует оба как invalid_grant. Тот же вердикт применяет token-hook, поэтому разойтись они больше не могут.
- Применяет user-level revoke-all cutoff (ForceLogout / Revoke(revoke_all) / восстановление пароля), сверяя его с моментом аутентификации сессии.
- Re-injects ext_claims (same as token_hook).
- Audit emit `authn.refresh.issued` (либо `authn.refresh.denied`).
Пер-jti гейта здесь НЕТ, и это не упущение: тело этого хука вообще не несёт claims предъявленного токена (см. testdata/README.md), поэтому гейт, ключевавшийся на jti, ни разу не выполнился по существу — он отклонял КАЖДОЕ обновление за отсутствием поля, у которого нет источника, и делал недостижимым всё, что стояло за ним. Отзыв конкретного токена проверяется там, где предъявленный токен есть: на краю, интроспекцией, плюс `InternalSessionRevocationsService.IsRevoked`.
token_hook_handler.go — Hydra access_token webhook.
POST /iam/v1/hooks/token
Hydra (configured oauth2.token_hook.url) вызывает этот endpoint каждый раз перед выдачей access_token. Payload содержит session+request; ответ обогащает session.access_token.ext_claims с kaname-specific полями.
Index ¶
- Constants
- func LaneOutcomeForStatus(status int) string
- func LaneOutcomes() []string
- func LoggerMiddleware(h http.Handler, logFn func(method, path string, status int)) http.Handler
- func NewMux(h Handlers) *http.ServeMux
- func Routes() []string
- type AuditAdapter
- type AuditEmitter
- type AuditEvent
- type Handlers
- type LaneObserver
- type ProvisionHookConfig
- type ProvisionHookHandler
- type ProvisionInput
- type RecoveryCompleter
- type RecoveryHookConfig
- type RecoveryHookHandler
- type RecoveryInput
- type RefreshHookConfig
- type RefreshHookHandler
- type TokenEnricher
- type TokenHookConfig
- type TokenHookHandler
- type UserClaimsAssembler
- type UserLookupPort
- type UserProvisioner
- type UserRevocationLookup
Constants ¶
const ( // RouteToken — выдача утверждений токена. RouteToken = "token" // RouteRefresh — продление. RouteRefresh = "refresh" // RouteProvision — заведение по первому входу. RouteProvision = "provision" // RouteRecovery — завершение восстановления доступа. RouteRecovery = "recovery" )
Имена маршрутов полосы. Метка величины, а не путь: путь несёт версию и префикс, и его смена переименовала бы ряд, ничего не изменив по существу.
const ( // LaneOutcomeOK — хук сделал работу. LaneOutcomeOK = "ok" // LaneOutcomeRefused — обращение отвергнуто до работы: провязка на стороне // поставщика не создана либо неверна. LaneOutcomeRefused = "refused" // LaneOutcomeFailed — ответить не смогли: сломан продукт. LaneOutcomeFailed = "failed" )
Исходы одного обращения. Набор ЗАКРЫТ.
Variables ¶
This section is empty.
Functions ¶
func LaneOutcomeForStatus ¶ added in v0.2.0
LaneOutcomeForStatus переводит состояние ответа в объявленный исход.
Функция ПОЛНАЯ: у неё нет входа, на котором она вернула бы значение вне LaneOutcomes, — и именно это делает набор закрытым by construction, а не по договорённости.
func LaneOutcomes ¶ added in v0.2.0
func LaneOutcomes() []string
LaneOutcomes — закрытый набор исходов в порядке объявления.
func LoggerMiddleware ¶
LoggerMiddleware — minimal access log wrapper.
Types ¶
type AuditAdapter ¶
type AuditAdapter struct {
EmitFn func(ctx context.Context, eventType string, tenantAccountID string, payload map[string]any) error
}
AuditAdapter — функциональный adapter, превращающий callback в AuditEmitter.
func (*AuditAdapter) Emit ¶
func (a *AuditAdapter) Emit(ctx context.Context, evt AuditEvent) error
type AuditEmitter ¶
type AuditEmitter interface {
Emit(ctx context.Context, evt AuditEvent) error
}
AuditEmitter — append-only audit log.
type AuditEvent ¶
AuditEvent — structured event для audit_outbox.
type Handlers ¶
type Handlers struct {
TokenHook http.Handler
RefreshHook http.Handler
ProvisionHook http.Handler
// RecoveryHook — завершение восстановления пароля. Появился позже трёх
// соседних: до него провайдер бил в легаси gRPC-порт с REST-подобным путём,
// и событие не доезжало никогда (см. recovery_hook_handler.go).
RecoveryHook http.Handler
// Health — объявленный носитель разведённых живости и готовности. ЧТО именно
// проверяет каждая зависимость, знает композиционный корень (он один знает,
// какая база своя и к кому сервис ходит); этот пакет только монтирует.
//
// nil означает «корень не провязал» и даёт fail-closed готовность, а не
// молчаливые 200 (см. errHealthCarrierNotWired).
Health *health.Aggregator
// LaneObserver — приёмник исходов полосы (#2495). Без него у живого пути
// входа человека нет ни одной величины, и «полоса отказывает» неотличимо от
// «поставщик не настроен звать хук»: в первом случае растут строки журнала,
// во втором их нет вовсе, а отсутствие строк тревогой не бывает.
//
// Порт, а не готовый счётчик: этот пакет не знает ни реестра величин, ни
// prometheus. Нулевое значение — законное состояние пробы пакета, и полоса
// при нём обслуживает вход как обычно.
LaneObserver LaneObserver
}
Handlers — bundle всех hook handlers.
type LaneObserver ¶ added in v0.2.0
type LaneObserver interface {
// HookServed принимает исход ОДНОГО обращения: route из [Routes], outcome из
// [LaneOutcomes].
HookServed(route, outcome string)
}
LaneObserver — приёмник исходов полосы. Порт, а не готовый счётчик: слой транспорта не знает ни реестра величин, ни prometheus.
type ProvisionHookConfig ¶
type ProvisionHookConfig struct {
}
ProvisionHookConfig — runtime config для provision hook.
type ProvisionHookHandler ¶
type ProvisionHookHandler struct {
// contains filtered or unexported fields
}
ProvisionHookHandler — HTTP handler.
func NewProvisionHookHandler ¶
func NewProvisionHookHandler( cfg ProvisionHookConfig, provisioner UserProvisioner, logger *slog.Logger, ) *ProvisionHookHandler
NewProvisionHookHandler — constructor.
func (*ProvisionHookHandler) ServeHTTP ¶
func (h *ProvisionHookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP реализует http.Handler.
type ProvisionInput ¶
ProvisionInput — decoded identity-payload (см. identity-payload.jsonnet в kacho-deploy). Handler-local DTO: iamhooks НЕ импортирует use-case-пакет; composition root маппит это в user.UpsertFromIdentityInput.
type RecoveryCompleter ¶
type RecoveryCompleter interface {
CompleteRecovery(ctx context.Context, in RecoveryInput) error
}
RecoveryCompleter — узкий порт. Реализуется адаптером из composition root, который зовёт use-case завершения восстановления.
type RecoveryHookConfig ¶
type RecoveryHookConfig struct {
}
RecoveryHookConfig — runtime config хука восстановления.
type RecoveryHookHandler ¶
type RecoveryHookHandler struct {
// contains filtered or unexported fields
}
RecoveryHookHandler — HTTP-обработчик.
func NewRecoveryHookHandler ¶
func NewRecoveryHookHandler( cfg RecoveryHookConfig, completer RecoveryCompleter, logger *slog.Logger, ) *RecoveryHookHandler
NewRecoveryHookHandler — constructor.
func (*RecoveryHookHandler) ServeHTTP ¶
func (h *RecoveryHookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP реализует http.Handler.
Порядок отказов тот же, что у соседей, и он не случаен: метод → секрет → тело → обязательные поля. Проверка секрета стоит ДО разбора тела, чтобы неизвестный отправитель не заставлял нас разбирать его JSON.
type RecoveryInput ¶
type RecoveryInput struct {
// ExternalID — идентичность у провайдера (его `identity.id`).
ExternalID string
// Email — адрес, по которому шло восстановление.
Email string
// RecoveryJTI — идентификатор события восстановления. Нужен для
// идемпотентности: провайдер вправе повторить доставку, и повтор обязан
// быть no-op, а не вторым сдвигом отсечки.
RecoveryJTI string
}
RecoveryInput — расшифрованная полезная нагрузка провайдера.
Handler-local DTO: пакет НЕ импортирует use-case — composition root маппит это в свой вход. Тот же приём, что у соседних хуков, и по той же причине: транспорт не должен тянуть за собой типы бизнес-слоя.
type RefreshHookConfig ¶
RefreshHookConfig — runtime config.
type RefreshHookHandler ¶
type RefreshHookHandler struct {
// contains filtered or unexported fields
}
RefreshHookHandler — HTTP handler.
func NewRefreshHookHandler ¶
func NewRefreshHookHandler( cfg RefreshHookConfig, users UserLookupPort, claims UserClaimsAssembler, revocations UserRevocationLookup, audit AuditEmitter, logger *slog.Logger, ) *RefreshHookHandler
NewRefreshHookHandler — constructor.
claims is the SAME assembler the token hook's enricher is: one producer for both lanes (see UserClaimsAssembler).
func (*RefreshHookHandler) ServeHTTP ¶
func (h *RefreshHookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP реализует http.Handler.
type TokenEnricher ¶
type TokenEnricher interface {
EnrichClaims(ctx context.Context, subject string, hookCtx service.TokenHookContext) (map[string]any, service.ResolvedPrincipal, error)
MinimalClaims(subject string) map[string]any
}
TokenEnricher — service-layer use-case the handler delegates claims assembly to. Clean Architecture: the handler stays a thin transport shim; claims assembly / device-compliance heuristics / mfa_at derivation live in the service layer. Implemented by *service.TokenEnrichmentService.
type TokenHookConfig ¶
TokenHookConfig — runtime config для token hook.
type TokenHookHandler ¶
type TokenHookHandler struct {
// contains filtered or unexported fields
}
TokenHookHandler — HTTP handler.
func NewTokenHookHandler ¶
func NewTokenHookHandler( cfg TokenHookConfig, enricher TokenEnricher, revocations UserRevocationLookup, audit AuditEmitter, logger *slog.Logger, ) *TokenHookHandler
NewTokenHookHandler — constructor.
revocations is what makes "log this person out of everything" mean anything at the moment a token is MINTED. Without it the cutoff had three writers and one reader, on the path taken only when an EXISTING token is refreshed — so an administrator's force-logout returned success while the subject's live session kept obtaining fresh tokens on demand, and a personal access token, whose grant has no refresh hook at all, was never re-examined even once.
A nil reader is accepted so an in-process fixture can wire the hook without a revocation store; the composition root has no branch that leaves it out.
func (*TokenHookHandler) ServeHTTP ¶
func (h *TokenHookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP реализует http.Handler.
type UserClaimsAssembler ¶
type UserClaimsAssembler interface {
UserClaims(u domain.User, subject string, hookCtx service.TokenHookContext) map[string]any
}
UserClaimsAssembler — the service-layer producer of the claim set for a User the caller has already resolved. Implemented by *service.TokenEnrichmentService.
The refresh hook holds it rather than assembling claims itself: renewal and issuance owe ONE person ONE claim set, and while each lane assembled its own they were two places about one subject. Both lanes now ask the same producer, so a change to it cannot reach one and miss the other.
type UserLookupPort ¶
type UserLookupPort interface {
FindByExternalID(ctx context.Context, externalID domain.ExternalSubject) ([]domain.User, error)
GetByID(ctx context.Context, id domain.UserID) (domain.User, error)
}
UserLookupPort — read-side dependency для token/refresh hooks.
FindByExternalID возвращает ВСЕ row для identity (Kratos sub) через все Account-ы, в любом состоянии. Для multi-Account membership берётся первый row, который вправе аутентифицироваться (default active account); явный выбор аккаунта через `account_id` hint — будущее расширение.
ACTIVE-фильтрующего варианта здесь СОЗНАТЕЛЬНО нет. Он отвечает «дай пригодные строки», тогда как хук спрашивает «в каком состоянии субъект»: заблокированный пользователь возвращался пустым результатом, и два хука читали эту пустоту противоположно — один отказывал, второй принимал за «зеркало ещё не доехало» и выдавал урезанный набор claims. Вердикт выносит domain.InviteStatus.MayAuthenticate по прочитанной строке.
type UserProvisioner ¶
type UserProvisioner interface {
Provision(ctx context.Context, in ProvisionInput) error
}
UserProvisioner — narrow port. Реализуется adapter'ом из cmd/kaname, который вызывает UpsertFromIdentityUseCase.Execute. Handler не зависит от transport / use-case / operations-типов.
type UserRevocationLookup ¶
type UserRevocationLookup interface {
// UserRevokedBefore returns the cutoff recorded for a user and whether one
// exists. The error is surfaced rather than folded into "no cutoff" so the
// caller can fail closed: an unavailable store is not an answer of "no".
UserRevokedBefore(ctx context.Context, userID string) (time.Time, bool, error)
}
UserRevocationLookup — the revoke-all cutoff. Read by BOTH hooks: once where a token is MINTED, and again where one is refreshed.
Both is the point. The cutoff is written by an administrator forcing a user out, by a user revoking all of their own tokens, and by password recovery — and it used to be read only on the refresh path, so a live session kept obtaining brand-new tokens straight through a force-logout that had reported success. Worse for a personal access token: its grant has no refresh hook at all, so nothing minted through it was ever re-examined and the cutoff had no point of enforcement whatsoever. One port, one adapter, one row — the two hooks cannot answer the same question differently.
Narrow on purpose. This asks "has this person been logged out of everything, and when?" and nothing else. The port it replaces also declared the write path and a per-token lookup, the latter stated as being there because "the same adapter implements the write path" — but what an adapter can do is not what a caller needs, and neither was called from this package.