Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions authbridge/authlib/plugins/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: factory() is called here purely to probe the interface — it instantiates a full plugin instance just for the type assertion. If any factory has observable side effects (spawning goroutines, opening connections, registering metrics), this enumerate call would silently trigger them. Consider adding a lighter probe path — e.g. a PluginCapabilities() or a separate RegisterConsumerTag registration step — so SPIFFEConsumerPlugins doesn't need to construct live instances. (Fine to defer until a factory with side effects actually appears.)

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
Expand Down
31 changes: 0 additions & 31 deletions authbridge/authlib/tlsbridge/engine.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package tlsbridge

import (
"log/slog"
"net/http"
"os"
"strings"
)

// Engine bundles everything the forward proxy needs to bridge TLS.
Expand All @@ -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)")
}
65 changes: 62 additions & 3 deletions authbridge/cmd/authbridge-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
90 changes: 90 additions & 0 deletions authbridge/cmd/authbridge-proxy/main_test.go
Original file line number Diff line number Diff line change
@@ -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}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestioncovered is a hand-maintained set, so this asserts the consumer name is listed but not that spiffeProviderNeeded actually fires for that plugin's spiffe config. A stronger invariant: for each SPIFFEConsumerPlugins() entry, build a config with identity.type=spiffe and assert the predicate returns true. The current form is a fine tripwire — just noting it's a reminder, not a functional proof.

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand Down Expand Up @@ -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 (
Expand Down
Loading