service

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

authorize_service.go — AuthorizeService use-case.

Pipeline (per request):

  1. Resolve permission → FGA relation (`<domain>.<resource>.<verb>` → `<resource>_<verb>` per pkg/authz convention).
  2. Build Conditions context (`current_time` from server clock; merges user-provided `context` from the RPC body).
  3. Вердикт реляционной формы по плану, скомпилированному из модели прав. Allowed=false → return deny ("no path").
  4. Allow.

Clean Architecture: domain.* + port-ifaces only. Adapter wiring lives in cmd/kaname/main.go.

The OPA guardrail overlay step (`data.kacho.iam.guardrails.deny`) was removed. FGA is the sole policy gate; the OPA sidecar and bundle wiring are gone.

Latency budget: ≤30ms p95 — FGA Check ≤10ms, 20ms margin for principal-extraction + transport.

Cluster-admin short-circuit cost: the per-object FGA resolve runs FIRST and the cluster-admin super-gate (cluster:…#system_admin) is the FALLBACK on a DENY. So the common ALLOW path is ONE FGA round-trip (no redundant cluster-admin Check); only a DENIED request pays a SECOND round-trip to test cluster-admin authority. BatchCheck memoizes the cluster-admin verdict per-subject so a same-subject batch resolves it at most once. Correctness/fail-closed unchanged — cluster-admin is still allowed on everything, resolved second.

Package service — use-case-слой kaname (Clean Architecture, service-слой).

Содержит бизнес-логику, не зависящую от транспорта (gRPC/HTTP) и storage (pgx/sqlc). Использует port-интерфейсы для repo и peer-клиентов; реализации инжектируются из cmd/kaname/main.go.

governance_ports.go — narrow port-iface definitions for the writer-tx outbox emitters. TxBeginner opens the transaction; RelationOutboxEmitter (fga_outbox), ResourceMirrorEmitter (resource_mirror) and AuditOutboxEmitter (audit_outbox) emit their rows inside that same caller-owned transaction, so each side-effect commits atomically with the mutation that produced it.

Service layer defines these ports; adapters in repo/kaname/pg and clients/ implement them. Composition root (cmd/kaname/main.go) injects concrete implementations.

subject_change_service.go — read-side of subject_change_outbox. Exposes the outbox by ascending-id cursor for api-gateway authz-cache invalidation. Read-only; no mutation.

token_enrichment_own_lane.go — вторая точка входа в ОДНО объявление состава утверждений (задача #898, приёмка F2 §2.11).

Почему второй ВХОД, а не второй состав

С этой фазы токен принципалу выдают ДВА пути: обратный вызов прежнего провайдера, пока он жив, и наш собственный эндпоинт. Пока перечень утверждений и правила их вычисления живут у каждого свои, различие между ними НЕ ЯВЛЯЕТСЯ НИЧЬЕЙ НАХОДКОЙ: оно не выражено и потому не может покраснеть. Первая же правка одной стороны разойдётся с другой молча — и разойдётся у ПРИНЦИПАЛА, чей токен выдан не тем путём.

Поэтому здесь заводится не вторая сборка утверждений, а второй СПОСОБ ДОЙТИ до той же: состав по-прежнему собирают `userTokenClaims` и `saClaims`, и правка любого из них доезжает до обеих сторон by construction.

Чем эта точка входа отличается от прежней

Прежняя резолвит строку по ЗЕРКАЛЬНОМУ значению — идентификатору клиента во внешнем сервере, потому что именно его прежний провайдер кладёт субъектом выпускаемого токена. Наш путь резолвит по НАШЕМУ идентификатору: зеркальное значение на пути разрешения клиента не участвует вовсе.

Значением утверждения зеркало при этом остаётся — и это не противоречие, а разные роли одного поля. «По чему мы НАХОДИМ строку» и «что мы КЛАДЁМ в токен» — разные вопросы: первый решает, кого мы аутентифицировали, второй обязан дать тот же состав, что и прежний путь, иначе сверка составов невозможна. Роль зеркала как значения истекает вместе с самим внешним сервером.

token_enrichment_service.go — use-case: assemble kaname-specific ext_claims for an OAuth2 access_token.

Clean Architecture requires the Hydra token-hook HTTP handler (handler/iamhooks/token_hook_handler.go) to stay a thin transport shim — claims assembly, device-compliance heuristics and mfa_at derivation are domain decisions and belong in the service layer.

tx.go — opaque transaction handle for the service layer.

Clean Architecture boundary: the service layer must depend ONLY on domain types and its own port-interfaces — never on the Postgres driver.

`Tx` is an opaque transaction handle. The service layer drives its lifecycle (Begin via TxBeginner, Commit/Rollback here) but never inspects the concrete type. The concrete pgx transaction is materialized only inside repo/pg adapters via a type assertion (see repo/kaname/pg/service_tx.go::txAsPgx).

Index

Constants

This section is empty.

Variables

View Source
var ErrCredentialExpired = stderrors.New("credential expired")

ErrCredentialExpired — the OAuth2 client behind this token request maps to a kacho credential (SA key / personal access token) whose stated expiry has passed. The token hook translates it into a 403, which is how Hydra is told to deny the token request.

It is deliberately NOT an iamerr sentinel. "Expired" and "not found" are different verdicts and the hook owes them different answers: not-found is refused for a machine credential but still mints the reduced claim set for an interactive identity whose mirror has not committed yet. An expired credential collapsing into "not found" would therefore be able to reach that surviving branch, and the gate would be defeated for exactly the requests it exists for.

View Source
var ErrServiceAccountDisabled = stderrors.New("service account disabled")

ErrServiceAccountDisabled — the subject behind this token request IS a kacho service account, and `service_accounts.enabled` forbids it from authenticating. The token hook translates it into a 403.

Separate from ErrSubjectNotActive above, which carries the same fact about a USER, because the hook owes the two different answers: what fails for a machine credential is client authentication (RFC 6749 §5.2 `invalid_client`), and the operator reading the trail needs to know which table to look in. Nothing distinguishes them further down — a personal access token is a machine request whose subject is a person — so the distinction has to be made here, where the kind of subject is known.

Also separate from iamerr.ErrNotFound: a mapping that resolves to no account is refused through its own branch, and reporting an account that exists as missing would send whoever is debugging it looking for a row that is right there.

View Source
var ErrSubjectChangeNotSettled = errors.New("subject change journal position is not settled yet")

ErrSubjectChangeNotSettled — граница журнала ЕЩЁ НЕ УСТОЯЛАСЬ: позиции нет.

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

Состояние ХОЛОДНОГО СТАРТА, а не режим работы: признак подтверждённости монотонен и однажды подтвердившись не отзывается. Вызывающий переспрашивает на следующем такте; вечное молчание закрывает его собственный fail-closed.

View Source
var ErrSubjectNotActive = stderrors.New("subject not active")

ErrSubjectNotActive — the subject behind this token request IS a kacho user, and its state forbids authentication. Both hooks translate it into a 403.

Like ErrCredentialExpired it is deliberately NOT an iamerr sentinel, and for the same reason, sharpened by what actually happened: "blocked" and "not found" are different verdicts and the hook owes them different answers. Not-found still mints the reduced claim set for an interactive identity whose mirror has not committed yet — that is first login. A blocked user collapsing into not-found therefore reached that surviving branch and was ISSUED a token, which is precisely the defect this sentinel exists to make impossible.

Functions

This section is empty.

Types

type AuditEvent

type AuditEvent = outboxtypes.AuditEvent

AuditEvent — service-layer payload for a durable kaname.audit_outbox compliance row. The repo adapter generates the id (evt_<22-char> — bug #126 regression-guard), marshals Payload to the event_payload jsonb, and inserts it with status='pending'. EventType must satisfy the audit_outbox_event_type CHECK (`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`).

Payload carries the compliance dimensions (actor / subject / resource / key domain fields). It MUST NOT contain secret material (no tokens, no key PEM, no client_secret) — see security.md / acceptance 5.2-36.

Neutral value type owned by internal/outboxtypes so the repo-ports package can reference it without importing this use-case package (dependency-rule fix); the alias keeps the ergonomic service.AuditEvent name.

type AuditOutboxEmitter

type AuditOutboxEmitter interface {
	EmitTx(ctx context.Context, tx Tx, ev AuditEvent) error
}

AuditOutboxEmitter — port for emitting one durable kaname.audit_outbox row inside a caller-owned writer-tx. Atomic with the surrounding security-relevant mutation (запрет #10): the audit row commits iff the mutation commits, so a rolled-back mutation leaves no orphan compliance row and a committed mutation always leaves its trail.

Mirrors RelationOutboxEmitter's emit-in-tx shape: the concrete pgx.Tx is recovered from the opaque service.Tx inside the repo adapter (txAsPgx).

type AuthorizeService

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

func NewAuthorizeService

func NewAuthorizeService(cfg AuthorizeServiceConfig) *AuthorizeService

NewAuthorizeService — builder.

func (*AuthorizeService) BatchCheck

func (s *AuthorizeService) BatchCheck(ctx context.Context, reqs []CheckRequest) ([]*CheckResult, error)

BatchCheck — партия разбирается ПРОГОНАМИ, результаты — в порядке запроса.

Что такое прогон и почему единица именно он

Это дверь, в которую входит фильтр списка КАЖДОГО сервиса-соседа: vpc, compute, nlb, storage и registry читают страницу из СВОЕЙ базы, режут её на партии не более чем по сто идентификаторов (предел проверяется ниже) и отдают их сюда. Такая партия ОДНОРОДНА by construction — один субъект, один тип, одно отношение, одни доводы условий, различаются только идентификаторы.

Предикат пообъектен, и это свойство вопроса. Число ОБРАЩЕНИЙ к хранилищу пообъектным быть не обязано — и до этой правки было им: партия из ста стоила ста вопросов о вердикте и до ста вопросов за хвостом текста отказа. Здесь стояло, что «один вопрос на пункт присущ предикату»; это было неверно, и собственный комментарий рядом называл остаток открытым.

Теперь пункты партии сводятся в ПРОГОНЫ по ключу «субъект · отношение · тип · доводы условий», и каждый прогон стоит:

один вопрос о вердикте всей своей страницы  +
один вопрос надзора администратора облака на СУБЪЕКТА (мемоизирован) +
один вопрос диагностики на все отказы прогона.

Однородная партия любой длины — это ОДИН прогон, то есть постоянная цена. Разнородная партия платит по прогону на группу; групп не больше, чем пунктов, поэтому хуже прежнего не становится никогда.

Почему пул остался, хотя однородная партия — один вопрос

Единица работы сменилась (прогон вместо пункта), а довод — нет: бюджет вызывающего принадлежит ЗАПРОСУ. Партия, чьи пункты называют РАЗНЫЕ субъекты (форма, которую метод поддерживает явно), даёт столько прогонов, сколько субъектов, и последовательный проход по ним стоил бы прогоны × время ответа хранилища. Предел `batchCheckParallelism` держит их одновременность ограниченной: неограниченный веер выложил бы всю страницу на хранилище разом, а одновременные списки других вызывающих умножились бы друг на друга.

Что НЕ изменилось, и это утверждается пробами

  • ПОРЯДОК ЗАПРОСА. Ответ пишется в СВОЙ индекс, никогда не дописывается: верный, но переставленный вердикт фильтрует страницу чужим ответом, и заметить это вызывающий не может.
  • ОТКАЗ ЦЕЛИКОМ на недоступности хранилища. Временный сбой — не пообъектный запрет: он утёк бы сырым текстом транспорта на пользовательскую поверхность и превратил бы перебой в постоянный 403. Первая такая ошибка прекращает проход и отменяет остальные; пообъектная ошибка входа по-прежнему вырождается в отказ с причиной и партию не роняет.
  • ОДИН вопрос надзора НА СУБЪЕКТА (мемо — единая попытка, см. clusterAdminMemo).
  • СОСТАВ ОТВЕТА, включая ТЕКСТ отказа: он собирается тем же `denyReasonText` из той же диагностики, спрошенной иначе.

Часы — ОДНИ на проход

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

func (*AuthorizeService) Check

Check — single-tuple authorization check (with Conditions + OPA overlay).

func (*AuthorizeService) CheckRelation

func (s *AuthorizeService) CheckRelation(ctx context.Context, req CheckRelationRequest) (result *CheckResult, err error)

CheckRelation — relation-native authorization check (FGA Check + OPA overlay). Used by the cluster-internal per-RPC authz gate (`InternalIAMService.Check`). Reuses the same FGA + OPA pipeline as `Check`, but skips the action→relation resolution step because the caller already supplies the resolved relation.

func (*AuthorizeService) ExpandRelations

func (s *AuthorizeService) ExpandRelations(ctx context.Context, req ExpandRequest) (*ExpandResult, error)

ExpandRelations — ИЗ ЧЕГО складывается право на объекте.

Отвечает реляционная форма: основания права разворачиваются в набор субъектов, которые это право в итоге получают. Ответ ОДНОУРОВНЕВЫЙ, и это свойство источника, а не упрощение переходника: основание — плоская запись (факт · выдача · членство), и глубины у него не бывает.

Графовые рёбра сняты с контракта вместе с движком, который их производил (решение S6): поле, которое не заполняется никогда, обещает возможность, которой нет.

func (*AuthorizeService) ListSubjects

func (s *AuthorizeService) ListSubjects(ctx context.Context, req ListSubjectsRequest) (_ *ListSubjectsResult, err error)

ListSubjects — inverse of ListObjects.

type AuthorizeServiceConfig

type AuthorizeServiceConfig struct {
	Relations Authorizer
	// ClusterAdminChecker — плоский надзор администратора облака.
	//
	// Спрашивает о ДРУГОМ объекте, чем тот, о котором идёт вопрос:
	// `cluster:<синглтон>#system_admin`. Именно поэтому три верхних уровня
	// супер-доступа сделаны каскадом, а не материализацией: человек, обязанный
	// всё починить, не должен зависеть от состояния доставки.
	//
	// Со снятия внешнего движка на этот вопрос отвечает ТА ЖЕ форма, что и на
	// вопрос об объекте, — обе стороны спрашивают одно значение, поэтому «два
	// действующих источника ответа» перестало быть возможным by construction.
	ClusterAdminChecker authzguard.RelationChecker
}

AuthorizeServiceConfig — вход сборщика.

type Authorizer

type Authorizer interface {
	// CheckWithContext — вердикт об объекте с условным контекстом запроса.
	CheckWithContext(ctx context.Context, subject, relation, object string, condCtx map[string]any) (bool, error)
	// BatchCheckWithContext — вердикт о СТРАНИЦЕ объектов ОДНИМ вопросом.
	//
	// Объявлен ОБЯЗАТЕЛЬНЫМ методом порта, а не «способностью, если она есть».
	// Необязательность здесь была бы запасным путём с тихой деградацией: дверь без
	// этого метода молча возвращала бы партию к пообъектной полосе, и свойство
	// «партия стоит один вопрос» держалось бы тем, какую реализацию провязал
	// композиционный корень, — то есть не держалось бы ничем.
	//
	// Ответ — той же длины и в порядке заданных объектов: верный, но
	// переставленный вердикт отфильтровал бы страницу чужим ответом.
	BatchCheckWithContext(ctx context.Context, subject, relation string, objects []string,
		condCtx map[string]any) ([]bool, error)
	// ListSubjects — кто держит отношение на объекте, страницей С КУРСОРОМ.
	ListSubjects(ctx context.Context, objectType, objectID, relation string, pageSize int, pageToken string) ([]string, string, error)
	// Sources — кого называют основания права на объекте (разбор «почему»).
	Sources(ctx context.Context, objectType, objectID, relation string) ([]string, error)
	// DirectRelations — какие отношения субъект уже держит НА ЭТОМ объекте.
	//
	// Читатель один — текст отказа («не хватает `editor`; сейчас есть [`viewer`]»).
	// Прежде на это отвечало чтение кортежей у движка; читатель и единица те же,
	// источник — своя таблица.
	DirectRelations(ctx context.Context, subject, objectType, objectID string, limit int) ([]string, error)
	// DirectRelationsMany — то же о СТРАНИЦЕ объектов одного типа, одним вопросом.
	//
	// Хвост текста отказа платится на КАЖДОМ отказанном объекте, а страница
	// списка отказами и состоит — она ими и сужается. Пообъектная диагностика
	// поэтому возвращала стоимость набора ровно там, где вердикт её уже перестал
	// платить: партия из ста отказов стоила ста вопросов диагностики.
	//
	// Ключ ответа — идентификатор объекта; объект без прямых отношений в ответе
	// отсутствует (пустой срез и отсутствие ключа означают одно и то же — «хвоста
	// не будет»).
	DirectRelationsMany(ctx context.Context, subject, objectType string, objectIDs []string,
		limit int) (map[string][]string, error)
}

Authorizer — port-iface narrowed to AuthorizeService needs. Authorizer — ИСТОЧНИК ВЕРДИКТА для края.

Поверхность сузилась вместе со снятием внешнего движка отношений, и сузилась по существу, а не по вкусу: из неё ушло всё, что было вопросом к ЧУЖОМУ хранилищу — перечисление объектов без продолжения, чтение и запись кортежей, сведения о хранилище. Осталось то, что спрашивают у РЕШЕНИЯ.

Реализация — `internal/authzcascade.Client` поверх реляционной формы.

type CheckRelationRequest

type CheckRelationRequest struct {
	Subject  string // "user:usr_xxx" / "service_account:sva_xxx" / "group:grp_xxx#member"
	Relation string // pre-resolved FGA relation
	Object   string // FGA object string "<type>:<id>"
	// HigherConsistency — вызывающий требует чтения, которое НЕ отстаёт от его
	// собственной только что закоммиченной записи.
	//
	// ТРЕБОВАНИЕ ВЫПОЛНЯЕТСЯ ВСЕГДА, И ЭТО НЕ «ПОЛЕ, НА КОТОРОЕ НЕ СМОТРЯТ».
	// Просьба адресовалась ЧУЖОМУ хранилищу, у которого была своя копия и свои
	// кэши чтения: без неё оно отвечало со своей отстающей стороны. Реляционная
	// форма читает ведущую базу службы, поэтому read-after-write у неё держится
	// by construction — гарантия, которую поле просит, дана безусловно, а не
	// проигнорирована.
	//
	// Поле остаётся на контракте намеренно: оно называет ТРЕБОВАНИЕ вызывающего,
	// а не способ его исполнения. Появится путь чтения с реплики (§7 приёмки R7-3
	// держит его вне границ) — требование снова станет различающим, и различать
	// его будет тот, кто этот путь заведёт.
	HigherConsistency bool
}

CheckRelationRequest — input for `CheckRelation` — the FGA-native variant of `Check` used by the server-side per-RPC authz gate (`InternalIAMService.Check`).

Unlike CheckRequest, the caller supplies an already-resolved FGA `Relation` (`viewer`/`editor`/`admin`/…) and an FGA `Object` string (`<type>:<id>`) — the gateway/service-side permission-map has already done the action→relation resolution.

type CheckRequest

type CheckRequest struct {
	Subject  string // "user:usr_xxx" / "service_account:sva_xxx" / "group:grp_xxx#member"
	Resource ResourceRef
	Action   string // "<domain>.<resource>.<verb>"
	// RequiredRelation — when non-empty, overrides verb-derived relation.
	// api-gateway middleware populates this from the catalog's
	// `required_relation` annotation so admin-only RPCs (e.g.
	// `vpc.address_pools.list` with `required_relation=system_admin`) gate
	// on the explicit relation instead of the auto-derived `viewer` which
	// would slip through `cluster.viewer = user:*`.
	RequiredRelation string
	Context          map[string]any // optional CEL-context
}

CheckRequest — input for `Check`.

type CheckResult

type CheckResult struct {
	Allowed     bool
	DenyReasons []string
	CheckedAt   time.Time
}

CheckResult — output.

type ExpandRequest

type ExpandRequest struct {
	ResourceType string
	ResourceID   string
	Relation     string
}

ExpandRequest — input.

type ExpandResult

type ExpandResult struct {
	Resource ResourceRef
	Relation string
	Tree     *authztypes.ExpandTree
}

ExpandResult — output.

type ListSubjectsRequest

type ListSubjectsRequest struct {
	ResourceType      string
	ResourceID        string
	Action            string
	PageSize          int
	PageToken         string
	SubjectTypeFilter string
}

ListSubjectsRequest — input.

type ListSubjectsResult

type ListSubjectsResult struct {
	Subjects      []string
	NextPageToken string
}

ListSubjectsResult — output.

type PrincipalKind

type PrincipalKind string

PrincipalKind names WHOSE authority a token carries, as the enricher resolved it. Not a claim and not a copy of one: it is what a caller needs in order to ask a FURTHER question about the same row the enricher just read.

const (
	// PrincipalUnresolved — nothing in kacho answers to this subject. Only the
	// reduced claim set can be minted, and there is no identifier to look
	// anything else up by.
	PrincipalUnresolved PrincipalKind = ""
	// PrincipalUser — a person, whether they authenticated interactively or
	// presented a personal access token they had issued earlier.
	PrincipalUser PrincipalKind = "user"
	// PrincipalServiceAccount — a machine credential. Not a person's session, so
	// a person's revoke-all cutoff says nothing about it.
	PrincipalServiceAccount PrincipalKind = "service_account"
)

type RelationOutboxEmitter

type RelationOutboxEmitter interface {
	EmitWriteTx(ctx context.Context, tx Tx, tuples []RelationTuple) error
	EmitDeleteTx(ctx context.Context, tx Tx, tuples []RelationTuple) error
}

RelationOutboxEmitter — port for emitting kaname.fga_outbox grant/revoke rows from writer-tx-owning code paths. Atomic with the surrounding mutation; the drainer applies tuples to the relation backend asynchronously.

type RelationTuple

type RelationTuple = outboxtypes.RelationTuple

RelationTuple — {User, Relation, Object} triple for fga_outbox writes. Neutral value type owned by internal/outboxtypes so the repo-ports package (internal/repo/kaname) can reference it without importing this use-case package (dependency-rule fix); the alias keeps the ergonomic service.RelationTuple name.

type ResolvedPrincipal

type ResolvedPrincipal struct {
	// Kind — whose authority the token carries.
	Kind PrincipalKind
	// UserID — `users.id`. Set only when Kind is PrincipalUser.
	UserID string
	// StandingCredentialIssuedAt — when the long-lived credential behind this
	// exchange was issued, for the exchanges that HAVE one (a personal access
	// token). nil for an interactive exchange, where the session states its own
	// authentication instant and that is the instant to weigh.
	//
	// The distinction is the whole reason this field exists. A person forced out
	// re-authenticates and their session moves past the cutoff; a standing
	// credential never re-authenticates, so its anchor is the moment it was
	// minted — one minted after the cutoff is authority the subject established
	// since, and one minted before it is exactly what "log this person out
	// everywhere" is about.
	StandingCredentialIssuedAt *time.Time
}

ResolvedPrincipal — who the enricher decided the token is for, expressed in the identifiers this service's own tables are keyed on rather than in the terms of the claim set.

It exists because the subject the provider states is NOT such an identifier: interactively it is the external identity from the login provider, and for a machine-shaped exchange it is an OAuth client registration. The revoke-all cutoff is keyed on `users.id`, and until this type existed only the claim assembly ever learned that id — so a caller wanting to weigh the cutoff had either to resolve the subject a second time or to scrape the answer back out of the claims it had just been handed.

type ResourceMirrorEmitter

type ResourceMirrorEmitter interface {
	UpsertTx(ctx context.Context, tx Tx, row ResourceMirrorRow) (applied, projectionUnchanged bool, err error)
	DeleteTx(ctx context.Context, tx Tx, objectType, objectID string, tombstone time.Time) error
}

ResourceMirrorEmitter — port for UPSERT/DELETE of a kaname.resource_mirror row inside a caller-owned writer-tx. Atomic with the owner-tuple fga_outbox emit (one writer-tx): a rolled-back caller-tx leaves neither the mirror row nor the tuple intent. The mirror-fill path only FILLS the mirror; the reconciler reads it. UPSERT-on-PK gives idempotency under the at-least-once drainer. UpsertTx reports TWO independent DB-decided facts about the write, because the duplicate delivery every consumer performs cannot be recognised by one of them alone.

  • `applied` — the statement CHANGED a row. The monotonic guard means a register whose SourceVersion is not strictly newer than the stored one updates ZERO rows — a redelivery whose work was already done, so the second delivery skips it.
  • `projectionUnchanged` — the write advanced ONLY SourceVersion: parent-scope and labels were already byte-identical. This is the case `applied` CANNOT see. The two deliveries of one registration carry DIFFERENT versions (the synchronous registrar stamps wall-clock after the commit; the drainer replays the version the DB stamped inside the writer-tx, i.e. earlier), and their arrival order is not fixed — so when the drainer arrives first, the synchronous call applies with the NEWER version while changing nothing about the object. Only a registration that REPLACED a different projection can have made an earlier materialization stale, and only that one needs the delete-stale-capable reconcile pass.

Reporting both costs nothing: the statements already evaluate the conditions.

type ResourceMirrorRow

type ResourceMirrorRow struct {
	ObjectType      string
	ObjectID        string
	ParentProjectID string
	ParentAccountID string
	// ParentChain — цепь предков от ближайшего к дальнему, каждый элемент
	// `"<type>:<id>"`. Двух колонок выше хватает не всякому объекту: модель
	// требует цепи произвольной формы, а объект без предка молча выпадает из
	// области выдачи и из каскада.
	ParentChain []string
	Labels      map[string]string
	// SourceVersion — monotonic per-object marker from the owner.
	// The mirror UPSERT applies a register only when this is strictly newer than
	// the stored version (last-source-state-wins). Zero → '-infinity' (legacy).
	SourceVersion time.Time
}

ResourceMirrorRow — service-layer payload for one kaname.resource_mirror row. OUTPUT-ONLY mirror of the labels + parent-scope of a resource owned by another service (source of truth = owner). Labels nil → persisted as JSONB '{}'.

type ResourceRef

type ResourceRef struct {
	Type string
	ID   string
}

ResourceRef — typed resource ref.

type SubjectChange

type SubjectChange struct {
	ID        int64
	SubjectID string
	Op        string
	// SubjectType — тип субъекта в словаре модели прав (`user` |
	// `service_account` | `group`). Пусто у строк, записанных до того, как
	// производители начали его проставлять.
	//
	// Едет наружу, потому что идентификатор БЕЗ типа субъекта не называет: пара
	// собирается только вместе, и вызывающий, получивший половину, не может ни
	// закрыть поток названного субъекта, ни сбросить его записи поимённо.
	SubjectType string
}

SubjectChange — a row of kaname.subject_change_outbox, plain Go (no proto).

type SubjectChangePositionLostError

type SubjectChangePositionLostError struct {
	// EarliestResumable — нижняя позиция, с которой возобновление ещё ничего не
	// теряет: «самая ранняя удержанная строка минус один», а у вычищенного
	// целиком журнала — сама граница устоявшегося.
	EarliestResumable int64
}

SubjectChangePositionLostError — КУРСОР НИЖЕ ПОЛА ЖУРНАЛА (задача #1712).

Строки между курсором вызывающего и полом СНЯТЫ, и он их уже не получит.

Почему это отказ, а не тишина

Чтение идёт окном `id > since AND id <= settled`: снятая строка в него просто не попадает, курсор переезжает через неё по последней прочитанной позиции, и «строк не было» становится НЕОТЛИЧИМО от «строки убрали». Полоса при этом fail-open by design — пропущенная строка означает непогашенный кэш вердиктов края, то есть неприменённый отзыв доступа, молча.

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

Почему тип, а не значение-часовой

Возобновимая позиция здесь НЕСУЩАЯ: без неё вызывающему некуда сесть — принять ноль значило бы проиграть журнал с начала, остаться на месте — получать тот же отказ вечно. Значение-часовой позиции не носит by construction.

Не путать с ErrSubjectChangeNotSettled: тот говорит «переспроси на следующем такте», этот — «повтор не пройдёт никогда, пересядь». Советы противоположные.

func (*SubjectChangePositionLostError) Error

type SubjectChangeReader

type SubjectChangeReader interface {
	// PollSubjectChanges returns rows of the window `(sinceID, settled]` in
	// ascending order, at most limit rows, plus the position the caller may adopt
	// as its cursor — the settled boundary, narrowed to the last delivered row
	// when the page was cut by limit.
	//
	// Never "everything above the cursor" and never `MAX(id)`: a position issued
	// past a number still in flight loses that row silently and forever.
	// [ErrSubjectChangeNotSettled] when there is no settled position yet.
	// [SubjectChangePositionLostError] when sinceID sits BELOW the journal floor:
	// the rows between them have been removed and will never be delivered, so a
	// silent empty page would read as "nothing changed" — i.e. an unapplied
	// revocation, silently.
	PollSubjectChanges(ctx context.Context, sinceID int64, limit int32) (changes []SubjectChange, headID int64, err error)
}

SubjectChangeReader — port: read side of subject_change_outbox.

type SubjectChangeService

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

SubjectChangeService — read-only use-case that drains subject_change_outbox by ascending-id cursor. Used by InternalIAMService.PollSubjectChanges.

func NewSubjectChangeService

func NewSubjectChangeService(reader SubjectChangeReader) *SubjectChangeService

NewSubjectChangeService constructs a SubjectChangeService backed by the given SubjectChangeReader port.

func (*SubjectChangeService) PollSubjectChanges

func (s *SubjectChangeService) PollSubjectChanges(ctx context.Context, sinceID int64, limit int32) ([]SubjectChange, int64, error)

PollSubjectChanges returns up to `limit` rows of the window `(sinceID, settled]`, ordered ascending. limit is clamped to [1, 1000]; zero or negative defaults to 256. Also returns the position a freshly started caller may seed its cursor with — the settled boundary, never `MAX(id)`.

type TokenEnrichmentConfig

type TokenEnrichmentConfig struct {
	// Domain — public Kachō audience.
	Domain string
	// HydraIssuer — token issuer URL.
	HydraIssuer string
}

TokenEnrichmentConfig — static issuer/audience metadata stamped into claims.

type TokenEnrichmentOwnClientPort

type TokenEnrichmentOwnClientPort interface {
	// GetUserToken читает клиента пользовательского токена по нашему id.
	GetUserToken(ctx context.Context, id domain.UserOAuthClientID) (domain.UserOAuthClient, error)
	// GetSAKey читает клиента ключа служебной учётки по нашему id.
	GetSAKey(ctx context.Context, id domain.SAOAuthClientID) (domain.ServiceAccountOAuthClient, error)
}

TokenEnrichmentOwnClientPort — чтение строки реестра по НАШЕМУ идентификатору.

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

type TokenEnrichmentSAPort

type TokenEnrichmentSAPort interface {
	// LookupByOAuthClientID resolves the kaname SA + OAuth-client mapping
	// from a Hydra `client_id`. Returns iamerr.ErrNotFound when the client
	// id is unknown (e.g. legacy Hydra registration outside kaname).
	LookupByOAuthClientID(ctx context.Context, hydraClientID domain.OAuthClientID) (domain.ServiceAccountOAuthClient, error)
	// GetServiceAccount fetches the SA referenced by a mapping row.
	GetServiceAccount(ctx context.Context, id domain.ServiceAccountID) (domain.ServiceAccount, error)
	// FindByExternalSubject resolves the Phase 3b federated SA mapping by
	// (external OIDC issuer, external sub). Returns iamerr.ErrNotFound when
	// no `trusted_subjects` entry matches.
	FindByExternalSubject(ctx context.Context, issuer, sub string) (domain.ServiceAccountOAuthClient, error)
}

TokenEnrichmentSAPort — read-side dependency: resolve a ServiceAccount and its OAuth-client mapping. Used for the Phase 3a SA-token path (`client_credentials` → Hydra mints a token whose `subject` is the Hydra client id; we map it back to the kacho SA and stamp principal_type/id/ account_id claims) AND the Phase 3b federation-IN path (Hydra forwards an external OIDC assertion `(iss, sub)` plus its own `client_id`; we recover the SA mapping by matching `trusted_subjects[*].issuer` + regex on `sub`).

type TokenEnrichmentService

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

TokenEnrichmentService — use-case for token-hook claims assembly.

func NewTokenEnrichmentService

func NewTokenEnrichmentService(cfg TokenEnrichmentConfig, users TokenEnrichmentUserPort) *TokenEnrichmentService

NewTokenEnrichmentService — constructor. A nil now-func defaults to time.Now.

func (*TokenEnrichmentService) ClaimsForAssertionClient

func (s *TokenEnrichmentService) ClaimsForAssertionClient(
	ctx context.Context, client domain.AssertionClient, hookCtx TokenHookContext,
) (map[string]any, ResolvedPrincipal, error)

ClaimsForAssertionClient собирает утверждения для клиента, аутентифицировавшего себя подписанным утверждением.

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

func (*TokenEnrichmentService) EnrichClaims

func (s *TokenEnrichmentService) EnrichClaims(ctx context.Context, subject string, hookCtx TokenHookContext) (map[string]any, ResolvedPrincipal, error)

EnrichClaims assembles the kaname-specific ext_claims map for an access_token, and states WHO it resolved the subject to.

The second return is not a summary of the first. The claim set is what the provider will stamp into a token; the ResolvedPrincipal is what this service knows the subject to be, in identifiers the claim set does not fully carry — a standing credential's issuance instant is not a claim and must not become one. A caller needing to ask a further question about the subject asks it with this.

Resolution order:

  1. Federated SA (Phase 3b): `GrantType == urn:ietf:params:oauth:grant- type:jwt-bearer` AND `(ExternalIssuer, subject)` matches a `trusted_subjects` entry on a SA-OAuth-client mapping.
  2. SA by Hydra client_id (Phase 3a `client_credentials`). For federated tokens this is also tried as a fallback using `OAuthClientID`.
  3. User-token by Hydra client_id (personal-access-token `client_credentials`): `subject` is the client_id of a UserOAuthClient; mapped back to the owning User → `principal_type=user`. Tried after the SA lookup (a client_id is either an SA-key or a User-token client, never both). Skipped when the User-token port is unwired.
  4. User by external_id (interactive Kratos sessions).
  5. iamerr.ErrNotFound — nothing answers to this subject. What the caller does with that depends on the request: the token hook refuses a MACHINE credential (its client is not a kacho credential) and falls back to MinimalClaims only for an interactive identity whose mirror has not committed yet.

func (*TokenEnrichmentService) MinimalClaims

func (s *TokenEnrichmentService) MinimalClaims(subject string) map[string]any

MinimalClaims returns the reduced ext_claims set for a subject with NO User or SA mapping at all.

Its ONE population is the interactive identity whose kacho mirror has not committed yet: provisioning is asynchronous (the provision hook returns once the Operation is accepted), so a freshly registered human can request their first token before the User row exists. The caller — the token hook — refuses an unresolved MACHINE credential outright instead of coming here, so this set is not what an unknown or revoked OAuth client receives.

It is also not what a BLOCKED user receives, and that sentence used to be false. The user lookup filtered on ACTIVE, so a blocked row came back as an empty result and landed here — this docstring claimed the population was one thing while the query fed it another. The lookup now returns rows as they are and the state is judged (ErrSubjectNotActive), so absence again means only absence.

The principal type says `user` because that is what this population is, and because the value is read as a decision, not as a label. Two platform controls treat `service_account` as "there is no person here": grpcsrv.EvaluateStepUp lifts the interactive-authentication floor for it, and the gateway demands a sender-constrained token from it. Stamping it on a human hands them an exemption built for machines and a requirement they cannot meet — their tokens are ordinary bearers.

It carries no principal id by construction. Wherever the gateway resolves the subject from the token's OWN claims that is the end of it: nothing resolves, and the request is unauthenticated.

One path is not that, and the difference is worth stating rather than implying. With DPoP enabled (off by default) the gateway substitutes the OIDC `sub` as the principal id when the claim set names none, and stamps THIS type on it. A `user`-typed subject then satisfies relations granted to `user:*` — today exactly the global reference catalogue of machine and disk types, which the platform grants to every authenticated subject by design and which this population is about to need. A `service_account`-typed one did not satisfy them, so the change is not neutral there; it admits an authenticated human to data meant for authenticated humans. Stating an untruth about what they are is not the way to withhold it — the substitution is what turns a set that names nobody into a subject, and that belongs to the gateway.

func (*TokenEnrichmentService) UserClaims

func (s *TokenEnrichmentService) UserClaims(u domain.User, subject string, hookCtx TokenHookContext) map[string]any

UserClaims assembles the claim set for a User subject the CALLER has already resolved.

This is the same producer EnrichClaims uses on its user branch, exported so the refresh lane can reach it. The refresh hook resolves the subject itself — it has to, its revoke-all gate weighs EVERY row of the identity — and used to assemble the claim set itself as well. That second assembly was a second place about one subject: a change to the device-compliance derivation in the service would not have reached the refresh lane, and one person would have been handed different claims at issuance and at renewal, with each lane's own probe green.

func (*TokenEnrichmentService) WithClock

func (s *TokenEnrichmentService) WithClock(now func() time.Time) *TokenEnrichmentService

WithClock injects the clock this service stamps `kaname_issued_at` from. A nil func keeps time.Now.

It exists because the claim set carries a value derived from the clock, and the two issuance lanes (the provider's token hook and its refresh hook) must be comparable BYTE FOR BYTE on one principal. Two clocks would let that one value diverge — and the divergence would be a property of the probe, not of the product, which is the shape of a check that cannot tell the two apart. One instance, one clock, both lanes: whatever still differs belongs to the lanes.

func (*TokenEnrichmentService) WithOwnClientPort

WithOwnClientPort провязывает чтение по нашему идентификатору.

func (*TokenEnrichmentService) WithSAPort

WithSAPort wires the ServiceAccount lookup port enabling Phase 3a SA-token enrichment (`kaname_principal_type=service_account` + principal_id + account_id claims). Returning the receiver keeps the constructor chainable and lets test wiring stay nil.

func (*TokenEnrichmentService) WithUserTokenPort

WithUserTokenPort wires the User-token lookup port enabling personal-access-token enrichment (`kaname_principal_type=user` + principal_id + account_id claims for a token minted from a UserOAuthClient client_credentials client). Returning the receiver keeps the constructor chainable; nil-wiring keeps User-token enrichment disabled.

type TokenEnrichmentUserPort

type TokenEnrichmentUserPort interface {
	// FindByExternalID returns EVERY User row for an identity across every
	// Account, whatever its state. The first row that may authenticate is the
	// default active account.
	//
	// An ACTIVE-filtering variant is deliberately absent. That filter answers
	// "give me the usable rows", which is the wrong question here: a blocked
	// user comes back as an empty result, indistinguishable from an identity
	// that has no mirror yet — and the reduced claim set that exists for the
	// latter was therefore minted for the former. Reading the rows as they are
	// lets the state be judged instead of inferred from an absence.
	FindByExternalID(ctx context.Context, externalID domain.ExternalSubject) ([]domain.User, error)
}

TokenEnrichmentUserPort — read-side dependency: resolve a User mirror by its external identity subject (Kratos `sub`).

type TokenEnrichmentUserTokenPort

type TokenEnrichmentUserTokenPort interface {
	// LookupByOAuthClientID resolves the kaname User-token (UserOAuthClient)
	// mapping from a Hydra `client_id`. Returns iamerr.ErrNotFound when the
	// client id is not a User-token client.
	LookupByOAuthClientID(ctx context.Context, hydraClientID domain.OAuthClientID) (domain.UserOAuthClient, error)
	// GetUser fetches the User referenced by a mapping row.
	GetUser(ctx context.Context, id domain.UserID) (domain.User, error)
}

TokenEnrichmentUserTokenPort — read-side dependency: resolve a User + its personal-access-token (UserOAuthClient) mapping from a Hydra `client_id`. Used for the User-token path (`client_credentials` → Hydra mints a token whose `subject` is the Hydra client id; we map it back to the kacho User and stamp principal_type=user + principal_id/account_id claims — the net-new mapping that lets a personal token authenticate as `user:<id>` rather than a service account).

type TokenHookContext

type TokenHookContext struct {
	// GrantedScopes — OAuth2 scopes granted for this token.
	GrantedScopes []string
	// AuthTime — session auth_time (unix seconds); 0 when unknown.
	AuthTime int64
	// ACR — Authentication Context Class Reference.
	ACR string
	// CnfJkt — DPoP confirmation thumbprint (RFC 9449).
	CnfJkt string
	// CnfX5tS256 — mTLS certificate confirmation thumbprint (RFC 8705).
	CnfX5tS256 string
	// OAuthClientID — `request.client_id` as Hydra knows it. For
	// client_credentials this equals `subject`; for jwt-bearer (Phase 3b
	// federation IN) this is the kaname-issued client_id while `subject`
	// is the EXTERNAL assertion sub (e.g. `repo:acme/infra:ref:refs/heads/
	// main`). Empty when the handler cannot recover it.
	OAuthClientID string
	// GrantType — OAuth2 grant exercised. Used to disambiguate the
	// federated path (`urn:ietf:params:oauth:grant-type:jwt-bearer`) from
	// `client_credentials`. Empty when not provided by Hydra.
	GrantType string
	// ExternalIssuer — `iss` of the external assertion in the jwt-bearer
	// flow, populated by the handler when it can decode the form payload.
	// Empty for the non-federated paths.
	ExternalIssuer string
}

TokenHookContext — transport-agnostic projection of the inbound token-hook request. The handler maps the Hydra wire payload onto this struct so the service never depends on the HTTP/Hydra contract.

type Tx

type Tx interface {
	// Commit commits the transaction.
	Commit(ctx context.Context) error
	// Rollback aborts the transaction. Safe to call after Commit (no-op).
	Rollback(ctx context.Context) error
}

Tx is an opaque transaction handle. The concrete pgx transaction type is materialized only inside repo/pg adapters via type assertion. The service layer uses it solely to drive transaction lifecycle.

A concrete pgx transaction value satisfies this interface automatically (it already exposes Commit/Rollback with these signatures), so adapters can pass it where a service.Tx is expected and type-assert it back inside repo methods.

type TxBeginner

type TxBeginner interface {
	Begin(ctx context.Context) (Tx, error)
}

TxBeginner opens a transaction. The returned handle is the opaque service.Tx (see tx.go) — the concrete pgx.Tx is materialized only inside repo adapters.

Jump to

Keyboard shortcuts

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