service

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Oct 24, 2026 License: AGPL-3.0 Imports: 17 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.

session_record_check.go — доводы способов и момента условия `mfa_fresh` из записи сессии ВОПРОСА (kaname#133, исход (а); приёмка `assurance-level-is-declared-by-our-session.md` §3.4, звено З1).

Откуда доводы и почему не из тела вопроса

Вопрос о правах доступен на публичном слушателе, и вопрос о себе пропускает гейт полномочия вызывающего: тело вопроса — вход вызывающего, и довод о его же аутентификации, взятый оттуда, вызывающий назвал бы себе сам (CWE-807). Поэтому тело по-прежнему вырезается (`buildCondContext`), а доводы `amr_claims` и `mfa_at` служба выводит из СВОЕЙ записи сессии, на которую указывает номер записи, пересланный краем доверенным ключом фундамента (`x-kacho-token-session-id`, читатель — `internal/edgecredential`). Уровень — как прежде, доверенный, пересланный краем.

  • `amr_claims` — множество предъявленного записи (Р2, слова словаря Р8);
  • `mfa_at` — момент АУТЕНТИФИКАЦИИ записи (Р6, редакция 7): «3» в продукте производит только выдача сессии входом ключом, поэтому момент предъявления, обосновавшего уровень, и момент аутентификации совпадают. Момент последнего предъявления доводом не служит: его двигает и пароль (Ф11-37).

Когда запись годна

Только когда она ЖИВА (не окончена, срок не вышел) на часах вопроса и её личность равна ДОВЕРЕННОЙ личности вопроса; иначе доводов нет — как у вопроса без нашей сессии (Ф11-43, -44, -48). Оба условия судит один оператор хранилища (`LiveSessionOf`), а не код после чтения: окончание между ответом службы краю о сессии и вопросом о правах — законный порядок двух вызовов, и живость судится на самом вопросе.

Доводы прикладываются, только если вопрос — о самом вызывающем (`user:<его id>`): аутентификация вызывающего не описывает аутентификацию другого субъекта. Вопрос без ссылки, с недоверенной личностью либо от не-человека доводов не получает, и последнюю либо иную сессию личности служба вместо ссылки не подставляет.

Цена

Запись читается лишь для вопроса, чей план достигает условного прямого списка (`authzplan.Plan.Conditioned`): доводы способов и момента читает только условие, и чтение записи на каждом вопросе края было бы платой ни за что. Партия читает запись не больше одного раза на проход.

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 — вторая точка входа в ОДНО объявление состава утверждений (задача kacho#898, приёмка F2 §2.11).

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

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

Обратного вызова провайдера больше нет (kaname#363), и этот вход — один; все полосы собственной выдачи идут через него.

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

По НАШЕМУ идентификатору строки из реестра утверждений. С kaname#362 второго имени у клиента нет, столбец, где его хранили, снят. Значением утверждения `kaname_external_id` на этом пути стоит поэтому идентификатор строки.

token_enrichment_service.go — use-case: assemble kaname-specific ext_claims for an access token minted by one of this service's own issuance lanes.

Claims assembly is a domain decision and belongs in the service layer, not in the transport of whichever lane mints. The external identity provider's token hooks used to be the first such transport; they are gone with the provider (kaname#363), and the own lanes enter through ClaimsForAssertionClient (token_enrichment_own_lane.go).

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

View Source
const DenyReasonEmailNotVerified = admission.DenyReason

DenyReasonEmailNotVerified — причина отказа в ответе двери, когда субъект — человек с неподтверждённым адресом (Р4а). Одно значение на Check, пункт BatchCheck и CheckRelation.

Variables

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.

Separate from ErrSubjectNotActive above, which carries the same fact about a USER, because the lane 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.

It is deliberately NOT an iamerr sentinel: "blocked" and "not found" are different verdicts and the lane owes them different answers. A blocked user collapsing into not-found once reached a surviving branch of the provider's token hook and was ISSUED a token — 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 kacho-iam#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 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.

func (*AuthorizeService) NeutralDenyReasons added in v0.5.0

func (s *AuthorizeService) NeutralDenyReasons(ctx context.Context, req CheckRequest) []string

NeutralDenyReasons — текст отказа, который дверь дала бы ПОДТВЕРЖДЁННОМУ субъекту без отношения на объекте (kaname#456, раскрытие Р3): тот, кто спросил о ДРУГОМ человеке, не узнаёт из ответа, подтверждён ли его адрес. Разбор пункта тот же, что у вопроса (`planCheck`); хвост — «прямых отношений нет»: записи о выдаче неподтверждённому, существующие у него, спросившему о нём наружу не называются.

type AuthorizeServiceConfig

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

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` (выдано классу «вошедшие»).
	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: the zero
	// value, returned together with an error.
	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 PublicReadPublisher added in v0.5.0

type PublicReadPublisher interface {
	ApplyTx(ctx context.Context, tx Tx, objectType, objectID string, published bool, version time.Time) (applied bool, err error)
}

PublicReadPublisher — применяет намерение владельца о публикации объекта для анонимного чтения (`user:*` либо `system_class:anonymous`, `#v_get`) в транзакции вызывающего, ПОРЯДКОМ ВЕРСИЙ ВЛАДЕЛЬЦА: намерение, не новее последнего применённого по объекту, не меняет ничего и строки журнала не кладёт. `objectType` — словарь модели прав. Нулевая `version` — доставка без маркера (см. public_read.ApplyTx). Реализация — *repo/kaname/pg.PublicReadPublisher (kaname#107).

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 SessionRecords added in v0.5.0

type SessionRecords interface {
	// LiveSessionOf — запись `id`, если она ЖИВА в момент `at` (не окончена,
	// срок позже `at`) и принадлежит личности `person`; иначе found=false.
	// Ошибка — хранилище не ответило.
	LiveSessionOf(ctx context.Context, id domain.HumanSessionID, person domain.UserID,
		at time.Time) (rec domain.HumanSession, found bool, err error)
}

SessionRecords — порт: запись сессии человека, годная доводом условия.

Реализует хранилище сессий (`internal/repo/kaname/pg.HumanSessionRepo`).

type SubjectAdmission added in v0.5.0

type SubjectAdmission interface {
	SubjectAdmitted(ctx context.Context, subject string) (bool, error)
}

SubjectAdmission — допуск субъекта к решению: false — человек с неподтверждённым адресом (kaname#456, Р4а). Реализует дверь решения (`authzcascade.Client.SubjectAdmitted`).

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 — КУРСОР НИЖЕ ПОЛА ЖУРНАЛА (задача kacho#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
}

TokenEnrichmentConfig — static metadata stamped into claims.

It carries the audience and nothing about the issuer. An issuer claim used to ride here, filled with the address of the provider this service is retiring: nothing in the service, the platform or the foundation ever read it, and on a landing without that provider it named a server that answers nothing. The token's own `iss` is the signer's, stated by whichever lane signs.

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 {
	// GetServiceAccount fetches the SA referenced by a mapping row.
	GetServiceAccount(ctx context.Context, id domain.ServiceAccountID) (domain.ServiceAccount, error)
}

TokenEnrichmentSAPort — read-side dependency of the service-account lane: the owning ServiceAccount of a key row, whose state the lane judges before it mints.

It used to resolve the key row as well — by client id for the provider's `client_credentials` exchange and by `(external issuer, sub)` for its federation-IN exchange. Both lookups were the provider's token hook's (gone, kaname#363); the own lane reads the row by OUR id (TokenEnrichmentOwnClientPort).

type TokenEnrichmentService

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

TokenEnrichmentService — use-case for claims assembly of the own issuance lanes.

func NewTokenEnrichmentService

func NewTokenEnrichmentService(cfg TokenEnrichmentConfig) *TokenEnrichmentService

NewTokenEnrichmentService — constructor. The clock 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) 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 a probe comparing the claim sets of two lanes on one principal BYTE FOR BYTE needs both to read one clock. Two clocks would let that one value diverge — and the divergence would be a property of the probe, not of the product.

func (*TokenEnrichmentService) WithOwnClientPort

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

func (*TokenEnrichmentService) WithSAPort

WithSAPort wires the owner read of the service-account lane (`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 owner read of the personal-token lane (`kaname_principal_type=user` + principal_id + account_id claims for a token exchanged by a UserOAuthClient). Returning the receiver keeps the constructor chainable; without it the own lane refuses a personal token rather than mint for an owner it could not judge.

type TokenEnrichmentUserTokenPort

type TokenEnrichmentUserTokenPort interface {
	// GetUser fetches the User referenced by a mapping row.
	GetUser(ctx context.Context, id domain.UserID) (domain.User, error)
}

TokenEnrichmentUserTokenPort — read-side dependency of the personal-token lane: the owning User of a personal access token, whose state the lane judges before it mints.

It used to resolve the token row as well, by the name the previous external issuer gave its client. That lookup is gone with the column it read (kaname#362): the issuer registers no personal token, so there was no row it could find. The row is read by OUR id on the own lane (TokenEnrichmentOwnClientPort).

type TokenHookContext

type TokenHookContext struct {
	// ACR — Authentication Context Class Reference.
	ACR string
	// CnfJkt — DPoP confirmation thumbprint (RFC 9449).
	CnfJkt string
	// CnfX5tS256 — mTLS certificate confirmation thumbprint (RFC 8705).
	CnfX5tS256 string
}

TokenHookContext — transport-agnostic projection of what the minting lane knows about the exchange: the confirmation it binds the token to and the assurance level it states. The name is historical — the first transport was the provider's token hook (gone, kaname#363); the own lanes fill it now.

Fields only that hook wrote or only its branches read (granted scopes, the session instant, the grant type, the provider's client id, the external assertion issuer) left together with it.

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