diff --git a/authbridge/authlib/plugins/registry.go b/authbridge/authlib/plugins/registry.go index a69801a9e..85e401ea9 100644 --- a/authbridge/authlib/plugins/registry.go +++ b/authbridge/authlib/plugins/registry.go @@ -80,6 +80,31 @@ func RegisteredPlugins() []string { return names } +// SPIFFEConsumerPlugins returns the sorted names of registered plugins whose +// instances implement spiffe.ProviderConsumer — i.e. the plugins BuildWithSPIFFE +// would inject the Provider into. Callers that gate Provider construction on +// actual need (see cmd/authbridge-proxy's spiffeProviderNeeded) use this to +// assert their need-detection covers every consumer; a new consumer that slips +// past the predicate would otherwise silently receive a nil Provider. +// +// This probes by constructing each plugin and type-asserting. That is safe +// because PluginFactory is contractually a cheap, side-effect-free constructor +// (see PluginFactory: no construction args; deps + goroutines are created in +// Configure/Init, not the factory). If a factory ever gains construction-time +// side effects, switch this to a static capability tag instead of a live probe. +func SPIFFEConsumerPlugins() []string { + registryMu.RLock() + defer registryMu.RUnlock() + var names []string + for name, factory := range registry { + if _, ok := factory().(spiffe.ProviderConsumer); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + // CatalogEntry pairs a registered plugin's name with the capabilities // it advertises and the field-level schema of its config (if it // implements pipeline.SchemaProvider). Surfaces in `abctl`'s catalog diff --git a/authbridge/authlib/tlsbridge/engine.go b/authbridge/authlib/tlsbridge/engine.go index 20430ae13..d90ebaa3b 100644 --- a/authbridge/authlib/tlsbridge/engine.go +++ b/authbridge/authlib/tlsbridge/engine.go @@ -1,10 +1,7 @@ package tlsbridge import ( - "log/slog" "net/http" - "os" - "strings" ) // Engine bundles everything the forward proxy needs to bridge TLS. @@ -16,31 +13,3 @@ type Engine struct { Upstream *http.Client CAPEM []byte } - -// RunTrustSelfCheck logs a loud WARN when the bridge CA is not present in the -// trust file the agent runtime is told to use (SSL_CERT_FILE / NODE_EXTRA_CA_CERTS -// / REQUESTS_CA_BUNDLE). A trust-miss is then a visible signal, not an opaque -// in-agent handshake error. Best-effort. In Phase 1 (test-only) no agent trust -// env is set, so this simply notes that egress will safely tunnel. -func RunTrustSelfCheck(caPEM []byte) { - want := strings.TrimSpace(string(caPEM)) - if want == "" { - // An empty CA would make strings.Contains below always true → a false - // "trust self-check OK". Guard so an empty/misconstructed CA is visible. - slog.Warn("tls-bridge trust self-check skipped: empty CA PEM") - return - } - for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} { - p := os.Getenv(env) - if p == "" { - continue - } - if b, err := os.ReadFile(p); err == nil && strings.Contains(string(b), want) { - slog.Info("tls-bridge trust self-check OK", "env", env, "path", p) - return - } - } - slog.Warn("tls-bridge trust self-check: CA not found in any agent trust file " + - "(SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/REQUESTS_CA_BUNDLE); agent will not trust " + - "minted leaves and egress will safely tunnel (expected in Phase 1 / test-only)") -} diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 587cea56b..e997b0982 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -11,6 +11,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "log" @@ -52,7 +53,9 @@ import ( _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/opa" _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/sparc" _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker" - _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" + // Named (not blank) so pluginUsesSPIFFEIdentity can reference the shared + // SpiffeIdentity constant instead of duplicating the "spiffe" literal. + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" ) var logLevel = new(slog.LevelVar) @@ -87,6 +90,51 @@ func startSignalToggle() { }() } +// spiffeProviderNeeded reports whether any configured feature actually consumes +// the SPIFFE Provider: top-level mTLS (needs the X509Source on both listeners) +// or a plugin whose identity is spiffe-based (needs the JWT-SVID source — today +// only token-exchange, gated on identity.type=spiffe). When nothing consumes +// it, the provider — and its blocking SPIRE Workload API dial in NewProvider — +// is skipped, so the binary boots even on clusters without SPIRE. +func spiffeProviderNeeded(c *config.Config) bool { + if c.MTLS != nil { + return true + } + for _, p := range c.Pipeline.Inbound.Plugins { + if pluginUsesSPIFFEIdentity(p) { + return true + } + } + for _, p := range c.Pipeline.Outbound.Plugins { + if pluginUsesSPIFFEIdentity(p) { + return true + } + } + return false +} + +// pluginUsesSPIFFEIdentity reports whether a plugin's config selects the spiffe +// identity scheme (identity.type=spiffe) — the only plugin-level consumer of +// the Provider today (token-exchange). The `identity` block is a shared +// convention; a new SPIFFE-consuming plugin must either follow it or extend +// this predicate. +func pluginUsesSPIFFEIdentity(p config.PluginEntry) bool { + if len(p.Config) == 0 { + return false + } + var probe struct { + Identity struct { + Type string `json:"type"` + } `json:"identity"` + } + if err := json.Unmarshal(p.Config, &probe); err != nil { + // Unparseable here just means the plugin's own typed decode will fail + // later with a precise error; don't force the provider on for it. + return false + } + return probe.Identity.Type == tokenexchange.SpiffeIdentity +} + func main() { configPath := flag.String("config", "", "path to config YAML file") flag.Parse() @@ -112,8 +160,17 @@ func main() { if err != nil { log.Fatalf("initial config load: %v", err) } + // Build the SPIFFE Provider only when something actually consumes it — + // top-level mTLS (X509Source for the listeners) or a plugin whose identity + // is spiffe-based (JWT-SVID for token-exchange). The platform's base config + // ships an empty `spiffe: {}` for every agent, and NewProvider blocks until + // the SPIRE Workload API returns the first SVID; constructing it on mere + // presence of the block would hang any agent on a cluster without SPIRE — + // e.g. a proxy-sidecar agent that only runs the TLS bridge, which mints + // leaves from a cert-manager CA and never touches an SVID. Need-driven + // construction keeps such agents decoupled from SPIRE. See spiffeProviderNeeded. var provider *spiffe.Provider - if bootCfg.SPIFFE != nil { + if bootCfg.SPIFFE != nil && spiffeProviderNeeded(bootCfg) { mirrorFiles := true if bootCfg.SPIFFE.MirrorFiles != nil { mirrorFiles = *bootCfg.SPIFFE.MirrorFiles @@ -127,6 +184,9 @@ func main() { log.Fatalf("spiffe provider: %v", err) } defer provider.Close() + } else if bootCfg.SPIFFE != nil { + slog.Info("spiffe block present but unused (no mTLS, no spiffe-identity plugin) — " + + "skipping SPIRE provider; no Workload API connection will be attempted") } // This binary is hardcoded to proxy-sidecar. Rejecting other modes @@ -286,7 +346,6 @@ func main() { Upstream: up, CAPEM: src.CACertPEM(), } - tlsbridge.RunTrustSelfCheck(bridge.CAPEM) slog.Info("tls-bridge enabled", "ca_dir", cfg.TLSBridge.CADir) } diff --git a/authbridge/cmd/authbridge-proxy/main_test.go b/authbridge/cmd/authbridge-proxy/main_test.go new file mode 100644 index 000000000..4f51b0dc9 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/main_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" +) + +func identityConfig(idType string) json.RawMessage { + return json.RawMessage(`{"identity":{"type":"` + idType + `"}}`) +} + +// TestSpiffeProviderNeeded pins the need-driven gate: the SPIFFE provider is +// built only when mTLS is on or a plugin selects the spiffe identity scheme. +// A bare `spiffe: {}` block with neither must NOT trigger it — that is what +// lets the TLS bridge (cert-manager CA, no SVID) boot without SPIRE. +func TestSpiffeProviderNeeded(t *testing.T) { + outbound := func(entries ...config.PluginEntry) *config.Config { + return &config.Config{Pipeline: config.PipelineConfig{ + Outbound: config.PipelineStageConfig{Plugins: entries}, + }} + } + inbound := func(entries ...config.PluginEntry) *config.Config { + return &config.Config{Pipeline: config.PipelineConfig{ + Inbound: config.PipelineStageConfig{Plugins: entries}, + }} + } + + tests := []struct { + name string + cfg *config.Config + want bool + }{ + {"empty config", &config.Config{}, false}, + {"mtls present", &config.Config{MTLS: &config.MTLSConfig{}}, true}, + {"token-exchange client-secret", outbound(config.PluginEntry{ + Name: "token-exchange", Config: identityConfig("client-secret"), + }), false}, + {"token-exchange spiffe (outbound)", outbound(config.PluginEntry{ + Name: "token-exchange", Config: identityConfig("spiffe"), + }), true}, + {"spiffe identity (inbound)", inbound(config.PluginEntry{ + Name: "some-plugin", Config: identityConfig("spiffe"), + }), true}, + {"plugin with no config", outbound(config.PluginEntry{Name: "jwt-validation"}), false}, + {"malformed plugin config", outbound(config.PluginEntry{ + Name: "bad-plugin", Config: json.RawMessage(`{not valid json`), + }), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := spiffeProviderNeeded(tt.cfg); got != tt.want { + t.Errorf("spiffeProviderNeeded() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestProviderConsumersCoveredByPredicate enforces the invariant that ties +// spiffeProviderNeeded to plugins.BuildWithSPIFFE: the predicate detects a +// plugin's need via identity.type=spiffe, which covers token-exchange — the +// only spiffe.ProviderConsumer today. If a new ProviderConsumer is registered, +// this fails so the author confirms spiffeProviderNeeded detects its need; +// otherwise that plugin would silently receive a nil Provider on a SPIRE-less +// cluster. The main package blank-imports every plugin, so the registry here is +// the full production set. +func TestProviderConsumersCoveredByPredicate(t *testing.T) { + // Plugins whose SPIFFE need spiffeProviderNeeded is known to detect. + covered := map[string]bool{"token-exchange": true} + for _, name := range plugins.SPIFFEConsumerPlugins() { + // Tripwire: a new consumer must be consciously reviewed and listed. + if !covered[name] { + t.Errorf("plugin %q implements spiffe.ProviderConsumer but is not covered by "+ + "spiffeProviderNeeded; make it signal its need via identity.type=spiffe (or "+ + "extend the predicate), then add %q to this set", name, name) + } + // Functional: the predicate must actually fire for that consumer's + // spiffe config, so it never receives a nil Provider. + cfg := &config.Config{Pipeline: config.PipelineConfig{ + Outbound: config.PipelineStageConfig{Plugins: []config.PluginEntry{ + {Name: name, Config: identityConfig("spiffe")}, + }}, + }} + if !spiffeProviderNeeded(cfg) { + t.Errorf("spiffeProviderNeeded must return true for consumer %q with identity.type=spiffe", name) + } + } +} diff --git a/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md index 7d0974487..a3fbf4b4b 100644 --- a/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md +++ b/authbridge/docs/superpowers/plans/2026-06-17-authbridge-tlsbridge-phase1.md @@ -40,7 +40,7 @@ - `authlib/tlsbridge/upstream.go` — `NewUpstreamClient` (system + injected roots). - `authlib/tlsbridge/terminator.go` — `Terminator` (tls.Server wrap, ALPN h2+http/1.1). - `authlib/tlsbridge/serve.go` — `ServeConn` (one-conn keep-alive http.Server, h2-enabled). -- `authlib/tlsbridge/engine.go` — `Engine` facade + `RunTrustSelfCheck`. +- `authlib/tlsbridge/engine.go` — `Engine` facade + `RunTrustSelfCheck`. **(Historical: `RunTrustSelfCheck` was removed in #523 — it inspected the sidecar's own trust env, but the operator sets that on the agent container, so it false-WARNed on every deployment.)** - `authlib/tlsbridge/*_test.go` — per-unit tests. **Modified (proxy integration):** @@ -1405,7 +1405,7 @@ Hook it into the loader beside the existing `MTLS`/`SPIFFE` validation (`config. Run: `go test ./config/ -run TestConfig_TLSBridge -v` -- [ ] **Step 5: Add `RunTrustSelfCheck` to `engine.go`** +- [ ] **Step 5: Add `RunTrustSelfCheck` to `engine.go`** — _(removed in #523; an in-process check can't see the agent container's trust store, so it false-WARNed on every operator deployment)_ ```go import (