Documentation
¶
Overview ¶
acr_floor.go — the per-RPC `required_acr_min` (step-up / MFA-freshness floor) enforcement on the cluster-internal listener (:9091) for the GATEWAY-FRONTED privileged RPCs.
AuthN+AuthZ-everywhere invariant ("Internal = trusted, mTLS is enough" is a FORBIDDEN assumption): `required_acr_min` is enforced on the public path (api-gateway StepUpGate), but the gateway does NOT re-run that gate when it re-dials :9091 on the caller's behalf — so a privileged gateway-fronted internal RPC (notably InternalClusterService/{Get,GrantAdmin,RevokeAdmin, ListAdmins}, which already carry required_acr_min=2) would be un-enforced on the internal route. This interceptor closes that arm: the gateway forwards the validated acr as trusted metadata (grpcsrv.MDKeyTokenACR) and the floor enforces the catalog requirement here too.
For each RPC in the GATEWAY-FRONTED set (GatewayFrontedInternalRPCs — caller- context = api-gateway acting for an end user) whose catalog `required_acr_min > 0`, it applies THE step-up rule — grpcsrv.EvaluateStepUp, the single implementation the public api-gateway StepUpGate calls too. This floor decides nothing itself: it selects WHICH calls are subject to the rule (gateway-fronted ∧ production ∧ acr_min>0), reads the inputs from the trusted ctx, and renders the verdict as a gRPC status. It must never re-derive the ranking or the machine exemption — that split is precisely what let a machine principal clear the front door and then be denied here forever (acr_floor_stepup_parity_test.go drives this entrypoint against the shared rule, machine principal included).
Inputs are read ONLY under the trust invariant: grpcsrv.TrustedACRFromContext for the acr and grpcsrv.TrustedPrincipalFromContext for the principal type. On the mTLS-verified gateway→iam edge the gateway forwards x-kacho-token-acr and x-kacho-principal-type; on an unverified/foreign-SAN peer the acr is dropped upstream (corelib) and the principal is flagged untrusted — this floor then passes an EMPTY principal type to the rule, so a forged `service_account` header cannot buy the exemption (anti-spoof), and the absent acr ranks 0 → denied.
EXEMPT (deliberately not enforced here):
- Non-gateway-fronted internal RPCs (InternalIAMService/Check, /RegisterResource, /InternalAddressService/Allocate*, …) — called by MODULE SAs (vpc/compute/nlb), not by a user. The floor never touches an RPC outside the gateway-fronted set (selection arm, independent of who calls).
- MACHINE principals on gateway-fronted RPCs — exempt via the shared rule, not via a local branch. A service account has no interactive ceremony and can never present acr ≥ 1, so the floor would deny it permanently rather than protect anything. The exemption lifts assurance only: the in-handler ReBAC Check and internalCallerPolicy (gateway-SAN-only) are untouched.
- gateway-fronted RPCs whose required_acr_min == 0 — no requirement (latent-until-policy: the floor fires for them the moment policy raises their acr_min, proving the mechanism is generic).
Ordering (serve.go): chained AFTER UnaryTrustedPrincipalExtract (sets the trusted acr) AND internalCallerPolicy (which already DENIES a non-gateway SAN on a gateway-fronted RPC BEFORE the acr-floor — so a compromised module cannot reach the floor with a spoofed acr; the acr-exemption of module SAs cannot be abused). Mirrors the SystemViewerFloor: default-OFF (dev/newman no-op, byte-identical), fail-closed in production.
Package authzguard — minimal per-use-case guard for kaname. It answers "did the caller name itself?", never "is the caller allowed": the per-RPC authorization Check is made against the rights model, at the edge and in the interceptor chain.
Use-cases call RequireAuthenticated(ctx) in the first sync step, BEFORE creating an Operation. If principal-type == "anonymous" (or missing) → PermissionDenied. The bootstrap admin (system/bootstrap) is rejected too — it is only used by backend-internal paths and tests, which bypass guards via WithPrincipal directly.
The layer was written as transitional, "until a permission_map interceptor lands". That interceptor exists and gates every RPC on both listeners, so what remains here is deliberately narrower: an authentication floor at the first sync step of a use-case, not a second opinion about rights.
caller_policy.go — the per-RPC CALLER policy for the cluster-internal listener (:9091). It does NOT re-ReBAC the end user — that is the api-gateway's job (the platform's single authZ front door validates the JWT and runs per-user ReBAC via iam.Check). iam's :9091 enforces only WHO MAY CALL each RPC:
- Floor — every internal RPC requires a VERIFIED mTLS module cert (SPIRE SAN spiffe://kacho.cloud/ns/<ns>/sa/kacho-<svc>) in production. dev (no verified cert) → no-op (insecure back-compat, mirror RelationWriteGate). Пол удовлетворяет ТАКЖЕ хоп собственного REST-фронта службы — лист того же внутреннего центра, чьё имя учётной записи приставки модулей не несёт. Он объявляется корнем (WithOwnFrontHop) и допускается РОВНО к тому, к чему допущен любой проверенный модуль: круга края ниже он не проходит никогда.
- Gateway-only — the gateway-fronted privileged admin RPCs (GatewayFrontedInternalRPCs) may ONLY be called by the api-gateway SA. A direct call from any other module (e.g. a compromised kacho-vpc) → DENY in prod (a data-plane module cannot escalate via :9091). dev → no-op.
- SAN-restricted — a small set of RPCs whose caller must appear on an EXPLICIT, operator-supplied allow-list of client-certificate SPIFFE SANs (WithSANAllowlist). Unlike arms 1-2 this arm is enforced in EVERY mode and an empty/absent allow-list denies EVERYONE (fail-closed: these RPCs have no default caller). Today: InternalBootstrapTokenService/MintBootstrapToken, which hands out a cluster `system_admin` Bearer and therefore cannot be gated by "any verified module cert" — a compromised data-plane module must not be able to mint cluster-admin — nor by a ReBAC relation (it exists to obtain the FIRST token, when no relation exists yet). The credential is the caller's verified certificate identity; network position is NOT a credential (security.md — "internal = trusted" is forbidden).
WHY this replaces the former cert-bound ReBAC interceptor: the api-gateway re-dials :9091 with ITS OWN client cert (SAN .../sa/kacho-api-gateway) and forwards the end-user principal in x-kacho-principal-* metadata. A cert-bound ReBAC Check on the gateway SA would DENY legitimate admin calls (system_admin@ cluster is held by the user, not the gateway SA); ReBAC-ing the forwarded user would couple iam to the gateway's relation map. So iam does NO ReBAC and trusts NO metadata here — it only verifies the caller IS the gateway for admin RPCs.
The fga-proxy write RPCs (RegisterResource / UnregisterResource) are NOT gateway-only — their callers are vpc/compute/nlb MODULE SAs — and stay gated IN-HANDLER by RelationWriteGate (fga_writer), unchanged. They satisfy the floor like any other module RPC.
caller_service.go — SAN→service-short-name helper shared by the caller policy.
ServiceNameFromSAN resolves a verified SPIRE module SAN to its service short-name (e.g. "api-gateway"). The per-RPC CallerPolicy (caller_policy.go) uses it both for the floor (any kacho-<svc> SAN) and for the gateway-only set (svc == "api-gateway"). The former fixed-allow-list gate (CallerServiceGate) has been superseded by the per-RPC CallerPolicy and removed.
fgaproxy.go — the FGA-proxy authz gate.
RegisterResource / UnregisterResource carry `permission = "<exempt>"` in the proto catalog (like every Internal IAM RPC), so least-privilege is NOT expressed as a flat permission-string. It is enforced HERE as ReBAC:
- The mTLS client-cert SAN (SPIRE format spiffe://<trust-domain>/ns/<ns>/sa/kacho-<svc>, extracted by SEC-B's grpcsrv.CertIdentityFromContext under the domain the installation declared) is mapped to a deterministic ServiceAccount id (`'sva' || substr(md5('kacho-<svc>'),1,17)`).
- A ReBAC Check `service_account:<sva>#fga_writer@cluster:cluster_root` is issued. ALLOW → the RPC proceeds; DENY → PermissionDenied. Право выдаётся системной выдачей на кластере (#914) — оно видно перечислением выдач и закрывается отзывом.
Fail-closed: an unverified peer, a malformed / foreign-trust-domain SAN, an unknown SA, or a denied relation all collapse to PermissionDenied. The service→service (mTLS-SA) path never consults `required_acr_min` — ACR-floor is a user-token concern only; the gate decides purely on the ReBAC relation.
principal.go — helper: principal-id-as-string for handlers that need to stamp `created_by` / `reviewer` / similar identity-derived fields from the authenticated caller — never from request-body.
Anti-identity-spoofing: handlers must source these from PrincipalFromContext, not from request fields. PrincipalUserID is the canonical accessor used by sa_keys, jit_eligibility, jit_pending.
public_caller_policy.go — the per-RPC CALLER policy for the PUBLIC listener (:9090). Sibling of caller_policy.go (:9091), same shape, different table.
Why the public listener needs one at all. iam deliberately does NOT re-ReBAC the end user on its own listeners — the api-gateway is the platform's single authZ front door: it validates the JWT, runs per-user ReBAC via iam.Check with the permission catalogue, and only then forwards the resolved identity in x-kacho-principal-* metadata. Everything downstream of that decision therefore acts with WHATEVER identity arrives in those headers.
:9090 is not gateway-only, though. Five consumer services dial it on their request path (ProjectService.Get — project existence + owning account before a Create) and four of them ask it the per-page visibility question behind their own List (AuthorizeService.BatchCheck). All of them present a client certificate from the same internal authority as the gateway, and the port is an ordinary Service inside the namespace with no NetworkPolicy of its own. So before this policy, ANY pod holding an internal-CA certificate could reach ANY public RPC and — since the trust-aware extract believed any verified peer while the forwarder allow-list was empty — do it in a named victim's name: read and mutate that tenant's accounts, projects, groups, roles and grants, and mint personal tokens and service-account keys.
Two layers, and both are needed:
- the forwarder allow-list (authn.trusted-forwarder-sans → corelib WithTrustedForwarders) decides WHO MAY SPEAK FOR A USER at all. It narrows "anyone with a certificate" down to "our modules" — but a module in that list could still speak for a user on EVERY public RPC.
- this policy decides WHO MAY CALL WHICH RPC. It narrows the peers down to the query edges they actually use, so a compromised neighbour cannot reach the credential-issuing and grant-writing surface at all.
Arms (evaluated in order):
- Floor — every public RPC requires a VERIFIED mTLS module certificate (SPIFFE SAN spiffe://kacho.cloud/ns/<ns>/sa/kacho-<svc>) in production. dev (no verified cert) → no-op, mirroring CallerPolicy / RelationWriteGate.
- Gateway — the api-gateway SA may call everything: it is the front door, and the per-user ReBAC decision the whole design rests on happens there.
- Peer-callable — a small, static, MUTATION-FREE table naming, per RPC, which other module SAs may call it. Anything not in it is gateway-only. prod → PermissionDenied; dev → no-op.
The table is STATIC and lives in code, exactly like GatewayFrontedInternalRPCs: membership is a property of the runtime edge (recorded in the polyrepo edge list), not of what an operator happened to configure. A new service that needs a public iam RPC adds itself here, in a reviewed change — a config omission must never widen this.
And membership is a property of an edge that EXISTS. An entry naming a caller with no module in the tree has the same shape as a live one, yet it admits nobody today and admits everything it names on the day someone issues a certificate with that SAN — an open surface reserved for a name. TestPublicPeerCallableRPCs_EveryCallerHasAModuleInTheTree derives the admissible callers from `services/*` and fails on the difference; the allowance for a module returns by itself once the module does.
system_viewer_floor.go — the per-RPC `system_viewer`-FLOOR on the cluster-internal listener's READ RPCs.
The AuthN+AuthZ-everywhere invariant requires EVERY internal RPC (not only public) to pass a per-RPC authz-Check beyond mTLS: read-RPC gate viewer-tier (system_viewer). Today :9091 READ-RPCs are gated ONLY by the coarse mTLS caller-policy floor (CallerPolicy) — no relation-tier Check. This interceptor closes that gap.
For the READ-RPC set (ReadFloorRPCs), it requires the CALLER MODULE ServiceAccount (derived from the verified mTLS cert SAN, the SAME SAN→sva derivation as fgaproxy) to hold a COARSE cluster relation `system_viewer` on the singleton `cluster:cluster_root`, checked via the SAME RelationChecker port used by RelationWriteGate / InternalIAMService.Check.
This is a COARSE "is this a legitimate internal reader" gate (defense-in-depth against a compromised module holding a valid cert but not authorized to read IAM) — NOT a per-resource viewer-check. Per-user authz is the api-gateway's job, run BEFORE it forwards to :9091. The floor never ReBACs the forwarded end-user principal (x-kacho-principal-*) — the subject of the Check is the caller MODULE-SA, not the end user.
Default-OFF / production-mode-only: in dev/newman (no mTLS, FGA-on-internal disabled) the floor is a NO-OP pass-through, byte-identical to today — the same prodMode signal the CallerPolicy / RelationWriteGate use. This keeps the newman E2E stand green.
Fail-closed in production-mode: an unverified/absent SAN → PermissionDenied; a checker/FGA backend error → Unavailable (retryable). Mirrors RelationWriteGate error semantics verbatim.
EXEMPT from the floor (see ReadFloorRPCs / the exemption rationale below):
- InternalIAMService.Check — the PDP. The caller acts on behalf of an end-user, NOT as the subject; floor-gating it on "caller has viewer" would break the core authz path (every downstream Check would deny). It stays on the mTLS-module floor only.
- InternalUserService.OnRecoveryCompleted — Kratos recovery hook, HMAC/secret-authed; Kratos is not a kaname-seeded SA → relation-Check inapplicable. (Hydra token/refresh hooks live on the separate :9092 HTTP listener, not this gRPC chain — N/A by construction.)
- InternalSessionRevocationsService.IsRevoked — курица и яйцо (шло бы до того, как может пойти пер-пользовательская проверка); пер-вызовный поход в движок добавил бы задержку, а его недоступность массово роняла бы обновление токенов. Остаётся на полу mTLS-модуля. ВЫЗЫВАЮЩИЙ У ЭТОГО ПОСЛАБЛЕНИЯ ЕСТЬ, и он ровно тот, ради которого оно заведено: клиент края (`IsSessionRevoked`) спрашивает полосу на КАЖДОМ предъявлении удостоверения, до того как может пойти проверка доступа (#1122). Здесь стояло «вызывающего у метода в дереве нет ни одного (#797)» — утверждение пережило свой предмет и читалось как «послаблению нечего исключать» (#1156). refresh-хук эту проверку по-прежнему НЕ зовёт и пер-jti гейта не несёт вовсе — он прямо это оговаривает (в его теле нет claims предъявленного токена).
- все мутации (Register/Unregister) остаются за fga_writer-// gated; ForceLogout/GrantAdmin/… stay system_admin / gateway-only) — this is a READ floor; the mutation surface is unchanged.
Index ¶
- Constants
- Variables
- func AllowsVGet(ctx context.Context, checker RelationChecker, fgaType, id string) (bool, error)
- func AllowsVerb(ctx context.Context, checker RelationChecker, relation, fgaType, id string) (bool, error)
- func AntiAnonymousStream(logger *slog.Logger) grpc.StreamServerInterceptor
- func AntiAnonymousUnary(logger *slog.Logger) grpc.UnaryServerInterceptor
- func AuthzBackendUnavailable() error
- func CallerAuthorityGatedMethods() []string
- func ClusterObject() string
- func DenyDetailUnary(catalog DenyActionLookup) grpc.UnaryServerInterceptor
- func GatewayFrontedInternalRPCs() []string
- func HumanUserID(ctx context.Context) string
- func IsAnonymous(ctx context.Context) bool
- func IsClusterAdmin(ctx context.Context, checker RelationChecker) bool
- func IsClusterAdminE(ctx context.Context, checker RelationChecker) (bool, error)
- func IsSelf(ctx context.Context, targetID string) bool
- func OwnDoorProtoPackages() []string
- func PermissionDenied() error
- func PrincipalSubject(ctx context.Context) (string, bool)
- func PrincipalUserID(ctx context.Context) string
- func PublicPeerCallableRPCs() map[string][]string
- func ReadFloorRPCs() []string
- func RequireAuthenticated(ctx context.Context) error
- func RequireScopeRelation(ctx context.Context, checker RelationChecker, ...) error
- func SANRestrictedInternalRPCs() []string
- func SANToServiceAccountID(d grpcsrv.TrustDomain, san string) (string, bool)
- func SANToServiceDomain(d grpcsrv.TrustDomain, san string) (string, bool)
- func ServiceAccountIDForService(svc string) string
- func ServiceNameFromSAN(d grpcsrv.TrustDomain, san string) (string, bool)
- func SubjectFromPrincipal(p operations.Principal) (string, bool)
- func SubjectIsClusterAdmin(ctx context.Context, checker RelationChecker, subject string) bool
- func SubjectIsClusterAdminE(ctx context.Context, chk ContextRelationChecker, subject string) (bool, error)
- func SubjectIsClusterAdminPlainE(ctx context.Context, checker RelationChecker, subject string) (bool, error)
- type ACRFloor
- type ACRRequirementLookup
- type CallerPolicy
- type ContextRelationChecker
- type CredentialPresence
- type DenyActionLookup
- type OwnDoor
- type OwnDoorOptions
- type PublicCallerPolicy
- type RelationChecker
- type RelationWriteGate
- type SystemViewerFloor
Constants ¶
const BootstrapMintFullMethod = "/kaname.cloud.iam.v1.InternalBootstrapTokenService/MintBootstrapToken"
BootstrapMintFullMethod — InternalBootstrapTokenService/MintBootstrapToken, the SAN-restricted cluster-admin token mint (arm 3). Exported so the composition root can key its allow-list without re-spelling the FQN.
const ( // RelationWriteRelation — отношение, которое обязана держать служебная учётка // модуля, чтобы писать кортежи через iam. Экспортировано намеренно: пробы и // перепись обязаны спрашивать ТО ЖЕ имя, которое спрашивает гейт, — имя, // выписанное на стороне пробы, остаётся зелёным, когда гейт спрашивает другое. RelationWriteRelation = "fga_writer" )
const UnnamedCallerMessage = "no credential presented; authenticate with authorization: Bearer <token>"
UnnamedCallerMessage — ЕДИНСТВЕННЫЙ текст отказа тому, кто не назвался ничем.
Почему отдельный текст, а не «permission denied» ¶
Отказ существует затем, чтобы вызывающий построил следующий шаг. «Не пускают» предлагает просить прав; «назовись» называет действие, которое вызывающий может совершить сам. Для арендатора чужого облака, у которого нашего края нет by construction, второе — единственный исполнимый ответ: прав он попросить не у кого, а удостоверение выдаём мы.
Почему это не оракул ¶
Различаются здесь два состояния ЗАПРОСА, а не сервера, и оба известны самому вызывающему до ответа: он либо приложил удостоверение, либо нет. Отказ ПРЕДЪЯВИВШЕМУ негодное остаётся единственным побайтово равным текстом (`presentedcred.RefusalMessage`) — там различимость и правда сообщила бы, какая половина предъявленного неверна.
Текст — часть контракта, и он экспортирован затем, чтобы проба утверждала ЕГО, а не свою копию.
Variables ¶
var MutateRelations = []string{"editor", "admin"}
MutateRelations — FGA relations that grant authority to mutate a resource. `editor` is sufficient for Update; `admin` (a superset) is accepted too.
Functions ¶
func AllowsVGet ¶
AllowsVGet reports whether the ctx principal may read the object `<fgaType>:<id>`: it is a cluster-admin (flat super-gate, D-9) OR it holds the `v_get` relation on the object (owner-binding materializes it for the owner; an explicit `iam.<res>.get` grant materializes it for a delegate).
(false, nil) is a decision (hide existence); (false, err) means the decision could not be made (map to UNAVAILABLE, never to NotFound).
fgaType is the rights-model object_type (e.g. "account", "project", "iam_user"); id is the bare resource id (no type prefix). The object string is composed as `<fgaType>:<id>` — the SAME object the reconciler materializes and the gateway Check's against.
func AllowsVerb ¶
func AllowsVerb(ctx context.Context, checker RelationChecker, relation, fgaType, id string) (bool, error)
AllowsVerb is the generic verb-bearing read-authorization gate: cluster-admin super-gate OR the ctx principal holds `relation` on `<fgaType>:<id>`. AllowsVGet is the get-specialization (relation == "v_get"). Kept generic so a future read path (e.g. a v_list object-existence probe) reuses the same fail-closed posture.
An allow is returned as soon as ONE question answers yes, even if the other failed — an allow needs no second opinion. A non-allow is only reported as a decision when EVERY question this gate asked was actually answered; otherwise the error of the unanswered one is returned.
func AntiAnonymousStream ¶
func AntiAnonymousStream(logger *slog.Logger) grpc.StreamServerInterceptor
AntiAnonymousStream — symmetric stream-RPC interceptor (same policy).
func AntiAnonymousUnary ¶
func AntiAnonymousUnary(logger *slog.Logger) grpc.UnaryServerInterceptor
AntiAnonymousUnary — gRPC unary server interceptor. Policy: default-deny anonymous unless (a) FullMethod is in whitelistFullMethod, or (b) method-name ends in a read-only suffix (Get/List/Watch/Resolve/…). Everything else (Create/Update/Delete/Approve/Deny/Issue/Revoke/Generate/ Cancel/Activate/…) is rejected for anonymous principals.
func AuthzBackendUnavailable ¶
func AuthzBackendUnavailable() error
AuthzBackendUnavailable — canonical answer for "the model could not be asked".
It is NOT PermissionDenied, and the difference is the whole point: a refusal says "you may not", which tells the caller that retrying is pointless — the decision depends on (subject, relation, object) and an identical retry changes none of the three. A backend outage says nothing about rights at all; the same question a moment later gets an answer. Collapsing the two hands a caller a terminal verdict on a transient flap, and callers that classify peer answers by lane (outbox drainers, reconcilers, peer clients) will mark the intent permanently failed.
Fail-closed is unchanged either way: the request is refused, nothing runs. Only the code differs, and the code is the whole signal.
The text carries no detail of the backend failure (security.md §Hardening-инварианты п.1 — no leak); the raw error is logged, not surfaced. Single source for the three gates that already answered this way inline (RelationWriteGate, SystemViewerFloor, scope) and for those that used to collapse it into a refusal.
func CallerAuthorityGatedMethods ¶
func CallerAuthorityGatedMethods() []string
CallerAuthorityGatedMethods — RPC, которыми у модели СПРАШИВАЮТ, а не действуют: «можно ли субъекту S сделать A с объектом O».
Почему у них нет ОБЪЕКТА, о котором спросить ¶
Субъект такого RPC стоит В ЗАПРОСЕ, а вызывающий — модуль: край на пути каждого запроса и сервисы-соседи при сужении списочной выдачи. Вопрос задаётся ДО того, как решение о доступе принято, и задаёт его не тот, о ком спрашивают, поэтому единого объекта, которым дверь могла бы их гейтить, не существует: у `Check` это и объявлено контрактом (`scope_filtered`).
ЛИЧНОСТЬ АРЕНДАТОРА У НИХ ПРИ ЭТОМ БЫВАЕТ, и здесь стояло обратное («её нет by construction»). Сужатель списочной выдачи зовёт соседа под личностью ИНИЦИАТОРА — `pkg/listnarrow/client.go` передаёт `auth.PropagateOutgoing(ctx)`, — так что на пути «арендатор перечисляет свою страницу» вызывающий назван всегда. Разбор следствия и цена — в разделе о безусловности поправки ниже.
Кто их решает — ОДИН, и он назван ¶
`api/authorize/caller_authority.go` — единственный решатель вопроса «кто вправе спрашивать», и его шапка это заявляет прямо. Он строго fail-closed: вызывающий без личности проходит ТОЛЬКО с проверенным сертификатом модуля (`authorizeAnonymousPeer`), арендатор — только про себя, про объект, которым администрирует, либо будучи администратором кластера. Соседний рубеж того же слушателя (`AntiAnonymousUnary`) вычитает эти RPC по той же причине.
Дверь для них добавляла ровно одно требование — «вызывающий назван», — и именно оно неверно: модуль называет себя сертификатом, а не заголовком личности. Наблюдалось: пять шардов сквозных проб, у каждого 40+ отказов `authz_no_principal` на `AuthorizeService/Check`, при нуле таких записей на стволе; посев умирал на первой же мутации, потому что ОТКАЗОМ становилось всякое решение о доступе на стенде.
Круг узок намеренно, и у каждой записи назван ПРОИЗВОДИТЕЛЬ ¶
- `Check` — край, `gateway/internal/clients/iam_authorize_client.go` (вопрос на пути каждого запроса);
- `BatchCheck` — сужатель списочной выдачи, `pkg/listnarrow/client.go` (вопрос о странице у сервисов-соседей).
`ListSubjects` и `ExpandRelations` тот же обработчик судит тем же гейтом, но производителя вне iam у них в этом дереве НЕТ (предикат: `git grep -l 'ExpandRelations\|ListSubjects' -- gateway pkg services ':!services/iam'`), поэтому записи им здесь не заводится: освобождение заводится ВМЕСТЕ с тем, что оно освобождает, а не вперёд него. Появится производитель — запись приедет вместе с ним.
Поправка живёт В ЗВЕНЕ, а не в карте — и это решение, а не вкус ¶
Карту двери судит перепись публичной поверхности (`internal/publicauthzcensus`), и её шапка объявляет: перепись обязана читать ТУ ЖЕ карту, которую звено спрашивает в проде. Перекрой мы здесь выведенную запись — перепись назвала бы эти два RPC освобождёнными КОНТРАКТОМ, чего контракт не объявлял; получилось бы второе место об одном предмете, и разошлись бы они молча. Карта остаётся выведенной, а поправка стоит там, где её видно как отдельное звено, — рядом с двумя другими рубежами того же слушателя.
Поправка снимает дверь там, где у неё НЕТ ОБЪЕКТА, — и таких случаев ДВА ¶
Здесь стояло «дверь снимается ТОЛЬКО когда личности арендатора нет вовсе», а основанием ему служил абзац выше — «личности арендатора в их исходящем контексте нет by construction». ОСНОВАНИЕ НЕВЕРНО, и это измерено: сужатель списочной выдачи зовёт соседа через `auth.PropagateOutgoing(ctx)` (`pkg/listnarrow/client.go`), а тот дописывает принципала в исходящие метаданные, когда он непуст. На пути «арендатор перечисляет свою страницу» принципал непуст ВСЕГДА, то есть вызывающий назван, — и снятие по первому случаю к нему не относилось.
Для `Check` это не значило ничего: контракт объявляет его `scope_filtered`, и звено выходит до вопроса об объекте при любой личности. Для `BatchCheck` значило всё — он несёт `required_relation: viewer` и `scope_extractor{project, scope_id}`, поэтому названный вызывающий доходил до вопроса об объекте. Объекта у ЭТОГО вопроса нет: `scope_id` контракт объявляет НЕОБЯЗАТЕЛЬНЫМ дословно («Optional scope id for authz of the batch as a whole. When set, the gateway gates the entire batch on this scope's `iam.authorize.batchCheck` permission instead of per-item»), и не заполняет его ни один вызывающий в дереве — предикаты: `git grep -n 'ScopeId' -- pkg/listnarrow` → пусто, `git grep -n 'ScopeId:' -- . | grep -v _test` → среди попаданий нет ни одного `BatchAuthorizeCheckRequest`. Выведенный извлекатель читает поле как заданное всегда и отдаёт `project:` с пустым идентификатором; пустой отвергается `FormatObject` ДО вопроса к модели, то есть и до плоского надзора администратора облака, который живёт внутри `checkAdapter.Check`. Прежняя редакция называла эту полосу штатной («по объекту у `BatchCheck`»), тогда как пройти её нельзя было НИКОГДА.
Наблюдалось на голове `d1c7a4a89b`: журнал звена называл причину прямо — `authz_object_format_failed err="authz: empty object id"`, — край отдавал 503 на `GET /vpc/v1/securityGroups`, консоль показывала «Сервер не смог ответить · list filter: AuthorizeService.BatchCheck PermissionDenied», а СПИСКИ ВСЕХ РЕСУРСОВ ПРОЕКТА были пусты у каждого арендатора. В шарде vpc этим объясняются все 123 отказа (118 прямых + 5 каскадом), в шарде iam — ещё 4; пять сквозных проб консоли падали тем же корнем. Сужение страницы ломалось у КАЖДОГО соседа, который его делает.
Почему снятие привязано к ОТСУТСТВИЮ ОБЪЕКТА, а не сделано безусловным ¶
Починку писали две полосы одной волны, и разошлись они ровно в одном: снимать ли дверь и с запроса, НАЗВАВШЕГО `scope_id`. Измеренный дефект такого запроса не касается — у него объект ЕСТЬ, и вопрос `viewer @ project:<scope_id>` есть ровно тот, который контракт для этого поля и объявляет. Снять гейт заодно значило бы принять поле контракта и на него не смотреть, а послабление выдать ВПЕРЁД производителя, которого сегодня нет: запас здесь — не осторожность, а слепая зона, в которую уедет первый же настоящий вызывающий.
Поэтому снятие привязано к тому, что измерено: извлекатель отдал ПУСТОЙ идентификатор — спрашивать не о чем. Три исхода извлечения разведены в `questionNamesNoScope`, и средний, «извлечение отказало», двери НЕ снимает: неполученный ответ не есть «спрашивать не о чем».
Что остаётся и что не трогается ¶
Запрос, назвавший `scope_id`, проходит дверь как прежде: объект есть, и гейт контракта на нём осмыслен. `Check` не затронут вовсе — он объявлен `scope_filtered`, извлекателя у него нет by construction, и его полосой остаётся полоса данных.
Второго рубежа этим RPC не нужно ТАМ, ГДЕ ОБЪЕКТА НЕТ: `authorizeCaller` полон для ОБОИХ видов вызывающего — безымянный проходит только проверенным сертификатом модуля, названный арендатор — про себя, про объект, которым администрирует, либо будучи администратором кластера, — и он fail-closed. Дверь добавляла им не второе мнение, а вопрос об объекте, которого у вопроса о правах нет.
Порог свежести подтверждения (`required_acr_min`) поправка НЕ трогает: его держит отдельный страж того же слушателя (`acr_floor.go`), а не это звено.
func ClusterObject ¶
func ClusterObject() string
ClusterObject — the singleton every cluster-tier tuple hangs off. Named once so a caller composing the question by hand cannot spell it differently.
func DenyDetailUnary ¶
func DenyDetailUnary(catalog DenyActionLookup) grpc.UnaryServerInterceptor
DenyDetailUnary returns an interceptor that attaches the machine-readable reason to a refusal that does not already carry one.
What it does NOT attach, and why:
- the subject — the caller already knows who it is, and echoing it adds nothing a client can act on;
- the resource — on a data-filtered method there is no single resource by construction; naming one would be a claim the service cannot make. The edge fills that field only where it resolved a scope object.
WHAT IT ALSO ATTACHES, AND WHY THAT IS NOT AN ORACLE ---------------------------------------------------- A refusal exists so the caller can build the NEXT STEP. A bare one does not restore it: the administrator learns neither which permission to request nor from whom. So the refusal carries the step — in the DETAILS, never in the prose. The message stays the verbatim "permission denied" on every refusal, byte for byte, because a distinguishable text is exactly the existence oracle the fixed wording exists to close.
The form is not invented here: it is the one the step-up refusal already uses (acr_floor.go — a PreconditionFailure violation the edge turns into an RFC 9470 challenge). Type "authz.grant_required", Subject = the scope object type, Description = the permission to ask for and where it is granted.
A refusal that ALREADY names its own next step (the step-up one) gets no second advice: the caller there may hold the grant in full, and "ask for the permission" would send them the wrong way.
A method with no catalog row gets NOTHING attached. That is the whole point: an absent action is how a caller recognises a catalog miss, so inventing an empty one would erase the distinction this exists to create.
func GatewayFrontedInternalRPCs ¶
func GatewayFrontedInternalRPCs() []string
GatewayFrontedInternalRPCs returns the full-method set of internal RPCs that the api-gateway fronts on behalf of an end user (admin UI / admin tooling). These privileged RPCs may ONLY be called by the api-gateway SA — a direct call from any other module is a privilege-escalation attempt.
NOT in this set (any verified module — floor only):
- InternalIAMService/{Check,LookupSubject,PollSubjectChanges} — hot-path service→service RPCs.
- InternalSessionRevocationsService/IsRevoked — заведено под собственный запрос края, идущий до того, как может пойти пер-пользовательская проверка (курица и яйцо). ПРЕДМЕТ У ПОСЛАБЛЕНИЯ ЕСТЬ: клиент края экспонирует `IsSessionRevoked`, и он провязан в слой аутентификации (`middleware.NewLocalThenProviderRevocation`, #1122) — то есть край спрашивает эту полосу на каждом предъявлении удостоверения. Здесь стояло «этого вызывающего в дереве СЕГОДНЯ НЕТ (#797)»: утверждение пережило свой предмет (#1156). Исчезнет метод у края — снимать и эту строку.
- InternalUserService/Get — service→service lookup.
- Hydra hook callbacks are not in this set and cannot be: they are served over HTTP by internal/handler/iamhooks, not as gRPC methods. The gRPC declaration that once mirrored them (InternalIamHooksService) had no implementation and was retired — see retiredRPCSurface in internal/repohygiene.
- the fga-proxy writes InternalIAMService/{RegisterResource, UnregisterResource} — gated in-handler by RelationWriteGate (module SAs). The third one, WriteCreatorTuple, was retired with zero callers (#788).
func HumanUserID ¶
HumanUserID returns the principal's id ONLY when the principal is a human user; "" for a service account, a system/bootstrap principal, an unknown type, anonymous, or an empty ctx.
Use this — NOT PrincipalUserID — for a column that is a foreign key into `users(id)` (`users.invited_by` and anything like it). The distinction is not cosmetic: PrincipalUserID deliberately answers for machine and system principals too, so a caller that wants "the user" and reaches for the user-shaped name gets `sva…`/`bootstrap` back and writes it into a column where no such row can exist. That is a constraint violation at insert time, surfaced to the caller as the unmapped-FK fallback text, which names neither the column nor the cause.
A non-user principal is not an error here and must not be turned into one: it is a legitimate actor with no inviting/creating USER to record. Callers leave the column NULL and rely on the Operation's `principalType`/`principalId` for attribution, which is where a non-user actor belongs.
func IsAnonymous ¶
IsAnonymous — true if the principal is anonymous / empty / system+anonymous / system+bootstrap-fallback.
api-gateway injects anonymous as {Type:"system", ID:"anonymous"}; api-gateway may also fail to forward principal-headers entirely — in that case PrincipalFromContext returns SystemPrincipal{Type:"system", ID:"bootstrap"} (fallback). In a gRPC handler both cases = anonymous.
**Internal background-job bootstrap** uses `WithPrincipal` directly with a concrete {Type, ID} — never the empty-ctx fallback. So rejecting system/bootstrap here is safe.
func IsClusterAdmin ¶
func IsClusterAdmin(ctx context.Context, checker RelationChecker) bool
IsClusterAdmin reports whether the ctx principal holds the flat cluster super-admin relation. fail-closed: nil checker / anonymous / empty id / Check error → false.
func IsClusterAdminE ¶
func IsClusterAdminE(ctx context.Context, checker RelationChecker) (bool, error)
IsClusterAdminE is IsClusterAdmin with the reason kept: it separates "the store answered: not an admin" (false, nil) from "the store could not be asked" (false, err).
Когда обязателен именно он ¶
Всюду, где несработавший супер-гейт МЕНЯЕТ наблюдаемый ответ, а не просто «не срабатывает». Списочный путь — главный такой случай: проглоченная неполадка отдаёт well-formed `200` с молча суженной страницей, которую вызывающий не отличит от отзыва прав. Ровно это и требует godoc SubjectIsClusterAdminPlainE ниже; булева обёртка остаётся для мест, где обычная пообъектная полоса всё равно отработает и сама сообщит о неполадке.
func OwnDoorProtoPackages ¶
func OwnDoorProtoPackages() []string
OwnDoorProtoPackages — пакеты контракта, чьи службы iam поднимает на своих слушателях.
Перечень ЗАКРЫТ и обязан покрывать всё, что регистрируется: карта тотальна над названными пакетами, а RPC из НЕ названного пакета в неё не попадает вовсе и отвергается как незамапленный. То есть пропуск пакета здесь — не послабление, а отказ обслуживать его целиком; ошибка громкая, и это выбрано намеренно.
func PermissionDenied ¶
func PermissionDenied() error
PermissionDenied — canonical PermissionDenied gRPC error (Kachō error text).
func PrincipalSubject ¶
PrincipalSubject is the ctx variant of SubjectFromPrincipal: it reads the principal from ctx and returns its FGA subject. Anonymous / empty ctx → ("", false) — fail-closed, the same posture as the FGA Check guards that consume it.
func PrincipalUserID ¶
PrincipalUserID returns the principal's user-id for user / service-account / system-bootstrap principals; empty string for anonymous or empty ctx.
Use this when writing DB rows or audit-log entries that must carry the authenticated caller's id. Never trust a request-body field for these.
NOTE the name is wider than it reads: the returned id is NOT necessarily a `users(id)`. For a foreign key into that table use HumanUserID above.
Bootstrap-principal (system/bootstrap) is treated as a legitimate identity so internal seeds / migrations / fixtures continue to work; the audit row carries `created_by="bootstrap"` which is correct.
func PublicPeerCallableRPCs ¶
PublicPeerCallableRPCs returns the public RPCs a NON-gateway module may call, mapped to the exact module service short-names permitted to call each.
Every entry is a QUERY, and that is a rule rather than a coincidence: a mutating RPC here would hand a neighbouring service the ability to change tenant data in a forwarded user's name, which is the very hole this policy closes. The lock TestPublicPeerCallableRPCs_CarryNoMutation enforces it against the proto itself — in Kachō a mutation is exactly an RPC that returns an Operation, so the check reads the contract rather than a naming habit.
The api-gateway is deliberately absent — it is admitted by its own arm, and naming it here too would create a second source of truth that drifts.
Membership is derived from the runtime edges, not from a name search:
- ProjectService/Get — vpc, compute, nlb, storage and registry each hold a ProjectServiceClient pointed at iam's PUBLIC address and call Get on the request path of their own Create (project existence + owning account).
- AuthorizeService/BatchCheck — the per-page visibility filter in vpc/compute/nlb/storage (internal/authzfilter, the sole AuthorizeService method any of them calls). Its edge belongs on the internal listener and most profiles put it there, but a profile pointing it at the public address exists, and a denial there would fail the filter closed and empty a tenant's own List.
func ReadFloorRPCs ¶
func ReadFloorRPCs() []string
ReadFloorRPCs returns the full-method set of cluster-internal READ RPCs that must pass the `system_viewer@cluster` floor. Membership is the single source of truth for the floor; the exemption set is expressed by ABSENCE from this list and asserted by TestReadFloorRPCs_Membership.
NOT in this set (exempt — see the package doc-comment for the rationale):
- InternalIAMService/Check — PDP, never floor-gated.
- InternalUserService/OnRecoveryCompleted — Kratos secret-authed hook.
- InternalSessionRevocationsService/IsRevoked — курица и яйцо: клиент края (`IsSessionRevoked`) спрашивает её до того, как может пойти проверка доступа (#1122).
- InternalIAMService/{RegisterResource,UnregisterResource} — fga_writer-gated mutations.
- ForceLogout / Cluster GrantAdmin/RevokeAdmin / Authorize WriteTuples/ReloadModel / SessionRevocations Revoke / UpsertFromIdentity — gateway-only / admin-tier mutations.
func RequireAuthenticated ¶
RequireAuthenticated returns PermissionDenied if the principal in ctx is anonymous or absent. Only `user` and `service_account` are passed through.
system/bootstrap is REFUSED, exactly as the package comment says: that pair is what PrincipalFromContext returns when the request carried no principal at all, so admitting it would turn "the edge forwarded nothing" into a privileged identity. The previous edition of this sentence listed it among the passed-through principals — two statements about one subject, of which one was true — and the false one was read as a licence: three listing use-cases carried an "unfiltered page for the bootstrap identity" branch that no input could reach, because IsAnonymous had already refused (#648).
Message text is the Kachō canonical `"permission denied"` (no leak of internal details).
IMPORTANT: kacho-api-gateway injects an anonymous request as Principal{Type:"system", ID:"anonymous"} (see auth.go:189 — injectAnonymous). A principal with ID="anonymous" MUST NOT have privileges — despite type=system. Both fields are checked.
func RequireScopeRelation ¶
func RequireScopeRelation( ctx context.Context, checker RelationChecker, scopeType, scopeID, ownerUserID string, relations ...string, ) error
RequireScopeRelation — defense-in-depth authority gate for a mutating use-case. Authority is granted when EITHER:
- the principal owns the owning Account (ownerUserID, bootstrap path), OR
- the principal holds one of `relations` on the FGA scope object (delegated administration — e.g. a project-editor who is not the owner).
`scopeType`/`scopeID` identify the FGA object (`project:<id>`, `account:<id>`). `ownerUserID` is the owning Account's owner_user_id (may be empty for scopes without an owning Account — then only the FGA path applies).
When `checker` is nil (unit tests / degraded mode) the guard falls back to owner-only and DENIES non-owners — fail-closed, never fail-open.
func SANRestrictedInternalRPCs ¶
func SANRestrictedInternalRPCs() []string
SANRestrictedInternalRPCs returns the full-method set gated by arm 3 — an explicit client-certificate SPIFFE SAN allow-list.
The SET IS STATIC, deliberately independent of configuration: membership is a property of the RPC, not of what an operator happened to configure. A deployment that never supplies an allow-list must end up with the mint DENIED to everyone, not silently downgraded to the "any verified module cert" floor — a config omission must never open a cluster-admin mint.
func SANToServiceAccountID ¶
func SANToServiceAccountID(d grpcsrv.TrustDomain, san string) (string, bool)
SANToServiceAccountID maps a verified SPIRE SAN to the deterministic module ServiceAccount id.
func SANToServiceDomain ¶
func SANToServiceDomain(d grpcsrv.TrustDomain, san string) (string, bool)
SANToServiceDomain maps a verified SPIRE SAN to the module service short-name (the domain: `vpc`/`compute`/`nlb`). Accepts only `spiffe://<trust-domain>/ns/<ns>/sa/kacho-<svc>` with a non-empty <svc>; any other shape returns ("", false). The domain drives object-type binding in ValidateProxyTuple (a vpc module may only register `vpc_*` objects).
Почему домен доверия — АРГУМЕНТ, а не константа ¶
Домен объявляет установка, и разбор обязан спрашивать ТОТ ЖЕ домен, который впустил эту личность (`grpcsrv.CertIdentityDomainFromContext`). Пока он стоял здесь литералом, разбор утверждал о домене независимо от того, под каким доменом установка выпускает сертификаты, — и утверждал бы это молча.
Проверка домена здесь остаётся не как «второй независимый слой», а как требование к ВХОДУ: строка, не прошедшая извлекатель, сюда попасть не должна, и если попадёт — не будет принята. Называть это защитой в глубину было бы неточно: предикат и величина у обоих слоёв одни.
Необъявленный домен не признаёт своим никого (`TrustDomain.Matches`), поэтому нулевое значение здесь фейл-клоуз.
func ServiceAccountIDForService ¶
ServiceAccountIDForService derives the deterministic module SA id from a service short-name (`'sva' || substr(md5('kacho-<svc>'),1,17)`). Single source of truth shared by the gate and the seed migration helper.
Формула ОДНА на дерево и живёт в `domain.DerivedIDSuffix`: своя копия здесь разошлась бы с постгресовой молча — полученный идентификатор остался бы синтаксически верным и перестал бы находить строку.
func ServiceNameFromSAN ¶
func ServiceNameFromSAN(d grpcsrv.TrustDomain, san string) (string, bool)
ServiceNameFromSAN extracts the module service short-name from a verified SPIRE SAN (`spiffe://<trust-domain>/ns/<ns>/sa/kacho-<svc>` → `<svc>`). Returns ("", false) for any other shape.
Одна реализация, а не две ¶
Здесь стояла ПОБАЙТОВАЯ КОПИЯ тела SANToServiceDomain — двадцать строк, повторяющих тот же разбор. Расходятся такие копии молча: правка одной не доезжает до другой, и вторая продолжает принимать то, что первая уже отвергает. Свели их вместе с переводом домена доверия в величину — иначе литерал пришлось бы снимать дважды, а копия осталась бы поводом завести его снова.
func SubjectFromPrincipal ¶
func SubjectFromPrincipal(p operations.Principal) (string, bool)
SubjectFromPrincipal builds the FGA subject string for a principal: `user:<id>` for users, `service_account:<id>` for service accounts. It is the single source of truth consolidating the previously-inline (`subjType:="user"; if p.Type=="service_account" {…}; subject:=t+":"+id`) copies scattered across the authz call-sites (#10).
Fail-closed: an unknown principal type, or an empty id, yields ("", false). This is STRICTLY SAFER than the inline copies it replaces, which defaulted unknown types to "user:" (a latent over-grant). Callers must treat ok=false as "no resolvable subject → deny".
func SubjectIsClusterAdmin ¶
func SubjectIsClusterAdmin(ctx context.Context, checker RelationChecker, subject string) bool
SubjectIsClusterAdmin is the subject-string variant of IsClusterAdmin — used by authorize_service.Check, whose request already carries a pre-formatted FGA subject ("user:usr_xxx" / "service_account:sva_xxx") rather than a ctx principal. fail-closed: nil checker / empty subject / Check error → false.
func SubjectIsClusterAdminE ¶
func SubjectIsClusterAdminE(ctx context.Context, chk ContextRelationChecker, subject string) (bool, error)
SubjectIsClusterAdminE answers "is this caller a cluster administrator" and keeps the reason.
(false, nil) is an ANSWER — the store said no. (false, err) means the question was not answered, and the caller must refuse (UNAVAILABLE) rather than narrow.
A nil checker or an unresolvable subject yields (false, nil): those are not outages, they are callers this gate does not apply to, and the list decides what to do about them before it gets here.
func SubjectIsClusterAdminPlainE ¶
func SubjectIsClusterAdminPlainE(ctx context.Context, checker RelationChecker, subject string) (bool, error)
SubjectIsClusterAdminPlainE is SubjectIsClusterAdmin with the reason kept: it separates "the store answered: not an admin" (false, nil) from "the store could not be asked" (false, err). Gates that turn a non-allow into a 404 need that distinction — see AllowsVerb. The bool-only wrappers above stay for the decision sites that are ALREADY fail-closed by construction (a super-gate that cannot be evaluated simply does not fire, and the ordinary per-object path still runs and still reports its own outage).
Which of the two E-variants to call ¶
This one and SubjectIsClusterAdminE (subject_question.go) ask the SAME question — same relation, same singleton — and differ only in the port they ask it through: this one over RelationChecker (plain `Check`), that one over the context-carrying `CheckWithContext`. Call the one matching the port the use-case already holds; do not wire a second port to reach the other.
A list whose page is a page of the visible must use an E-variant: swallowing this failure there produces a well-formed, silently narrowed `200` that the caller cannot tell from a revocation (task #645, acceptance §3.6).
Types ¶
type ACRFloor ¶
type ACRFloor struct {
// contains filtered or unexported fields
}
ACRFloor enforces the gateway-fronted `required_acr_min` step-up floor on the internal listener. Construct via NewACRFloor.
func NewACRFloor ¶
func NewACRFloor(catalog ACRRequirementLookup, gatewayFrontedRPCs []string) *ACRFloor
NewACRFloor builds the floor over the gateway-fronted RPC set. Defaults to dev-mode (no-op); use WithProductionMode to enable strict enforcement.
func (*ACRFloor) Stream ¶
func (f *ACRFloor) Stream() grpc.StreamServerInterceptor
Stream returns the stream interceptor enforcing the acr-floor.
func (*ACRFloor) Unary ¶
func (f *ACRFloor) Unary() grpc.UnaryServerInterceptor
Unary returns the unary interceptor enforcing the acr-floor.
func (*ACRFloor) WithProductionMode ¶
WithProductionMode toggles strict fail-closed enforcement (production AuthN).
type ACRRequirementLookup ¶
ACRRequirementLookup — narrow port resolving an RPC's `required_acr_min` from the permission catalog. The key is the catalog FQN (NO leading slash, e.g. "kaname.cloud.iam.v1.InternalClusterService/GrantAdmin"); an unknown FQN or an RPC without an acr requirement returns "". Satisfied by an adapter over seed.PermissionRegistry in the composition root; a fake in tests.
type CallerPolicy ¶
type CallerPolicy struct {
// contains filtered or unexported fields
}
CallerPolicy enforces the per-RPC caller policy on the internal listener. Construct via NewCallerPolicy.
func NewCallerPolicy ¶
func NewCallerPolicy(prodMode bool, gatewayOnlyRPCs []string) *CallerPolicy
NewCallerPolicy builds the caller policy. gatewayOnlyRPCs is the set of full-method names restricted to the api-gateway SA (see GatewayFrontedInternalRPCs). prodMode comes from cfg.AuthN.Mode.IsProduction().
The arm-3 METHOD set is static (SANRestrictedInternalRPCs); WithSANAllowlist only supplies WHICH certificate identities may call them. A policy built without WithSANAllowlist therefore denies every caller of those methods.
func (*CallerPolicy) Stream ¶
func (p *CallerPolicy) Stream() grpc.StreamServerInterceptor
Stream returns the stream interceptor enforcing the caller policy.
func (*CallerPolicy) Unary ¶
func (p *CallerPolicy) Unary() grpc.UnaryServerInterceptor
Unary returns the unary interceptor enforcing the caller policy.
func (*CallerPolicy) WithOwnFrontHop ¶
func (p *CallerPolicy) WithOwnFrontHop(san string) *CallerPolicy
WithOwnFrontHop объявляет имя клиентского листа, которым СОБСТВЕННЫЙ REST-фронт службы приходит к этому же слушателю, и тем самым признаёт его ХОПОМ, а не вызывающим.
Предмет: сертификат на этом хопе называет НЕ ЗВОНЯЩЕГО ¶
Фронт — обычный клиент своего же слушателя, и представляется он листом самой службы. Учётная запись у службы своя, приставки платформенных модулей она не несёт, поэтому ServiceNameFromSAN на такой строке не срабатывает — и пол (рукав 1) отвергал КАЖДЫЙ запрос, пришедший через фронт, в боевой посадке. На стенде рукав вырождается целиком, поэтому расхождение было невидимо ровно там, где его ищут.
Что этот хоп получает — РОВНО ПОЛ, и ни одним методом больше ¶
Вопрос пола — «стоит ли за запросом проверенный лист нашего внутреннего центра». На хопе фронта ответ ДА, и он получен на хоп раньше: в боевой посадке фронт поднимается только с требованием проверенного клиентского сертификата, а страж посадки отказывает в старте, когда это не так (`requireInternalRESTMutualClientAuth`). Тот же класс удостоверения, тот же внутренний центр — просто предыдущее звено.
Вопрос ВТОРОГО рукава — «этот вызывающий есть край» — на хопе НЕ ОТВЕЧАЕМ, и ответ его не сохраняет: фронт не переносит внутрь ни одного заголовка запроса, а внутренний слушатель предъявленного удостоверения не читает вовсе. Поэтому хоп краем не становится: короткое имя службы у него остаётся пустым, и круг края отвергает его ровно так же, как отверг бы соседний модуль. Иначе полоса HTTP оказалась бы ШИРЕ полосы gRPC — держатель любого листа внутреннего центра дотянулся бы через фронт до глаголов, которые ему на gRPC запрещены.
Третий рукав хоп тоже не смягчает: он стоит выше и терминален, а чеканка маршрута HTTP не имеет вовсе.
Итог одной фразой: хоп фронта допускается ТОЧНО К ТОМУ, к чему допущен любой проверенный модуль, — и это утверждается равенством множеств, а не комментарием.
Почему величина приходит извне, а не выводится разбором ¶
Разбор судил бы ФОРМУ имени, то есть допускал бы КЛАСС строк. Здесь допущена одна личность — наша собственная, — и предъявить её может только держатель нашего же ключа. Соседний модуль держит свой и этой строкой назваться не может. Корень собирает величину из того сертификата, который фронт ФАКТИЧЕСКИ предъявляет, поэтому разойтись с проводом ей нечем.
func (*CallerPolicy) WithSANAllowlist ¶
func (p *CallerPolicy) WithSANAllowlist(perRPC map[string][]string) *CallerPolicy
WithSANAllowlist supplies the arm-3 allow-list: fullMethod → the exact client-certificate SPIFFE SAN URIs permitted to call it. Callers whose verified SAN is not listed are denied — in EVERY mode; a restricted method with no entry (or an empty one) is denied to everyone (fail-closed: the mint has no default caller). Exact URI match, not a service short-name — the ns AND the sa are part of the identity.
Blank entries are dropped so an accidentally empty config value (`KANAME_AUTHN__BOOTSTRAP_MINT__ALLOWED_CLIENT_SANS=""` → [""]) can never match a caller whose SAN failed to parse.
type ContextRelationChecker ¶
type ContextRelationChecker interface {
CheckWithContext(ctx context.Context, subject, relation, object string, condCtx map[string]any) (allowed bool, err error)
}
ContextRelationChecker — narrow port: ONE relation question, carrying a condition context. Satisfied by clients.RelationQueries.
type CredentialPresence ¶
CredentialPresence — приложил ли вызывающий удостоверение к ЭТОМУ запросу.
Предикат отвечает о ЗАПРОСЕ, а не о его годности: «предъявил и не сошлось» сюда не доходит — читатель отвергает такое выше по цепочке. Различие нужно ровно затем, чтобы не советовать назваться тому, кто уже назвался.
type DenyActionLookup ¶
type DenyActionLookup interface {
ActionForMethod(fqn string) string
ScopeForMethod(fqn string) string
}
DenyActionLookup — port: full method name (no leading slash) → what the catalog says about that method.
ActionForMethod — the permission name, or "" when the catalog has no row for it or the row is exempt.
ScopeForMethod — the OBJECT TYPE on which that permission is granted (project / account / cluster), or "" when the row names none. It is the other half of the answer a refusal owes its caller: the action says WHAT is missing, the scope says WHERE it is granted — and therefore whom to ask.
Both are functions of the METHOD alone. That is not an implementation detail but the property that keeps the enriched refusal free of an existence oracle: nothing from the request, the object id or the subject enters the answer, so a refusal on an existing object stays byte-identical to a refusal on one that does not exist. Asserted by TestDenyNextStep_IsAFunctionOfTheMethodOnly.
Implemented by *seed.PermissionRegistry.
type OwnDoor ¶
type OwnDoor struct {
// contains filtered or unexported fields
}
OwnDoor — собственная дверь iam вместе с поправкой на вопросы о правах.
Тип существует затем, чтобы у двери был ОДИН конструктор: композиционный корень и пробы обязаны собирать её одинаково, иначе проба судила бы вторую проводку, которой в проде нет.
func NewOwnDoor ¶
func NewOwnDoor(opts OwnDoorOptions) (*OwnDoor, error)
NewOwnDoor собирает звено решения о доступе для собственных слушателей iam.
Отказ возвращается там, где дверь получилась бы НЕДЕЙСТВУЮЩЕЙ: нет решателя, не выводится карта. Оба случая обязаны ронять старт, а не тихо давать пропускающее звено, — иначе провязка выглядит исполненной, и отличить её от исправной нельзя ничем.
func (*OwnDoor) Stream ¶
func (d *OwnDoor) Stream() grpc.StreamServerInterceptor
Stream — то же на второй полосе.
Стримовых RPC у iam сегодня НОЛЬ, поэтому поправка здесь ничего не решает СЕЙЧАС и стоит ради того, чтобы решать, когда предмет появится: полоса без поправки при полосе с поправкой — различие, которого никто не принимал.
func (*OwnDoor) Unary ¶
func (d *OwnDoor) Unary() grpc.UnaryServerInterceptor
Unary — звено публичного слушателя.
type OwnDoorOptions ¶
type OwnDoorOptions struct {
// SelfCheck — решатель ВЛАДЕЛЬЦА модели: собственный порт отношений iam.
// Обязателен. nil → конструктор отказывает, а не заводит дверь, которая
// никого не спрашивает: дверь без решателя пропускала бы всех, оставаясь на
// вид провязанной.
SelfCheck RelationChecker
// Logger — журнал звена. nil → slog.Default().
Logger *slog.Logger
// CheckTimeout — предел одного вопроса к модели. ≤0 → умолчание звена.
CheckTimeout time.Duration
// DenyRateLimitPerSec — потолок темпа отказов на принципала. 0 → выключен.
DenyRateLimitPerSec float64
// PositiveTTL — окно вердикта: срок жизни ПОЛОЖИТЕЛЬНОГО ответа, то есть
// время, в течение которого субъект с уже отобранным правом продолжает
// проходить. Это параметр безопасности, а не производительности.
//
// Неположительной сюда НЕ ПРИХОДИТ: величину отвергает страж старта
// (`AuthZConfig.Validate`, задача #2307). Здесь стояло «0 — ЯВНОЕ „беру
// умолчание политики“, как того требует конструктор кеша»: конструктор и
// правда подставит умолчание, но умолчание это живёт в фундаменте, который
// служба резолвит ПИНОМ, — то есть величина уезжала бы в чужую ревизию, а
// оператор посадки не мог бы её ни сузить, ни прочитать.
PositiveTTL time.Duration
}
OwnDoorOptions — то, что композиционный корень приносит двери.
type PublicCallerPolicy ¶
type PublicCallerPolicy struct {
// contains filtered or unexported fields
}
PublicCallerPolicy enforces the per-RPC caller policy on the public listener. Construct via NewPublicCallerPolicy.
func NewPublicCallerPolicy ¶
func NewPublicCallerPolicy( prodMode bool, peerCallable map[string][]string, acrCatalog ACRRequirementLookup, presented CredentialPresence, ) *PublicCallerPolicy
NewPublicCallerPolicy builds the policy. peerCallable is the per-RPC table of non-gateway callers (see PublicPeerCallableRPCs); prodMode comes from cfg.AuthN.Mode.IsProduction().
presented — реализация порта CredentialPresence. Параметр ОБЯЗАТЕЛЕН и позиционен: сборка, забывшая его подставить, не соберётся вовсе — тогда как умолчание сделало бы её молча отвечающей «назовись» тому, кто назвался.
func (*PublicCallerPolicy) Stream ¶
func (p *PublicCallerPolicy) Stream() grpc.StreamServerInterceptor
Stream returns the stream interceptor enforcing the public caller policy.
func (*PublicCallerPolicy) Unary ¶
func (p *PublicCallerPolicy) Unary() grpc.UnaryServerInterceptor
Unary returns the unary interceptor enforcing the public caller policy.
type RelationChecker ¶
type RelationChecker interface {
Check(ctx context.Context, subject, relation, object string) (bool, error)
}
RelationChecker — narrow port for a relation check. Satisfied by clients.RelationStore (same Check signature). Use-cases depend on this narrow interface, not on the whole decision door (Interface Segregation).
type RelationWriteGate ¶
type RelationWriteGate struct {
// contains filtered or unexported fields
}
RelationWriteGate authorizes RegisterResource / UnregisterResource via ReBAC. It reuses the package RelationChecker port (scope.go) — the same FGA `Check(subject, relation, object)` surface used by the scope guard, satisfied directly by clients.RelationStore (no extra adapter at the composition root).
func NewRelationWriteGate ¶
func NewRelationWriteGate(checker RelationChecker) *RelationWriteGate
NewRelationWriteGate — constructor. Defaults to dev-mode (backward-compat); use WithProductionMode to enable strict fail-closed enforcement.
func (*RelationWriteGate) Authorize ¶
func (g *RelationWriteGate) Authorize(ctx context.Context) (string, error)
Authorize returns nil iff the verified mTLS client-cert resolves to a module ServiceAccount holding `fga_writer` on `cluster:cluster_root`. Every other outcome is PermissionDenied (fail-closed). Message text is the fixed, non-leaking `"permission denied"`. Authorize проверяет, что caller — модульная SA с `fga_writer@cluster:cluster_root`, и возвращает ее домен (vpc/compute/nlb) для object-type binding на write-path. Dev-mode без cert → ("", nil): домен неизвестен, domain-binding в ValidateProxyTuple отключается, но relation-allowlist и forbidden-object-type там действуют всегда.
func (*RelationWriteGate) WithProductionMode ¶
func (g *RelationWriteGate) WithProductionMode(prod bool) *RelationWriteGate
WithProductionMode toggles strict fail-closed enforcement (production AuthN).
type SystemViewerFloor ¶
type SystemViewerFloor struct {
// contains filtered or unexported fields
}
SystemViewerFloor enforces the `system_viewer@cluster` floor on the READ-RPC set. Construct via NewSystemViewerFloor.
func NewSystemViewerFloor ¶
func NewSystemViewerFloor(checker RelationChecker, readFloorRPCs []string) *SystemViewerFloor
NewSystemViewerFloor builds the floor over the given READ-RPC set. Defaults to dev-mode (no-op); use WithProductionMode to enable strict enforcement.
func (*SystemViewerFloor) Stream ¶
func (f *SystemViewerFloor) Stream() grpc.StreamServerInterceptor
Stream returns the stream interceptor enforcing the floor.
func (*SystemViewerFloor) Unary ¶
func (f *SystemViewerFloor) Unary() grpc.UnaryServerInterceptor
Unary returns the unary interceptor enforcing the floor.
func (*SystemViewerFloor) WithProductionMode ¶
func (f *SystemViewerFloor) WithProductionMode(prod bool) *SystemViewerFloor
WithProductionMode toggles strict fail-closed enforcement (production AuthN).