영어 원문
이 설계 기록은 역사적 맥락과 원문의 의미를 보존하기 위해 영어로 제공합니다.
ADR-040: Credential brokering — inferplaned vends short-lived Bedrock credentials to mayu¶
Date: 2026-08-18 Status: Accepted — design gate passed (3-AI panel: Kiro/Codex/Agy, 3 rounds, 10 findings fixed, final round unanimous PASS with zero CRITICAL/MAJOR); implementation deferred to a follow-up plan Related: ADR-031 §8 (the original sketch: "Credentials never rest in the data plane. The control plane … brokers short-lived credentials (e.g. STS AssumeRole), which also anchors per-user attribution at issuance time"), ADR-034 (the shared-bearer-token channel auth this design inherits, and its "mTLS/OIDC-brokered channel auth is future work alongside credential brokering" deferral), ADR-036 (the separate-channel precedent this endpoint copies), ADR-037 (the OIDC console branch this endpoint must exclude), ADR-038 §7 (the same shared-identity limitation, accepted the same way)
Context¶
Today every mayu instance calls Bedrock with its own local AWS
identity: providers/bedrock/client.go loads the default credential chain
(config.LoadDefaultConfig — env, shared config, IRSA, IMDS) once per
provider construction, and inferplaned has zero aws-sdk imports. The
control plane distributes policy and budget leases; it never touches
credentials. This has two costs:
- Bypass is only discouraged, never prevented. The IAM permission that
lets mayu call Bedrock (
bedrock:InvokeModel*) sits on the developer machine / node itself — so the same permission lets a client skiplocalhost:8081entirely and call Bedrock directly, outside every quota, budget, and audit control inferplane exists to enforce. The governance model is "point yourANTHROPIC_BASE_URLat mayu, please." - Per-node IAM toil. Every node that runs mayu needs its own role/profile provisioned with Bedrock permissions, and rotating or revoking access means touching every node's identity.
Centralizing the credential and vending short-lived STS sessions inverts
both: the bedrock:Invoke* permission moves to a single broker role only
inferplaned can assume, nodes need no Bedrock permissions of their
own, and a client that bypasses mayu has no way to obtain credentials at
all. Enforcement moves from convention to IAM — on hosts where mayu's
environment is not readable by its users (K8s nodes, dedicated proxy
hosts). On a developer-owned machine running mayu locally, the developer
can read broker_token_ref's secret out of mayu's environment and mint
credentials directly; there, brokering still removes the standing node
IAM grant but bypass prevention requires the trusted-host topology. The
claim is scoped accordingly throughout this ADR.
Verified constraints this design is built on¶
- 1-hour hard cap. inferplaned runs on ECS; its task role
(
inferplane-demo-TaskRole-…) is itself an assumed role, so broker-rolests:AssumeRolefrom it is role chaining — AWS caps chained sessions at 3600s regardless of the role's configured maximum. Renewal cadence must live inside that cap. - The refresh seam already exists in the SDK, not in our code. The
repo has zero credential-cache/injection code (no
CredentialsCache,StaticCredentialsProvider, orstscredsanywhere); the bedrock client is built once perBuildState(boot, SIGHUP reload, UI-write validation —cmd/mayu/gateway.go:279/:829/:890) and never on the request path.aws.CredentialsProvideris consulted at signing time, so a rotating source needs no client re-construction. auth.modeis an open slot.client.gobranches only on"profile";irsa/pod_identity/static/defaultall fall through to the sameLoadDefaultConfig, and"static"exists only in a comment. Adding"broker"collides with nothing.- Channel-separation precedent. ADR-036's usage push is a separate endpoint + goroutine + bounded buffer + HTTP client that shares only the bearer token with the sync heartbeat. Same split applies here, for the same reason: different lifecycle (10s heartbeat vs ≤1h credentials), different blast radius.
- The OIDC console branch must be excluded.
internal/controlplane/ auth.go'sauthngrants whole-console access to both the static token AND any verified OIDC identity (ADR-037). Mounting a credentials endpoint behind plainauthnwould let a browser SSO session mint AWS credentials — a machine-channel secret reachable from a web login. - Dataplane identity is self-reported.
handleSyncchecks only thatdataplaneis non-empty; any holder of the shared token can claim any dataplane id (and already can overwrite that id's ledger rows). The default id is per-boot (host + ULID,gateway.go:1406-1412). stsis already in go.mod (v1.43.3, indirect) — promoting it to a direct dependency downloads nothing new. inferplaned's env-only config surface (9 vars, no config file) has an established opt-in pattern (INFERPLANED_POLICY_DSN, ADR-038).
Why only Bedrock (v1)¶
| Provider | Short-lived credentials | v1 |
|---|---|---|
| AWS Bedrock | STS AssumeRole — native | ✅ |
| 1P Anthropic (api.anthropic.com) | No temporary-token mechanism exists — only long-lived API keys. Centrally storing and distributing a static key is a different feature with none of the short-lived security benefit, and is out of scope here. | ❌ |
| GCP Vertex | SA impersonation / STS token exchange would work, but no GCP provider exists in the tree | ❌ (interface slot left open) |
Decision¶
1. A dedicated endpoint, machine-channel only: POST /v1alpha1/credentials¶
Mounted on inferplaned only when INFERPLANED_BROKER_ROLE_ARN is set
(the ADR-038 opt-in pattern: unset ⇒ route absent ⇒ 404; default behavior
byte-identical). Request {dataplane, provider: "bedrock"} (1 MiB
MaxBytesReader, the sync precedent); response
{accessKeyId, secretAccessKey, sessionToken, expiration}.
Auth is a dedicated broker token, not INFERPLANED_TOKEN: a new
INFERPLANED_BROKER_TOKEN env var on inferplaned, required whenever the
role ARN is set, and rejected at boot if it equals INFERPLANED_TOKEN
(the ADR-028 distinct-client-id rule, applied to tokens). The mayu side
gets the matching wiring the first draft forgot: a new
control_plane.broker_token_ref secret ref (env/file, never inline — the
house rule), rejected at config load if it resolves to the same value as
control_plane.token_ref.
Broker mode requires an https (or loopback) control-plane URL. The
existing heartbeat channel deliberately tolerates plain http://
(validateControlPlane — policy documents and lease numbers), and the
live demo runs that way today. Credentials change the calculus: an STS
triplet on the wire in plaintext is a credential grant to the network
path. Config load rejects auth.mode: "broker" when control_plane.url
is non-loopback http://. The panel review was blunt and
right about why: the heartbeat token already sits in env on every node and
grants policy reads — if the same token minted portable AWS credentials,
compromising any one node's env would yield Bedrock access without mayu,
re-opening the exact bypass this feature exists to close. A distinct secret
means heartbeat-token compromise stops at policy, and the broker secret can
be scoped to fewer places. The route additionally rejects OIDC-authenticated
callers with 403 — a browser console session must never be able to mint AWS
credentials. This is a machine channel, like the usage push.
Secret hygiene is a stated requirement, not an implication: the mayu
fetcher and the inferplaned handler never log credential fields, never wrap
an upstream response body into a returned error (%w on an STS error is
fine; embedding the HTTP body is not), and the retry path stores no response
bytes — the same rule internal/policystore already applies to DSN-bearing
pgx errors. The rejection of heartbeat piggybacking above ("never audited
for secret hygiene") applies with equal force to the new channel and is
discharged by these requirements plus tests that grep the error/log paths.
Not on the heartbeat, deliberately: credentials have a different lifecycle (≤1h vs 10s cadence), a different security class (signable secrets vs policy documents), and piggybacking them on sync would put a secret in every heartbeat response body. ADR-036 already established the separate-channel shape.
2. inferplaned brokers via STS AssumeRole¶
- New env
INFERPLANED_BROKER_ROLE_ARNnames the broker role, which holdsbedrock:InvokeModel/bedrock:InvokeModelWithResponseStream(and nothing else). IAM wiring is two-sided and the trust-policy half is easy to miss: inferplaned's task role gainssts:AssumeRole+sts:TagSession+sts:SetSourceIdentityon that one role ARN in its identity policy, and the broker role's trust policy must allow those same three actions for the task-role principal —sts:TagSession/sts:SetSourceIdentitydenied in the trust policy fail the whole AssumeRole, not just the tag. - Recommended (runbook, not enforceable by inferplane): condition the
broker role's Bedrock permissions on
aws:SourceIp/aws:SourceVpceso an exfiltrated credential triplet is dead on arrival outside the network — the single cheapest mitigation for the portable-credentials risk below. - Per grant:
RoleSessionNameandSourceIdentity= the dataplane id sanitized to the STS constraint (64 chars,[\w+=,.@-]— the default id ishost-ULIDand can exceed it); session tagdataplane=<id>.DurationSeconds≤ 3600 (the chaining cap). SourceIdentitysurvives further chaining and lands in CloudTrail — the issuance-time attribution anchor ADR-031 §8 named. Caveat: if the task-role session already carries a SourceIdentity (set by the orchestrator), AWS forbids changing it on the next hop — the broker probes this at boot and, when inherited, propagates it and falls back to session tags alone for the dataplane axis.- Operators who want a stable audit trail across mayu restarts set
control_plane.dataplaneexplicitly (runbook note); the per-boot default is harmless but noisy in CloudTrail.
3. mayu opts in with auth.mode: "broker" and a neutral CredentialSource¶
providers.Configgains one field (theHTTPClientprecedent — a documented, narrow, core→provider injection):
// CredentialSource supplies rotating upstream credentials to providers
// that opt in (auth.mode "broker"). Provider-neutral on purpose: no AWS
// types cross the core/provider boundary.
type CredentialSource interface {
Credentials(ctx context.Context) (id, secret, session string, expires time.Time, err error)
}
- In
providers/bedrock,authMode == "broker"wraps the source inaws.CredentialsCache(aws.CredentialsProviderFunc(...))and passes it viaconfig.WithCredentialsProvider. The cache refreshes before expiry automatically; the once-per-BuildState client construction is untouched and rotation never requires a reload. - The fetching implementation lives in
internal/proxy(a sibling of the Syncer and UsagePusher, sharing the control-plane URL/token config);cmd/mayu/gateway.gothreads the source into provider construction.internal/proxystays disconnected frominternal/live's import graph — the wiring point is the gateway, same as everything else. - Fail-closed is a testable invariant, not an implication. Two requirements the panel demanded be stated, because either omission silently fails open into the node's ungoverned local credentials:
- Broker mode never falls back to the default credential chain.
authMode == "broker"constructs the client with the injected source or fails construction — no code path reachesLoadDefaultConfig's chain. A brokered provider that cannot get credentials returns errors; it never quietly signs with node IAM. (BuildState failing on boot/reload is the correct behavior — same posture as any other invalid provider config.) - Unknown
auth.modevalues are rejected at config load. Today every unrecognized mode falls through to the default chain, so a typo ("brokre") would silently deploy the bypassable posture this feature exists to remove. This ADR adds mode validation (irsa|pod_identity|profile|static|default|broker) — an independent pre-existing gap this feature makes dangerous. Migration note: configs that relied on the silent fall-through (any unlisted string quietly meaning "default") now fail boot — a breaking change for typo'd configs only, called out in the changelog. auth.mode: "broker"without acontrol_planeblock is a config-load error — the fetcher has no URL or token otherwise; leaving this implied would let it degrade into rule 1's territory.- The initial credential fetch is eager, not lazy. Wrapping the
source in
aws.CredentialsCachealone defers the first fetch to request-signing time, which would let a BuildState with a bad broker token or unreachable broker "succeed" at boot/SIGHUP and fail only on user traffic. Provider construction performs one blockingRetrieve(); failure fails BuildState. On reload that is exactly ADR-006's rollback semantics — the old topology stays live and the bad config is rejected loudly instead of half-applied. Accepted cost: every BuildState — including each UI-write validation (gateway.go:890) — mints a fresh STS session; the implementation may cache the fetcher across rebuilds to damp that churn, as long as the eager-validation property is preserved. With those two held: broker unreachable ⇒ cached credentials remain valid until expiry (≤1h) ⇒ after expiry, Bedrock signing fails and requests error — the same philosophy as hard-cap leases. An operator who cannot accept a ≤1h control-plane dependency keeps using the local IAM modes, which remain the default and are unchanged.
4. Accepted limitation (v1): one broker token, self-reported dataplane ids¶
Any holder of INFERPLANED_BROKER_TOKEN can request credentials and claim
any dataplane id. The panel rejected the first draft's framing of this
as "the same limitation ADR-038 §7 accepted" — it is worse, and the record
should say so plainly:
- ADR-038's token wrote policy documents inside the system; this one mints
portable AWS credentials usable without mayu — a holder skips quota,
budget, and inferplane's audit entirely. The distinct-token requirement
(decision 1) keeps the widely-deployed heartbeat token out of that blast
radius, and the
aws:SourceIp/aws:SourceVpceconditions (decision 2) keep exfiltrated credentials from working off-network, but within the network a broker-token holder has ungoverned Bedrock access for ≤1h per mint. - Because the dataplane id is caller-chosen,
SourceIdentity/session-tag attribution is frameable: an attacker with the token can stamp an innocent dataplane's id on their sessions. CloudTrail attribution under v1 is therefore "which id was claimed," never "which machine called."
What v1 buys despite this: the legitimate fleet's nodes carry zero
Bedrock permissions, developers on trusted-host deployments (where
mayu's env is not theirs to read) cannot bypass mayu, and the secret that
matters is one revocable env var instead of N node roles. On
developer-owned machines the broker token is readable by the developer —
brokering there is an IAM-hygiene win, not a bypass control (see
Context).
Per-dataplane identity (bootstrap tokens, or authenticating the channel
with the node's own IAM identity) is the v2 follow-up that closes the
impersonation gap; it converges with roadmap item ②'s dataplanes table.
Non-goals (v1)¶
- 1P Anthropic key brokering — no temporary-token mechanism exists; distributing static keys centrally is a different feature.
- GCP Vertex — no provider in the tree; the neutral
CredentialSourceleaves the slot open. - Per-team credentials (session policies narrowing model ARNs per
team — attractive because it would double-enforce
modelAccessat IAM level) — the bedrock client is one-per-provider, so per-team credentials mean per-team clients: a real structural change, recorded as the v2 candidate it is. - Forcing local-IAM removal — stripping
bedrock:*from developer/node roles is the operator's infrastructure change that makes bypass-prevention real; inferplane enables it, a runbook documents it, the product cannot do it for you.
Alternatives considered¶
- Carry credentials on the sync heartbeat. Rejected — puts a signable secret in every 10s response, couples a ≤1h artifact to a 10s cadence, and heartbeat handling code paths (retry, logging) were never audited for secret hygiene.
sts:GetFederationTokeninstead of AssumeRole. Rejected — not callable from an assumed role (inferplaned's task role is one), and it cannot use role session policies as cleanly.- Pre-created per-team IAM roles. Rejected for v1 — role-per-team multiplies IAM objects to manage and still needs per-team clients in mayu; the single-role + session-tags design gets attribution now and leaves session-policy narrowing as a compatible v2.
- mTLS / per-dataplane identity first. Deferred, not rejected — it is the right v2, but requiring it first would block the bypass-closing benefit on a second large feature. ADR-038 set the precedent for shipping behind the shared token with the limitation recorded.
Consequences¶
- Bedrock access can be revoked fleet-wide by disabling one role; node IAM shrinks to zero Bedrock permissions.
- New hard dependency for opted-in providers only: a mayu whose
bedrock provider uses
auth.mode: "broker"stops working ≤1h after the control plane becomes unreachable. All other auth modes are untouched; the config default remains local IAM. - inferplaned takes its first aws-sdk dependency (
sts, already in go.mod as indirect) and, when the env var is set, must itself run with an AWS identity allowed to assume the broker role. - The shared-token limitation of ADR-034/038 now also covers credential issuance — recorded above, resolved by the v2 identity work.
- CloudTrail gains per-dataplane session attribution for every Bedrock call made through brokered credentials — an audit surface inferplane's own hash-chain cannot provide (it ends at mayu; CloudTrail covers the provider side). Under v1 that attribution is claimed-id, not proven-id (decision 4).
- Brokered sessions carry unrestricted
bedrock:Invoke*across all models — per-team session policies are the v2 that would narrow them. Until then, a credential holder outside mayu is bound by the network conditions (decision 2) but not by any model allow-list; inside mayu,modelAccesspolicy enforcement is unchanged and unaffected. - Two new env vars on inferplaned (
INFERPLANED_BROKER_ROLE_ARN,INFERPLANED_BROKER_TOKEN) and one new config value on mayu (auth.mode: "broker"), plus auth-mode validation that tightens a pre-existing silently-ignored field.