iamhooks

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

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:

  1. Проверяет shared-secret.
  2. Читает строки субъекта КАК ЕСТЬ и выносит вердикт по состоянию (domain.InviteStatus.MayAuthenticate): ни одной строки → 403 user_disabled с причиной user_not_found; строки есть, но ни одна не вправе аутентифицироваться → 403 user_disabled с причиной user_blocked. Hydra пропагирует оба как invalid_grant. Тот же вердикт применяет token-hook, поэтому разойтись они больше не могут.
  3. Применяет user-level revoke-all cutoff (ForceLogout / Revoke(revoke_all) / восстановление пароля), сверяя его с моментом аутентификации сессии.
  4. Re-injects ext_claims (same as token_hook).
  5. 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

View Source
const (
	// RouteToken — выдача утверждений токена.
	RouteToken = "token"
	// RouteRefresh — продление.
	RouteRefresh = "refresh"
	// RouteProvision — заведение по первому входу.
	RouteProvision = "provision"
	// RouteRecovery — завершение восстановления доступа.
	RouteRecovery = "recovery"
)

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

View Source
const (
	// LaneOutcomeOK — хук сделал работу.
	LaneOutcomeOK = "ok"
	// LaneOutcomeRefused — обращение отвергнуто до работы: провязка на стороне
	// поставщика не создана либо неверна.
	LaneOutcomeRefused = "refused"
	// LaneOutcomeFailed — ответить не смогли: сломан продукт.
	LaneOutcomeFailed = "failed"
)

Исходы одного обращения. Набор ЗАКРЫТ.

Variables

This section is empty.

Functions

func LaneOutcomeForStatus added in v0.2.0

func LaneOutcomeForStatus(status int) string

LaneOutcomeForStatus переводит состояние ответа в объявленный исход.

Функция ПОЛНАЯ: у неё нет входа, на котором она вернула бы значение вне LaneOutcomes, — и именно это делает набор закрытым by construction, а не по договорённости.

func LaneOutcomes added in v0.2.0

func LaneOutcomes() []string

LaneOutcomes — закрытый набор исходов в порядке объявления.

func LoggerMiddleware

func LoggerMiddleware(h http.Handler, logFn func(method, path string, status int)) http.Handler

LoggerMiddleware — minimal access log wrapper.

func NewMux

func NewMux(h Handlers) *http.ServeMux

NewMux собирает Handlers в один http.ServeMux. Каждый handler уже несет auth-проверку — mux только маршрутизирует.

func Routes added in v0.2.0

func Routes() []string

Routes — закрытый набор маршрутов полосы в порядке объявления.

ВЫВОДИТСЯ отсюда всяким, кому нужен перечень: заведение клеток величины, проба переписи. Вторая копия разошлась бы молча.

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

type AuditEvent struct {
	EventType       string
	TenantAccountID string
	Payload         map[string]any
}

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 {
	HookSharedSecret string
}

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

type ProvisionInput struct {
	ExternalID  string
	Email       string
	DisplayName string
}

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 {
	HookSharedSecret string
}

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

type RefreshHookConfig struct {
	HookSharedSecret string
	Domain           string
	HydraIssuer      string
}

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

type TokenHookConfig struct {
	HookSharedSecret string
	Domain           string
	HydraIssuer      string
}

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.

Jump to

Keyboard shortcuts

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