Robin v0.1 — workload-identity injector - #1
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
file(projected SA token, re-read per request so it's never stale) andjwtsvid(SPIFFE Workload API; cache + proactive refresh + serve-stale-on-error, single-flight collapse).httputil.ReverseProxythat injectsAuthorization: Bearerover 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./healthz,/readyz), graceful SIGTERM drain of in-flight egress.ROBIN_*env / flags /.env(precedence flags > env > file) with fail-closed validation. No config language.restartPolicy: Always).Hardening / review fixes folded in
refreshis 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).Rundrains both planes on a listener error; TLS 1.2 floor pinned on the broker transport; the proxy plane defaults to127.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.