Skip to content

Robin v0.1 — workload-identity injector - #1

Merged
stephnangue merged 12 commits into
mainfrom
v0.1
Jun 19, 2026
Merged

Robin v0.1 — workload-identity injector#1
stephnangue merged 12 commits into
mainfrom
v0.1

Conversation

@stephnangue

Copy link
Copy Markdown
Owner

Robin injects a workload's short-lived, rotating identity — a Kubernetes projected ServiceAccount token or a SPIFFE JWT-SVID — as a fresh bearer on every outbound request. Applications that only understand a static credential can then live behind rotation without holding a secret: they present identity to Robin on loopback, and a downstream broker turns that identity into the real upstream credential.

What's in here (v0.1)

  • Providers: file (projected SA token, re-read per request so it's never stale) and jwtsvid (SPIFFE Workload API; cache + proactive refresh + serve-stale-on-error, single-flight collapse).
  • Proxy core: httputil.ReverseProxy that injects Authorization: Bearer over any inbound placeholder; fail-closed 503 when identity is unavailable (broker never contacted); 502 on an unreachable broker (single attempt, no retry); body-agnostic and streaming; optional broker-TLS pinning.
  • Server: loopback proxy plane + a separate admin plane (/healthz, /readyz), graceful SIGTERM drain of in-flight egress.
  • Config: flat ROBIN_* env / flags / .env (precedence flags > env > file) with fail-closed validation. No config language.
  • Deploy: static distroless image (~14 MB, nonroot) + native-sidecar manifest (K8s 1.29+, restartPolicy: Always).
  • Structured logging that never logs the token value.

Hardening / review fixes folded in

  • jwtsvid rejects empty/already-expired SVIDs, and the proxy 503s on an empty token (fail-closed at both layers).
  • refresh is panic-safe (a misbehaving Workload API client can't wedge the provider) and the single-flight collapse is airtight (no redundant fetch under a burst).
  • Run drains both planes on a listener error; TLS 1.2 floor pinned on the broker transport; the proxy plane defaults to 127.0.0.1.

Quality

Single direct dependency: go-spiffe v2.8.0. go build, go vet, go test -race (incl. concurrency-collapse stress) and the example manifest (kubeconform, k8s 1.29) all pass.

stephnangue and others added 12 commits June 17, 2026 18:24
Architecture of record for Robin, the workload-identity injector:
component designs, config reference, failure semantics, the v0.1/v0.2
split, and the PR-by-PR implementation plan. Docs only; no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
go.mod (github.com/snangue/robin, go 1.26), Makefile, .golangci.yml,
.gitignore, GitHub Actions CI (build/vet/test -race + lint).
internal/version (ldflags build metadata), internal/obs/log.go (slog
JSON logger + field-key constants), and a stub cmd/robin entrypoint
that handles --version/--help.
ROBIN_* configuration with precedence flags > env > file. config.Load
(injectable getenv/args for testable precedence), fail-closed Validate
(required absolute upstream URL, jwtsvid requires audience, unknown
source rejected), and a dependency-free .env parser. main now loads
and validates config, exiting non-zero on error. Table tests for
precedence, defaults, validation, UID parsing, and dotenv edge cases.
IdentityProvider interface (Token/Close) with sentinel errors, and the
Kubernetes projected ServiceAccount token provider: per-request file
re-read (no cache, matching kubelet in-place rotation), whitespace
trim, ErrNoToken on empty. factory.New dispatches on token source;
jwtsvid returns an explicit "not yet available" until PR4. Tests cover
trim, rotation, empty, and missing-file cases.
Adds the jwtsvid identity provider: cache keyed by audience, proactive
refresh ahead of expiry (with a half-TTL clamp for short lifetimes),
serve-stale-on-error while the cached token is still valid, and a
singleflight collapse so a burst of requests triggers one Workload API
call. The cache/refresh logic sits behind a jwtFetcher seam (fake +
injectable clock in tests) so it needs no running SPIRE agent; the
production source wraps workloadapi.JWTSource, passing the socket via
WithAddr only when configured. Single direct dependency: go-spiffe v2.8.0.
Reverse-proxy core built on httputil.ReverseProxy with the Rewrite
hook: resolves identity in an outer handler (503 + broker untouched
when identity is unavailable), injects Authorization: Bearer over any
inbound placeholder, and surfaces an unreachable broker as 502 with a
single attempt (no retry). Body-agnostic and streaming — the request
body is never read. ModifyResponse emits one access-log line per
request (decision + upstream status); the token value is never logged.
Broker TLS via a cloned default transport with optional pinned RootCAs.
Tests: inject/overwrite/body+path passthrough, 503, 502, CA load paths.
Dual-listener server: the proxy plane (TCP or UDS) and a separate admin
plane (/healthz liveness, /readyz readiness via provider.Token). Both
listeners bind up front so addresses are testable; Run drains in-flight
egress on SIGTERM/SIGINT (proxy first, then admin) within a 25s budget.
main now wires config -> provider -> proxy -> server with signal-driven
shutdown. Robin runs end to end. Tests cover health/readiness/proxy,
readyz reflecting provider failure, and graceful-shutdown draining.
Multi-stage Dockerfile: static CGO-free build into distroless/static
nonroot (~24MB), version via ldflags. Native-sidecar Kubernetes example
(initContainer restartPolicy: Always, K8s 1.29+) with an audience-scoped
projected SA token, hardened securityContext, probes on the admin plane,
and terminationGracePeriodSeconds above the drain budget. Proxy plane
binds loopback (pod-local trust boundary); admin plane binds all
interfaces so kubelet probes reach it. README quickstart added and the
design-doc bind-address note corrected for probe reachability.
H1 fail-closed: the jwtsvid provider now rejects an empty or
already-expired SVID (mirroring the file provider ErrNoToken) instead
of caching it, and the proxy treats a ("", nil) token as 503 -- closing
a path that forwarded "Authorization: Bearer " (empty) to the broker.

H2 panic-safety: jwtsvid refresh moves inflight cleanup and waiter
wakeup into a deferred recover, so a panicking Workload API client can
no longer permanently wedge the provider (poisoned inflight / unclosed
channel).

Also closes a TOCTOU gap the new test surfaced: refresh re-checks the
cache under the lock before becoming leader, so a burst truly collapses
to one fetch (previously a waiter could start a redundant fetch).

M1: server Run drains BOTH planes when one listener errors, not only on
SIGTERM, so a Serve failure never leaves the other plane running.

L3: pin TLS MinVersion 1.2 on the broker transport even without a CA file.

L4: the concurrency-collapse test is now deterministic (leader-first
handshake, no sleep). New tests cover empty/expired/panic and the proxy
empty-token 503.
M2 loopback default: ROBIN_LISTEN_ADDR now defaults to 127.0.0.1:4000
instead of :4000, so a deployment that forgets to set it does not expose
the identity-injection port pod-wide. The admin plane stays :4001 (all
interfaces) so kubelet probes can reach it.

L1: document restricting the admin port with a NetworkPolicy (manifest
comment + design/README notes) -- it serves only health/readiness/
metrics, never identity.

L2: the UDS is chmod 0o600 (owner-only) rather than 0o660, until v0.2
SO_PEERCRED enforcement narrows the local trust boundary further.

NIT: rename the identity.IdentityProvider interface to identity.Provider
(drops the package stutter) across provider/factory/proxy/admin, and sync
docs/design.md.
Rewrite the README to lead with the problem: Kubernetes hands out
short-lived, rotating credentials, but almost no application knows how
to consume one -- apps read a token once and hold it forever, so teams
fall back to a long-lived static secret. Robin injects a fresh bearer
on every request so the app carries no credential at all.

No application/framework is named (the problem is universal). Correct
the status to reflect that v0.1 is implemented, and generalize the
sidecar manifest (placeholder app image and UPSTREAM_* env, no
framework-specific names).

Stop publishing the internal design notes: untrack docs/design.md and
gitignore it (the local copy is kept), and remove all README links to it.
@stephnangue
stephnangue merged commit 9cdc162 into main Jun 19, 2026
2 of 3 checks passed
@stephnangue
stephnangue deleted the v0.1 branch June 19, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant